#!/usr/bin/env node /** * Replace with in JSX. * Skips tags that already opt out of navigation (target, download, * rel, mailto, tel, external, hash-only) and any non-string-literal * href expressions. * * Idempotent: re-running on an already-converted file is a no-op. * * Mirrors the react-doctor rule * `react-doctor/nextjs-no-a-element`. */ const fs = require("node:fs"); const path = require("node:path"); const ts = require("typescript"); function findInternalAnchorOpeningElements(src) { const sf = ts.createSourceFile("x.tsx", src, ts.ScriptTarget.Latest, true); /** @type {Array<{pos:number,end:number,href:string}>} */ const matches = []; function visit(node) { if ( ts.isJsxOpeningElement(node) && ts.isIdentifier(node.tagName) && node.tagName.text === "a" ) { let hrefValue = null; let isInternalLiteral = false; let isOptOut = false; for (const attr of node.attributes.properties) { if (!ts.isJsxAttribute(attr) || !attr.name) continue; const name = ts.isIdentifier(attr.name) ? attr.name.text : attr.name.text; if (name === "href" && attr.initializer && ts.isStringLiteral(attr.initializer)) { hrefValue = attr.initializer.text; if (hrefValue.startsWith("/")) { isInternalLiteral = true; } } if (name === "target" || name === "download" || name === "rel" || name === "onClick") { isOptOut = true; } } if (isInternalLiteral && !isOptOut) { matches.push({ pos: node.getStart(sf), end: node.getEnd(), href: hrefValue, }); } } ts.forEachChild(node, visit); } visit(sf); return matches; } function ensureLinkImport(src) { if (/from\s+["']next\/link["']/.test(src)) return src; // Insert after the last existing import line. const sf = ts.createSourceFile("x.tsx", src, ts.ScriptTarget.Latest, true); let lastImportEnd = 0; for (const stmt of sf.statements) { if (ts.isImportDeclaration(stmt)) { lastImportEnd = Math.max(lastImportEnd, stmt.getEnd()); } } if (lastImportEnd === 0) { return `import Link from "next/link";\n${src}`; } return `${src.slice(0, lastImportEnd)}\nimport Link from "next/link";${src.slice(lastImportEnd)}`; } function processFile(filePath) { const abs = path.resolve(filePath); if (!fs.existsSync(abs)) { console.error(`skip (not found): ${abs}`); return 0; } const original = fs.readFileSync(abs, "utf8"); const matches = findInternalAnchorOpeningElements(original); if (matches.length === 0) return 0; let out = original; // Apply edits in reverse so positions don't shift. matches.sort((a, b) => b.pos - a.pos); for (const m of matches) { out = out.slice(0, m.pos) + ` with in the same file. Safer // to do this as a single global swap after the opening-tag edits // since the file is the scope. out = out.replace(/<\/a>/g, ""); out = ensureLinkImport(out); if (out !== original) { fs.writeFileSync(abs, out, "utf8"); console.log(`patched (${matches.length} anchors): ${abs}`); return 1; } return 0; } const files = process.argv.slice(2); if (files.length === 0) { console.error("usage: node scripts/fix-next-anchor.js ..."); process.exit(2); } let n = 0; for (const f of files) { n += processFile(f); } console.log(`done — ${n} files patched`);