Merge pull request 'feat/proposer-antifragile' (#31) from feat/proposer-antifragile into master
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
30
docs/anchors.md
Normal file
30
docs/anchors.md
Normal file
@@ -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 `<p>`.
|
||||
|
||||
**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”.
|
||||
@@ -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",
|
||||
|
||||
154
scripts/check-anchors.mjs
Executable file
154
scripts/check-anchors.mjs
Executable file
@@ -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 = /<p\b[^>]*\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");
|
||||
})();
|
||||
@@ -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;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -156,29 +156,19 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
||||
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");
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
158
tests/anchors-baseline.json
Normal file
158
tests/anchors-baseline.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user