TableOfContents.tsx98 lines · main
1'use client'
2
3import { useEffect, useState } from 'react'
4import { cn } from 'ui'
5
6function slugify(text: string) {
7 let slug = text
8 .toLowerCase()
9 .replace(/[^\w\s-]/g, '')
10 .replace(/\s/g, '-')
11 .replace(/-{2,}/g, '-')
12 .trim()
13 if (/^\d/.test(slug)) slug = `section-${slug}`
14 return slug
15}
16
17interface Heading {
18 text: string
19 slug: string
20 level: number
21}
22
23function extractHeadings(markdown: string): Heading[] {
24 const headings: Heading[] = []
25 // Trim each line first to handle indented markdown, then match ATX headings.
26 // Using a literal space (not \s+) after the hashes avoids polynomial backtracking.
27 const regex = /^(#{1,3}) (.+)$/gm
28 const normalized = markdown
29 .split('\n')
30 .map((line) => line.trimStart())
31 .join('\n')
32 let match
33 while ((match = regex.exec(normalized)) !== null) {
34 const text = match[2].trim()
35 headings.push({
36 text,
37 slug: slugify(text),
38 level: match[1].length,
39 })
40 }
41 return headings
42}
43
44export default function TableOfContents({ content }: { content: string }) {
45 const headings = extractHeadings(content)
46 const [activeSlug, setActiveSlug] = useState<string>('')
47
48 useEffect(() => {
49 const elements = headings
50 .map((h) => document.getElementById(h.slug))
51 .filter(Boolean) as HTMLElement[]
52
53 if (elements.length === 0) return
54
55 const observer = new IntersectionObserver(
56 (entries) => {
57 for (const entry of entries) {
58 if (entry.isIntersecting) {
59 setActiveSlug(entry.target.id)
60 }
61 }
62 },
63 { rootMargin: '-80px 0px -70% 0px' }
64 )
65
66 elements.forEach((el) => observer.observe(el))
67 return () => observer.disconnect()
68 }, [headings])
69
70 if (headings.length === 0) return null
71
72 return (
73 <nav className="sticky top-24">
74 <ul className="flex flex-col gap-2 border-l border-muted border-dashed">
75 {headings.map((heading) => (
76 <li key={heading.slug}>
77 <a
78 href={`#${heading.slug}`}
79 className={cn(
80 'block text-xs -ml-px border-l transition-colors',
81 heading.level === 1 && 'pl-4',
82 heading.level === 2 && 'pl-4',
83 heading.level === 3 && 'pl-8',
84 activeSlug === heading.slug
85 ? 'text-foreground border-foreground'
86 : 'text-foreground-muted border-transparent hover:text-foreground-lighter'
87 )}
88 >
89 {heading.text}
90 </a>
91 </li>
92 ))}
93 </ul>
94 </nav>
95 )
96}
97
98export { slugify }