262 lines
7.8 KiB
JavaScript
262 lines
7.8 KiB
JavaScript
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 <span class="para-alias">...</span> contigus juste avant la cible
|
|
let s = beforeTargetHtml;
|
|
const spans = [];
|
|
while (true) {
|
|
s = s.replace(/\s+$/g, "");
|
|
const m = s.match(/<span\b[^>]*\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 (<span class="para-alias">).</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}`);
|