Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: TypeScript resolver #35

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,8 @@
}
}
]
},
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
}
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './types.js';
export * from './get-tsconfig.js';
export * from './parse-tsconfig/index.js';
export * from './paths-matcher/index.js';
export * from './resolver/index.js';
2 changes: 1 addition & 1 deletion src/parse-tsconfig/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function parseTsconfig(
throw new Error(`Cannot resolve tsconfig at path: ${tsconfigPath}`);
}
const directoryPath = path.dirname(realTsconfigPath);
let config: TsConfigJson = readJsonc(realTsconfigPath) || {};
let config = readJsonc<TsConfigJson>(realTsconfigPath) || {};

if (typeof config !== 'object') {
throw new SyntaxError(`Failed to parse tsconfig at: ${tsconfigPath}`);
Expand Down
4 changes: 2 additions & 2 deletions src/parse-tsconfig/resolve-extends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'fs';
import Module from 'module';
import { findUp } from '../utils/find-up.js';
import { readJsonc } from '../utils/read-jsonc.js';
import { parsePackageName } from '../utils/parse-package-name.js';

const { existsSync } = fs;

Expand Down Expand Up @@ -60,8 +61,7 @@ export function resolveExtends(
const pnpApi = getPnpApi();
if (pnpApi) {
const { resolveRequest: resolveWithPnp } = pnpApi;
const [first, second] = filePath.split('/');
const packageName = first.startsWith('@') ? `${first}/${second}` : first;
const { packageName } = parsePackageName(filePath);

try {
if (packageName === filePath) {
Expand Down
4 changes: 3 additions & 1 deletion src/paths-matcher/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ function parsePaths(
});
}

export type PathsMatcher = (specifier: string) => string[];

/**
* Reference:
* https://github.com/microsoft/TypeScript/blob/3ccbe804f850f40d228d3c875be952d94d39aa1d/src/compiler/moduleNameResolver.ts#L2465
*/
export function createPathsMatcher(
tsconfig: TsConfigResult,
) {
): PathsMatcher | null {
if (!tsconfig.config.compilerOptions) {
return null;
}
Expand Down
49 changes: 49 additions & 0 deletions src/resolver/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import fs from 'fs';
import path from 'path';
import { createPathsMatcher } from '../paths-matcher/index';
import type { TsConfigResult } from '../types';
import { resolveBareSpecifier } from './resolve-bare-specifier';
import type { FsAPI } from './types';
import { resolvePathExtension } from './resolve-path-extension';

export function createResolver(
tsconfig?: TsConfigResult,
api: FsAPI = fs,
) {
const preserveSymlinks = tsconfig?.config.compilerOptions?.preserveSymlinks;
const pathsResolver = tsconfig && createPathsMatcher(tsconfig);

// TODO: include Node.js's --preserve-symlinks
const resolveSymlink = (path: string | undefined) => (
(!path || preserveSymlinks)
? path
: api.realpathSync(path)
);

return function resolver(
request: string,
context: string,
conditions?: string[],
): string | undefined {
// Resolve relative specifier
if (request.startsWith('.')) {
request = path.join(context, request);
}

// Absolute specifier
if (request.startsWith('/')) {
return resolveSymlink(resolvePathExtension(request, api));
}

// Resolve bare specifier
return resolveSymlink(
resolveBareSpecifier(
request,
context,
conditions,
pathsResolver,
api,
),
);
};
}
104 changes: 104 additions & 0 deletions src/resolver/resolve-bare-specifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import path from 'path';
import type { PackageJson } from 'type-fest';
import { type PathsMatcher } from '../paths-matcher/index';
import { findUp } from '../utils/find-up';
import { parsePackageName } from '../utils/parse-package-name';
import { readJsonc } from '../utils/read-jsonc';
import type { FsAPI } from './types';
import { resolvePathExtension } from './resolve-path-extension';
import { resolveExports } from 'resolve-pkg-exports';

// Export maps
// https://github.com/microsoft/TypeScript/blob/71e852922888337ef51a0e48416034a94a6c34d9/src/compiler/moduleSpecifiers.ts#L663

// Import maps
//https://github.com/microsoft/TypeScript/blob/71e852922888337ef51a0e48416034a94a6c34d9/src/compiler/moduleNameResolver.ts#L2101

export function resolveBareSpecifier(
request: string,
context: string,
conditions: string[] | undefined,
pathsResolver: PathsMatcher | null | undefined,
api: FsAPI,
) {
// Try tsconfig.paths
if (pathsResolver) {
const possiblePaths = pathsResolver(request);

for (const possiblePath of possiblePaths) {
/**
* If a path resolves to a package,
* would it resolve the export maps?
*
* Also, what if we resolve a package path
* by absolute path? Would it resolve the export map?
* Or does it need to be resolved by bare specifier?
*/
const resolved = resolvePathExtension(possiblePath, api);
if (resolved) {
return resolved;
}
}
}

/*
1. Find all node_module parent directories
2. parse the package to find package directory (eg. dep/a -> dep)
3. check exports map and check path against it
*/
const nodeModuleDirectories = findUp(context, 'node_modules', true, api);
const { packageName, packageSubpath } = parsePackageName(request);

for (const nodeModuleDirectory of nodeModuleDirectories) {
const dependencyPath = path.join(nodeModuleDirectory, packageName);
const packageJsonPath = path.join(dependencyPath, 'package.json');

if (api.existsSync(packageJsonPath)) {
const packageJson = readJsonc<PackageJson>(packageJsonPath, api);

if (packageJson) {
const { exports } = packageJson;
if (exports) {
const resolvedSubpaths = resolveExports(
exports,
packageSubpath,
conditions || [],
);

for (const possibleSubpath of resolvedSubpaths) {
const resolved = resolvePathExtension(
path.join(dependencyPath, possibleSubpath),
api,
);

if (resolved) {
return resolved;
}
}

// If not in export maps, dont allow lookups
continue;
}

if (!packageSubpath && packageJson.main) {
const resolved = resolvePathExtension(
path.join(dependencyPath, packageJson.main),
api,
);
if (resolved) {
return resolved;
}
}
}
}

// Also resolves subpaths if packgae.json#main or #exports doesnt exist
const resolved = resolvePathExtension(
path.join(nodeModuleDirectory, request),
api,
);
if (resolved) {
return resolved;
}
}
}
125 changes: 125 additions & 0 deletions src/resolver/resolve-path-extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import path from 'path';
import type { PackageJson } from 'type-fest';
import { readJsonc } from '../utils/read-jsonc';
import type { FsAPI } from './types';

const stripExtensionPattern = /\.([mc]js|jsx?)$/;

const safeStat = (
api: FsAPI,
request: string,
) => (api.existsSync(request) && api.statSync(request));

function tryExtensions(
request: string,
api: FsAPI,
extensions: string[],
) {
for (const extension of extensions) {
const checkPath = request + extension;
if (api.existsSync(checkPath)) {
return checkPath;
}
}
}

function getPackageEntry(
request: string,
api: FsAPI,
) {
const packageJsonPath = `${request}/package.json`;
if (api.existsSync(packageJsonPath)) {
const packageJson = readJsonc<PackageJson>(packageJsonPath, api);
return packageJson?.main;
}
}

function resolve(
request: string,
api: FsAPI,
extensions: string[],
nextGenExtensions: Record<string, string[]>,
): string | undefined {
let resolved = tryExtensions(request, api, extensions);
if (resolved) {
return resolved;
}

// If it has .js, strip it off and try again from start
const hasExtension = request.match(stripExtensionPattern);
if (hasExtension) {
resolved = tryExtensions(
request.slice(0, hasExtension.index),
api,
(
nextGenExtensions[hasExtension[1] as string]
|| extensions
),
);

if (resolved) {
return resolved;
}
}

const stat = safeStat(api, request);
if (stat && stat.isDirectory()) {
// Check if package.json#main exists
const hasMain = getPackageEntry(request, api);
if (hasMain) {
const mainPath = path.join(request, hasMain);
const mainStat = safeStat(api, mainPath);
if (mainStat && mainStat.isFile()) {
return mainPath;
}

resolved = resolve(mainPath, api, extensions, nextGenExtensions);

if (resolved) {
return resolved;
}
}

// Fallback to index if main path doesnt exist
resolved = tryExtensions(path.join(request, 'index'), api, extensions);

if (resolved) {
return resolved;
}
}
}

export function resolvePathExtension(
request: string,
api: FsAPI,
) {
// Try resolving in TypeScript mode
let resolved = resolve(
request,
api,
['.ts', '.tsx'],
{
mjs: ['.mts'],
cjs: ['.cts'],
},
);

if (resolved) {
return resolved;
}

// Try resolving in JavaScript mode
resolved = resolve(
request,
api,
['.js', '.jsx'],
{
mjs: ['.mjs'],
cjs: ['.cjs'],
},
);

if (resolved) {
return resolved;
}
}
3 changes: 3 additions & 0 deletions src/resolver/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import fs from 'fs';

export type FsAPI = Pick<typeof fs, 'existsSync' | 'readFileSync' | 'statSync' | 'realpathSync'>;
Loading