feat/anchors-alias-buildtime #37

Merged
Archicratia merged 2 commits from feat/anchors-alias-buildtime into master 2026-01-20 16:11:59 +01:00
6 changed files with 179 additions and 7 deletions

View File

@@ -7,7 +7,7 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"postbuild": "npx pagefind --site dist",
"postbuild": "node scripts/inject-anchor-aliases.mjs && npx pagefind --site dist",
"import": "node scripts/import-docx.mjs",
"apply:ticket": "node scripts/apply-ticket.mjs",
"test": "npm run build && npm run test:anchors && node scripts/check-inline-js.mjs",

View File

@@ -29,14 +29,29 @@ async function walk(dir) {
return out;
}
// Contrat : .reading p[id^="p-"]
// Contrat :
// - paragraphes citables : .reading p[id^="p-"]
// - alias web-natifs : .reading span.para-alias[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 ids = [];
let m;
// 1) IDs principaux (paragraphes)
const reP = /<p\b[^>]*\sid="(p-[^"]+)"/g;
while ((m = reP.exec(html))) ids.push(m[1]);
// 2) IDs alias (spans injectés)
// cas A : id="..." avant class="...para-alias..."
const reA1 = /<span\b[^>]*\bid="(p-[^"]+)"[^>]*\bclass="[^"]*\bpara-alias\b[^"]*"/g;
while ((m = reA1.exec(html))) ids.push(m[1]);
// cas B : class="...para-alias..." avant id="..."
const reA2 = /<span\b[^>]*\bclass="[^"]*\bpara-alias\b[^"]*"[^>]*\bid="(p-[^"]+)"/g;
while ((m = reA2.exec(html))) ids.push(m[1]);
// Dé-doublonnage (on garde un ordre stable)
const seen = new Set();
const uniq = [];
for (const id of ids) {

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const CWD = process.cwd();
const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_PATH = path.join(CWD, "src", "anchors", "anchor-aliases.json");
const argv = process.argv.slice(2);
const DRY_RUN = argv.includes("--dry-run");
const STRICT = argv.includes("--strict");
function escRe(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeRoute(route) {
let r = String(route || "").trim();
if (!r.startsWith("/")) r = "/" + r;
if (!r.endsWith("/")) r = r + "/";
r = r.replace(/\/{2,}/g, "/");
return r;
}
async function exists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
function hasId(html, id) {
const re = new RegExp(`\\bid=(["'])${escRe(id)}\\1`, "i");
return re.test(html);
}
function injectBeforeId(html, newId, injectHtml) {
// insère juste avant la balise qui porte id="newId"
const re = new RegExp(
`(<[^>]+\\bid=(["'])${escRe(newId)}\\2[^>]*>)`,
"i"
);
const m = html.match(re);
if (!m || m.index == null) return { html, injected: false };
const i = m.index;
const out = html.slice(0, i) + injectHtml + "\n" + html.slice(i);
return { html: out, injected: true };
}
async function main() {
if (!(await exists(ALIASES_PATH))) {
console.log(" Aucun fichier d'aliases (src/anchors/anchor-aliases.json). Skip.");
return;
}
const raw = await fs.readFile(ALIASES_PATH, "utf-8");
/** @type {Record<string, Record<string,string>>} */
const aliases = JSON.parse(raw);
const routes = Object.keys(aliases || {});
if (routes.length === 0) {
console.log(" Aliases vides. Rien à injecter.");
return;
}
let changedFiles = 0;
let injectedCount = 0;
let warnCount = 0;
for (const routeKey of routes) {
const route = normalizeRoute(routeKey);
const map = aliases[routeKey] || {};
const entries = Object.entries(map);
if (entries.length === 0) continue;
const rel = route.replace(/^\/+|\/+$/g, ""); // sans slash
const htmlPath = path.join(DIST_ROOT, rel, "index.html");
if (!(await exists(htmlPath))) {
const msg = `⚠️ dist introuvable pour route=${route} (${htmlPath})`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
let html = await fs.readFile(htmlPath, "utf-8");
let fileChanged = false;
for (const [oldId, newId] of entries) {
if (!oldId || !newId) continue;
if (hasId(html, oldId)) {
// alias déjà présent → idempotent
continue;
}
if (!hasId(html, newId)) {
const msg = `⚠️ newId introuvable: ${route} old=${oldId} -> new=${newId}`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
const aliasSpan = `<span id="${oldId}" class="para-alias" aria-hidden="true"></span>`;
const r = injectBeforeId(html, newId, aliasSpan);
if (!r.injected) {
const msg = `⚠️ injection impossible (pattern non trouvé) : ${route} new=${newId}`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
html = r.html;
fileChanged = true;
injectedCount++;
}
if (fileChanged) {
changedFiles++;
if (!DRY_RUN) await fs.writeFile(htmlPath, html, "utf-8");
}
}
console.log(
`✅ inject-anchor-aliases: files_changed=${changedFiles} aliases_injected=${injectedCount} warnings=${warnCount}` +
(DRY_RUN ? " (dry-run)" : "")
);
if (STRICT && warnCount > 0) process.exit(2);
}
main().catch((e) => {
console.error("💥 inject-anchor-aliases:", e?.message || e);
process.exit(1);
});

View File

@@ -0,0 +1,6 @@
{
"/archicratie/prologue/": {
"p-8-e7075fe3": "p-8-0e65838d"
}
}

View File

@@ -121,3 +121,10 @@ body[data-reading-level="2"] .level-3 { display: none; }
border-radius: 999px;
padding: 2px 8px;
}
.para-alias {
display: block;
height: 0;
/* ajuste si header sticky : */
scroll-margin-top: var(--scroll-margin-top, 96px);
}

View File

@@ -141,7 +141,8 @@
"p-134-358f5875",
"p-135-c19330ce",
"p-136-17f1cf51",
"p-137-d8f1539e"
"p-137-d8f1539e",
"p-8-e7075fe3"
],
"atlas/00-demarrage/index.html": [
"p-0-97681330"