"`). */
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}${tag}>`;
});
}
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 };
}