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