Created
November 11, 2025 13:01
-
-
Save voltrevo/56ec5b07b8b8bd91eff766108eee157f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // rewrite-path-aliases.ts | |
| // Usage: | |
| // npx tsx rewrite-path-aliases.ts # dry run | |
| // npx tsx rewrite-path-aliases.ts --write # apply edits | |
| // Options: | |
| // --extensions keep # keep file extensions in imports (default: strip) | |
| // --root <dir> # project root (defaults to nearest tsconfig.json) | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import * as ts from "typescript"; | |
| type Alias = { | |
| pattern: string; // e.g. "@app/*" | |
| prefix: string; // e.g. "@app/" | |
| targets: string[]; // e.g. ["src/app/*"] | |
| hasStar: boolean; | |
| }; | |
| type SpecEdit = { | |
| file: string; | |
| from: string; | |
| to: string; | |
| start: number; | |
| end: number; | |
| }; | |
| const argv = new Map<string, string | true>(); | |
| for (let i = 2; i < process.argv.length; i++) { | |
| const a = process.argv[i]; | |
| if (a.startsWith("--")) { | |
| const [k, v] = a.includes("=") ? a.slice(2).split("=", 2) : [a.slice(2), undefined]; | |
| argv.set(k, v ?? true); | |
| } | |
| } | |
| const WRITE = argv.has("write"); | |
| const KEEP_EXT = argv.get("extensions") === "keep"; | |
| const USER_ROOT = typeof argv.get("root") === "string" ? (argv.get("root") as string) : undefined; | |
| function findNearestTsconfig(startDir: string): string | null { | |
| let dir = startDir; | |
| while (true) { | |
| const candidate = path.join(dir, "tsconfig.json"); | |
| if (fs.existsSync(candidate)) return candidate; | |
| const parent = path.dirname(dir); | |
| if (parent === dir) return null; | |
| dir = parent; | |
| } | |
| } | |
| const cwd = process.cwd(); | |
| const tsconfigPath = USER_ROOT | |
| ? path.join(USER_ROOT, "tsconfig.json") | |
| : findNearestTsconfig(cwd); | |
| if (!tsconfigPath || !fs.existsSync(tsconfigPath)) { | |
| console.error("❌ Could not find tsconfig.json. Use --root <dir> if needed."); | |
| process.exit(1); | |
| } | |
| const projectRoot = path.dirname(tsconfigPath); | |
| const raw = ts.readConfigFile(tsconfigPath, ts.sys.readFile); | |
| if (raw.error) { | |
| console.error("❌ Failed to read tsconfig.json:", raw.error.messageText); | |
| process.exit(1); | |
| } | |
| const parsed = ts.parseJsonConfigFileContent( | |
| raw.config, | |
| ts.sys, | |
| projectRoot, | |
| undefined, | |
| tsconfigPath | |
| ); | |
| const compilerOptions = parsed.options; | |
| const baseUrl = compilerOptions.baseUrl | |
| ? path.resolve(projectRoot, compilerOptions.baseUrl) | |
| : projectRoot; | |
| const paths = compilerOptions.paths ?? {}; | |
| const aliases: Alias[] = Object.entries(paths).map(([pattern, targets]) => { | |
| const hasStar = pattern.includes("*"); | |
| const prefix = hasStar ? pattern.slice(0, pattern.indexOf("*")) : pattern; | |
| return { | |
| pattern, | |
| prefix: hasStar && !pattern.endsWith("/") ? prefix : prefix, // keep as-is | |
| targets: targets.map((t) => t as string), | |
| hasStar, | |
| }; | |
| }); | |
| // Sort longer prefixes first to prefer the most specific alias | |
| aliases.sort((a, b) => b.prefix.length - a.prefix.length); | |
| if (aliases.length === 0) { | |
| console.log("No path aliases found in tsconfig.json. Nothing to do."); | |
| process.exit(0); | |
| } | |
| // Build the file list from tsconfig (respects include/exclude) | |
| const sourceFiles = parsed.fileNames.filter((f) => { | |
| // exclude declarations and things in node_modules / outDir | |
| if (f.endsWith(".d.ts")) return false; | |
| if (f.includes(`${path.sep}node_modules${path.sep}`)) return false; | |
| return true; | |
| }); | |
| type ModuleRef = { | |
| node: ts.StringLiteralLike; | |
| kind: "import" | "export" | "dynamic-import" | "require"; | |
| }; | |
| function gatherModuleSpecifiers(sf: ts.SourceFile): ModuleRef[] { | |
| const refs: ModuleRef[] = []; | |
| const visit = (node: ts.Node) => { | |
| if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { | |
| refs.push({ node: node.moduleSpecifier, kind: "import" }); | |
| } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { | |
| refs.push({ node: node.moduleSpecifier, kind: "export" }); | |
| } else if ( | |
| ts.isCallExpression(node) && | |
| node.expression.kind === ts.SyntaxKind.ImportKeyword && | |
| node.arguments.length === 1 && | |
| ts.isStringLiteralLike(node.arguments[0]) | |
| ) { | |
| refs.push({ node: node.arguments[0], kind: "dynamic-import" }); | |
| } else if ( | |
| ts.isCallExpression(node) && | |
| ts.isIdentifier(node.expression) && | |
| node.expression.text === "require" && | |
| node.arguments.length === 1 && | |
| ts.isStringLiteralLike(node.arguments[0]) | |
| ) { | |
| refs.push({ node: node.arguments[0], kind: "require" }); | |
| } | |
| ts.forEachChild(node, visit); | |
| }; | |
| visit(sf); | |
| return refs; | |
| } | |
| function stripKnownExt(p: string): string { | |
| if (KEEP_EXT) return p; | |
| // remove .ts/.tsx/.mts/.cts/.js/.jsx when present | |
| const exts = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]; | |
| const ext = exts.find((e) => p.endsWith(e)); | |
| if (!ext) return p; | |
| return p.slice(0, -ext.length); | |
| } | |
| // Try to resolve a mapped specifier to an absolute filesystem path using the alias targets. | |
| function resolveAliasTarget(spec: string, fileDir: string): string | null { | |
| for (const a of aliases) { | |
| if (!spec.startsWith(a.prefix)) continue; | |
| const rest = spec.slice(a.prefix.length); | |
| for (const target of a.targets) { | |
| // Replace "*" in target (if any) | |
| let relTarget = target; | |
| if (a.hasStar) { | |
| if (!target.includes("*")) continue; // mismatch | |
| relTarget = target.replace("*", rest); | |
| } else { | |
| if (rest.length > 0) continue; // exact-only mapping | |
| } | |
| // The mapping is from baseUrl | |
| // Strip known extensions from relTarget before resolving, since we'll try different extensions | |
| let relTargetNoExt = relTarget; | |
| const knownExts = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]; | |
| for (const ext of knownExts) { | |
| if (relTarget.endsWith(ext)) { | |
| relTargetNoExt = relTarget.slice(0, -ext.length); | |
| break; | |
| } | |
| } | |
| const absCandidateNoExt = path.resolve(baseUrl, relTargetNoExt); | |
| // Try resolutions in TS module fashion (file, index, with extensions) | |
| const tried: string[] = []; | |
| const candidates: string[] = [ | |
| absCandidateNoExt, | |
| absCandidateNoExt + ".ts", | |
| absCandidateNoExt + ".tsx", | |
| absCandidateNoExt + ".mts", | |
| absCandidateNoExt + ".cts", | |
| absCandidateNoExt + ".js", | |
| absCandidateNoExt + ".jsx", | |
| absCandidateNoExt + ".mjs", | |
| absCandidateNoExt + ".cjs", | |
| path.join(absCandidateNoExt, "index.ts"), | |
| path.join(absCandidateNoExt, "index.tsx"), | |
| path.join(absCandidateNoExt, "index.mts"), | |
| path.join(absCandidateNoExt, "index.cts"), | |
| path.join(absCandidateNoExt, "index.js"), | |
| path.join(absCandidateNoExt, "index.jsx"), | |
| path.join(absCandidateNoExt, "index.mjs"), | |
| path.join(absCandidateNoExt, "index.cjs"), | |
| ]; | |
| for (const c of candidates) { | |
| tried.push(c); | |
| if (fs.existsSync(c) && fs.statSync(c).isFile()) { | |
| return c; | |
| } | |
| } | |
| // If nothing matched, keep looking at next target | |
| } | |
| } | |
| return null; | |
| } | |
| function toRelativeImport(fromFile: string, targetAbsFile: string): string { | |
| const fromDir = path.dirname(fromFile); | |
| let rel = path.relative(fromDir, targetAbsFile); | |
| // normalize to POSIX separators for import specifiers | |
| rel = rel.split(path.sep).join("/"); | |
| // drop extension if configured | |
| rel = stripKnownExt(rel); | |
| if (!rel.startsWith(".")) rel = "./" + rel; | |
| return rel; | |
| } | |
| const edits: SpecEdit[] = []; | |
| for (const file of sourceFiles) { | |
| const text = fs.readFileSync(file, "utf8"); | |
| const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); | |
| const refs = gatherModuleSpecifiers(sf); | |
| for (const ref of refs) { | |
| const rawSpec = ref.node.getText(sf); | |
| // rawSpec includes quotes; get the actual string | |
| const spec = (ref.node as ts.StringLiteralLike).text; | |
| // Only rewrite imports that start with one of our alias prefixes | |
| if (!aliases.some((a) => spec.startsWith(a.prefix))) continue; | |
| const targetAbs = resolveAliasTarget(spec, path.dirname(file)); | |
| if (!targetAbs) continue; // unresolved; skip | |
| const newSpec = toRelativeImport(file, targetAbs); | |
| if (newSpec === spec) continue; | |
| edits.push({ | |
| file, | |
| from: spec, | |
| to: newSpec, | |
| start: ref.node.getStart(sf) + 1, // inside the quotes | |
| end: ref.node.getEnd() - 1, | |
| }); | |
| } | |
| } | |
| // Group edits per file and apply | |
| const byFile = new Map<string, SpecEdit[]>(); | |
| for (const e of edits) { | |
| if (!byFile.has(e.file)) byFile.set(e.file, []); | |
| byFile.get(e.file)!.push(e); | |
| } | |
| let changedFiles = 0; | |
| let changedImports = 0; | |
| for (const [file, fes] of byFile) { | |
| const original = fs.readFileSync(file, "utf8"); | |
| // Apply from end to start to keep offsets valid | |
| const sorted = fes.sort((a, b) => b.start - a.start); | |
| let out = original; | |
| for (const e of sorted) { | |
| out = out.slice(0, e.start) + e.to + out.slice(e.end); | |
| } | |
| if (original !== out) { | |
| changedFiles++; | |
| changedImports += fes.length; | |
| if (WRITE) { | |
| fs.writeFileSync(file, out, "utf8"); | |
| } | |
| } | |
| } | |
| if (edits.length === 0) { | |
| console.log("✅ No alias-based imports found that need rewriting."); | |
| process.exit(0); | |
| } | |
| console.log( | |
| `${WRITE ? "✍️ Rewrote" : "🔎 Would rewrite"} ${changedImports} import(s) across ${changedFiles} file(s).` | |
| ); | |
| // Pretty print a summary | |
| for (const [file, fes] of byFile) { | |
| console.log(`\n${file}`); | |
| for (const e of fes) { | |
| console.log(` ${e.from} → ${e.to}`); | |
| } | |
| } | |
| if (!WRITE) { | |
| console.log('\nDry run complete. Re-run with --write to apply changes.'); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment