/** GitHub-style heading slug: lowercase, markdown stripped, punctuation dropped, spaces to hyphens. */ export function slugify(heading: string): string { return heading .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // links and images keep their text .replace(/[`*~]/g, "") // code spans and emphasis markers .replace(/<[^>]+>/g, "") // inline html .trim() .toLowerCase() .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "") .replace(/\s/g, "-"); } interface Heading { line: number; level: number; text: string; } function headings(lines: string[]): Heading[] { const out: Heading[] = []; let fence: string | null = null; lines.forEach((line, i) => { const f = /^ {0,3}(`{3,}|~{3,})/.exec(line); if (f) { const marker = f[1]![0]!; if (fence === null) fence = marker; else if (fence === marker) fence = null; return; } if (fence !== null) return; const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); if (h) out.push({ line: i, level: h[1]!.length, text: h[2]! }); }); return out; } /** Heading line plus everything up to the next heading of the same or a higher level. */ export function extractSection(markdown: string, anchor: string): string | null { const lines = markdown.split(/\r?\n/); const hs = headings(lines); const seen = new Map(); for (let i = 0; i < hs.length; i++) { const h = hs[i]!; const base = slugify(h.text); const n = seen.get(base) ?? 0; seen.set(base, n + 1); const slug = n === 0 ? base : `${base}-${n}`; // GitHub numbers duplicates if (slug !== anchor) continue; const next = hs.slice(i + 1).find((x) => x.level <= h.level); return lines.slice(h.line, next ? next.line : lines.length).join("\n").trimEnd(); } return null; } /** `
……
` blocks whose summary starts with `match`, in document order. */ export function extractHints(markdown: string, match: string): { label: string; body: string }[] { const out: { label: string; body: string }[] = []; const re = /
\s*([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/g; for (const m of markdown.matchAll(re)) { const summary = m[1]!.trim(); if (!summary.startsWith(match)) continue; const label = summary.slice(match.length).replace(/^\s*(?:—|-|\()\s*/, "").trim(); out.push({ label, body: m[2]!.trim() }); } return out; }