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