Files
tolaria/src/utils/tableOfContents.ts
2026-05-04 06:08:09 +02:00

125 lines
3.1 KiB
TypeScript

export interface TableOfContentsItem {
id: string
level: number
text: string
children: TableOfContentsItem[]
}
type UnknownRecord = Record<string, unknown>
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readString(record: UnknownRecord, key: string): string | null {
const value = record[key]
return typeof value === 'string' ? value : null
}
function readChildren(record: UnknownRecord): unknown[] {
const value = record.children
return Array.isArray(value) ? value : []
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function collectTextSegments(value: unknown, segments: string[]) {
if (typeof value === 'string') {
segments.push(value)
return
}
if (Array.isArray(value)) {
for (const item of value) collectTextSegments(item, segments)
return
}
if (!isRecord(value)) return
const text = readString(value, 'text')
if (text !== null) segments.push(text)
collectTextSegments(value.content, segments)
collectTextSegments(value.children, segments)
}
function extractInlineText(content: unknown): string {
const segments: string[] = []
collectTextSegments(content, segments)
return normalizeText(segments.join(' '))
}
function parseHeadingLevel(rawLevel: unknown): number | null {
const level =
typeof rawLevel === 'string'
? Number(rawLevel)
: typeof rawLevel === 'number'
? rawLevel
: Number.NaN
if (!Number.isInteger(level)) return null
if (level < 1 || level > 6) return null
return level
}
function headingLevel(block: UnknownRecord): number | null {
if (readString(block, 'type') !== 'heading') return null
const props = isRecord(block.props) ? block.props : {}
return parseHeadingLevel(props.level)
}
function buildHeadingItem(block: UnknownRecord): TableOfContentsItem | null {
const id = readString(block, 'id')
const level = headingLevel(block)
if (!id || level === null) return null
return {
id,
level,
text: extractInlineText(block.content),
children: [],
}
}
function collectHeadingItems(blocks: unknown): TableOfContentsItem[] {
if (!Array.isArray(blocks)) return []
const items: TableOfContentsItem[] = []
for (const block of blocks) {
if (!isRecord(block)) continue
const heading = buildHeadingItem(block)
if (heading) items.push(heading)
items.push(...collectHeadingItems(readChildren(block)))
}
return items
}
function appendHeadingItem(
roots: TableOfContentsItem[],
stack: TableOfContentsItem[],
item: TableOfContentsItem,
) {
while (stack.length > 0 && stack[stack.length - 1].level >= item.level) {
stack.pop()
}
const parent = stack[stack.length - 1]
if (parent) {
parent.children.push(item)
} else {
roots.push(item)
}
stack.push(item)
}
export function extractTableOfContents(blocks: unknown): TableOfContentsItem[] {
const roots: TableOfContentsItem[] = []
const stack: TableOfContentsItem[] = []
for (const item of collectHeadingItems(blocks)) {
appendHeadingItem(roots, stack, item)
}
return roots
}