diff --git a/src/converter/convertFile.test.ts b/src/converter/convertFile.test.ts index ec86f39..9100b34 100644 --- a/src/converter/convertFile.test.ts +++ b/src/converter/convertFile.test.ts @@ -31,6 +31,10 @@ describe('convertFile', () => { await testFileConversion('tsconfig-include', 'consumer'); }); + it('resolves path aliases declared in an extended tsconfig of a monorepo', async () => { + await testFileConversion('monorepo-paths/apps/myapp'); + }); + it('converts CJS require statements into ESM imports', async () => { await testFileConversion('require-import'); }); diff --git a/src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts new file mode 100644 index 0000000..fed08ab --- /dev/null +++ b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts @@ -0,0 +1,7 @@ +// Paths come from the root tsconfig.json, which this app's tsconfig.json extends +import {lib} from '@mylib'; +import {helper} from '@mylib/utils/helper.js'; +// Only the second alias target exists +import {schema} from '@generated/schema.js'; + +console.log(lib, helper, schema); diff --git a/src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts new file mode 100644 index 0000000..7e7c91e --- /dev/null +++ b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts @@ -0,0 +1,7 @@ +// Paths come from the root tsconfig.json, which this app's tsconfig.json extends +import {lib} from '@mylib'; +import {helper} from '@mylib/utils/helper'; +// Only the second alias target exists +import {schema} from '@generated/schema'; + +console.log(lib, helper, schema); diff --git a/src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json b/src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/src/test/fixtures/monorepo-paths/libs/generated/schema.ts b/src/test/fixtures/monorepo-paths/libs/generated/schema.ts new file mode 100644 index 0000000..6f6a2a5 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/libs/generated/schema.ts @@ -0,0 +1 @@ +export const schema = 1; diff --git a/src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts b/src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts new file mode 100644 index 0000000..dd55cf5 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts @@ -0,0 +1 @@ +export const lib = 1; diff --git a/src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts b/src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts new file mode 100644 index 0000000..e94ac33 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts @@ -0,0 +1 @@ +export const helper = 1; diff --git a/src/test/fixtures/monorepo-paths/tsconfig.json b/src/test/fixtures/monorepo-paths/tsconfig.json new file mode 100644 index 0000000..381c176 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "Node16", + "paths": { + "@generated/*": ["./libs/missing/*", "./libs/generated/*"], + "@mylib": ["./libs/mylib/src/index.ts"], + "@mylib/*": ["./libs/mylib/src/*"] + }, + "strict": true + } +} diff --git a/src/util/PathUtil.test.ts b/src/util/PathUtil.test.ts index 0b70907..8680419 100644 --- a/src/util/PathUtil.test.ts +++ b/src/util/PathUtil.test.ts @@ -1,6 +1,6 @@ import { findBestMatch, - getNormalizedPath, + getNormalizedPaths, hasRelativePath, isMatchingPath, removePathAlias, @@ -58,27 +58,42 @@ describe('PathUtil', () => { // falsy expect(isMatchingPath('@app/*', './removeSuffix')).toBeFalsy(); expect(isMatchingPath('🐵', './removeSuffix')).toBeFalsy(); + expect(isMatchingPath('@app/*', '@app')).toBeFalsy(); + expect(isMatchingPath('@app/*', '@application/dto')).toBeFalsy(); }); }); - describe('getNormalizedPath', () => { + describe('getNormalizedPaths', () => { const projectDirectory = '/home/bennycode/dev/bennycode/ts-demo-npm-cjs'; - const expectation = `${projectDirectory}/src/helpers/removeSuffix`; - const info = { normalized: '@helpers/removeSuffix', pathAlias: '@helpers/*', quoteSymbol: '"', }; - const paths = { - '@helpers/*': ['./src/helpers/*'], - '~/*': ['./src/*'], - 'helpers/*': ['./src/helpers/*'], - }; + it('resolves a path alias relative to the given directory', () => { + const paths = { + '@helpers/*': ['./src/helpers/*'], + '~/*': ['./src/*'], + 'helpers/*': ['./src/helpers/*'], + }; - expect(getNormalizedPath(projectDirectory, info, paths)).toBe(expectation); + expect(getNormalizedPaths(projectDirectory, info, paths)).toEqual([ + `${projectDirectory}/src/helpers/removeSuffix`, + ]); + }); + + it('returns one path per alias target', () => { + const paths = { + '@helpers/*': ['./src/helpers/*', './generated/helpers/*'], + }; + + expect(getNormalizedPaths(projectDirectory, info, paths)).toEqual([ + `${projectDirectory}/src/helpers/removeSuffix`, + `${projectDirectory}/generated/helpers/removeSuffix`, + ]); + }); }); describe('hasRelativePath', () => { diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index 5abb5bb..598040e 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -6,8 +6,10 @@ import type {ModuleInfo} from '../parser/InfoParser.js'; * @see https://www.typescriptlang.org/docs/handbook/modules/reference.html#wildcard-substitutions */ export function isMatchingPath(pattern: string, path: string) { - const normalized = removeWildCards(pattern); - if (path.startsWith(normalized)) { + // Everything before the wildcard has to match, so "@lib/*" matches "@lib/utils" but not "@lib" or "@library" + const wildcardIndex = pattern.indexOf('*'); + const prefix = wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex); + if (path.startsWith(prefix)) { return pattern.length; } return 0; @@ -35,17 +37,19 @@ export function findBestMatch(aliasMap: Record, path: string) } /*** - * Use this if your path includes a path alias. + * Use this if your path includes a path alias. Returns one path per alias target, in the order TypeScript tries them. + * @param pathsBaseDirectory Directory that the "paths" in tsconfig.json are relative to */ -export function getNormalizedPath( - projectDirectory: string, +export function getNormalizedPaths( + pathsBaseDirectory: string, info: Pick, paths: Record ) { - const pathAliasResolution = `${paths[info.pathAlias]}`; - const normalizedResolution = removeWildCards(pathAliasResolution).replaceAll(info.quoteSymbol, ''); const normalizedFilePath = removePathAlias(info.pathAlias, info.normalized); - return path.join(projectDirectory, normalizedResolution, normalizedFilePath); + return (paths[info.pathAlias] ?? []).map(target => { + const normalizedResolution = removeWildCards(target).replaceAll(info.quoteSymbol, ''); + return path.join(pathsBaseDirectory, normalizedResolution, normalizedFilePath); + }); } export function hasRelativePath(path: string) { diff --git a/src/util/ProjectUtil.ts b/src/util/ProjectUtil.ts index 3100fb7..669585a 100644 --- a/src/util/ProjectUtil.ts +++ b/src/util/ProjectUtil.ts @@ -6,6 +6,23 @@ export const ProjectUtil = { // Note: getCompilerOptions() cannot be cached and has to be used everytime the config is accessed return project.getCompilerOptions().paths; }, + /** + * TypeScript resolves "paths" relative to "baseUrl" or, without "baseUrl", relative to the tsconfig.json that + * declares them. That can be a parent config pulled in through "extends" (i.e. a monorepo root config). + * @see https://github.com/bennycode/ts2esm/issues/127 + */ + getPathsBaseDirectory: (sourceFile: SourceFile): string => { + const options = sourceFile.getProject().getCompilerOptions(); + if (options.baseUrl) { + return options.baseUrl; + } + // "pathsBasePath" is set by TypeScript's config parser but is not part of its public types + if ('pathsBasePath' in options && typeof options.pathsBasePath === 'string') { + return options.pathsBasePath; + } + return path.dirname(ProjectUtil.getTsConfigFilePath(sourceFile)); + }, + getProject: (tsConfigFilePath: string) => { return new Project({ // Limit the scope of source files to those directly listed as opposed to also all diff --git a/src/util/replaceModulePath.ts b/src/util/replaceModulePath.ts index 19f3b48..e46aa3a 100644 --- a/src/util/replaceModulePath.ts +++ b/src/util/replaceModulePath.ts @@ -2,7 +2,7 @@ import {SourceFile, StringLiteral, ts} from 'ts-morph'; import {ModuleInfo, parseInfo} from '../parser/InfoParser.js'; import {ProjectUtil} from './ProjectUtil.js'; import {toImport, toImportAttribute} from '../converter/ImportConverter.js'; -import {getNormalizedPath} from './PathUtil.js'; +import {getNormalizedPaths} from './PathUtil.js'; import path from 'node:path'; import {PathFinder} from './PathFinder.js'; @@ -16,10 +16,9 @@ export function replaceModulePath({ sourceFile: SourceFile; }) { const paths = ProjectUtil.getPaths(sourceFile.getProject()); - const tsConfigFilePath = ProjectUtil.getTsConfigFilePath(sourceFile); - const projectDirectory = ProjectUtil.getRootDirectory(tsConfigFilePath); + const pathsBaseDirectory = ProjectUtil.getPathsBaseDirectory(sourceFile); const info = parseInfo(sourceFile.getFilePath(), stringLiteral, paths); - const replacement = createReplacementPath({hasAttributesClause, info, paths, projectDirectory}); + const replacement = createReplacementPath({hasAttributesClause, info, paths, pathsBaseDirectory}); if (replacement) { stringLiteral.replaceWithText(replacement); return true; @@ -31,12 +30,12 @@ function createReplacementPath({ hasAttributesClause, info, paths, - projectDirectory, + pathsBaseDirectory, }: { hasAttributesClause: boolean; info: ModuleInfo; paths: Record | undefined; - projectDirectory: string; + pathsBaseDirectory: string; }) { if (hasAttributesClause) { return null; @@ -55,13 +54,15 @@ function createReplacementPath({ // If an import does not have a file extension or isn't an extension recognized here and can't be found locally (perhaps // file had . in name), try to find a matching file by traversing through all valid TypeScript source file extensions. - const baseFilePath = comesFromPathAlias - ? getNormalizedPath(projectDirectory, info, paths) - : path.join(info.directory, info.normalized); + const baseFilePaths = comesFromPathAlias + ? getNormalizedPaths(pathsBaseDirectory, info, paths) + : [path.join(info.directory, info.normalized)]; - const foundPath = PathFinder.findPath(baseFilePath, info.extension); - if (foundPath) { - return toImport({...info, extension: foundPath.extension}); + for (const baseFilePath of baseFilePaths) { + const foundPath = PathFinder.findPath(baseFilePath, info.extension); + if (foundPath) { + return toImport({...info, extension: foundPath.extension}); + } } } return null;