diff --git a/astro.config.mjs b/astro.config.mjs index a6438b3..95d50d3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -10,41 +10,101 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeDetailsSections from "./scripts/rehype-details-sections.mjs"; import rehypeParagraphIds from "./src/plugins/rehype-paragraph-ids.js"; -const must = (name, fn) => { - if (typeof fn !== "function") { - throw new Error(`[astro.config] rehype plugin "${name}" is not a function (export default vs named?)`); - } - return fn; -}; +/** + * Cast minimal pour satisfaire @ts-check sans dépendre de types internes Astro/Unified. + * @param {unknown} x + * @returns {any} + */ +const asAny = (x) => /** @type {any} */ (x); + +/** + * @param {any} node + * @param {string} cls + * @returns {boolean} + */ +function hasClass(node, cls) { + const cn = node?.properties?.className; + if (Array.isArray(cn)) return cn.includes(cls); + if (typeof cn === "string") return cn.split(/\s+/).includes(cls); + return false; +} + +/** + * Rehype plugin: retire les ids dupliqués en gardant en priorité: + * 1) span.details-anchor + * 2) h1..h6 + * 3) sinon: premier rencontré + * @returns {(tree: any) => void} + */ +function rehypeDedupeIds() { + /** @param {any} tree */ + return (tree) => { + /** @type {Map>} */ + const occ = new Map(); + let idx = 0; + + /** @param {any} node */ + const walk = (node) => { + if (!node || typeof node !== "object") return; + + if (node.type === "element") { + const id = node.properties?.id; + if (typeof id === "string" && id) { + let pref = 2; + if (node.tagName === "span" && hasClass(node, "details-anchor")) pref = 0; + else if (/^h[1-6]$/.test(String(node.tagName || ""))) pref = 1; + + const arr = occ.get(id) || []; + arr.push({ node, pref, idx: idx++ }); + occ.set(id, arr); + } + + const children = node.children; + if (Array.isArray(children)) for (const c of children) walk(c); + } else if (Array.isArray(node.children)) { + for (const c of node.children) walk(c); + } + }; + + walk(tree); + + for (const [id, items] of occ.entries()) { + if (items.length <= 1) continue; + + items.sort((a, b) => (a.pref - b.pref) || (a.idx - b.idx)); + const keep = items[0]; + + for (let i = 1; i < items.length; i++) { + const n = items[i].node; + if (n?.properties?.id === id) delete n.properties.id; + } + + // safety: on s'assure qu'un seul garde bien l'id + if (keep?.node?.properties) keep.node.properties.id = id; + } + }; +} export default defineConfig({ output: "static", trailingSlash: "always", - site: process.env.PUBLIC_SITE ?? "http://localhost:4321", integrations: [ - mdx(), + // Important: MDX hérite du pipeline markdown (ids p-… + autres plugins) + mdx({ extendMarkdownConfig: true }), sitemap({ filter: (page) => !page.includes("/api/") && !page.endsWith("/robots.txt"), }), ], - // ✅ Plugins appliqués AU MDX - mdx: { - // ✅ MDX hérite déjà de markdown.rehypePlugins - // donc ici on ne met QUE le spécifique MDX - rehypePlugins: [ - must("rehype-details-sections", rehypeDetailsSections), - ], - }, - - // ✅ Plugins appliqués au Markdown non-MDX markdown: { rehypePlugins: [ - must("rehype-slug", rehypeSlug), - [must("rehype-autolink-headings", rehypeAutolinkHeadings), { behavior: "append" }], - must("rehype-paragraph-ids", rehypeParagraphIds), + asAny(rehypeSlug), + [asAny(rehypeAutolinkHeadings), { behavior: "append" }], + asAny(rehypeDetailsSections), + asAny(rehypeParagraphIds), + asAny(rehypeDedupeIds), ], }, }); diff --git a/package.json b/package.json index 42824ee..4b01f46 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "astro build", "build:clean": "npm run clean && npm run build", - "postbuild": "node scripts/inject-anchor-aliases.mjs && npx pagefind --site dist", + "postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && npx pagefind --site dist", "import": "node scripts/import-docx.mjs", "apply:ticket": "node scripts/apply-ticket.mjs", diff --git a/scripts/dedupe-ids-dist.mjs b/scripts/dedupe-ids-dist.mjs new file mode 100644 index 0000000..1aed449 --- /dev/null +++ b/scripts/dedupe-ids-dist.mjs @@ -0,0 +1,134 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const DIST_DIR = path.resolve("dist"); + +/** @param {string} dir */ +async function walkHtml(dir) { + /** @type {string[]} */ + const out = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...(await walkHtml(p))); + else if (e.isFile() && p.endsWith(".html")) out.push(p); + } + return out; +} + +/** @param {string} attrs */ +function getClass(attrs) { + const m = attrs.match(/\bclass="([^"]*)"/i); + return m ? m[1] : ""; +} + +/** @param {{tag:string,id:string,cls:string}} occ */ +function score(occ) { + // plus petit = mieux (on garde) + if (occ.tag === "span" && /\bdetails-anchor\b/.test(occ.cls)) return 0; + if (/^h[1-6]$/.test(occ.tag)) return 1; + if (occ.tag === "p" && occ.id.startsWith("p-")) return 2; + return 10; // tout le reste (toc, nav, etc.) +} + +async function main() { + let changedFiles = 0; + let removed = 0; + + const files = await walkHtml(DIST_DIR); + + for (const file of files) { + let html = await fs.readFile(file, "utf8"); + + // capture: + const re = /<([A-Za-z][\w:-]*)([^>]*?)\s+id="([^"]+)"([^>]*?)>/g; + + /** @type {Array<{id:string,tag:string,pre:string,post:string,start:number,end:number,cls:string,idx:number}>} */ + const occs = []; + let m; + let idx = 0; + + while ((m = re.exec(html)) !== null) { + const tag = m[1].toLowerCase(); + const pre = m[2] || ""; + const id = m[3] || ""; + const post = m[4] || ""; + const fullAttrs = `${pre}${post}`; + const cls = getClass(fullAttrs); + + occs.push({ + id, + tag, + pre, + post, + start: m.index, + end: m.index + m[0].length, + cls, + idx: idx++, + }); + } + + if (occs.length === 0) continue; + + /** @type {Map>} */ + const byId = new Map(); + for (const o of occs) { + if (!o.id) continue; + const arr = byId.get(o.id) || []; + arr.push(o); + byId.set(o.id, arr); + } + + /** @type {Array<{start:number,end:number,repl:string}>} */ + const edits = []; + + for (const [id, arr] of byId.entries()) { + if (arr.length <= 1) continue; + + // choisir le “meilleur” porteur d’id : details-anchor > h2/h3... > p-... > reste + const sorted = [...arr].sort((a, b) => { + const sa = score(a); + const sb = score(b); + if (sa !== sb) return sa - sb; + return a.idx - b.idx; // stable: premier + }); + + const keep = sorted[0]; + + for (const o of sorted.slice(1)) { + // remplacer l’ouverture de tag en supprimant l’attribut id + // ==> + const repl = `<${o.tag}${o.pre}${o.post}>`; + edits.push({ start: o.start, end: o.end, repl }); + removed++; + } + + // sécurité: on “force” l'id sur le keep (au cas où il aurait été modifié plus haut) + // (on ne touche pas au keep ici, juste on ne le retire pas) + void keep; + void id; + } + + if (edits.length === 0) continue; + + // appliquer de la fin vers le début + edits.sort((a, b) => b.start - a.start); + for (const e of edits) { + html = html.slice(0, e.start) + e.repl + html.slice(e.end); + } + + await fs.writeFile(file, html, "utf8"); + changedFiles++; + } + + if (changedFiles > 0) { + console.log(`✅ dedupe-ids-dist: files_changed=${changedFiles} ids_removed=${removed}`); + } else { + console.log("ℹ️ dedupe-ids-dist: no duplicates found"); + } +} + +main().catch((err) => { + console.error("❌ dedupe-ids-dist failed:", err); + process.exit(1); +});