From fd9612d33389947aafd3d2cab569c3d19cb8910f Mon Sep 17 00:00:00 2001 From: Archicratia Date: Tue, 20 Jan 2026 16:00:54 +0100 Subject: [PATCH] p0: build-time anchor aliases (web-native) --- package.json | 2 +- scripts/inject-anchor-aliases.mjs | 143 ++++++++++++++++++++++++++++++ src/anchors/anchor-aliases.json | 6 ++ src/styles/global.css | 7 ++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 scripts/inject-anchor-aliases.mjs create mode 100644 src/anchors/anchor-aliases.json diff --git a/package.json b/package.json index 0f990e5..2be8357 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/inject-anchor-aliases.mjs b/scripts/inject-anchor-aliases.mjs new file mode 100644 index 0000000..b1d5657 --- /dev/null +++ b/scripts/inject-anchor-aliases.mjs @@ -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>} */ + 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 = ``; + 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); +}); diff --git a/src/anchors/anchor-aliases.json b/src/anchors/anchor-aliases.json new file mode 100644 index 0000000..3c06377 --- /dev/null +++ b/src/anchors/anchor-aliases.json @@ -0,0 +1,6 @@ +{ + "/archicratie/prologue/": { + "p-8-e7075fe3": "p-8-0e65838d" + } + } + \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index 7a262d2..70386b6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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); +}