// scripts/check-annotations.mjs import fs from "node:fs/promises"; import path from "node:path"; import YAML from "yaml"; const CWD = process.cwd(); const ANNO_DIR = path.join(CWD, "src", "annotations"); const DIST_DIR = path.join(CWD, "dist"); const ALIASES_PATH = path.join(CWD, "src", "anchors", "anchor-aliases.json"); async function exists(p) { try { await fs.access(p); return true; } catch { return false; } } async function walk(dir) { const out = []; const ents = await fs.readdir(dir, { withFileTypes: true }); for (const e of ents) { const p = path.join(dir, e.name); if (e.isDirectory()) out.push(...(await walk(p))); else out.push(p); } return out; } function escRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function normalizePageKey(s) { return String(s || "").replace(/^\/+/, "").replace(/\/+$/, ""); } function isPlainObject(x) { return !!x && typeof x === "object" && !Array.isArray(x); } function isParaId(s) { return /^p-\d+-/i.test(String(s || "")); } /** * Supporte: * - monolith: src/annotations/.yml -> pageKey = rel sans ext * - shard : src/annotations//.yml -> pageKey = dirname(rel), paraId = basename * * shard seulement si le fichier est dans un sous-dossier (anti cas pathologique). */ function inferFromFile(fileAbs) { const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/"); const relNoExt = rel.replace(/\.(ya?ml|json)$/i, ""); const parts = relNoExt.split("/").filter(Boolean); const base = parts[parts.length - 1] || ""; const dirParts = parts.slice(0, -1); const isShard = dirParts.length > 0 && isParaId(base); const pageKey = isShard ? dirParts.join("/") : relNoExt; const paraId = isShard ? base : ""; return { pageKey: normalizePageKey(pageKey), paraId }; } async function loadAliases() { if (!(await exists(ALIASES_PATH))) return {}; try { const raw = await fs.readFile(ALIASES_PATH, "utf8"); const json = JSON.parse(raw); return isPlainObject(json) ? json : {}; } catch { return {}; } } function parseDoc(raw, fileAbs) { if (/\.json$/i.test(fileAbs)) return JSON.parse(raw); return YAML.parse(raw); } function getAlias(aliases, pageKey, oldId) { // supporte: // 1) { "": { "": "" } } // 2) { "": "" } const k1 = String(pageKey || ""); const k2 = k1 ? ("/" + k1.replace(/^\/+|\/+$/g, "") + "/") : ""; const a1 = (aliases?.[k1]?.[oldId]) || (k2 ? aliases?.[k2]?.[oldId] : ""); if (a1) return String(a1); const a2 = aliases?.[oldId]; if (a2) return String(a2); return ""; } async function main() { if (!(await exists(ANNO_DIR))) { console.log("✅ annotations: aucun dossier src/annotations — rien à vérifier."); process.exit(0); } if (!(await exists(DIST_DIR))) { console.error("FAIL: dist/ absent. Lance d’abord `npm run build` (ou `npm test`)."); process.exit(1); } const aliases = await loadAliases(); const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p)); // perf: cache HTML par page (shards = beaucoup de fichiers pour 1 page) const htmlCache = new Map(); // pageKey -> html const missingDistPage = new Set(); // pageKey let pagesSeen = new Set(); let checked = 0; let failures = 0; const notes = []; for (const f of files) { const rel = path.relative(CWD, f).replace(/\\/g, "/"); const raw = await fs.readFile(f, "utf8"); let doc; try { doc = parseDoc(raw, f); } catch (e) { failures++; notes.push(`- PARSE FAIL: ${rel} (${String(e?.message ?? e)})`); continue; } if (!isPlainObject(doc) || doc.schema !== 1) { failures++; notes.push(`- INVALID: ${rel} (schema must be 1)`); continue; } const { pageKey, paraId: shardParaId } = inferFromFile(f); if (doc.page != null && normalizePageKey(doc.page) !== pageKey) { failures++; notes.push(`- PAGE MISMATCH: ${rel} (page="${doc.page}" != path="${pageKey}")`); continue; } if (!isPlainObject(doc.paras)) { failures++; notes.push(`- INVALID: ${rel} (missing object key "paras")`); continue; } // shard invariant (fort) : doit contenir paras[paraId] if (shardParaId) { if (!Object.prototype.hasOwnProperty.call(doc.paras, shardParaId)) { failures++; notes.push(`- SHARD MISMATCH: ${rel} (expected paras["${shardParaId}"] present)`); continue; } // si extras -> warning (non destructif) const keys = Object.keys(doc.paras); if (!(keys.length === 1 && keys[0] === shardParaId)) { notes.push(`- WARN shard has extra paras: ${rel} (expected only "${shardParaId}", got ${keys.join(", ")})`); } } pagesSeen.add(pageKey); const distFile = path.join(DIST_DIR, pageKey, "index.html"); if (!(await exists(distFile))) { if (!missingDistPage.has(pageKey)) { missingDistPage.add(pageKey); failures++; notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`); } else { notes.push(`- WARN missing page already reported: dist/${pageKey}/index.html (from ${rel})`); } continue; } let html = htmlCache.get(pageKey); if (!html) { html = await fs.readFile(distFile, "utf8"); htmlCache.set(pageKey, html); } for (const paraId of Object.keys(doc.paras)) { checked++; if (!isParaId(paraId)) { failures++; notes.push(`- INVALID ID: ${rel} (${paraId})`); continue; } const re = new RegExp(`\\bid=["']${escRe(paraId)}["']`, "g"); if (re.test(html)) continue; const alias = getAlias(aliases, pageKey, paraId); if (alias) { const re2 = new RegExp(`\\bid=["']${escRe(alias)}["']`, "g"); if (re2.test(html)) { notes.push(`- WARN alias used: ${pageKey} ${paraId} -> ${alias}`); continue; } } failures++; notes.push(`- MISSING ID: ${pageKey} (#${paraId})`); } } const warns = notes.filter((x) => x.startsWith("- WARN")); const pages = pagesSeen.size; if (failures > 0) { console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`); for (const n of notes) console.error(n); process.exit(1); } for (const w of warns) console.log(w); console.log(`✅ annotations OK: pages=${pages} checked=${checked} warnings=${warns.length}`); } main().catch((e) => { console.error("FAIL: annotations check crashed:", e); process.exit(1); });