diff --git a/public/media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png b/public/media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png new file mode 100644 index 0000000..cc7b22f Binary files /dev/null and b/public/media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png differ diff --git a/scripts/apply-annotation-ticket.mjs b/scripts/apply-annotation-ticket.mjs index 4424e5e..9103305 100644 --- a/scripts/apply-annotation-ticket.mjs +++ b/scripts/apply-annotation-ticket.mjs @@ -1,12 +1,17 @@ #!/usr/bin/env node // scripts/apply-annotation-ticket.mjs +// // Applique un ticket Gitea "type/media | type/reference | type/comment" vers: // -// ✅ src/annotations///.yml (sharding par paragraphe) +// ✅ src/annotations///.yml (sharding par paragraphe) // ✅ public/media//// // -// Robuste, idempotent, non destructif. +// Compat rétro : lit (si présent) l'ancien monolithe: +// src/annotations//.yml +// et deep-merge NON destructif dans le shard lors d'une nouvelle application, +// pour permettre une migration progressive sans perte. // +// Robuste, idempotent, non destructif. // DRY RUN si --dry-run // Options: --dry-run --no-download --verify --strict --commit --close // @@ -49,8 +54,8 @@ Flags: --dry-run : n'écrit rien (affiche un aperçu) --no-download : n'essaie pas de télécharger les pièces jointes (media) --verify : vérifie que (page, ancre) existent (dist/para-index.json si dispo, sinon baseline) - --strict : refuse si URL ref invalide (http/https) OU caption media vide - --commit : git add + git commit (le script commit dans la branche courante) + --strict : refuse si URL ref invalide (http/https) OU caption media vide OU verify impossible + --commit : git add + git commit (commit dans la branche courante) --close : ferme le ticket (nécessite --commit) Env requis: @@ -191,6 +196,7 @@ function normalizeChemin(chemin) { } function normalizePageKeyFromChemin(chemin) { + // ex: /archicrat-ia/chapitre-4/ => archicrat-ia/chapitre-4 return normalizeChemin(chemin).replace(/^\/+|\/+$/g, ""); } @@ -226,90 +232,156 @@ function isHttpUrl(u) { } } -/* ------------------------------ para-index (verify + sort) ------------------------------ */ +function stableSortByTs(arr) { + if (!Array.isArray(arr)) return; + arr.sort((a, b) => { + const ta = Date.parse(a?.ts || "") || 0; + const tb = Date.parse(b?.ts || "") || 0; + if (ta !== tb) return ta - tb; + return JSON.stringify(a).localeCompare(JSON.stringify(b)); + }); +} + +function normPage(s) { + let x = String(s || "").trim(); + if (!x) return ""; + // retire origin si on a une URL complète + x = x.replace(/^https?:\/\/[^/]+/i, ""); + // enlève query/hash + x = x.split("#")[0].split("?")[0]; + // enlève index.html + x = x.replace(/index\.html$/i, ""); + // enlève slashs de bord + x = x.replace(/^\/+/, "").replace(/\/+$/, ""); + return x; + } + +/* ------------------------------ para-index (verify + order) ------------------------------ */ async function loadParaOrderFromDist(pageKey) { - const distIdx = path.join(CWD, "dist", "para-index.json"); - if (!(await exists(distIdx))) return null; - - let j; - try { - j = JSON.parse(await fs.readFile(distIdx, "utf8")); - } catch { + const distIdx = path.join(CWD, "dist", "para-index.json"); + if (!(await exists(distIdx))) return null; + + let j; + try { + j = JSON.parse(await fs.readFile(distIdx, "utf8")); + } catch { + return null; + } + + const want = normPage(pageKey); + + // Support A) { items:[{id,page,...}, ...] } (ou variantes) + const items = Array.isArray(j?.items) + ? j.items + : Array.isArray(j?.index?.items) + ? j.index.items + : null; + + if (items) { + const ids = []; + for (const it of items) { + // page peut être dans plein de clés différentes + const pageCand = normPage( + it?.page ?? + it?.pageKey ?? + it?.path ?? + it?.route ?? + it?.href ?? + it?.url ?? + "" + ); + + // id peut être dans plein de clés différentes + let id = String(it?.id ?? it?.paraId ?? it?.anchorId ?? it?.anchor ?? ""); + if (id.startsWith("#")) id = id.slice(1); + + if (pageCand === want && id) ids.push(id); + } + if (ids.length) return ids; + } + + // Support B) { byId: { "p-...": { page:"...", ... }, ... } } + if (j?.byId && typeof j.byId === "object") { + const ids = Object.keys(j.byId) + .filter((id) => { + const meta = j.byId[id] || {}; + const pageCand = normPage(meta.page ?? meta.pageKey ?? meta.path ?? meta.route ?? meta.url ?? ""); + return pageCand === want; + }); + + if (ids.length) { + ids.sort((a, b) => { + const ia = paraIndexFromId(a); + const ib = paraIndexFromId(b); + if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib; + return String(a).localeCompare(String(b)); + }); + return ids; + } + } + + // Support C) { pages: { "archicrat-ia/chapitre-4": { ids:[...] } } } (ou variantes) + if (j?.pages && typeof j.pages === "object") { + // essaie de trouver la bonne clé même si elle est /.../ ou .../index.html + const keys = Object.keys(j.pages); + const hit = keys.find((k) => normPage(k) === want); + if (hit) { + const pg = j.pages[hit]; + if (Array.isArray(pg?.ids)) return pg.ids.map(String); + if (Array.isArray(pg?.paras)) return pg.paras.map(String); + } + } + return null; } - // Support several shapes: - // A) { items:[{id,page,...}, ...] } - if (Array.isArray(j?.items)) { - const ids = []; - for (const it of j.items) { - const p = String(it?.page || it?.pageKey || ""); - const id = String(it?.id || it?.paraId || ""); - if (p === pageKey && id) ids.push(id); - } - if (ids.length) return ids; - } - - // B) { byId: { "p-...": { page:"archicrat-ia/chapitre-4", ... }, ... } } - if (j?.byId && typeof j.byId === "object") { - // cannot rebuild full order; but can verify existence - // return a pseudo-order map from known ids sorted by p-- then alpha - const ids = Object.keys(j.byId).filter((id) => String(j.byId[id]?.page || "") === pageKey); - if (ids.length) { - ids.sort((a, b) => { - const ia = paraIndexFromId(a); - const ib = paraIndexFromId(b); - if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib; - return String(a).localeCompare(String(b)); - }); - return ids; - } - } - - // C) { pages: { "archicrat-ia/chapitre-4": { ids:[...]} } } - if (j?.pages && typeof j.pages === "object") { - const pg = j.pages[pageKey]; - if (Array.isArray(pg?.ids)) return pg.ids.map(String); - if (Array.isArray(pg?.paras)) return pg.paras.map(String); - } - - return null; -} - -async function tryVerifyAnchor(pageKey, anchorId) { - // 1) dist/para-index.json - const order = await loadParaOrderFromDist(pageKey); - if (order) return order.includes(anchorId); - - // 2) tests/anchors-baseline.json (fallback) - const base = path.join(CWD, "tests", "anchors-baseline.json"); - if (await exists(base)) { - try { - const j = JSON.parse(await fs.readFile(base, "utf8")); - const candidates = []; - if (j?.pages && typeof j.pages === "object") { - for (const [k, v] of Object.entries(j.pages)) { - if (!Array.isArray(v)) continue; - if (String(k).includes(pageKey)) candidates.push(...v); + async function tryVerifyAnchor(pageKey, anchorId) { + // 1) dist/para-index.json : order complet si possible + const order = await loadParaOrderFromDist(pageKey); + if (order) return order.includes(anchorId); + + // 1bis) dist/para-index.json : fallback “best effort” => recherche brute (IDs quasi uniques) + const distIdx = path.join(CWD, "dist", "para-index.json"); + if (await exists(distIdx)) { + try { + const raw = await fs.readFile(distIdx, "utf8"); + if (raw.includes(`"${anchorId}"`) || raw.includes(`"#${anchorId}"`)) { + return true; } + } catch { + // ignore } - if (Array.isArray(j?.entries)) { - for (const it of j.entries) { - const p = String(it?.page || ""); - const ids = it?.ids; - if (Array.isArray(ids) && p.includes(pageKey)) candidates.push(...ids); - } - } - if (candidates.length) return candidates.some((x) => String(x) === anchorId); - } catch { - // ignore } + + // 2) tests/anchors-baseline.json (fallback) + const base = path.join(CWD, "tests", "anchors-baseline.json"); + if (await exists(base)) { + try { + const j = JSON.parse(await fs.readFile(base, "utf8")); + const candidates = []; + if (j?.pages && typeof j.pages === "object") { + for (const [k, v] of Object.entries(j.pages)) { + if (!Array.isArray(v)) continue; + if (normPage(k).includes(normPage(pageKey))) candidates.push(...v); + } + } + if (Array.isArray(j?.entries)) { + for (const it of j.entries) { + const p = String(it?.page || ""); + const ids = it?.ids; + if (Array.isArray(ids) && normPage(p).includes(normPage(pageKey))) candidates.push(...ids); + } + } + if (candidates.length) return candidates.some((x) => String(x) === anchorId); + } catch { + // ignore + } + } + + return null; // cannot verify } - return null; // cannot verify -} - /* ----------------------------- deep merge helpers (non destructive) ----------------------------- */ function keyMedia(x) { @@ -360,7 +432,6 @@ function deepMergeEntry(dst, src) { } if (Array.isArray(v)) { - // fallback: union by JSON string const cur = Array.isArray(dst[k]) ? dst[k] : []; const seen = new Set(cur.map((x) => JSON.stringify(x))); const out = [...cur]; @@ -382,16 +453,6 @@ function deepMergeEntry(dst, src) { } } -function stableSortByTs(arr) { - if (!Array.isArray(arr)) return; - arr.sort((a, b) => { - const ta = Date.parse(a?.ts || "") || 0; - const tb = Date.parse(b?.ts || "") || 0; - if (ta !== tb) return ta - tb; - return JSON.stringify(a).localeCompare(JSON.stringify(b)); - }); -} - /* ----------------------------- annotations I/O ----------------------------- */ async function loadAnnoDocYaml(fileAbs, pageKey) { @@ -424,9 +485,7 @@ async function loadAnnoDocYaml(fileAbs, pageKey) { function sortParasObject(paras, order) { const keys = Object.keys(paras || {}); const idx = new Map(); - if (Array.isArray(order)) { - order.forEach((id, i) => idx.set(String(id), i)); - } + if (Array.isArray(order)) order.forEach((id, i) => idx.set(String(id), i)); keys.sort((a, b) => { const ha = idx.has(a); @@ -448,9 +507,9 @@ function sortParasObject(paras, order) { async function saveAnnoDocYaml(fileAbs, doc, order = null) { await fs.mkdir(path.dirname(fileAbs), { recursive: true }); + doc.paras = sortParasObject(doc.paras, order); - // also sort known lists inside each para for stable diffs for (const e of Object.values(doc.paras || {})) { if (!isPlainObject(e)) continue; stableSortByTs(e.media); @@ -632,7 +691,6 @@ async function main() { const pageKey = normalizePageKeyFromChemin(chemin); assert(pageKey, "Ticket: impossible de dériver pageKey.", 2); - // para order (used for verify + sorting) const paraOrder = DO_VERIFY ? await loadParaOrderFromDist(pageKey) : null; if (DO_VERIFY) { @@ -641,46 +699,43 @@ async function main() { throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 }); } if (ok === null) { - if (STRICT) throw Object.assign(new Error(`Ticket verify (strict): impossible de vérifier (pas de dist/para-index.json ou baseline)`), { __exitCode: 2 }); + if (STRICT) { + throw Object.assign( + new Error(`Ticket verify (strict): impossible de vérifier (pas de dist/para-index.json ou baseline)`), + { __exitCode: 2 } + ); + } console.warn("⚠️ verify: impossible de vérifier (pas de dist/para-index.json ou baseline) — on continue."); } } - // ✅ SHARD FILE: src/annotations//.yml - const annoShardFileAbs = path.join(ANNO_DIR, pageKey, `${ancre}.yml`); - const annoShardFileRel = path.relative(CWD, annoShardFileAbs).replace(/\\/g, "/"); + // ✅ shard path: src/annotations//.yml + const shardAbs = path.join(ANNO_DIR, ...pageKey.split("/"), `${ancre}.yml`); + const shardRel = path.relative(CWD, shardAbs).replace(/\\/g, "/"); - // legacy (read-only, used as base to avoid losing previously stored data) - const annoLegacyFileAbs = path.join(ANNO_DIR, `${pageKey}.yml`); + // legacy monolith: src/annotations/.yml (read-only, for migration) + const legacyAbs = path.join(ANNO_DIR, `${pageKey}.yml`); - console.log("✅ Parsed:", { - type, - chemin, - ancre: `#${ancre}`, - pageKey, - annoFile: annoShardFileRel, - }); + console.log("✅ Parsed:", { type, chemin, ancre: `#${ancre}`, pageKey, annoFile: shardRel }); // load shard doc - const doc = await loadAnnoDocYaml(annoShardFileAbs, pageKey); - - // merge legacy para into shard as base (non destructive) - if (await exists(annoLegacyFileAbs)) { - try { - const legacy = await loadAnnoDocYaml(annoLegacyFileAbs, pageKey); - const legacyEntry = legacy?.paras?.[ancre]; - if (isPlainObject(legacyEntry)) { - if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {}; - deepMergeEntry(doc.paras[ancre], legacyEntry); - } - } catch { - // ignore legacy parse issues (shard still works) - } - } - + const doc = await loadAnnoDocYaml(shardAbs, pageKey); if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {}; const entry = doc.paras[ancre]; + // merge legacy entry into shard in-memory (non destructive) to keep compat + enable progressive migration + if (await exists(legacyAbs)) { + try { + const legacy = await loadAnnoDocYaml(legacyAbs, pageKey); + const legacyEntry = legacy?.paras?.[ancre]; + if (isPlainObject(legacyEntry)) { + deepMergeEntry(entry, legacyEntry); + } + } catch { + // ignore legacy parse issues; shard still applies new data + } + } + const touchedFiles = []; const notes = []; let changed = false; @@ -696,10 +751,13 @@ async function main() { const before = entry.comments_editorial.length; entry.comments_editorial = uniqUnion(entry.comments_editorial, [item], keyComment); - changed = changed || entry.comments_editorial.length !== before; - + if (entry.comments_editorial.length !== before) { + changed = true; + notes.push(`+ comment added (len=${text.length})`); + } else { + notes.push(`~ comment already present (dedup)`); + } stableSortByTs(entry.comments_editorial); - notes.push(changed ? `+ comment added (len=${text.length})` : `~ comment already present (dedup)`); } else if (type === "type/reference") { @@ -722,15 +780,24 @@ async function main() { const before = entry.refs.length; entry.refs = uniqUnion(entry.refs, [item], keyRef); - changed = changed || entry.refs.length !== before; - + if (entry.refs.length !== before) { + changed = true; + notes.push(`+ reference added (${item.url ? "url" : "label"})`); + } else { + notes.push(`~ reference already present (dedup)`); + } stableSortByTs(entry.refs); - notes.push(changed ? `+ reference added (${item.url ? "url" : "label"})` : `~ reference already present (dedup)`); } else if (type === "type/media") { if (!Array.isArray(entry.media)) entry.media = []; + const caption = (title || "").trim(); + if (STRICT && !caption) { + throw Object.assign(new Error("Ticket media (strict): caption vide (titre de ticket requis)."), { __exitCode: 2 }); + } + const captionFinal = caption || "."; + const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum }); if (!atts.length) notes.push("! no assets found (nothing to download)."); @@ -739,13 +806,7 @@ async function main() { const dl = a?.browser_download_url || a?.download_url || ""; if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; } - const caption = (title || "").trim(); - if (STRICT && !caption) { - throw Object.assign(new Error("Ticket media (strict): caption vide (titre de ticket requis)."), { __exitCode: 2 }); - } - const captionFinal = caption || "."; - - const mediaDirAbs = path.join(PUBLIC_DIR, "media", pageKey, ancre); + const mediaDirAbs = path.join(PUBLIC_DIR, "media", ...pageKey.split("/"), ancre); const destAbs = path.join(mediaDirAbs, name); const urlPath = `${MEDIA_URL_ROOT}/${pageKey}/${ancre}/${name}`.replace(/\/{2,}/g, "/"); @@ -790,7 +851,7 @@ async function main() { if (DRY_RUN) { console.log("\n--- DRY RUN (no write) ---"); - console.log(`Would update: ${annoShardFileRel}`); + console.log(`Would update: ${shardRel}`); for (const n of notes) console.log(" ", n); console.log("\nExcerpt (resulting entry):"); console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd()); @@ -798,10 +859,10 @@ async function main() { return; } - await saveAnnoDocYaml(annoShardFileAbs, doc, paraOrder); - touchedFiles.unshift(annoShardFileRel); + await saveAnnoDocYaml(shardAbs, doc, paraOrder); + touchedFiles.unshift(shardRel); - console.log(`✅ Updated: ${annoShardFileRel}`); + console.log(`✅ Updated: ${shardRel}`); for (const n of notes) console.log(" ", n); if (DO_COMMIT) { diff --git a/scripts/build-annotations-index.mjs b/scripts/build-annotations-index.mjs index 9eca3c8..38a88d1 100644 --- a/scripts/build-annotations-index.mjs +++ b/scripts/build-annotations-index.mjs @@ -1,28 +1,106 @@ +#!/usr/bin/env node // scripts/build-annotations-index.mjs +// Construit dist/annotations-index.json à partir de src/annotations/**/*.yml +// Supporte: +// - monolith : src/annotations/.yml +// - shard : src/annotations//.yml (paraId = p--...) +// Invariants: +// - doc.schema === 1 +// - doc.page (si présent) == pageKey déduit du chemin +// - shard: doc.paras doit contenir EXACTEMENT la clé paraId (sinon fail) +// +// Deep-merge non destructif (media/refs/comments dédupliqués), tri stable. + import fs from "node:fs/promises"; import path from "node:path"; import YAML from "yaml"; -function parseArgs(argv) { - const out = { - inDir: "src/annotations", - outFile: "dist/annotations-index.json", - }; +const ROOT = process.cwd(); +const ANNO_ROOT = path.join(ROOT, "src", "annotations"); +const DIST_DIR = path.join(ROOT, "dist"); +const OUT = path.join(DIST_DIR, "annotations-index.json"); - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} - if (a === "--in" && argv[i + 1]) out.inDir = argv[++i]; - else if (a.startsWith("--in=")) out.inDir = a.slice("--in=".length); +function isObj(x) { + return !!x && typeof x === "object" && !Array.isArray(x); +} +function isArr(x) { + return Array.isArray(x); +} - if (a === "--out" && argv[i + 1]) out.outFile = argv[++i]; - else if (a.startsWith("--out=")) out.outFile = a.slice("--out=".length); +function normPath(s) { + return String(s || "") + .replace(/\\/g, "/") + .replace(/^\/+|\/+$/g, ""); +} + +function paraNum(pid) { + const m = String(pid).match(/^p-(\d+)-/i); + return m ? Number(m[1]) : Number.POSITIVE_INFINITY; +} + +function stableSortByTs(arr) { + if (!Array.isArray(arr)) return; + arr.sort((a, b) => { + const ta = Date.parse(a?.ts || "") || 0; + const tb = Date.parse(b?.ts || "") || 0; + if (ta !== tb) return ta - tb; + return JSON.stringify(a).localeCompare(JSON.stringify(b)); + }); +} + +function keyMedia(x) { return String(x?.src || ""); } +function keyRef(x) { + return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`; +} +function keyComment(x) { return String(x?.text || "").trim(); } + +function uniqUnion(dst, src, keyFn) { + const out = isArr(dst) ? [...dst] : []; + const seen = new Set(out.map((x) => keyFn(x))); + for (const it of (isArr(src) ? src : [])) { + const k = keyFn(it); + if (!k) continue; + if (!seen.has(k)) { + seen.add(k); + out.push(it); + } } return out; } -async function exists(p) { - try { await fs.access(p); return true; } catch { return false; } +function deepMergeEntry(dst, src) { + if (!isObj(dst) || !isObj(src)) return; + + for (const [k, v] of Object.entries(src)) { + if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; } + if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; } + 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); + continue; + } + + if (isArr(v)) { + const cur = isArr(dst[k]) ? dst[k] : []; + const seen = new Set(cur.map((x) => 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; + continue; + } + + // scalar: set only if missing/empty + if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v; + } } async function walk(dir) { @@ -30,111 +108,116 @@ async function walk(dir) { 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); + if (e.isDirectory()) out.push(...await walk(p)); + else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p); } return out; } -function inferPageKeyFromFile(inDirAbs, fileAbs) { - // src/annotations/.yml -> "" - const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/"); - return rel.replace(/\.(ya?ml|json)$/i, ""); +function inferExpectedFromRel(relNoExt) { + const parts = relNoExt.split("/").filter(Boolean); + const last = parts.at(-1) || ""; + 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 }; } -function assert(cond, msg) { - if (!cond) throw new Error(msg); -} +function validateAndNormalizeDoc(doc, relFile, expectedPageKey, expectedParaId) { + assert(isObj(doc), `${relFile}: doc must be an object`); + assert(doc.schema === 1, `${relFile}: schema must be 1`); + assert(isObj(doc.paras), `${relFile}: missing object key "paras"`); -function isPlainObject(x) { - return !!x && typeof x === "object" && !Array.isArray(x); -} + const gotPage = doc.page != null ? normPath(doc.page) : ""; + const expPage = normPath(expectedPageKey); -function normalizePageKey(s) { - // pas de / en tête/fin - return String(s || "").replace(/^\/+/, "").replace(/\/+$/, ""); -} - -function validateAndNormalizeDoc(doc, pageKey, fileRel) { - assert(isPlainObject(doc), `${fileRel}: document must be an object`); - assert(doc.schema === 1, `${fileRel}: schema must be 1`); - if (doc.page != null) { + if (gotPage) { assert( - normalizePageKey(doc.page) === pageKey, - `${fileRel}: page mismatch (page="${doc.page}" vs path="${pageKey}")` + gotPage === expPage, + `${relFile}: page mismatch (page="${doc.page}" vs path="${expectedPageKey}")` + ); + } else { + doc.page = expPage; + } + + if (expectedParaId) { + const keys = Object.keys(doc.paras || {}).map(String); + assert( + keys.includes(expectedParaId), + `${relFile}: shard mismatch: must contain paras["${expectedParaId}"]` + ); + assert( + keys.length === 1 && keys[0] === expectedParaId, + `${relFile}: shard invariant violated: shard file must contain ONLY paras["${expectedParaId}"] (got: ${keys.join(", ")})` ); } - assert(isPlainObject(doc.paras), `${fileRel}: missing object key "paras"`); - const parasOut = Object.create(null); - - for (const [paraId, entry] of Object.entries(doc.paras)) { - assert(/^p-\d+-/i.test(paraId), `${fileRel}: invalid para id "${paraId}"`); - - // entry peut être vide, mais doit être un objet si présent - assert(entry == null || isPlainObject(entry), `${fileRel}: paras.${paraId} must be an object`); - - const e = entry ? { ...entry } : {}; - - // Sanity checks (non destructifs : on n’écrase pas, on vérifie juste les types) - if (e.refs != null) assert(Array.isArray(e.refs), `${fileRel}: paras.${paraId}.refs must be an array`); - if (e.authors != null) assert(Array.isArray(e.authors), `${fileRel}: paras.${paraId}.authors must be an array`); - if (e.quotes != null) assert(Array.isArray(e.quotes), `${fileRel}: paras.${paraId}.quotes must be an array`); - if (e.media != null) assert(Array.isArray(e.media), `${fileRel}: paras.${paraId}.media must be an array`); - if (e.comments_editorial != null) assert(Array.isArray(e.comments_editorial), `${fileRel}: paras.${paraId}.comments_editorial must be an array`); - - parasOut[paraId] = e; - } - - return parasOut; -} - -async function readDoc(fileAbs) { - const raw = await fs.readFile(fileAbs, "utf8"); - if (/\.json$/i.test(fileAbs)) return JSON.parse(raw); - return YAML.parse(raw); + return doc; } async function main() { - const { inDir, outFile } = parseArgs(process.argv.slice(2)); - const CWD = process.cwd(); + const pages = {}; + const errors = []; - const inDirAbs = path.isAbsolute(inDir) ? inDir : path.join(CWD, inDir); - const outAbs = path.isAbsolute(outFile) ? outFile : path.join(CWD, outFile); + await fs.mkdir(DIST_DIR, { recursive: true }); - // antifragile - if (!(await exists(inDirAbs))) { - console.log(`ℹ️ annotations-index: skip (input missing): ${inDir}`); - process.exit(0); - } + const files = await walk(ANNO_ROOT); - const files = (await walk(inDirAbs)).filter((p) => /\.(ya?ml|json)$/i.test(p)); - if (!files.length) { - console.log(`ℹ️ annotations-index: skip (no .yml/.yaml/.json found in): ${inDir}`); - process.exit(0); - } + for (const fp of files) { + const rel = normPath(path.relative(ANNO_ROOT, fp)); + const relNoExt = rel.replace(/\.ya?ml$/i, ""); + const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt); - const pages = Object.create(null); - let paraCount = 0; - - for (const f of files) { - const fileRel = path.relative(CWD, f).replace(/\\/g, "/"); - const pageKey = normalizePageKey(inferPageKeyFromFile(inDirAbs, f)); - assert(pageKey, `${fileRel}: cannot infer page key`); - - let doc; try { - doc = await readDoc(f); + const raw = await fs.readFile(fp, "utf8"); + const doc = YAML.parse(raw) || {}; + + if (!isObj(doc) || doc.schema !== 1) continue; + + validateAndNormalizeDoc( + doc, + `src/annotations/${rel}`, + pageKey, + isShard ? paraId : null + ); + + const pg = (pages[pageKey] ??= { paras: {} }); + + if (isShard) { + const entry = doc.paras[paraId]; + if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {}; + if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry); + + stableSortByTs(pg.paras[paraId].media); + stableSortByTs(pg.paras[paraId].refs); + stableSortByTs(pg.paras[paraId].comments_editorial); + } else { + for (const [pid, entry] of Object.entries(doc.paras || {})) { + const p = String(pid); + if (!isObj(pg.paras[p])) pg.paras[p] = {}; + if (isObj(entry)) deepMergeEntry(pg.paras[p], entry); + + stableSortByTs(pg.paras[p].media); + stableSortByTs(pg.paras[p].refs); + stableSortByTs(pg.paras[p].comments_editorial); + } + } } catch (e) { - throw new Error(`${fileRel}: parse failed: ${String(e?.message ?? e)}`); + errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) }); } + } - const paras = validateAndNormalizeDoc(doc, pageKey, fileRel); - - // 1 fichier = 1 page (canon) - assert(!pages[pageKey], `${fileRel}: duplicate page "${pageKey}" (only one file per page)`); - pages[pageKey] = { paras }; - paraCount += Object.keys(paras).length; + for (const [pageKey, pg] of Object.entries(pages)) { + const keys = Object.keys(pg.paras || {}); + keys.sort((a, b) => { + const ia = paraNum(a); + const ib = paraNum(b); + if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib; + return String(a).localeCompare(String(b)); + }); + const next = {}; + for (const k of keys) next[k] = pg.paras[k]; + pg.paras = next; } const out = { @@ -143,17 +226,21 @@ async function main() { pages, stats: { pages: Object.keys(pages).length, - paras: paraCount, + paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0), + errors: errors.length, }, + errors, }; - await fs.mkdir(path.dirname(outAbs), { recursive: true }); - await fs.writeFile(outAbs, JSON.stringify(out), "utf8"); + if (errors.length) { + throw new Error(`${errors[0].file}: ${errors[0].error}`); + } - console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> ${path.relative(CWD, outAbs)}`); + await fs.writeFile(OUT, JSON.stringify(out), "utf8"); + console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> dist/annotations-index.json`); } main().catch((e) => { - console.error("FAIL: build-annotations-index crashed:", e); + console.error(`FAIL: build-annotations-index crashed: ${e?.stack || e?.message || e}`); process.exit(1); -}); +}); \ No newline at end of file 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/annotations/archicrat-ia/chapitre-4/p-11-67c14c09.yml b/src/annotations/archicrat-ia/chapitre-4/p-11-67c14c09.yml new file mode 100644 index 0000000..a58703a --- /dev/null +++ b/src/annotations/archicrat-ia/chapitre-4/p-11-67c14c09.yml @@ -0,0 +1,19 @@ +schema: 1 +page: archicrat-ia/chapitre-4 +paras: + p-11-67c14c09: + media: + - type: image + src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2026-02-16_a_13.07.35.png + caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des + révolutions industrielles" + credit: "" + ts: 2026-02-26T13:17:41.286Z + fromIssue: 129 + - type: image + src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png + caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des + révolutions industrielles" + credit: "" + ts: 2026-02-27T09:17:04.386Z + fromIssue: 127 diff --git a/src/pages/annotations-index.json.ts b/src/pages/annotations-index.json.ts index 12de9fa..6f90970 100644 --- a/src/pages/annotations-index.json.ts +++ b/src/pages/annotations-index.json.ts @@ -1,23 +1,80 @@ +// src/pages/annotations-index.json.ts import type { APIRoute } from "astro"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { parse as parseYAML } from "yaml"; +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 ANNO_ROOT = path.join(CWD, "src", "annotations"); -// Strict en CI (ou override explicite) -const STRICT = - process.env.ANNOTATIONS_STRICT === "1" || - process.env.CI === "1" || - process.env.CI === "true"; +const isObj = (x: any) => !!x && typeof x === "object" && !Array.isArray(x); +const isArr = (x: any) => Array.isArray(x); -async function exists(p: string): Promise { - try { - await fs.access(p); - return true; - } catch { - return false; +function normPath(s: string) { + return String(s || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} +function paraNum(pid: string) { + const m = String(pid).match(/^p-(\d+)-/i); + return m ? Number(m[1]) : Number.POSITIVE_INFINITY; +} +function toIso(v: any) { + if (v instanceof Date) return v.toISOString(); + return typeof v === "string" ? v : ""; +} +function stableSortByTs(arr: any[]) { + if (!Array.isArray(arr)) return; + arr.sort((a, b) => { + const ta = Date.parse(toIso(a?.ts)) || 0; + const tb = Date.parse(toIso(b?.ts)) || 0; + if (ta !== tb) return ta - tb; + return JSON.stringify(a).localeCompare(JSON.stringify(b)); + }); +} + +function keyMedia(x: any) { return String(x?.src || ""); } +function keyRef(x: any) { + return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`; +} +function keyComment(x: any) { return String(x?.text || "").trim(); } + +function uniqUnion(dst: any[], src: any[], keyFn: (x:any)=>string) { + const out = isArr(dst) ? [...dst] : []; + const seen = new Set(out.map((x) => keyFn(x))); + for (const it of (isArr(src) ? src : [])) { + const k = keyFn(it); + if (!k) continue; + if (!seen.has(k)) { seen.add(k); out.push(it); } + } + return out; +} + +function deepMergeEntry(dst: any, src: any) { + if (!isObj(dst) || !isObj(src)) return; + + for (const [k, v] of Object.entries(src)) { + if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; } + if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; } + if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; } + + if (isObj(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 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 as any)[k] = out; + continue; + } + + if (!(k in (dst as any)) || (dst as any)[k] == null || (dst as any)[k] === "") (dst as any)[k] = v; } } @@ -26,154 +83,98 @@ async function walk(dir: string): Promise { 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); + if (e.isDirectory()) out.push(...await walk(p)); + else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p); } return out; } -function isPlainObject(x: unknown): x is Record { - return !!x && typeof x === "object" && !Array.isArray(x); -} - -function normalizePageKey(s: unknown): string { - return String(s ?? "") - .replace(/^\/+/, "") - .replace(/\/+$/, "") - .trim(); -} - -function inferPageKeyFromFile(inDirAbs: string, fileAbs: string): string { - const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/"); - return rel.replace(/\.(ya?ml|json)$/i, ""); -} - -function parseDoc(raw: string, fileAbs: string): unknown { - if (/\.json$/i.test(fileAbs)) return JSON.parse(raw); - return parseYAML(raw); -} - -function hardFailOrCollect(errors: string[], msg: string): void { - if (STRICT) throw new Error(msg); - errors.push(msg); -} - -function sanitizeEntry( - fileRel: string, - paraId: string, - entry: unknown, - errors: string[] -): Record { - if (entry == null) return {}; - - if (!isPlainObject(entry)) { - hardFailOrCollect(errors, `${fileRel}: paras.${paraId} must be an object`); - return {}; - } - - const e: Record = { ...entry }; - - const arrayFields = [ - "refs", - "authors", - "quotes", - "media", - "comments_editorial", - ] as const; - - for (const k of arrayFields) { - if (e[k] == null) continue; - if (!Array.isArray(e[k])) { - errors.push(`${fileRel}: paras.${paraId}.${k} must be an array (coerced to [])`); - e[k] = []; - } - } - - return e; +function inferExpected(relNoExt: string) { + const parts = relNoExt.split("/").filter(Boolean); + const last = parts.at(-1) || ""; + 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 }; } export const GET: APIRoute = async () => { - if (!(await exists(ANNO_DIR))) { - const out = { - schema: 1, - generatedAt: new Date().toISOString(), - pages: {}, - stats: { pages: 0, paras: 0, errors: 0 }, - errors: [] as string[], - }; + const pages: Record }> = {}; + const errors: Array<{ file: string; error: string }> = []; - return new Response(JSON.stringify(out), { - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - }, - }); + let files: string[] = []; + try { + files = await walk(ANNO_ROOT); + } catch (e: any) { + throw new Error(`Missing annotations root: ${ANNO_ROOT} (${e?.message || e})`); } - const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p)); + for (const fp of files) { + const rel = normPath(path.relative(ANNO_ROOT, fp)); + const relNoExt = rel.replace(/\.ya?ml$/i, ""); + const { isShard, pageKey, paraId } = inferExpected(relNoExt); - const pages: Record> }> = - Object.create(null); - - const errors: string[] = []; - let paraCount = 0; - - for (const f of files) { - const fileRel = path.relative(CWD, f).replace(/\\/g, "/"); - const pageKey = normalizePageKey(inferPageKeyFromFile(ANNO_DIR, f)); - - if (!pageKey) { - hardFailOrCollect(errors, `${fileRel}: cannot infer page key`); - continue; - } - - let doc: unknown; try { - const raw = await fs.readFile(f, "utf8"); - doc = parseDoc(raw, f); - } catch (e) { - hardFailOrCollect(errors, `${fileRel}: parse failed: ${String((e as any)?.message ?? e)}`); - continue; - } + const raw = await fs.readFile(fp, "utf8"); + const doc = YAML.parse(raw) || {}; - if (!isPlainObject(doc) || (doc as any).schema !== 1) { - hardFailOrCollect(errors, `${fileRel}: schema must be 1`); - continue; - } + if (!isObj(doc) || doc.schema !== 1) continue; - if ((doc as any).page != null) { - const declared = normalizePageKey((doc as any).page); - if (declared !== pageKey) { - hardFailOrCollect( - errors, - `${fileRel}: page mismatch (page="${declared}" vs path="${pageKey}")` - ); + const docPage = normPath(doc.page || ""); + if (docPage && docPage !== pageKey) { + throw new Error(`page mismatch (page="${doc.page}" vs path="${pageKey}")`); } - } + if (!doc.page) doc.page = pageKey; - const parasAny = (doc as any).paras; - if (!isPlainObject(parasAny)) { - hardFailOrCollect(errors, `${fileRel}: missing object key "paras"`); - continue; - } + if (!isObj(doc.paras)) throw new Error(`missing object key "paras"`); - if (pages[pageKey]) { - hardFailOrCollect(errors, `${fileRel}: duplicate page "${pageKey}" (only one file per page)`); - continue; - } + const pg = pages[pageKey] ??= { paras: {} }; - const parasOut: Record> = Object.create(null); + if (isShard) { + if (!paraId) throw new Error("internal: missing paraId"); + 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(", ")})`); + } - for (const [paraId, entry] of Object.entries(parasAny)) { - if (!/^p-\d+-/i.test(paraId)) { - hardFailOrCollect(errors, `${fileRel}: invalid para id "${paraId}"`); - continue; + const entry = doc.paras[paraId]; + if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {}; + if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry); + + stableSortByTs(pg.paras[paraId].media); + stableSortByTs(pg.paras[paraId].refs); + stableSortByTs(pg.paras[paraId].comments_editorial); + } else { + for (const [pid, entry] of Object.entries(doc.paras)) { + const p = String(pid); + if (!isObj(pg.paras[p])) pg.paras[p] = {}; + if (isObj(entry)) deepMergeEntry(pg.paras[p], entry); + + stableSortByTs(pg.paras[p].media); + stableSortByTs(pg.paras[p].refs); + stableSortByTs(pg.paras[p].comments_editorial); + } } - parasOut[paraId] = sanitizeEntry(fileRel, paraId, entry, errors); + } catch (e: any) { + errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) }); } + } - pages[pageKey] = { paras: parasOut }; - paraCount += Object.keys(parasOut).length; + for (const [pk, pg] of Object.entries(pages)) { + const keys = Object.keys(pg.paras || {}); + keys.sort((a, b) => { + const ia = paraNum(a); + const ib = paraNum(b); + if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib; + return String(a).localeCompare(String(b)); + }); + const next: Record = {}; + for (const k of keys) next[k] = pg.paras[k]; + pg.paras = next; } const out = { @@ -182,16 +183,17 @@ export const GET: APIRoute = async () => { pages, stats: { pages: Object.keys(pages).length, - paras: paraCount, + paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0), errors: errors.length, }, errors, }; + if (errors.length) { + throw new Error(`${errors[0].file}: ${errors[0].error}`); + } + return new Response(JSON.stringify(out), { - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - }, + headers: { "Content-Type": "application/json; charset=utf-8" }, }); -}; +}; \ No newline at end of file