diff --git a/.gitignore b/.gitignore index 4d1e470..65e152b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,6 @@ sources/ scripts/ # Backups / fichiers cassés (à garder hors repo) -src/layouts/*.bak* -src/layouts/*.BROKEN* # Astro generated .astro/ @@ -23,3 +21,8 @@ src/layouts/*.BROKEN* # --- allow the apply-ticket tool script to be versioned --- !scripts/ !scripts/apply-ticket.mjs + +# Local backups / crash copies +src/**/*.bak +src/**/*.BROKEN.* +src/**/*.step*-fix.bak diff --git a/docs/anchors.md b/docs/anchors.md new file mode 100644 index 0000000..8855cf9 --- /dev/null +++ b/docs/anchors.md @@ -0,0 +1,30 @@ +# Contrat des ancres (paragraphes opposables) + +## Source de vérité du sélecteur +Le site garantit la citabilité des paragraphes via des IDs injectés sur les balises `
`. + +**Sélecteur contractuel :** +- `.reading p[id^="p-"]` + +Tout outillage (scripts, tests, docs) doit utiliser ce sélecteur comme référence. + +## Ce que le test vérifie +Le test compare, page par page, la liste des IDs de paragraphes présents dans `dist/` contre une baseline versionnée. + +- Ajouts d’IDs : généralement OK (nouveaux paragraphes). +- Suppressions / churn élevé : alerte (risque de casser des citations existantes). + +## Fichier baseline +- `tests/anchors-baseline.json` + +## Commandes +1) Générer / mettre à jour la baseline (cas intentionnel) : +- `npm run build` +- `npm run test:anchors:update` + +2) Vérifier sans changer la baseline (cas normal) : +- `npm run build` +- `npm run test:anchors` + +## Politique d’échec (pragmatique) +Le test échoue si le churn d’une page dépasse un seuil (défaut : 20%) sur une page “suffisamment grande”. diff --git a/package.json b/package.json index 4af9898..887c576 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "astro": "astro", "postbuild": "npx pagefind --site dist", "import": "node scripts/import-docx.mjs", - "apply:ticket": "node scripts/apply-ticket.mjs" + "apply:ticket": "node scripts/apply-ticket.mjs", + "test:anchors": "node scripts/check-anchors.mjs", + "test:anchors:update": "node scripts/check-anchors.mjs --update" }, "dependencies": { "@astrojs/mdx": "^4.3.13", diff --git a/scripts/check-anchors.mjs b/scripts/check-anchors.mjs new file mode 100755 index 0000000..90578e8 --- /dev/null +++ b/scripts/check-anchors.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; + +const args = new Set(process.argv.slice(2)); +const getArg = (name, fallback = null) => { + const i = process.argv.indexOf(name); + if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; + return fallback; +}; + +const DIST_DIR = getArg("--dist", "dist"); +const BASELINE = getArg("--baseline", path.join("tests", "anchors-baseline.json")); +const UPDATE = args.has("--update"); + +const THRESHOLD = Number(getArg("--threshold", process.env.ANCHORS_THRESHOLD ?? "0.2")); +const MIN_PREV = Number(getArg("--min-prev", process.env.ANCHORS_MIN_PREV ?? "10")); + +const pct = (x) => (Math.round(x * 1000) / 10).toFixed(1) + "%"; + +async function walk(dir) { + const out = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const ent of entries) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) out.push(...(await walk(p))); + else if (ent.isFile() && ent.name.endsWith(".html")) out.push(p); + } + return out; +} + +// Contrat : .reading p[id^="p-"] +function extractIds(html) { + if (!html.includes('class="reading"')) return []; + const ids = []; + const re = /
]*\sid="(p-[^"]+)"/g; + let m; + while ((m = re.exec(html))) ids.push(m[1]); + + const seen = new Set(); + const uniq = []; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + uniq.push(id); + } + return uniq; +} + +async function buildSnapshot() { + const absDist = path.resolve(DIST_DIR); + const files = await walk(absDist); + + const snap = {}; + for (const f of files) { + const rel = path.relative(absDist, f).replace(/\\/g, "/"); + const html = await fs.readFile(f, "utf-8"); + const ids = extractIds(html); + if (ids.length === 0) continue; + snap[rel] = ids; + } + + const ordered = {}; + for (const k of Object.keys(snap).sort()) ordered[k] = snap[k]; + return ordered; +} + +async function readJson(p) { + const s = await fs.readFile(p, "utf-8"); + return JSON.parse(s); +} + +async function writeJson(p, obj) { + await fs.mkdir(path.dirname(p), { recursive: true }); + await fs.writeFile(p, JSON.stringify(obj, null, 2) + "\n", "utf-8"); +} + +function diffPage(prevIds, curIds) { + const prev = new Set(prevIds); + const cur = new Set(curIds); + const added = curIds.filter((x) => !prev.has(x)); + const removed = prevIds.filter((x) => !cur.has(x)); + return { added, removed }; +} + +(async () => { + const snap = await buildSnapshot(); + + if (UPDATE) { + await writeJson(BASELINE, snap); + const pages = Object.keys(snap).length; + const total = Object.values(snap).reduce((a, xs) => a + xs.length, 0); + console.log(`OK baseline updated -> ${BASELINE}`); + console.log(`Pages: ${pages}, Total paragraph IDs: ${total}`); + process.exit(0); + } + + let base; + try { + base = await readJson(BASELINE); + } catch { + console.error(`Baseline missing: ${BASELINE}`); + console.error(`Run: node scripts/check-anchors.mjs --update`); + process.exit(2); + } + + const allPages = new Set([...Object.keys(base), ...Object.keys(snap)]); + const pages = Array.from(allPages).sort(); + + let failed = false; + let changedPages = 0; + + for (const p of pages) { + const prevIds = base[p] || null; + const curIds = snap[p] || null; + + if (!prevIds && curIds) { + console.log(`+ PAGE ${p} (new) ids=${curIds.length}`); + continue; + } + if (prevIds && !curIds) { + console.log(`- PAGE ${p} (missing now) prevIds=${prevIds.length}`); + failed = true; + continue; + } + + const { added, removed } = diffPage(prevIds, curIds); + if (added.length === 0 && removed.length === 0) continue; + + changedPages += 1; + const prevN = prevIds.length || 1; + const churn = (added.length + removed.length) / prevN; + + const line = + `~ ${p} prev=${prevIds.length} now=${curIds.length}` + + ` +${added.length} -${removed.length} churn=${pct(churn)}`; + + console.log(line); + + if (removed.length) { + console.log(` removed: ${removed.slice(0, 20).join(", ")}${removed.length > 20 ? " …" : ""}`); + } + + if (prevIds.length >= MIN_PREV && churn > THRESHOLD) failed = true; + if (prevIds.length >= MIN_PREV && removed.length / prevN > THRESHOLD) failed = true; + } + + console.log(`\nSummary: pages compared=${pages.length}, pages changed=${changedPages}`); + if (failed) { + console.error(`FAIL: anchor churn above threshold (threshold=${pct(THRESHOLD)} minPrev=${MIN_PREV})`); + process.exit(1); + } + console.log("OK: anchors stable within threshold"); +})(); diff --git a/src/components/ProposeModal.astro b/src/components/ProposeModal.astro index a2481c8..976d5a1 100644 --- a/src/components/ProposeModal.astro +++ b/src/components/ProposeModal.astro @@ -95,9 +95,26 @@ /** @type {"correction"|"fact"|""} */ let kind = ""; + const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const upsertLine = (text, key, value) => { - const re = new RegExp(`^\\s*${key}\\s*:\\s*.*$`, "mi"); + const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi"); + + // value vide => supprimer la ligne si elle existe + if (!value) { + if (!re.test(text)) return text; + return ( + text + .replace(re, "") + .replace(/\n{3,}/g, "\n\n") + .trimEnd() + "\n" + ); + } + + // remplace si existe if (re.test(text)) return text.replace(re, `${key}: ${value}`); + + // sinon append const sep = text && !text.endsWith("\n") ? "\n" : ""; return text + sep + `${key}: ${value}\n`; }; @@ -171,12 +188,16 @@ const cat = btn.getAttribute("data-category") || ""; let body = pending.searchParams.get("body") || ""; - if (cat) body = upsertLine(body, "Category", cat); + body = upsertLine(body, "Category", cat); + pending.searchParams.set("body", body); + const u = pending.toString(); dlg.close(); - window.open(pending.toString(), "_blank", "noopener,noreferrer"); + + const w = window.open(u, "_blank", "noopener,noreferrer"); + if (!w) window.location.href = u; }); })(); diff --git a/src/layouts/EditionLayout.astro b/src/layouts/EditionLayout.astro index d9addae..b863dfe 100644 --- a/src/layouts/EditionLayout.astro +++ b/src/layouts/EditionLayout.astro @@ -154,33 +154,23 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? ""; tools.appendChild(citeBtn); if (giteaReady) { - const propose = document.createElement("a"); - propose.className = "para-propose"; - propose.href = "#"; - propose.textContent = "Proposer"; - propose.setAttribute("aria-label", "Proposer une correction sur Gitea"); - propose.setAttribute("data-propose","1"); + const propose = document.createElement("a"); + propose.className = "para-propose"; + propose.textContent = "Proposer"; + propose.setAttribute("aria-label", "Proposer une correction sur Gitea"); + propose.setAttribute("data-propose", "1"); - propose.addEventListener("click", (e) => { - e.preventDefault(); - const raw = (p.textContent || "").trim().replace(/\s+/g, " "); - const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw; - const url = buildIssueURL(p.id, excerpt); + const raw = (p.textContent || "").trim().replace(/\s+/g, " "); + const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw; + const url = buildIssueURL(p.id, excerpt); - // on stocke l'URL pour le modal - propose.dataset.url = url.toString(); + // progressive enhancement : sans JS/modal, href fonctionne. + propose.href = url; - // fallback: si le modal n'existe pas -> ouverture directe - const dlg = document.getElementById("propose-modal"); - if (!dlg || typeof dlg.showModal !== "function") { - window.open(propose.dataset.url, "_blank", "noopener,noreferrer"); - return; - } - // si modal existe, il va intercepter le click globalement - // donc on ne fait rien ici de plus. - }); + // compat : la modal lit data-url en priorité (garde aussi href). + propose.dataset.url = url; - tools.appendChild(propose); + tools.appendChild(propose); } p.appendChild(tools); diff --git a/tests/anchors-baseline.json b/tests/anchors-baseline.json new file mode 100644 index 0000000..9d33fe2 --- /dev/null +++ b/tests/anchors-baseline.json @@ -0,0 +1,158 @@ +{ + "archicratie/00-demarrage/index.html": [ + "p-0-d64c1c39", + "p-1-3f750540" + ], + "archicratie/prologue/index.html": [ + "p-0-d7974f88", + "p-1-2ef25f29", + "p-2-edb49e0a", + "p-3-76df8102", + "p-4-8ed4f807", + "p-5-85126fa5", + "p-6-3515039d", + "p-7-64a0ca9c", + "p-8-e7075fe3", + "p-9-5ff70fb7", + "p-10-e250e810", + "p-11-594bf307", + "p-12-a646a8f5", + "p-13-4ed7a199", + "p-14-edcf3420", + "p-15-0f64ad28", + "p-16-1318aeab", + "p-17-b8c5bf21", + "p-18-d38d4e1a", + "p-19-b9c1d59c", + "p-20-6c9b76cc", + "p-21-86372499", + "p-22-a416d473", + "p-23-d91a7b78", + "p-24-c44cc8ac", + "p-25-a182772f", + "p-26-c6b8b1f0", + "p-27-aad0efa5", + "p-28-464ce8ad", + "p-29-49e3d657", + "p-30-4224db6b", + "p-31-3248a592", + "p-32-b51a5980", + "p-33-dde26331", + "p-34-a110f27b", + "p-35-87338781", + "p-36-e68a28f9", + "p-37-cb0c66a4", + "p-38-1187ae85", + "p-39-a127b13e", + "p-40-43fc21e2", + "p-41-b8f4b969", + "p-42-0df88bcf", + "p-43-83309ffb", + "p-44-5fc0151c", + "p-45-6e70bc79", + "p-46-418f752d", + "p-47-73753452", + "p-48-e16b3487", + "p-49-57bb6ca6", + "p-50-ac1da1ad", + "p-51-a0462786", + "p-52-fd68db68", + "p-53-f498e04d", + "p-54-46eee1df", + "p-55-57231169", + "p-56-fbac6804", + "p-57-6adbbd7a", + "p-58-d9b19239", + "p-59-5a36bcd3", + "p-60-99c77374", + "p-61-1ed02f1a", + "p-62-1b9ed778", + "p-63-37fe9d27", + "p-64-62cbf51a", + "p-65-6c959f55", + "p-66-33a0759c", + "p-67-295fd24b", + "p-68-77cfd63b", + "p-69-9842c9db", + "p-70-ccf6f44e", + "p-71-48838687", + "p-72-25f521c5", + "p-73-d77db78d", + "p-74-52fda87a", + "p-75-fca128f1", + "p-76-e1475139", + "p-77-b3b1ff9c", + "p-78-b50a082d", + "p-79-0d4b92fc", + "p-80-9063c5e5", + "p-81-4b8c3114", + "p-82-98c28b00", + "p-83-09f4bae4", + "p-84-579dd8fd", + "p-85-2cc85f83", + "p-86-a6d8e7dd", + "p-87-4c7a5312", + "p-88-5eaeefbe", + "p-89-58f5046e", + "p-90-e8513d94", + "p-91-548c5ac8", + "p-92-180088f6", + "p-93-2a566bda", + "p-94-a4144a09", + "p-95-bb048846", + "p-96-f29c6d75", + "p-97-f210ef3e", + "p-98-35026cf3", + "p-99-11f4ca97", + "p-100-b90aa309", + "p-101-3d387c8e", + "p-102-0e8bfa53", + "p-103-be2c9a18", + "p-104-fedb49aa", + "p-105-176b0d9a", + "p-106-171d22ea", + "p-107-6f512b92", + "p-108-ea9a6441", + "p-109-973d0054", + "p-110-15ff6f67", + "p-111-42154a6b", + "p-112-7bfba774", + "p-113-cce5b54c", + "p-114-8d6d3f62", + "p-115-9b893988", + "p-116-5380dfcc", + "p-117-de90d916", + "p-118-3ab98e03", + "p-119-485048ef", + "p-120-207769f8", + "p-121-a3e8441a", + "p-122-8e9260a2", + "p-123-fd1cc033", + "p-124-0d841a3f", + "p-125-6ee0b4c6", + "p-126-da0b1447", + "p-127-55ac00d4", + "p-128-a0a6ccfa", + "p-129-6fe15281", + "p-130-956df10b", + "p-131-73c3222c", + "p-132-9571e24d", + "p-133-6a32a02a", + "p-134-358f5875", + "p-135-c19330ce", + "p-136-17f1cf51", + "p-137-d8f1539e" + ], + "atlas/00-demarrage/index.html": [ + "p-0-97681330" + ], + "glossaire/archicratie/index.html": [ + "p-0-942a2447" + ], + "ia/00-demarrage/index.html": [ + "p-0-e2fcdd67" + ], + "traite/00-demarrage/index.html": [ + "p-0-5abe0849" + ] +}