224 lines
6.5 KiB
JavaScript
224 lines
6.5 KiB
JavaScript
// 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/<pageKey>.yml -> pageKey = rel sans ext
|
||
* - shard : src/annotations/<pageKey>/<paraId>.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) { "<pageKey>": { "<old>": "<new>" } }
|
||
// 2) { "<old>": "<new>" }
|
||
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);
|
||
}); |