import fs from "node:fs/promises"; import path from "node:path"; function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function routeToHtmlPath(distDir, route) { if (typeof route !== "string") throw new Error(`Route must be a string, got ${typeof route}`); // Normalise: route must be like "/a/b/" or "/" let r = route.trim(); if (!r.startsWith("/")) r = "/" + r; if (r !== "/" && !r.endsWith("/")) r = r + "/"; const segments = r.split("/").filter(Boolean); // removes empty if (segments.length === 0) return path.join(distDir, "index.html"); return path.join(distDir, ...segments, "index.html"); } function countIdAttr(html, id) { const re = new RegExp(`\\bid=(["'])${escapeRegExp(id)}\\1`, "gi"); let c = 0; while (re.exec(html)) c++; return c; } function findStartTagWithId(html, id) { const re = new RegExp( `<([a-zA-Z0-9:-]+)\\b[^>]*\\bid=(["'])${escapeRegExp(id)}\\2[^>]*>`, "i" ); const m = re.exec(html); if (!m) return null; return { tagName: String(m[1]).toLowerCase(), tag: m[0], index: m.index ?? -1 }; } function isAliasSpanTag(tagName, tagHtml) { if (tagName !== "span") return false; return /\bclass=(["'])(?:(?!\1).)*\bpara-alias\b(?:(?!\1).)*\1/i.test(tagHtml); } function extractTrailingAliasBlock(beforeTargetHtml) { // Récupère les ... contigus juste avant la cible let s = beforeTargetHtml; const spans = []; while (true) { s = s.replace(/\s+$/g, ""); const m = s.match(/]*\bclass=(["'])(?:(?!\1).)*\bpara-alias\b(?:(?!\1).)*\1[^>]*>\s*<\/span>$/i); if (!m) break; spans.push(m[0]); s = s.slice(0, s.length - m[0].length); } return spans.reverse(); } function snippetAround(html, idx, beforeLines = 2, afterLines = 4) { const lines = html.split("\n"); // compute line number const upto = html.slice(0, Math.max(0, idx)); const lineNo = upto.split("\n").length; // 1-based const start = Math.max(1, lineNo - beforeLines); const end = Math.min(lines.length, lineNo + afterLines); const out = []; for (let i = start; i <= end; i++) { out.push(`${String(i).padStart(5, " ")}| ${lines[i - 1]}`); } return out.join("\n"); } function parseArgs(argv) { const args = { dist: "dist", aliases: path.join("src", "anchors", "anchor-aliases.json"), strict: true, }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--dist" && argv[i + 1]) args.dist = argv[++i]; else if (a === "--aliases" && argv[i + 1]) args.aliases = argv[++i]; else if (a === "--non-strict") args.strict = false; else if (a === "-h" || a === "--help") { console.log(`Usage: node scripts/verify-anchor-aliases-in-dist.mjs [--dist dist] [--aliases src/anchors/anchor-aliases.json] [--non-strict] Checks that every (route, oldId->newId) alias is injected into the built HTML in dist.`); process.exit(0); } else { console.error("Unknown arg:", a); process.exit(2); } } return args; } const { dist, aliases, strict } = parseArgs(process.argv); const CWD = process.cwd(); const distDir = path.isAbsolute(dist) ? dist : path.join(CWD, dist); const aliasesPath = path.isAbsolute(aliases) ? aliases : path.join(CWD, aliases); let data; try { data = JSON.parse(await fs.readFile(aliasesPath, "utf8")); } catch (e) { console.error(`❌ Cannot read/parse aliases JSON: ${aliasesPath}`); console.error(e?.message || e); process.exit(1); } if (!data || typeof data !== "object" || Array.isArray(data)) { console.error("❌ anchor-aliases.json must be an object of { route: { oldId: newId } }"); process.exit(1); } let pages = 0; let aliasesCount = 0; let checked = 0; const failures = []; for (const [route, mapping] of Object.entries(data)) { pages++; if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) { failures.push({ route, msg: "Mapping must be an object oldId->newId." }); continue; } const htmlPath = routeToHtmlPath(distDir, route); let html; try { html = await fs.readFile(htmlPath, "utf8"); } catch (e) { failures.push({ route, msg: `Missing built page: ${htmlPath}. Did you run 'npm run build'?`, }); continue; } for (const [oldId, newId] of Object.entries(mapping)) { aliasesCount++; checked++; if (typeof oldId !== "string" || typeof newId !== "string") { failures.push({ route, oldId, newId, htmlPath, msg: "oldId/newId must be strings." }); continue; } const oldCount = countIdAttr(html, oldId); const newCount = countIdAttr(html, newId); if (oldCount === 0) { failures.push({ route, oldId, newId, htmlPath, msg: `oldId not found in HTML (expected injected alias span).`, }); continue; } if (newCount === 0) { failures.push({ route, oldId, newId, htmlPath, msg: `newId not found in HTML (target missing).`, }); continue; } // Strictness: ensure uniqueness if (strict && oldCount !== 1) { failures.push({ route, oldId, newId, htmlPath, msg: `oldId occurs ${oldCount} times (expected exactly 1).`, }); continue; } if (strict && newCount !== 1) { failures.push({ route, oldId, newId, htmlPath, msg: `newId occurs ${newCount} times (expected exactly 1).`, }); continue; } // Require para-alias class on the injected span (contract) const oldStart = findStartTagWithId(html, oldId); if (!oldStart || !isAliasSpanTag(oldStart.tagName, oldStart.tag)) { const seen = oldStart ? oldStart.tag.slice(0, 140) : "(not found)"; failures.push({ route, oldId, newId, htmlPath, msg: `oldId present but is NOT an injected alias span ().\n` + `Saw: ${seen}`, }); continue; } // Adjacency (robuste): oldId doit être dans le "bloc d'alias" contigu juste avant la cible newId. const newStart = findStartTagWithId(html, newId); if (!newStart || newStart.index < 0) { failures.push({ route, oldId, newId, htmlPath, msg: `newId tag not found (unexpected).` }); continue; } const before = html.slice(0, newStart.index); const block = extractTrailingAliasBlock(before); const reOld = new RegExp(`\\bid=(["'])${escapeRegExp(oldId)}\\1`, "i"); const ok = block.some((span) => reOld.test(span)); if (!ok) { const oldIdx = html.search(new RegExp(`\\bid=(["'])${escapeRegExp(oldId)}\\1`, "i")); const newIdx = html.search(new RegExp(`\\bid=(["'])${escapeRegExp(newId)}\\1`, "i")); failures.push({ route, oldId, newId, htmlPath, msg: `oldId & newId are present, but oldId is NOT in the contiguous alias block right before target.\n` + `--- Context around oldId (line approx) ---\n${snippetAround(html, oldIdx)}\n\n` + `--- Context around newId (line approx) ---\n${snippetAround(html, newIdx)}\n`, }); continue; } } } if (failures.length) { console.error(`❌ Alias injection verification FAILED.`); console.error(`Checked: pages=${pages}, aliases=${aliasesCount}, verified_pairs=${checked}, strict=${strict}`); console.error(""); for (const f of failures) { console.error("------------------------------------------------------------"); console.error(`Route: ${f.route}`); if (f.htmlPath) console.error(`HTML: ${f.htmlPath}`); if (f.oldId) console.error(`oldId: ${f.oldId}`); if (f.newId) console.error(`newId: ${f.newId}`); console.error(`Reason: ${f.msg}`); } process.exit(1); } console.log(`✅ verify-anchor-aliases-in-dist OK: pages=${pages} aliases=${aliasesCount} strict=${strict}`);