path-transformer.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import * as ts from 'typescript';
  2. export interface PathTransformerOptions {
  3. baseUrl: string;
  4. paths: Record<string, string[]>;
  5. }
  6. interface PathMatcher {
  7. pattern: string;
  8. regex: RegExp;
  9. targets: string[];
  10. hasWildcard: boolean;
  11. }
  12. /**
  13. * Creates a TypeScript custom transformer that rewrites import/export paths
  14. * from tsconfig path aliases to their resolved relative paths.
  15. *
  16. * This is necessary for ESM mode where tsconfig-paths.register() doesn't work
  17. * because it only hooks into CommonJS require(), not ESM import().
  18. *
  19. * The transformer assumes that both the importing file and imported file compile
  20. * to the same flat output directory. For complex monorepo setups with nested output
  21. * structures, the `pathAdapter.transformTsConfigPathMappings` callback should be
  22. * used to adjust paths appropriately.
  23. *
  24. * Known limitations:
  25. * - Only the first path target is used when multiple fallbacks are configured
  26. * - `require()` calls via `createRequire` are not transformed
  27. */
  28. export function createPathTransformer(options: PathTransformerOptions): ts.TransformerFactory<ts.SourceFile> {
  29. const { paths } = options;
  30. // Compile the path patterns into matchers
  31. const pathMatchers = Object.entries(paths).map(([pattern, targets]) => {
  32. const hasWildcard = pattern.includes('*');
  33. // Escape special regex chars, then replace * with capture group
  34. const regexStr: string = pattern
  35. .replace(/[.+?^${}()|[\]\\]/g, String.raw`\$&`)
  36. .split('*')
  37. .join('(.*)');
  38. const regex = new RegExp('^' + regexStr + '$');
  39. return { pattern, regex, targets, hasWildcard };
  40. });
  41. return context => {
  42. const visitor: ts.Visitor = node => {
  43. // Handle import declarations: import { X } from 'module';
  44. if (
  45. ts.isImportDeclaration(node) &&
  46. node.moduleSpecifier &&
  47. ts.isStringLiteral(node.moduleSpecifier)
  48. ) {
  49. const resolvedPath = resolvePathAlias(node.moduleSpecifier.text, pathMatchers);
  50. if (resolvedPath) {
  51. return context.factory.updateImportDeclaration(
  52. node,
  53. node.modifiers,
  54. node.importClause,
  55. context.factory.createStringLiteral(resolvedPath),
  56. node.attributes,
  57. );
  58. }
  59. }
  60. // Handle export declarations: export { X } from 'module';
  61. if (
  62. ts.isExportDeclaration(node) &&
  63. node.moduleSpecifier &&
  64. ts.isStringLiteral(node.moduleSpecifier)
  65. ) {
  66. const resolvedPath = resolvePathAlias(node.moduleSpecifier.text, pathMatchers);
  67. if (resolvedPath) {
  68. return context.factory.updateExportDeclaration(
  69. node,
  70. node.modifiers,
  71. node.isTypeOnly,
  72. node.exportClause,
  73. context.factory.createStringLiteral(resolvedPath),
  74. node.attributes,
  75. );
  76. }
  77. }
  78. // Handle dynamic imports: import('module')
  79. if (
  80. ts.isCallExpression(node) &&
  81. node.expression.kind === ts.SyntaxKind.ImportKeyword &&
  82. node.arguments.length > 0 &&
  83. ts.isStringLiteral(node.arguments[0])
  84. ) {
  85. const resolvedPath = resolvePathAlias(node.arguments[0].text, pathMatchers);
  86. if (resolvedPath) {
  87. return context.factory.updateCallExpression(node, node.expression, node.typeArguments, [
  88. context.factory.createStringLiteral(resolvedPath),
  89. ...node.arguments.slice(1),
  90. ]);
  91. }
  92. }
  93. return ts.visitEachChild(node, visitor, context);
  94. };
  95. return sourceFile => ts.visitNode(sourceFile, visitor) as ts.SourceFile;
  96. };
  97. }
  98. /**
  99. * Resolves a path alias to its actual path.
  100. * Returns undefined if the module specifier doesn't match any path alias.
  101. */
  102. function resolvePathAlias(moduleSpecifier: string, pathMatchers: PathMatcher[]): string | undefined {
  103. if (moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')) {
  104. return undefined;
  105. }
  106. for (const { regex, targets, hasWildcard } of pathMatchers) {
  107. const match = regex.exec(moduleSpecifier);
  108. if (match) {
  109. const target = targets[0];
  110. const resolved = hasWildcard && match[1] ? target.split('*').join(match[1]) : target;
  111. return normalizeResolvedPath(resolved);
  112. }
  113. }
  114. return undefined;
  115. }
  116. /**
  117. * Normalizes a resolved path to a relative path with ./ prefix
  118. * and converts TypeScript extensions to JavaScript equivalents.
  119. */
  120. function normalizeResolvedPath(resolved: string): string {
  121. // Normalize to relative path with ./ prefix
  122. let result = resolved.startsWith('./') ? resolved.substring(2) : resolved;
  123. result = `./${result}`;
  124. result = result.split('\\').join('/');
  125. // Convert TypeScript extensions to JavaScript equivalents for ESM
  126. return convertExtension(result);
  127. }
  128. /**
  129. * Converts TypeScript extensions to JavaScript equivalents for ESM.
  130. * .ts -> .js, .tsx -> .js, .mts -> .mjs, .cts -> .cjs
  131. */
  132. function convertExtension(filePath: string): string {
  133. if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
  134. return filePath.replace(/\.tsx?$/, '.js');
  135. }
  136. if (filePath.endsWith('.mts')) {
  137. return filePath.replace(/\.mts$/, '.mjs');
  138. }
  139. if (filePath.endsWith('.cts')) {
  140. return filePath.replace(/\.cts$/, '.cjs');
  141. }
  142. // No extension - assume directory import, add /index.js
  143. if (!/\.\w+$/.test(filePath)) {
  144. return `${filePath}/index.js`;
  145. }
  146. // Files with other extensions (.json, .js, etc.) are left as-is
  147. return filePath;
  148. }