| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- import * as ts from 'typescript';
- export interface PathTransformerOptions {
- baseUrl: string;
- paths: Record<string, string[]>;
- }
- interface PathMatcher {
- pattern: string;
- regex: RegExp;
- targets: string[];
- hasWildcard: boolean;
- }
- /**
- * Creates a TypeScript custom transformer that rewrites import/export paths
- * from tsconfig path aliases to their resolved relative paths.
- *
- * This is necessary for ESM mode where tsconfig-paths.register() doesn't work
- * because it only hooks into CommonJS require(), not ESM import().
- *
- * The transformer assumes that both the importing file and imported file compile
- * to the same flat output directory. For complex monorepo setups with nested output
- * structures, the `pathAdapter.transformTsConfigPathMappings` callback should be
- * used to adjust paths appropriately.
- *
- * Known limitations:
- * - Only the first path target is used when multiple fallbacks are configured
- * - `require()` calls via `createRequire` are not transformed
- */
- export function createPathTransformer(options: PathTransformerOptions): ts.TransformerFactory<ts.SourceFile> {
- const { paths } = options;
- // Compile the path patterns into matchers
- const pathMatchers = Object.entries(paths).map(([pattern, targets]) => {
- const hasWildcard = pattern.includes('*');
- // Escape special regex chars, then replace * with capture group
- const regexStr: string = pattern
- .replace(/[.+?^${}()|[\]\\]/g, String.raw`\$&`)
- .split('*')
- .join('(.*)');
- const regex = new RegExp('^' + regexStr + '$');
- return { pattern, regex, targets, hasWildcard };
- });
- return context => {
- const visitor: ts.Visitor = node => {
- // Handle import declarations: import { X } from 'module';
- if (
- ts.isImportDeclaration(node) &&
- node.moduleSpecifier &&
- ts.isStringLiteral(node.moduleSpecifier)
- ) {
- const resolvedPath = resolvePathAlias(node.moduleSpecifier.text, pathMatchers);
- if (resolvedPath) {
- return context.factory.updateImportDeclaration(
- node,
- node.modifiers,
- node.importClause,
- context.factory.createStringLiteral(resolvedPath),
- node.attributes,
- );
- }
- }
- // Handle export declarations: export { X } from 'module';
- if (
- ts.isExportDeclaration(node) &&
- node.moduleSpecifier &&
- ts.isStringLiteral(node.moduleSpecifier)
- ) {
- const resolvedPath = resolvePathAlias(node.moduleSpecifier.text, pathMatchers);
- if (resolvedPath) {
- return context.factory.updateExportDeclaration(
- node,
- node.modifiers,
- node.isTypeOnly,
- node.exportClause,
- context.factory.createStringLiteral(resolvedPath),
- node.attributes,
- );
- }
- }
- // Handle dynamic imports: import('module')
- if (
- ts.isCallExpression(node) &&
- node.expression.kind === ts.SyntaxKind.ImportKeyword &&
- node.arguments.length > 0 &&
- ts.isStringLiteral(node.arguments[0])
- ) {
- const resolvedPath = resolvePathAlias(node.arguments[0].text, pathMatchers);
- if (resolvedPath) {
- return context.factory.updateCallExpression(node, node.expression, node.typeArguments, [
- context.factory.createStringLiteral(resolvedPath),
- ...node.arguments.slice(1),
- ]);
- }
- }
- return ts.visitEachChild(node, visitor, context);
- };
- return sourceFile => ts.visitNode(sourceFile, visitor) as ts.SourceFile;
- };
- }
- /**
- * Resolves a path alias to its actual path.
- * Returns undefined if the module specifier doesn't match any path alias.
- */
- function resolvePathAlias(moduleSpecifier: string, pathMatchers: PathMatcher[]): string | undefined {
- if (moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')) {
- return undefined;
- }
- for (const { regex, targets, hasWildcard } of pathMatchers) {
- const match = regex.exec(moduleSpecifier);
- if (match) {
- const target = targets[0];
- const resolved = hasWildcard && match[1] ? target.split('*').join(match[1]) : target;
- return normalizeResolvedPath(resolved);
- }
- }
- return undefined;
- }
- /**
- * Normalizes a resolved path to a relative path with ./ prefix
- * and converts TypeScript extensions to JavaScript equivalents.
- */
- function normalizeResolvedPath(resolved: string): string {
- // Normalize to relative path with ./ prefix
- let result = resolved.startsWith('./') ? resolved.substring(2) : resolved;
- result = `./${result}`;
- result = result.split('\\').join('/');
- // Convert TypeScript extensions to JavaScript equivalents for ESM
- return convertExtension(result);
- }
- /**
- * Converts TypeScript extensions to JavaScript equivalents for ESM.
- * .ts -> .js, .tsx -> .js, .mts -> .mjs, .cts -> .cjs
- */
- function convertExtension(filePath: string): string {
- if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
- return filePath.replace(/\.tsx?$/, '.js');
- }
- if (filePath.endsWith('.mts')) {
- return filePath.replace(/\.mts$/, '.mjs');
- }
- if (filePath.endsWith('.cts')) {
- return filePath.replace(/\.cts$/, '.cjs');
- }
- // No extension - assume directory import, add /index.js
- if (!/\.\w+$/.test(filePath)) {
- return `${filePath}/index.js`;
- }
- // Files with other extensions (.json, .js, etc.) are left as-is
- return filePath;
- }
|