Seed from NAS prod snapshot 20260130-190531
This commit is contained in:
164
scripts/rehype-details-sections.mjs
Normal file
164
scripts/rehype-details-sections.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user