/** * scripts/generate-docs/lib/page-outline.ts * * Works on a page's rendered HTML (markdown pages and `.hbs` pages alike): * - gives every

/

a stable `id` (marked 12 does not emit any), * - cuts the page into sections, with the FR and EN text of each. * The "on this page" TOC and the search index are both built from this outline, * so a search result can only point to an anchor that exists on the page. * * The HTML here is our own generator output, so a small balanced-tag scanner is * enough — no DOM library. */ import type { Lang, Localised } from './i18n-types'; export interface OutlineSection { id: string; level: 2 | 3; title: Localised; text: Localised; } export interface PageOutline { /** Text before the first heading (lead paragraph, "En bref" box). */ intro: Localised; sections: OutlineSection[]; } const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', rsquo: '’', lsquo: '‘', laquo: '«', raquo: '»', hellip: '…', mdash: '—', ndash: '–', rarr: '→', larr: '←', middot: '·', }; function decodeEntities(s: string): string { return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, code: string) => { if (code[0] === '#') { const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10); return Number.isFinite(n) ? String.fromCodePoint(n) : m; } return ENTITIES[code.toLowerCase()] ?? m; }); } /** Accent-free kebab slug: « Démarrage rapide » → `demarrage-rapide`. */ export function slugifyHeading(text: string): string { const slug = text .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); return slug || 'section'; } /** * Index just past the element that opens at `start` (its matching close tag), * counting nested elements of the same tag name. Void/self-closed elements end * at their own `>`. */ function elementEnd(html: string, start: number, tag: string): number { const openEnd = html.indexOf('>', start); if (openEnd < 0) return html.length; if (html[openEnd - 1] === '/') return openEnd + 1; const re = new RegExp(`<(/?)${tag}(?=[\\s>/])[^>]*>`, 'gi'); re.lastIndex = openEnd + 1; let depth = 1; for (let m = re.exec(html); m; m = re.exec(html)) { if (m[0].endsWith('/>')) continue; depth += m[1] ? -1 : 1; if (depth === 0) return m.index + m[0].length; } return html.length; } /** Removes every element whose opening tag matches `openRe` (must capture the tag name in group 1). */ function removeElements(html: string, openRe: RegExp): string { let out = ''; let pos = 0; const re = new RegExp(openRe.source, openRe.flags.includes('g') ? openRe.flags : openRe.flags + 'g'); for (let m = re.exec(html); m; m = re.exec(html)) { if (m.index < pos) continue; out += html.slice(pos, m.index); pos = elementEnd(html, m.index, m[1]); re.lastIndex = pos; } return out + html.slice(pos); } /** Drops the elements written in the other language (`data-lang=""`). */ export function keepLang(html: string, lang: Lang): string { const other = lang === 'fr' ? 'en' : 'fr'; return removeElements(html, new RegExp(`<([a-z][a-z0-9]*)\\b[^>]*\\bdata-lang="${other}"[^>]*>`, 'i')); } /** Visible text of an HTML fragment: no scripts, styles, SVG or skill lists; entities decoded. */ export function htmlToText(html: string): string { // The "En bref" label is chrome, not content. let s = html.replace(/(
\s*)[\s\S]*?<\/b>/g, '$1'); s = removeElements(s, /<(script|style|svg|template|button|kbd)\b[^>]*>/i); s = removeElements(s, /<([a-z][a-z0-9]*)\b[^>]*\bclass="[^"]*\b(?:skill-list|skill-filter|copy-btn|eyebrow|suggest)\b[^"]*"[^>]*>/i); s = s.replace(/<[^>]+>/g, ' '); return decodeEntities(s).replace(/\s+/g, ' ').trim(); } function localisedText(html: string): Localised { return { fr: htmlToText(keepLang(html, 'fr')), en: htmlToText(keepLang(html, 'en')) }; } const HEADING_RE = /<(h[23])(\s[^>]*)?>([\s\S]*?)<\/\1>/gi; /** * Adds an `id` to every

/

that has none, derived from its French text * (or its own text for a heading that is itself `data-lang="en"`). Existing ids * are kept; collisions get a `-2`, `-3`… suffix. */ export function addHeadingIds(html: string): string { const taken = new Set(Array.from(html.matchAll(/\sid="([^"]+)"/g), (m) => m[1])); return html.replace(HEADING_RE, (whole, tag: string, attrs = '', inner: string) => { if (/\sid="/.test(attrs)) return whole; const isEn = /\bdata-lang="en"/.test(attrs); const text = isEn ? htmlToText(inner) : htmlToText(keepLang(inner, 'fr')) || htmlToText(inner); const base = slugifyHeading(text); let id = base; for (let n = 2; taken.has(id); n++) id = `${base}-${n}`; taken.add(id); return `<${tag} id="${id}"${attrs}>${inner}`; }); } interface RawHeading { index: number; end: number; level: 2 | 3; id: string; lang: Lang | null; inner: string; } /** * Cuts the page into sections at each

/

carrying an id. A heading pair * written as `

…

…

` counts as one * section (the FR heading's id). */ export function outlinePage(html: string): PageOutline { const heads: RawHeading[] = []; for (const m of html.matchAll(HEADING_RE)) { const attrs = m[2] ?? ''; const id = /\sid="([^"]+)"/.exec(attrs)?.[1]; if (!id) continue; const lang = (/\bdata-lang="(fr|en)"/.exec(attrs)?.[1] as Lang | undefined) ?? null; heads.push({ index: m.index!, end: m.index! + m[0].length, level: m[1].toLowerCase() === 'h2' ? 2 : 3, id, lang, inner: m[3] }); } const sections: OutlineSection[] = []; for (let i = 0; i < heads.length; i++) { const h = heads[i]; let title: Localised; let bodyStart = h.end; const twin = heads[i + 1]; if (h.lang === 'fr' && twin && twin.lang === 'en' && twin.level === h.level && !html.slice(h.end, twin.index).trim()) { title = { fr: htmlToText(h.inner), en: htmlToText(twin.inner) }; bodyStart = twin.end; i++; } else if (h.lang) { const t = htmlToText(h.inner); title = { fr: t, en: t }; } else { title = localisedText(h.inner); } const next = heads[i + 1]; const body = html.slice(bodyStart, next ? next.index : html.length); sections.push({ id: h.id, level: h.level, title, text: localisedText(body) }); } const firstHead = heads[0]?.index ?? html.length; const beforeHeads = html.slice(0, firstHead).replace(//gi, ''); return { intro: localisedText(beforeHeads), sections }; }