ci: verify all anchor aliases are injected in dist
Some checks failed
CI / build-and-anchors (push) Failing after 26s
Some checks failed
CI / build-and-anchors (push) Failing after 26s
This commit is contained in:
@@ -1,63 +1,228 @@
|
||||
import fs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const CWD = process.cwd();
|
||||
const ALIASES_PATH = path.join(CWD, "src", "anchors", "anchor-aliases.json");
|
||||
const DIST_DIR = path.join(CWD, "dist");
|
||||
|
||||
if (!fs.existsSync(ALIASES_PATH)) {
|
||||
console.log("ℹ️ Pas d'aliases => skip verify.");
|
||||
process.exit(0);
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
if (!fs.existsSync(DIST_DIR)) {
|
||||
console.error("❌ dist/ introuvable. Lance d'abord le build.");
|
||||
|
||||
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)}"`, "g");
|
||||
let c = 0;
|
||||
while (re.exec(html)) c++;
|
||||
return c;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const aliases = JSON.parse(fs.readFileSync(ALIASES_PATH, "utf8"));
|
||||
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;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
for (const [route, mapping] of Object.entries(aliases)) {
|
||||
const htmlPath = path.join(DIST_DIR, route.replace(/^\//, ""), "index.html");
|
||||
if (!fs.existsSync(htmlPath)) {
|
||||
console.error(`❌ HTML introuvable pour route=${route} => ${htmlPath}`);
|
||||
failed++;
|
||||
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;
|
||||
}
|
||||
const html = fs.readFileSync(htmlPath, "utf8");
|
||||
|
||||
for (const [oldId, newId] of Object.entries(mapping)) {
|
||||
aliasesCount++;
|
||||
checked++;
|
||||
|
||||
const oldNeedle = `id="${oldId}"`;
|
||||
const newNeedle = `id="${newId}"`;
|
||||
|
||||
const oldPos = html.indexOf(oldNeedle);
|
||||
const newPos = html.indexOf(newNeedle);
|
||||
|
||||
if (newPos === -1) {
|
||||
console.error(`❌ newId absent: route=${route} newId=${newId}`);
|
||||
failed++;
|
||||
if (typeof oldId !== "string" || typeof newId !== "string") {
|
||||
failures.push({ route, oldId, newId, htmlPath, msg: "oldId/newId must be strings." });
|
||||
continue;
|
||||
}
|
||||
if (oldPos === -1) {
|
||||
console.error(`❌ alias oldId non injecté: route=${route} oldId=${oldId} (newId=${newId})`);
|
||||
failed++;
|
||||
|
||||
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 (oldPos > newPos) {
|
||||
console.error(`❌ alias placé APRÈS newId: route=${route} oldId=${oldId} newId=${newId}`);
|
||||
failed++;
|
||||
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 reAliasSpan = new RegExp(
|
||||
`<span[^>]*\\bid="${escapeRegExp(oldId)}"[^>]*\\bclass="[^"]*\\bpara-alias\\b[^"]*"[^>]*>\\s*<\\/span>`,
|
||||
"i"
|
||||
);
|
||||
if (!reAliasSpan.test(html)) {
|
||||
failures.push({
|
||||
route,
|
||||
oldId,
|
||||
newId,
|
||||
htmlPath,
|
||||
msg: `Injected alias span exists but does not match expected contract (missing class="...para-alias...").`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Adjacency: alias span immediately before the element carrying newId
|
||||
const reAdjacent = new RegExp(
|
||||
`<span[^>]*\\bid="${escapeRegExp(oldId)}"[^>]*\\bclass="[^"]*\\bpara-alias\\b[^"]*"[^>]*>\\s*<\\/span>\\s*<[^>]*\\bid="${escapeRegExp(
|
||||
newId
|
||||
)}"`,
|
||||
"is"
|
||||
);
|
||||
|
||||
if (!reAdjacent.test(html)) {
|
||||
const oldIdx = html.indexOf(`id="${oldId}"`);
|
||||
const newIdx = html.indexOf(`id="${newId}"`);
|
||||
failures.push({
|
||||
route,
|
||||
oldId,
|
||||
newId,
|
||||
htmlPath,
|
||||
msg:
|
||||
`oldId & newId are present, but alias is NOT immediately 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 (failed) {
|
||||
console.error(`❌ verify-anchor-aliases-in-dist: failed=${failed} checked=${checked}`);
|
||||
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: checked=${checked} (all good)`);
|
||||
console.log(`✅ verify-anchor-aliases-in-dist OK: pages=${pages} aliases=${aliasesCount} strict=${strict}`);
|
||||
|
||||
Reference in New Issue
Block a user