/** * Modern TOC generator with active state tracking * Builds table of contents from h2 and h3 headings with IDs */ function slugify(text: string): string { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); } export function initToc() { const tocContainers = document.querySelectorAll('[data-toc]'); if (tocContainers.length === 0) return; // Check for auto mode globally (only once) const hasAutoMode = document.querySelector('[data-toc-auto]'); if (hasAutoMode) { // Auto mode: pick up h2 without ids const allH2 = document.querySelectorAll('h2'); // Auto-generate IDs for h2 without them (once) allH2.forEach(h2 => { if (!h2.id && h2.textContent) { h2.id = slugify(h2.textContent); } }); } // Collect headings based on mode // Note: We prioritize h2/h3 over sections to avoid duplicates when section contains h2 let headings: Element[]; if (hasAutoMode) { const h2h3 = Array.from(document.querySelectorAll('h2[id], h3[id]')); const sections = Array.from(document.querySelectorAll('section[id]')); // Only include sections that don't contain h2/h3 with IDs const sectionsWithoutHeadings = sections.filter(section => { const hasHeadings = section.querySelector('h2[id], h3[id]'); return !hasHeadings; }); headings = [...h2h3, ...sectionsWithoutHeadings]; } else { headings = Array.from(document.querySelectorAll('h2[id], h3[id]')); } if (headings.length === 0) { // Hide the TOC sidebar if no headings found tocContainers.forEach(container => { const sidebar = container.closest('aside'); if (sidebar) { sidebar.style.display = 'none'; } }); return; } tocContainers.forEach(tocContainer => { // Prevent duplicate initialization if (tocContainer.hasAttribute('data-toc-initialized')) return; tocContainer.setAttribute('data-toc-initialized', 'true'); // Store original position for desktop view const originalParent = tocContainer.parentNode; const originalNextSibling = tocContainer.nextSibling; // Create or find mobile anchor let anchor = document.querySelector('.toc-mobile-anchor'); if (!anchor) { anchor = document.createElement('div'); anchor.className = 'toc-mobile-anchor'; // Find a good spot: after the first h1 + p, or after first h1, or at start of main const h1 = document.querySelector('main h1'); if (h1) { const nextEl = h1.nextElementSibling; if (nextEl && nextEl.tagName === 'P') { // Insert after the intro paragraph nextEl.parentNode?.insertBefore(anchor, nextEl.nextSibling); } else { // Insert after h1 h1.parentNode?.insertBefore(anchor, h1.nextSibling); } } else { // Fallback: insert at start of main content area const main = document.querySelector('main'); main?.insertBefore(anchor, main.firstChild); } } const placeToc = () => { const isMobile = window.innerWidth <= 1024; const isCurrentlyInDesktopPosition = tocContainer.parentNode === originalParent; if (isMobile && isCurrentlyInDesktopPosition) { // It's mobile screen, but TOC is in desktop slot. Move it. if (anchor && anchor.parentNode) { anchor.parentNode.insertBefore(tocContainer, anchor); } } else if (!isMobile && !isCurrentlyInDesktopPosition) { // It's desktop screen, but TOC is in mobile slot. Move it back. if (originalParent) { originalParent.insertBefore(tocContainer, originalNextSibling); } } }; // Initial placement on load placeToc(); // Re-place on resize (debounced) let resizeTimer: number; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = window.setTimeout(placeToc, 100); }); // Build TOC structure const nav = document.createElement('nav'); nav.className = 'toc'; const title = document.createElement('div'); title.className = 'toc-title'; title.textContent = 'On this page'; nav.appendChild(title); const list = document.createElement('ul'); list.className = 'toc-list'; headings.forEach(heading => { const li = document.createElement('li'); const link = document.createElement('a'); link.href = `#${heading.id}`; // For sections, use first h2/h3 content or id as fallback if (heading.tagName === 'SECTION') { const firstHeading = heading.querySelector('h2, h3'); link.textContent = firstHeading?.textContent || heading.id; } else { link.textContent = heading.textContent || ''; } link.className = heading.tagName === 'H3' ? 'toc-link toc-link--nested' : 'toc-link'; link.setAttribute('data-heading-id', heading.id); link.onclick = (e) => { e.preventDefault(); const element = heading as HTMLElement; const offsetTop = element.offsetTop - 100; window.scrollTo({ top: offsetTop, behavior: 'smooth' }); history.pushState(null, '', `#${heading.id}`); }; li.appendChild(link); list.appendChild(li); }); nav.appendChild(list); tocContainer.appendChild(nav); }); // Set up intersection observer for active state tracking (once for all TOCs) const observerOptions = { rootMargin: '-100px 0px -66% 0px', threshold: 0 }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { const id = entry.target.getAttribute('id'); const tocLinks = document.querySelectorAll(`[data-heading-id="${id}"]`); if (entry.isIntersecting) { // Remove active from all links document.querySelectorAll('.toc-link.active').forEach(link => { link.classList.remove('active'); }); // Add active to current link (in all TOC instances) tocLinks.forEach(link => link?.classList.add('active')); } }); }, observerOptions); headings.forEach(heading => observer.observe(heading)); } // Auto-init on load if (typeof window !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initToc); } else { initToc(); } }