Skip to content

Instantly share code, notes, and snippets.

@voltrevo
Created November 11, 2025 13:01
Show Gist options
  • Select an option

  • Save voltrevo/56ec5b07b8b8bd91eff766108eee157f to your computer and use it in GitHub Desktop.

Select an option

Save voltrevo/56ec5b07b8b8bd91eff766108eee157f to your computer and use it in GitHub Desktop.
// 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