'use client' import { useEffect, useMemo, useState } from 'react' import { cn } from '../../utils/cn' /** Must match MarkdownRenderer's heading id slugify exactly so anchors line up. */ function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') } export interface TocItem { id: string text: string level: number } /** Parse markdown headings (#, ##, ###) into a flat outline, skipping fenced code. */ export function extractToc(content: string): TocItem[] { const items: TocItem[] = [] let inCode = false for (const raw of content.split('\n')) { const line = raw.trimEnd() if (/^\s*```/.test(line)) { inCode = !inCode continue } if (inCode) continue const m = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line) if (!m) continue const text = m[2].replace(/[*_`]/g, '').trim() const id = slugify(text) if (!id) continue items.push({ id, text, level: m[1].length }) } return items } export interface TableOfContentsProps { content: string title?: string className?: string } /** * Sticky left-rail outline for an article, with scroll-spy highlighting. * Renders nothing for content with fewer than 2 headings. */ export function TableOfContents({ content, title = 'On this page', className }: TableOfContentsProps) { const items = useMemo(() => extractToc(content), [content]) const [activeId, setActiveId] = useState(null) useEffect(() => { if (items.length < 2) return const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) setActiveId(entry.target.id) } }, { rootMargin: '0px 0px -70% 0px', threshold: 0 }, ) items.forEach((it) => { const el = document.getElementById(it.id) if (el) observer.observe(el) }) return () => observer.disconnect() }, [items]) if (items.length < 2) return null return ( ) }