"use client" import { useEffect, useState } from "react" import { usePathname } from "next/navigation" import { useTranslations } from "next-intl" type Heading = { id: string; text: string; level: 2 | 3 } /** * Right-rail table of contents for docs pages. * * On mount it walks every

and

inside `
` (the docs * container), assigns each one an `id` derived from its text if it * doesn't already have one, and renders a sticky list of anchors. * * Why client-side and not server-rendered? Most docs pages emit * headings as plain JSX without ids — extracting them at build time * would require either touching all ~107 pages or a custom MDX * pipeline. A 15-line useEffect avoids both. * * Scroll-spy: an IntersectionObserver highlights the entry whose * heading is currently in the upper half of the viewport, so the user * can see where they are as they scroll. * * Only renders on xl+ screens (the layout reserves the right gutter * there); on smaller viewports the ToC stays hidden so the article * keeps full width. */ export function DocTableOfContents() { const pathname = usePathname() const t = useTranslations("tocPanel") const [headings, setHeadings] = useState([]) const [activeId, setActiveId] = useState("") // Re-scan whenever the route changes (so navigating between docs // pages refreshes the ToC). useEffect(() => { const main = document.querySelector("main") if (!main) { setHeadings([]) return } const nodes = Array.from(main.querySelectorAll("h2, h3")) as HTMLHeadingElement[] const used = new Set() const collected: Heading[] = nodes .map((node) => { const text = (node.textContent || "").trim() if (!text) return null // Always dedupe: if a heading carries an explicit id that already // appeared (e.g. a card

VM

auto-slugged to "vm" before a //

later in the page), append -2, -3, ... so React // keys stay unique. The scroll-anchor still works for the first // occurrence; subsequent ones get their own anchor. let base = node.id || slugify(text) let id = base let n = 1 while (used.has(id)) { n += 1 id = `${base}-${n}` } if (!node.id || node.id !== id) { node.id = id } used.add(id) return { id, text, level: node.tagName === "H3" ? 3 : 2 } as Heading }) .filter((h): h is Heading => h !== null) setHeadings(collected) }, [pathname]) // Scroll-spy: highlight the heading currently in the upper half of // the viewport. Using rootMargin pushes the trigger zone toward the // top so the active entry changes as you scroll, not only when the // heading is dead-centre. useEffect(() => { if (headings.length === 0) return const observer = new IntersectionObserver( (entries) => { const visible = entries.filter((e) => e.isIntersecting) if (visible.length > 0) { // First visible entry from the top of the viewport. visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) setActiveId(visible[0].target.id) } }, { rootMargin: "-80px 0px -70% 0px", threshold: 0 }, ) headings.forEach((h) => { const el = document.getElementById(h.id) if (el) observer.observe(el) }) return () => observer.disconnect() }, [headings]) if (headings.length === 0) return null return ( ) } function slugify(text: string): string { return text .toLowerCase() .normalize("NFD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") .slice(0, 80) }