250 lines (224 loc) · 9.76 KB
  1import { $, $$, prefersReducedMotion, sessionStore } from "./dom.js";
  2
  3// ---------------------------------------------------------------------------
  4// Page cache. A prefetch (or click) stores the pending HTML so the network
  5// round-trip is done by render time. Bounded to CACHE_MAX, oldest evicted.
  6// ---------------------------------------------------------------------------
  7const CACHE_MAX = 32;
  8const cache = new Map();
  9
 10const fetchPage = (url) => {
 11  let pending = cache.get(url);
 12  if (!pending) {
 13    pending = fetch(url, { headers: { "X-Router": "1" } })
 14      .then((res) => {
 15        if (!res.ok) throw new Error("bad status");
 16        return res.text();
 17      })
 18      .catch((err) => {
 19        cache.delete(url);
 20        throw err;
 21      });
 22    cache.set(url, pending);
 23    if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
 24  }
 25  return pending;
 26};
 27
 28// ---------------------------------------------------------------------------
 29// <head> sync. Only page-specific tags are swapped; the inlined <style>/<script>
 30// and charset/viewport are shared and left alone (re-running the script would
 31// re-bind everything). importNode (not cloneNode) re-homes the node into this
 32// document, avoiding Firefox's cross-document "Permission denied" error.
 33// ---------------------------------------------------------------------------
 34const HEAD_SELECTOR =
 35  'title, meta[name="description"], meta[property^="og:"], meta[name^="twitter:"], link[rel="canonical"]';
 36
 37const syncHead = (doc) => {
 38  $$(HEAD_SELECTOR, document.head).forEach((el) => el.remove());
 39  $$(HEAD_SELECTOR, doc.head).forEach((el) =>
 40    document.head.appendChild(document.importNode(el, true))
 41  );
 42};
 43
 44// ---------------------------------------------------------------------------
 45// Scrolling. Custom eased scroll (native smooth scroll offers no duration
 46// control). Per-path memory is restored only on Back/Forward; link clicks open
 47// at the top. currentPath also lets popstate tell a real navigation from an
 48// in-page hash change.
 49// ---------------------------------------------------------------------------
 50const smoothScrollTo = (to, duration = 350) => {
 51  const start = window.scrollY;
 52  const distance = to - start;
 53  const t0 = performance.now();
 54  const step = (now) => {
 55    const p = Math.min((now - t0) / duration, 1);
 56    window.scrollTo(0, start + distance * (1 - (1 - p) ** 3)); // easeOutCubic
 57    if (p < 1) requestAnimationFrame(step);
 58  };
 59  requestAnimationFrame(step);
 60};
 61
 62let currentPath = location.pathname;
 63
 64// Re-triggerable blink on the element we just jumped to. CSS :target only fires
 65// on a full page load, so anchor clicks and SPA navigations blink via this class.
 66const flashTarget = (el) => {
 67  if (!el) return;
 68  el.classList.remove("is-flash");
 69  void el.offsetWidth; // reflow so re-adding replays the animation
 70  el.classList.add("is-flash");
 71  el.addEventListener("animationend", () => el.classList.remove("is-flash"), { once: true });
 72};
 73
 74// Clicking a heading copies its link (you're already there — nothing to scroll to).
 75// A floating toast pops up at the cursor, outside the layout flow.
 76const copyHeadingLink = (url, x, y) => {
 77  navigator.clipboard.writeText(url).then(() => {
 78    const tip = document.createElement("div");
 79    tip.className = "copied-toast";
 80    tip.textContent = "link copied";
 81    tip.style.left = `${x}px`;
 82    tip.style.top = `${y}px`;
 83    document.body.appendChild(tip);
 84    setTimeout(() => tip.remove(), 1200);
 85  }).catch(() => {});
 86};
 87
 88// ---------------------------------------------------------------------------
 89// Navigation. Fetch the target, swap <main> + the header (so server-rendered
 90// active-nav comes along and the mobile menu resets), sync the <head>, then
 91// re-wire the page. adoptNode re-homes parsed nodes into this document before
 92// insertion (Firefox cross-document guard). Wrapped in the View Transitions
 93// API where available.
 94// ---------------------------------------------------------------------------
 95let afterSwap = () => {};
 96
 97export const navigate = async (url, push = true) => {
 98  sessionStore?.setItem(currentPath, window.scrollY); // remember the page we're leaving
 99
100  let html;
101  try {
102    html = await fetchPage(url);
103  } catch (_) {
104    location.href = url;
105    return;
106  }
107
108  const doc = new DOMParser().parseFromString(html, "text/html");
109  const nextMain = doc.querySelector("main");
110  const curMain = $("main");
111  if (!nextMain || !curMain) {
112    location.href = url;
113    return;
114  }
115
116  if (push) history.pushState(null, "", url);
117
118  const render = () => {
119    const nextHeader = doc.querySelector(".site-header-outer");
120    const curHeader = $(".site-header-outer");
121    if (nextHeader && curHeader) curHeader.replaceWith(document.adoptNode(nextHeader));
122    curMain.replaceWith(document.adoptNode(nextMain));
123    syncHead(doc);
124    afterSwap();
125    currentPath = location.pathname;
126    // Link clicks open at the top (or at the linked #anchor); Back/Forward
127    // restore the saved position. Re-apply next frame so late layout can't clamp.
128    const anchor = push && location.hash
129      && document.getElementById(decodeURIComponent(location.hash.slice(1)));
130    let y = push ? 0 : Number(sessionStore?.getItem(currentPath)) || 0;
131    if (anchor) {
132      const margin = parseFloat(getComputedStyle(anchor).scrollMarginTop) || 0;
133      y = anchor.getBoundingClientRect().top + window.scrollY - margin;
134      flashTarget(anchor);
135    }
136    window.scrollTo(0, y);
137    if (y) requestAnimationFrame(() => window.scrollTo(0, y));
138  };
139
140  // No startViewTransition: its whole-page crossfade reads as a blink on
141  // every swap — the instant replace is the point of the PJAX router.
142  render();
143};
144
145// ---------------------------------------------------------------------------
146// Event wiring.
147// ---------------------------------------------------------------------------
148const isModifiedClick = (e) =>
149  e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
150
151// Same-origin, in-page, non-download link we're willing to handle.
152const isNavigable = (a) =>
153  a &&
154  a.origin === location.origin &&
155  !a.hasAttribute("download") &&
156  (!a.target || a.target === "_self");
157
158const eligibleForPrefetch = (a) =>
159  isNavigable(a) &&
160  a.pathname !== location.pathname &&
161  a.getAttribute("href") &&
162  !a.getAttribute("href").startsWith("#");
163
164// Scroll to an in-page anchor without touching history or the URL. Returns
165// whether a target was found (and thus the click should be intercepted).
166const scrollToAnchor = (hash) => {
167  const target = document.getElementById(decodeURIComponent(hash.slice(1)));
168  if (!target) return false;
169  const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
170  const to = target.getBoundingClientRect().top + window.scrollY - margin;
171  if (prefersReducedMotion()) window.scrollTo(0, to);
172  else smoothScrollTo(to);
173  target.focus({ preventScroll: true }); // move focus (skip link); no-op on non-focusable targets
174  flashTarget(target);
175  return true;
176};
177
178/** Bind the document/window-level listeners. Run once for the session. */
179export const startRouter = (onAfterSwap) => {
180  afterSwap = onAfterSwap;
181  history.scrollRestoration = "manual";
182
183  // The page we booted on is already in the DOM — seed the cache with it so
184  // navigating back here doesn't re-fetch what we already have.
185  cache.set(location.href, Promise.resolve(document.documentElement.outerHTML));
186
187  // Intent prefetch: warm the cache after the cursor/focus rests on a link for
188  // ~150ms; cancel if it leaves first. Sweeping/tabbing through prefetches none.
189  let intentTimer;
190  const scheduleWarm = (e) => {
191    const a = e.target.closest("a");
192    if (!eligibleForPrefetch(a)) return;
193    clearTimeout(intentTimer);
194    intentTimer = setTimeout(() => fetchPage(a.href).catch(() => {}), 150);
195  };
196  const cancelWarm = () => clearTimeout(intentTimer);
197  document.addEventListener("mouseover", scheduleWarm, { passive: true });
198  document.addEventListener("focusin", scheduleWarm, { passive: true });
199  document.addEventListener("mouseout", cancelWarm, { passive: true });
200  document.addEventListener("focusout", cancelWarm, { passive: true });
201
202  // Intercept same-origin link clicks for a body-only swap. In-page anchors
203  // just scroll (no history/URL write).
204  document.addEventListener("click", (e) => {
205    if (isModifiedClick(e)) return;
206    const a = e.target.closest("a");
207    if (!isNavigable(a)) return;
208    const href = a.getAttribute("href");
209    if (!href) return;
210
211    if (a.hash && a.pathname === location.pathname) {
212      const heading = a.closest("h1, h2, h3, h4, h5, h6");
213      if (heading && navigator.clipboard) {
214        e.preventDefault();
215        const r = heading.getBoundingClientRect(); // keyboard (pageX 0) → near the heading
216        copyHeadingLink(
217          a.href,
218          e.pageX || r.left + window.scrollX + 16,
219          e.pageY || r.top + window.scrollY + r.height / 2,
220        );
221        return;
222      }
223      if (scrollToAnchor(a.hash)) e.preventDefault();
224      return;
225    }
226    if (href.startsWith("#")) return; // unresolved hash — leave it to the browser
227    e.preventDefault();
228    navigate(a.href);
229  });
230
231  window.addEventListener("popstate", () => {
232    if (location.pathname === currentPath) return; // in-page hash change
233    navigate(location.href, false);
234  });
235
236  // Leaving to an external site skips navigate() entirely, so its scroll-save
237  // never runs. pagehide covers that (and doesn't disable bfcache like
238  // beforeunload would); pageshow restores on the way back, whether the page
239  // reloaded fresh or was revived from bfcache.
240  window.addEventListener("pagehide", () => {
241    sessionStore?.setItem(currentPath, window.scrollY);
242  });
243  window.addEventListener("pageshow", () => {
244    const y = Number(sessionStore?.getItem(currentPath)) || 0;
245    if (y) {
246      window.scrollTo(0, y);
247      requestAnimationFrame(() => window.scrollTo(0, y));
248    }
249  });
250};