Files
archicratie-edition/src/pages/annotations-index.json.ts
Archicratia dfa311fb5b
All checks were successful
SMOKE / smoke (push) Successful in 18s
CI / build-and-anchors (push) Successful in 40s
CI / build-and-anchors (pull_request) Successful in 41s
fix(annotations): fail-open when src/annotations is missing
2026-03-04 22:39:09 +01:00

216 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/pages/annotations-index.json.ts
import type { APIRoute } from "astro";
import fs from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
const CWD = process.cwd();
const ANNO_ROOT = path.join(CWD, "src", "annotations");
const isObj = (x: any) => !!x && typeof x === "object" && !Array.isArray(x);
const isArr = (x: any) => Array.isArray(x);
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;
}
}
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
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 if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p);
}
return out;
}
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 () => {
const pages: Record<string, { paras: Record<string, any> }> = {};
const errors: Array<{ file: string; error: string }> = [];
let files: string[] = [];
let missingRoot = false;
try {
files = await walk(ANNO_ROOT);
} catch (e: any) {
// ✅ FAIL-OPEN : pas dannotations => index vide (ne casse pas la build)
missingRoot = true;
console.warn(`[annotations-index] Missing annotations root: ${ANNO_ROOT} (${e?.message || e})`);
files = [];
// ✅ surtout PAS d'errors.push ici
}
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);
try {
const raw = await fs.readFile(fp, "utf8");
const doc = YAML.parse(raw) || {};
if (!isObj(doc) || doc.schema !== 1) continue;
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;
if (!isObj(doc.paras)) throw new Error(`missing object key "paras"`);
const pg = pages[pageKey] ??= { paras: {} };
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(", ")})`);
}
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: any) {
errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) });
}
}
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<string, any> = {};
for (const k of keys) next[k] = pg.paras[k];
pg.paras = next;
}
const warnings: Array<{ where: string; warning: string }> = [];
if (missingRoot) {
warnings.push({
where: "src/pages/annotations-index.json.ts",
warning: `Missing annotations root "${ANNO_ROOT}" (treated as empty).`,
});
}
const out = {
schema: 1,
generatedAt: new Date().toISOString(),
pages,
stats: {
pages: Object.keys(pages).length,
paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0),
errors: errors.length,
},
warnings,
errors,
};
// ✅ FAIL-OPEN uniquement si le dossier manque.
// Si le dossier existe mais quun YAML est cassé -> fail-closed.
if (errors.length && !missingRoot) {
throw new Error(`${errors[0].file}: ${errors[0].error}`);
}
return new Response(JSON.stringify(out), {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
};