Files
archicratie-edition/scripts/rehype-details-sections.mjs
archicratia 60d88939b0
All checks were successful
CI / build-and-anchors (push) Successful in 1m25s
SMOKE / smoke (push) Successful in 11s
CI / build-and-anchors (pull_request) Successful in 1m20s
Seed from NAS prod snapshot 20260130-190531
2026-01-31 10:51:38 +00:00

164 lines
5.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// scripts/rehype-details-sections.mjs
// Rehype plugin: regroupe chaque section H2 en <details>/<summary> (accordion).
// FIX: anti-récursion / anti-boucle infinie -> post-order traversal (children first, then transform)
// et on ne retraverse pas les <details> 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 jusquau 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
// (lancre est portée par un <span id=...> 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 cest un <details> 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 quelquun a mis une class identique manuellement
const cls = classList(props.className);
if (cls.includes(detailsClass)) return;
}
// post-order : dabord 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);
};
}