diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b18bccd..89832f6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -90,6 +90,11 @@ jobs: git checkout -q FETCH_HEAD git log -1 --oneline + + - name: Anchor aliases schema + run: node scripts/check-anchor-aliases.mjs + - name: Verify anchor aliases injected in dist + run: node scripts/verify-anchor-aliases-in-dist.mjs - name: NPM harden run: | @@ -109,9 +114,10 @@ jobs: - name: Build run: npm run build + - name: Verify anchor aliases injected + run: node scripts/verify-anchor-aliases-in-dist.mjs + - name: Anchors contract run: npm run test:anchors - - - name: Anchor aliases schema - run: node scripts/check-anchor-aliases.mjs + diff --git a/scripts/verify-anchor-aliases-in-dist.mjs b/scripts/verify-anchor-aliases-in-dist.mjs index 7960820..dec9d95 100644 --- a/scripts/verify-anchor-aliases-in-dist.mjs +++ b/scripts/verify-anchor-aliases-in-dist.mjs @@ -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( + `]*\\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( + `]*\\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}`);