164 lines
5.1 KiB
JavaScript
164 lines
5.1 KiB
JavaScript
// 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 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 <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 c’est 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 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);
|
||
};
|
||
} |