Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/converter/convertFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
7 changes: 7 additions & 0 deletions src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 7 additions & 0 deletions src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
1 change: 1 addition & 0 deletions src/test/fixtures/monorepo-paths/libs/generated/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const schema = 1;
1 change: 1 addition & 0 deletions src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const lib = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const helper = 1;
11 changes: 11 additions & 0 deletions src/test/fixtures/monorepo-paths/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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
}
}
35 changes: 25 additions & 10 deletions src/util/PathUtil.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
findBestMatch,
getNormalizedPath,
getNormalizedPaths,
hasRelativePath,
isMatchingPath,
removePathAlias,
Expand Down Expand Up @@ -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', () => {
Expand Down
20 changes: 12 additions & 8 deletions src/util/PathUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,17 +37,19 @@ export function findBestMatch(aliasMap: Record<string, string[]>, 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<ModuleInfo, 'pathAlias' | 'quoteSymbol' | 'normalized'>,
paths: Record<string, string[]>
) {
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) {
Expand Down
17 changes: 17 additions & 0 deletions src/util/ProjectUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 13 additions & 12 deletions src/util/replaceModulePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -31,12 +30,12 @@ function createReplacementPath({
hasAttributesClause,
info,
paths,
projectDirectory,
pathsBaseDirectory,
}: {
hasAttributesClause: boolean;
info: ModuleInfo;
paths: Record<string, string[]> | undefined;
projectDirectory: string;
pathsBaseDirectory: string;
}) {
if (hasAttributesClause) {
return null;
Expand All @@ -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;
Expand Down
Loading