/** * T-147 — table-of-contents + heading anchors. * * A `toc` block is a pure marker (content `{}`); its entries are GENERATED * from the page's heading blocks at render time. This module is the shared * pure generator: it takes an ordered, parented block list and returns the * TOC entries (depth 1-3, in document order) + a stable heading anchor slug * for deep-linking. * * The slug is derived from the heading TEXT (GitHub-style: lowercase, * spaces → hyphens, drop non-alphanumerics except hyphens/underscores, * de-duplicate within the page so two "Intro" headings get distinct anchors). */ export interface TocEntry { blockId: string; level: 1 | 2 | 3; text: string; anchor: string; } export interface TocBlockInput { id: string; type: string; // uppercase DB wire type: HEADING_1 | HEADING_2 | HEADING_3 | … content: { text?: unknown }; parentId: string | null; position: string; // fractional ordering within the parent } function headingText(content: { text?: unknown }): string { if (typeof content.text === "string") return content.text; return ""; } /** * GitHub-compatible anchor slug. Empty text → "" (the renderer then falls * back to `heading-`). */ export function headingSlug(text: string): string { const s = text .toLowerCase() .trim() .replace(/<[^>]*>/g, " ") // strip inline html (legacy storage model) .replace(/[^a-z0-9 _-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); return s; } /** * Generate TOC entries from the page's ordered blocks (FLAT list, any * order — the function sorts by (parentId-chain, position)). Only direct * top-level headings are listed? No — every heading anywhere in the tree * is listed at its own depth, matching Notion's TOC behavior (a heading * nested inside a toggle still shows in the TOC at its level). */ export function generateToc(blocks: TocBlockInput[]): TocEntry[] { // document order: group by parent, sort each group by position, DFS from // the root (parentId null). Blocks whose parent is missing are treated as // root (defensive — a deleted parent should not drop its children from the // TOC). const byParent = new Map(); const known = new Set(blocks.map((b) => b.id)); for (const b of blocks) { const key = b.parentId && known.has(b.parentId) ? b.parentId : null; const arr = byParent.get(key) ?? []; arr.push(b); byParent.set(key, arr); } const pos = (s: string) => s; // fractional strings sort lexicographically const entries: TocEntry[] = []; const seen = new Map(); const visit = (parentId: string | null) => { const group = (byParent.get(parentId) ?? []).slice().sort((a, b) => pos(a.position) < pos(b.position) ? -1 : 1); for (const b of group) { const level = b.type === "HEADING_1" ? 1 : b.type === "HEADING_2" ? 2 : b.type === "HEADING_3" ? 3 : 0; if (level > 0) { const text = headingText(b.content).trim(); const base = headingSlug(text) || "heading"; const n = (seen.get(base) ?? 0) + 1; seen.set(base, n); entries.push({ blockId: b.id, level: level as 1 | 2 | 3, text, anchor: n === 1 ? base : `${base}-${n}` }); } visit(b.id); } }; visit(null); return entries; } /** Build the deep-link URL fragment for an anchor (`#`). */ export function anchorHref(anchor: string): string { return `#${anchor}`; }