davideisinger.com

My personal website
Log | Files | Refs | README

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 })();