Files
Nora 0ac4beaaa8 fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
2026-06-25 23:49:37 -06:00

113 lines
3.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Replace <a href="/internal"> with <Link href="/internal"> in JSX.
* Skips <a> 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) + `<Link href="${m.href}"` + out.slice(m.end);
}
// Replace any closing </a> with </Link> 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, "</Link>");
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 <file>...");
process.exit(2);
}
let n = 0;
for (const f of files) {
n += processFile(f);
}
console.log(`done — ${n} files patched`);