1(function () { 2 // Ensure global handlers only bind once 3 if (window.__tocGlobalInit) return; 4 window.__tocGlobalInit = true; 5 6 // Store cleanup function for previous TOC instance 7 let cleanupPreviousToc = null; 8 9 function initToc() { 10 // Cleanup previous instance if exists 11 if (cleanupPreviousToc) { 12 cleanupPreviousToc(); 13 cleanupPreviousToc = null; 14 } 15 16 let tocExpanded = false; 17 18 // Get all heading elements in the article + any headings referenced by TOC 19 const headings = document.querySelectorAll('article h2[id], article h3[id], #post-faqs'); 20 const desktopTocContainer = document.querySelector('#toc-container #toc'); 21 const tocLinks = desktopTocContainer ? desktopTocContainer.querySelectorAll('a') : []; 22 23 if (!headings.length || !tocLinks.length) { 24 return; 25 } 26 27 // Create map for heading elements. First occurrence wins so duplicate 28 // ids (legacy builds without dedupe) resolve like native anchors. 29 const headingElements = {};
30 headings.forEach((heading) => { 31 const id = heading.getAttribute('id'); 32 if (id && id !== 'toc-title' && !headingElements[id]) { 33 headingElements[id] = heading; 34 } 35 }); 36 37 // Build a set of IDs that are actually in the TOC 38 const tocLinkIds = new Set(); 39 tocLinks.forEach((link) => { 40 const href = link.getAttribute('href'); 41 if (href && href.startsWith('#')) { 42 const targetId = href 43 .substring(1) 44 .replace(/[\n\r\t]/g, '') 45 .trim(); 46 tocLinkIds.add(targetId); 47 if (!headingElements[targetId]) { 48 const element = document.getElementById(targetId); 49 if (element) { 50 headingElements[targetId] = element; 51 } 52 } 53 } 54 }); 55 56 // Find the TOC-visible ID for a heading (if h3 not in TOC, find parent h2) 57 function getTocVisibleId(id) { 58 if (tocLinkIds.has(id)) { 59 return id; 60 } 61 // Heading not in TOC, find parent h2 62 const element = headingElements[id]; 63 if (!element) return null; 64 65 let sibling = element.previousElementSibling; 66 while (sibling) { 67 if (sibling.tagName === 'H2' && sibling.id && tocLinkIds.has(sibling.id)) { 68 return sibling.id; 69 } 70 sibling = sibling.previousElementSibling; 71 } 72 return null; 73 } 74 75 function hasPassedMiddle(element) { 76 const rect = element.getBoundingClientRect(); 77 const viewportMiddle = window.innerHeight / 2; 78 return rect.top <= viewportMiddle; 79 } 80 81 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 82 83 // Track the last active ID to avoid redundant scroll operations 84 let lastActiveId = null; 85 86 // Find the TOC's scrollable container once (aside with overflow-auto or #toc-container) 87 const tocScrollParent = (function () { 88 let el = desktopTocContainer; 89 while (el && el !== document.body) { 90 el = el.parentElement; 91 if (!el) return null; 92 const overflow = getComputedStyle(el).overflowY; 93 if (overflow === 'auto' || overflow === 'scroll') return el; 94 } 95 return null; 96 })(); 97 98 function setActiveLink(id) { 99 tocLinks.forEach((link) => link.classList.remove('active', 'parent-active')); 100 101 const sanitizedId = id.replace(/[\n\r\t]/g, '').trim(); 102 let activeLink = null; 103 104 tocLinks.forEach((link) => { 105 const href = link.getAttribute('href'); 106 if (href && href.startsWith('#')) { 107 const linkId = href 108 .substring(1) 109 .replace(/[\n\r\t]/g, '') 110 .trim(); 111 if (linkId === sanitizedId && !activeLink) { 112 activeLink = link; 113 } 114 } 115 }); 116 117 if (activeLink) { 118 activeLink.classList.add('active'); 119 120 // Auto-scroll TOC container to keep active item visible 121 if (lastActiveId !== sanitizedId && tocScrollParent) { 122 lastActiveId = sanitizedId; 123 const containerRect = tocScrollParent.getBoundingClientRect(); 124 const linkRect = activeLink.getBoundingClientRect(); 125 if (linkRect.top < containerRect.top || linkRect.bottom > containerRect.bottom) { 126 const nextScrollTop = 127 tocScrollParent.scrollTop + (linkRect.top - containerRect.top - containerRect.height / 3); 128 tocScrollParent.scrollTo({ 129 top: nextScrollTop, 130 behavior: prefersReducedMotion ? 'auto' : 'smooth', 131 }); 132 } 133 } 134 135 // If this is an h3, also highlight its parent h2 link 136 const headingElement = document.getElementById(id); 137 if (headingElement && headingElement.tagName === 'H3') { 138 let currentNode = headingElement; 139 let foundH2 = null; 140 141 while (currentNode && !foundH2) { 142 let sibling = currentNode.previousElementSibling; 143 while (sibling && !foundH2) { 144 if (sibling.tagName === 'H2' && sibling.id) { 145 foundH2 = sibling; 146 break; 147 } 148 sibling = sibling.previousElementSibling; 149 } 150 if (!foundH2) { 151 currentNode = currentNode.parentElement; 152 if (!currentNode || currentNode.tagName === 'ARTICLE' || currentNode.tagName === 'BODY') { 153 break; 154 } 155 } 156 } 157
158 if (foundH2 && foundH2.id) { 159 const sanitizedParentId = foundH2.id.replace(/[\n\r\t]/g, '').trim(); 160 tocLinks.forEach((link) => { 161 const href = link.getAttribute('href'); 162 if (href && href.startsWith('#')) { 163 const linkId = href 164 .substring(1) 165 .replace(/[\n\r\t]/g, '') 166 .trim(); 167 if (linkId === sanitizedParentId && !link.classList.contains('active')) { 168 link.classList.add('parent-active'); 169 } 170 } 171 }); 172 } 173 } 174 } 175 } 176 177 // Set when a reader clicks a contents link, and honoured until the 178 // smooth scroll has finished. Without it the spy overrules the click: 179 // its rule is "the last heading above the viewport's middle", and the 180 // click leaves the target at y=100, so on a tall viewport any heading 181 // within the next few hundred pixels also qualifies and wins. Every 182 // section shorter than half a viewport highlighted the one after it. 183 let spySuppressedUntil = 0; 184 185 function updateActiveHeading() { 186 if (Date.now() < spySuppressedUntil) return; 187 let activeId = null; 188 189 // Find the last heading that has crossed the middle of the viewport 190 for (let i = headings.length - 1; i >= 0; i--) { 191 const id = headings[i].getAttribute('id'); 192 if (id && headingElements[id] && hasPassedMiddle(headingElements[id])) { 193 activeId = getTocVisibleId(id); 194 if (activeId) break; 195 } 196 } 197 198 if (activeId) {
199 setActiveLink(activeId); 200 } 201 } 202 203 function handleTocLinkClick(e) { 204 const link = e.target.closest('a'); 205 if (!link) return; 206 207 const href = link.getAttribute('href'); 208 if (!href || !href.startsWith('#')) return; 209 210 // Let browser handle the Contents title link natively (#post-container 211 // now; #post kept for pages served from an older build) 212 if (href === '#post-container' || href === '#post') return; 213 214 const id = href 215 .substring(1) 216 .replace(/[\n\r\t]/g, '') 217 .trim(); 218 219 e.preventDefault(); 220 221 const element = headingElements[id] || document.getElementById(id); 222 if (!element) return; 223 224 if (window.history && window.history.pushState) { 225 window.history.pushState(null, null, href); 226 } else { 227 window.location.hash = id; 228 } 229 230 // The reader asked for this section, so it is the active one. 231 // Mark it before scrolling and hold the spy off until the scroll 232 // settles, or the spy immediately reassigns it. 233 const clickedId = getTocVisibleId(id) || id; 234 if (clickedId) {
235 setActiveLink(clickedId); 236 } 237 spySuppressedUntil = Date.now() + (prefersReducedMotion ? 200 : 1200); 238 239 const scrollOffset = 100; // Space above heading for navbar 240 const targetPosition = element.getBoundingClientRect().top + window.scrollY - scrollOffset; 241 window.scrollTo({ top: targetPosition, behavior: prefersReducedMotion ? 'auto' : 'smooth' }); 242 } 243 244 // TOC toggle for mobile - entire header is clickable 245 function handleTocToggleClick(e) { 246 const tocHeader = e.target.closest('#mobile-toc-header'); 247 if (!tocHeader) return; 248 249 e.preventDefault(); 250 e.stopPropagation(); 251 252 const mobileContainer = document.getElementById('toc-container-mobile'); 253 const tocList = mobileContainer ? mobileContainer.querySelector('#mobile-toc-list') : null; 254 255 if (tocList) { 256 tocExpanded = !tocExpanded; 257 258 if (tocExpanded) { 259 tocList.classList.remove('hidden'); 260 tocList.classList.add('block'); 261 tocList.classList.remove('xl:block'); 262 } else { 263 tocList.classList.add('hidden'); 264 tocList.classList.remove('block'); 265 } 266 267 // Update aria-expanded for accessibility 268 tocHeader.setAttribute('aria-expanded', tocExpanded); 269 270 // Rotate the chevron icon 271 const tocToggle = tocHeader.querySelector('#mobile-toc-toggle'); 272 if (tocToggle) { 273 tocToggle.style.transform = tocExpanded ? 'rotate(180deg)' : 'rotate(0deg)'; 274 } 275 } 276 } 277 278 // Scroll handler - no debounce for real-time updates 279 function handleScroll() { 280 updateActiveHeading(); 281 } 282 283 // Resize handler 284 function handleResize() { 285 updateActiveHeading(); 286 } 287 288 // Add event listeners 289 if (desktopTocContainer) { 290 desktopTocContainer.addEventListener('click', handleTocLinkClick); 291 } 292 document.addEventListener('click', handleTocToggleClick); 293 window.addEventListener('scroll', handleScroll, { passive: true }); 294 window.addEventListener('resize', handleResize); 295 296 // Initial setup 297 setTimeout(updateActiveHeading, 100); 298 299 // Store cleanup function 300 cleanupPreviousToc = function () { 301 if (desktopTocContainer) { 302 desktopTocContainer.removeEventListener('click', handleTocLinkClick); 303 } 304 document.removeEventListener('click', handleTocToggleClick); 305 window.removeEventListener('scroll', handleScroll); 306 window.removeEventListener('resize', handleResize); 307 }; 308 } 309 310 // Run on initial load 311 if (document.readyState === 'loading') { 312 document.addEventListener('DOMContentLoaded', initToc); 313 } else { 314 initToc(); 315 } 316 317 // Re-run on Turbo navigation 318 document.addEventListener('turbo:load', initToc); 319})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.