From 210f6214876c8b4b0e9cd7bcd659c8ba649b5d4c Mon Sep 17 00:00:00 2001 From: Archicratia Date: Fri, 27 Feb 2026 13:13:31 +0100 Subject: [PATCH] ci: support shard annotations in checks + endpoint (pageKey inference) --- scripts/build-annotations-index.mjs | 10 ++-- scripts/check-annotations-media.mjs | 9 +++- scripts/check-annotations.mjs | 75 ++++++++++++++++++++++++----- src/pages/annotations-index.json.ts | 23 +++++---- 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/scripts/build-annotations-index.mjs b/scripts/build-annotations-index.mjs index 4654b0e..38a88d1 100644 --- a/scripts/build-annotations-index.mjs +++ b/scripts/build-annotations-index.mjs @@ -117,7 +117,7 @@ async function walk(dir) { function inferExpectedFromRel(relNoExt) { const parts = relNoExt.split("/").filter(Boolean); const last = parts.at(-1) || ""; - const isShard = /^p-\d+-/i.test(last); + const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt; const paraId = isShard ? last : null; return { isShard, pageKey, paraId }; @@ -141,7 +141,6 @@ function validateAndNormalizeDoc(doc, relFile, expectedPageKey, expectedParaId) } if (expectedParaId) { - // invariant shard : exactement 1 clé, celle du filename const keys = Object.keys(doc.paras || {}).map(String); assert( keys.includes(expectedParaId), @@ -165,15 +164,14 @@ async function main() { const files = await walk(ANNO_ROOT); for (const fp of files) { - const rel = normPath(path.relative(ANNO_ROOT, fp)); // e.g. archicrat-ia/chapitre-4/p-11-... - const relNoExt = rel.replace(/\.ya?ml$/i, ""); // no ext + const rel = normPath(path.relative(ANNO_ROOT, fp)); + const relNoExt = rel.replace(/\.ya?ml$/i, ""); const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt); try { const raw = await fs.readFile(fp, "utf8"); const doc = YAML.parse(raw) || {}; - // ignore non schema:1 if (!isObj(doc) || doc.schema !== 1) continue; validateAndNormalizeDoc( @@ -209,7 +207,6 @@ async function main() { } } - // sort paras per page for (const [pageKey, pg] of Object.entries(pages)) { const keys = Object.keys(pg.paras || {}); keys.sort((a, b) => { @@ -235,7 +232,6 @@ async function main() { errors, }; - // CI behaviour: if ANY error => fail build if (errors.length) { throw new Error(`${errors[0].file}: ${errors[0].error}`); } diff --git a/scripts/check-annotations-media.mjs b/scripts/check-annotations-media.mjs index 20af451..4c2c2b6 100644 --- a/scripts/check-annotations-media.mjs +++ b/scripts/check-annotations-media.mjs @@ -48,6 +48,9 @@ async function main() { let missing = 0; const notes = []; + // Optim: éviter de vérifier 100 fois le même fichier media + const seenMedia = new Set(); // src string + for (const f of files) { const rel = path.relative(CWD, f).replace(/\\/g, "/"); const raw = await fs.readFile(f, "utf8"); @@ -70,6 +73,10 @@ async function main() { const src = String(m?.src || ""); if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures + // dédupe + if (seenMedia.has(src)) continue; + seenMedia.add(src); + checked++; const p = toPublicPathFromUrl(src); if (!p) continue; @@ -94,4 +101,4 @@ async function main() { main().catch((e) => { console.error("FAIL: check-annotations-media crashed:", e); process.exit(1); -}); +}); \ No newline at end of file diff --git a/scripts/check-annotations.mjs b/scripts/check-annotations.mjs index 660bb74..d3843b6 100644 --- a/scripts/check-annotations.mjs +++ b/scripts/check-annotations.mjs @@ -27,11 +27,6 @@ function escRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function inferPageKeyFromFile(fileAbs) { - const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/"); - return rel.replace(/\.(ya?ml|json)$/i, ""); -} - function normalizePageKey(s) { return String(s || "").replace(/^\/+/, "").replace(/\/+$/, ""); } @@ -40,6 +35,31 @@ 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 { @@ -83,7 +103,11 @@ async function main() { const aliases = await loadAliases(); const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p)); - let pages = 0; + // 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 = []; @@ -107,7 +131,7 @@ async function main() { continue; } - const pageKey = normalizePageKey(inferPageKeyFromFile(f)); + const { pageKey, paraId: shardParaId } = inferFromFile(f); if (doc.page != null && normalizePageKey(doc.page) !== pageKey) { failures++; @@ -121,20 +145,44 @@ async function main() { 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))) { - failures++; - notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`); + 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; } - pages++; - const html = await fs.readFile(distFile, "utf8"); + 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 (!/^p-\d+-/i.test(paraId)) { + if (!isParaId(paraId)) { failures++; notes.push(`- INVALID ID: ${rel} (${paraId})`); continue; @@ -158,6 +206,7 @@ async function main() { } 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})`); @@ -172,4 +221,4 @@ async function main() { main().catch((e) => { console.error("FAIL: annotations check crashed:", e); process.exit(1); -}); +}); \ No newline at end of file diff --git a/src/pages/annotations-index.json.ts b/src/pages/annotations-index.json.ts index 2a0f491..6f90970 100644 --- a/src/pages/annotations-index.json.ts +++ b/src/pages/annotations-index.json.ts @@ -57,25 +57,24 @@ function deepMergeEntry(dst: any, src: any) { if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; } if (isObj(v)) { - if (!isObj(dst[k])) dst[k] = {}; - deepMergeEntry(dst[k], v); + if (!isObj((dst as any)[k])) (dst as any)[k] = {}; + deepMergeEntry((dst as any)[k], v); continue; } if (isArr(v)) { - const cur = isArr(dst[k]) ? dst[k] : []; + const cur = isArr((dst as any)[k]) ? (dst as any)[k] : []; const seen = new Set(cur.map((x:any) => JSON.stringify(x))); const out = [...cur]; for (const it of v) { const s = JSON.stringify(it); if (!seen.has(s)) { seen.add(s); out.push(it); } } - dst[k] = out; + (dst as any)[k] = out; continue; } - // scalar: set only if missing/empty - if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v; + if (!(k in (dst as any)) || (dst as any)[k] == null || (dst as any)[k] === "") (dst as any)[k] = v; } } @@ -93,7 +92,7 @@ async function walk(dir: string): Promise { function inferExpected(relNoExt: string) { const parts = relNoExt.split("/").filter(Boolean); const last = parts.at(-1) || ""; - const isShard = /^p-\d+-/i.test(last); + const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt; const paraId = isShard ? last : null; return { isShard, pageKey, paraId }; @@ -136,6 +135,12 @@ export const GET: APIRoute = async () => { if (!(paraId in doc.paras)) { throw new Error(`shard mismatch: file must contain paras["${paraId}"]`); } + // ✅ invariant aligné avec build-annotations-index + const keys = Object.keys(doc.paras).map(String); + if (!(keys.length === 1 && keys[0] === paraId)) { + throw new Error(`shard invariant violated: shard must contain ONLY paras["${paraId}"] (got: ${keys.join(", ")})`); + } + const entry = doc.paras[paraId]; if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {}; if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry); @@ -159,8 +164,7 @@ export const GET: APIRoute = async () => { } } - // sort paras - for (const [pageKey, pg] of Object.entries(pages)) { + for (const [pk, pg] of Object.entries(pages)) { const keys = Object.keys(pg.paras || {}); keys.sort((a, b) => { const ia = paraNum(a); @@ -185,7 +189,6 @@ export const GET: APIRoute = async () => { errors, }; - // 🔥 comportement “pro CI” : si erreurs => build fail if (errors.length) { throw new Error(`${errors[0].file}: ${errors[0].error}`); }