// scripts/rehype-details-sections.mjs // Rehype plugin: regroupe chaque section H2 en
/ (accordion). // FIX: anti-récursion / anti-boucle infinie -> post-order traversal (children first, then transform) // et on ne retraverse pas les
insérés. function isElement(node) { return node && node.type === "element" && typeof node.tagName === "string"; } function headingLevel(node) { if (!isElement(node)) return null; const m = node.tagName.match(/^h([1-6])$/i); return m ? Number(m[1]) : null; } function nodeText(node) { if (!node) return ""; if (node.type === "text") return String(node.value || ""); if (isElement(node) && Array.isArray(node.children)) { return node.children.map(nodeText).join(""); } return ""; } function classList(v) { if (!v) return []; if (Array.isArray(v)) return v.map(String); if (typeof v === "string") return v.split(/\s+/).filter(Boolean); return []; } export default function rehypeDetailsSections(options = {}) { const { // Par défaut : sections sur H2 uniquement minDepth = 2, maxDepth = 2, openByDefault = false, detailsClass = "details-section", summaryClass = "details-summary", bodyClass = "details-body", anchorClass = "details-anchor", } = options; const DETAILS_SENTINEL_ATTR = "data-details-sections"; function transformChildren(parent) { if (!parent || !Array.isArray(parent.children) || parent.children.length === 0) return; const children = parent.children; const out = []; for (let i = 0; i < children.length; i++) { const n = children[i]; const lvl = headingLevel(n); // Wrap uniquement les headings min/max if (lvl && lvl >= minDepth && lvl <= maxDepth) { const heading = n; const props = heading.properties || {}; const id = typeof props.id === "string" ? props.id : ""; const title = nodeText(heading).trim() || "Section"; // Capture jusqu’au prochain heading de niveau <= lvl const bodyNodes = []; i++; for (; i < children.length; i++) { const nxt = children[i]; const nlvl = headingLevel(nxt); if (nlvl && nlvl <= lvl) { i--; // rendre la main au for principal break; } bodyNodes.push(nxt); } // On garde le heading dans le body mais on retire l'id // (l’ancre est portée par un au-dessus) const headingClone = { ...heading, properties: { ...(heading.properties || {}) }, }; if (headingClone.properties) delete headingClone.properties.id; const details = { type: "element", tagName: "details", properties: { [DETAILS_SENTINEL_ATTR]: "1", className: [detailsClass], ...(openByDefault ? { open: true } : {}), "data-section-level": String(lvl), }, children: [ { type: "element", tagName: "summary", properties: { className: [summaryClass] }, children: [ ...(id ? [{ type: "element", tagName: "span", properties: { id, className: [anchorClass], "aria-hidden": "true" }, children: [], }] : []), (id ? { type: "element", tagName: "a", properties: { href: `#${id}` }, children: [{ type: "text", value: title }], } : { type: "text", value: title }), ], }, { type: "element", tagName: "div", properties: { className: [bodyClass] }, children: [headingClone, ...bodyNodes], }, ], }; out.push(details); continue; } out.push(n); } parent.children = out; } function walk(node) { if (!node) return; // IMPORTANT: si c’est un
déjà produit par nous, // on ne descend pas dedans (sinon boucle infinie). if (isElement(node) && node.tagName === "details") { const props = node.properties || {}; if (props[DETAILS_SENTINEL_ATTR] === "1") return; // Si jamais quelqu’un a mis une class identique manuellement const cls = classList(props.className); if (cls.includes(detailsClass)) return; } // post-order : d’abord les enfants if (node.type === "root" || isElement(node)) { const kids = node.children || []; for (const k of kids) walk(k); } // puis on transforme le parent transformChildren(node); } return function transformer(tree) { walk(tree); }; }