navigation.js (6370B)
1 // Keep the header and its audio element attached while navigating HTML pages. 2 (() => { 3 const content = document.querySelector('#content'); 4 const status = document.querySelector('#navigation-status'); 5 if (!content || !status || !document.querySelector('#site-header') 6 || !window.fetch || !window.AbortController) return; 7 8 const stateKey = 'siteNavigation'; 9 let currentURL = new URL(location.href); 10 let pending; 11 let restoring = false; 12 let scrollFrame; 13 14 function saveScroll() { 15 if (restoring) return; 16 history.replaceState({ ...history.state, [stateKey]: { x: scrollX, y: scrollY } }, ''); 17 } 18 saveScroll(); 19 history.scrollRestoration = 'manual'; 20 addEventListener('scroll', () => { 21 cancelAnimationFrame(scrollFrame); 22 scrollFrame = requestAnimationFrame(saveScroll); 23 }, { passive: true }); 24 addEventListener('pagehide', saveScroll); 25 26 function fragmentTarget(url) { 27 if (!url.hash) return null; 28 let id; 29 try { id = decodeURIComponent(url.hash.slice(1)); } catch { return null; } 30 return document.getElementById(id) || document.getElementsByName(id)[0]; 31 } 32 33 function positionPage(url, savedScroll, focus) { 34 const target = fragmentTarget(url); 35 if (focus) { 36 const element = target || content; 37 if (!element.hasAttribute('tabindex')) { 38 element.setAttribute('tabindex', '-1'); 39 element.addEventListener('blur', () => element.removeAttribute('tabindex'), { once: true }); 40 } 41 element.focus({ preventScroll: true }); 42 } 43 if (savedScroll) scrollTo(savedScroll.x, savedScroll.y); 44 else if (target) target.scrollIntoView(); 45 else scrollTo(0, 0); 46 } 47 48 function assetSignature(doc) { 49 return [...doc.head.querySelectorAll('link[rel="stylesheet"], script[src], style')] 50 .map(node => node.outerHTML).join('\n'); 51 } 52 53 async function navigate(url, { pop = false, savedScroll = null } = {}) { 54 pending?.abort(); 55 const controller = new AbortController(); 56 pending = controller; 57 cancelAnimationFrame(scrollFrame); 58 if (!pop) saveScroll(); 59 restoring = true; 60 content.setAttribute('aria-busy', 'true'); 61 status.textContent = 'Loading page…'; 62 const timeout = setTimeout(() => controller.abort('timeout'), 10000); 63 try { 64 const response = await fetch(url.href, { 65 signal: controller.signal, headers: { Accept: 'text/html' }, 66 }); 67 if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) { 68 throw new Error('Use normal navigation for this response'); 69 } 70 const destination = new URL(response.url); 71 if (destination.origin !== location.origin) throw new Error('External redirect'); 72 destination.hash = url.hash; 73 const next = new DOMParser().parseFromString(await response.text(), 'text/html'); 74 if (controller.signal.aborted) return; 75 const nextContent = next.querySelector('#content'); 76 const nextFooter = next.querySelector('#site-footer'); 77 // Different assets or page-specific scripts need a real page load. 78 if (!nextContent || !nextFooter || !next.querySelector('#site-header') 79 || next.querySelector('base, #content script') 80 || assetSignature(next) !== assetSignature(document)) { 81 throw new Error('Incompatible page'); 82 } 83 if (pop) history.replaceState(history.state, '', destination.href); 84 else history.pushState({ [stateKey]: { x: 0, y: 0 } }, '', destination.href); 85 currentURL = destination; 86 content.replaceChildren(...nextContent.childNodes); 87 document.dispatchEvent(new Event('site:navigated')); 88 document.querySelector('#site-footer').replaceWith(nextFooter); 89 document.title = next.title; 90 document.body.className = next.body.className; 91 // Update per-page metadata, including canonical links on republished posts. 92 const metadata = 'meta[name="description"], meta[name="robots"], meta[property], link[rel="canonical"]'; 93 document.head.querySelectorAll(metadata).forEach(node => node.remove()); 94 next.head.querySelectorAll(metadata).forEach(node => document.head.append(node)); 95 positionPage(destination, savedScroll, true); 96 status.textContent = `Loaded ${next.title}`; 97 } catch (error) { 98 if (pending !== controller) return; 99 if (controller.signal.aborted && controller.signal.reason !== 'timeout') return; 100 // Network failures, non-HTML responses, and new deployments fall back. 101 if (pop) location.replace(url.href); 102 else location.assign(url.href); 103 } finally { 104 clearTimeout(timeout); 105 if (pending === controller) { 106 pending = null; 107 restoring = false; 108 content.removeAttribute('aria-busy'); 109 if (status.textContent === 'Loading page…') status.textContent = ''; 110 saveScroll(); 111 } 112 } 113 } 114 115 document.addEventListener('click', event => { 116 const link = event.target.closest?.('a[href]'); 117 if (!link || event.defaultPrevented || event.button !== 0 118 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey 119 || link.hasAttribute('download') || link.hasAttribute('data-no-navigation') 120 || (link.target && link.target !== '_self')) return; 121 const url = new URL(link.href); 122 if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) return; 123 // Leave feeds, audio, images, and other files to the browser. 124 if (/\.[^/]+$/.test(url.pathname) && !/\.html?$/.test(url.pathname)) return; 125 if (url.pathname === currentURL.pathname && url.search === currentURL.search) { 126 pending?.abort(); 127 saveScroll(); 128 if (!url.hash) { 129 event.preventDefault(); 130 if (currentURL.hash) history.pushState({ [stateKey]: { x: 0, y: 0 } }, '', url.href); 131 currentURL = url; 132 positionPage(url, null, true); 133 } 134 return; // Native fragment navigation also preserves :target behavior. 135 } 136 event.preventDefault(); 137 navigate(url); 138 }); 139 140 addEventListener('popstate', event => { 141 const url = new URL(location.href); 142 if (url.pathname === currentURL.pathname && url.search === currentURL.search) { 143 pending?.abort(); 144 positionPage(url, event.state?.[stateKey], false); 145 currentURL = url; 146 return; 147 } 148 navigate(url, { pop: true, savedScroll: event.state?.[stateKey] }); 149 }); 150 151 })();