1
14
15export interface TocEntry {
16 blockId: string;
17 level: 1 | 2 | 3;
18 text: string;
19 anchor: string;
20}
21
22export interface TocBlockInput {
23 id: string;
24 type: string;
25 content: { text?: unknown };
26 parentId: string | null;
27 position: string;
28}
29
30function headingText(content: { text?: unknown }): string {
31 if (typeof content.text === "string") return content.text;
32 return "";
33}
34
35
39export function headingSlug(text: string): string {
40 const s = text
41 .toLowerCase()
42 .trim()
43 .replace(/<[^>]*>/g, " ")
44 .replace(/[^a-z0-9 _-]/g, "")
45 .replace(/\s+/g, "-")
46 .replace(/-+/g, "-")
47 .replace(/^-|-$/g, "");
48 return s;
49}
50
51
58export function generateToc(blocks: TocBlockInput[]): TocEntry[] {
59
60
61
62
63 const byParent = new Map<string | null, TocBlockInput[]>();
64 const known = new Set(blocks.map((b) => b.id));
65 for (const b of blocks) {
66 const key = b.parentId && known.has(b.parentId) ? b.parentId : null;
67 const arr = byParent.get(key) ?? [];
68 arr.push(b);
69 byParent.set(key, arr);
70 }
71 const pos = (s: string) => s;
72 const entries: TocEntry[] = [];
73 const seen = new Map<string, number>();
74
75 const visit = (parentId: string | null) => {
76 const group = (byParent.get(parentId) ?? []).slice().sort((a, b) => pos(a.position) < pos(b.position) ? -1 : 1);
77 for (const b of group) {
78 const level = b.type === "HEADING_1" ? 1 : b.type === "HEADING_2" ? 2 : b.type === "HEADING_3" ? 3 : 0;
79 if (level > 0) {
80 const text = headingText(b.content).trim();
81 const base = headingSlug(text) || "heading";
82 const n = (seen.get(base) ?? 0) + 1;
83 seen.set(base, n);
84 entries.push({ blockId: b.id, level: level as 1 | 2 | 3, text, anchor: n === 1 ? base : `${base}-${n}` });
85 }
86 visit(b.id);
87 }
88 };
89 visit(null);
90 return entries;
91}
92
93
94export function anchorHref(anchor: string): string {
95 return `#${anchor}`;
96}
97