Menu
Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8
AkurAI-Framework / site/frontend/spa.js
Latest change 3a1d4dc0df9425a910df82402300a2aeb40318ae - feat(frontend): client-side SPA router with View Transitions by Ólafur Búi Ólafsson
// Native ES module — no bundler, no dependencies.
// Client-side SPA router: intercepts same-origin link clicks, fetches the next
// page, swaps <main>, updates history + title, and animates the swap with the
// View Transitions API where available.
//
// PROGRESSIVE ENHANCEMENT: this only *enhances*. With the script absent or JS
// disabled, every link is an ordinary full-page navigation. Any error here falls
// back to a real navigation, so the router can never strand the user.
(function () {
"use strict";
// Guard: init once, even if the module is evaluated twice.
if (window.SpaRouter) return;
// Bail on ancient browsers that lack the primitives we rely on. The page keeps
// working as plain HTML — links just do full navigations.
if (!window.history || !window.history.pushState || typeof DOMParser === "undefined" || typeof fetch === "undefined") {
return;
}
var CONTENT_SELECTOR = "main";
function reducedMotion() {
return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// ── Link interception rules ───────────────────────────────────────────────
// Decide whether a click should be handled by the router. Anything that fails
// a check is left alone so the browser does its normal thing.
function shouldHandle(event, anchor) {
// Let modified clicks (open-in-new-tab / new-window / download) pass through.
if (event.defaultPrevented) return false;
if (event.button !== undefined && event.button !== 0) return false; // not a left click
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
if (!anchor || !anchor.href) return false;
// Explicit opt-outs.
if (anchor.target && anchor.target !== "" && anchor.target !== "_self") return false;
if (anchor.hasAttribute("download")) return false;
var rel = (anchor.getAttribute("rel") || "").toLowerCase();
if (rel.split(/\s+/).indexOf("external") !== -1) return false;
if (anchor.hasAttribute("data-no-spa")) return false;
var url;
try {
url = new URL(anchor.href, document.baseURI);
} catch (e) {
return false;
}
// Same-origin only; foreign origins are real navigations.
if (url.origin !== window.location.origin) return false;
// Non-HTTP(S) schemes (mailto:, tel:, etc.) are not ours.
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
// Pure in-page anchors / hash-only links: let the browser scroll natively.
var current = window.location;
if (url.hash && url.pathname === current.pathname && url.search === current.search) return false;
// Same exact URL (no hash) — nothing to navigate.
if (url.href === current.href) return false;
return url;
}
function findAnchor(node) {
while (node && node !== document) {
if (node.tagName === "A" && node.hasAttribute("href")) return node;
node = node.parentNode;
}
return null;
}
// ── Swap ──────────────────────────────────────────────────────────────────
// Replace the live <main> with the fetched document's <main>, update the title,
// and notify listeners. Returns false if the document had no <main> to swap.
function applySwap(doc) {
var nextMain = doc.querySelector(CONTENT_SELECTOR);
var currentMain = document.querySelector(CONTENT_SELECTOR);
if (!nextMain || !currentMain) return false;
currentMain.replaceWith(nextMain);
var nextTitle = doc.querySelector("title");
if (nextTitle) document.title = nextTitle.textContent;
return true;
}
// Wrap the DOM mutation in a View Transition when supported and motion is
// allowed; otherwise apply it immediately. Resolves to the swap's success.
function transitionSwap(doc) {
if (document.startViewTransition && !reducedMotion()) {
var ok = true;
var transition = document.startViewTransition(function () {
ok = applySwap(doc);
});
return transition.updateCallbackDone.then(function () {
return ok;
});
}
return Promise.resolve(applySwap(doc));
}
function hardNavigate(url) {
window.location.assign(typeof url === "string" ? url : url.href);
}
// ── Navigation ─────────────────────────────────────────────────────────────
// Fetch a URL, parse it, swap, then manage history + scroll.
// mode "push" → new history entry, scroll to top (a clicked link)
// mode "replace" → in-place (initial state seeding)
// mode "pop" → triggered by back/forward; history already moved
function navigate(url, mode) {
var href = typeof url === "string" ? url : url.href;
return fetch(href, {
headers: { accept: "text/html" },
credentials: "same-origin",
redirect: "follow"
})
.then(function (res) {
if (!res.ok) throw new Error("status " + res.status);
// A redirect to another origin (or a non-HTML response) — let the
// browser handle it natively.
var type = res.headers.get("content-type") || "";
if (type && type.indexOf("text/html") === -1) throw new Error("not html: " + type);
return res.text();
})
.then(function (html) {
var doc = new DOMParser().parseFromString(html, "text/html");
return transitionSwap(doc).then(function (ok) {
if (!ok) throw new Error("no <main> to swap");
if (mode === "push") {
window.history.pushState({ spa: true }, "", href);
window.scrollTo(0, 0);
}
// For "pop", the browser restores scroll on its own; we leave it.
document.dispatchEvent(
new CustomEvent("spa:navigated", {
detail: { url: href, mode: mode }
})
);
});
})
.catch(function () {
// Any failure → real navigation. On popstate we can't re-push, so reload
// the location the browser already moved to.
if (mode === "pop") window.location.reload();
else hardNavigate(href);
});
}
// ── Wiring ───────────────────────────────────────────────────────────────
function onClick(event) {
var anchor = findAnchor(event.target);
if (!anchor) return;
var url = shouldHandle(event, anchor);
if (!url) return;
event.preventDefault();
navigate(url, "push");
}
function onPopState(event) {
// Only handle entries this router created; let the browser own the rest.
if (!event.state || !event.state.spa) return;
navigate(window.location.href, "pop");
}
var started = false;
function start() {
if (started) return;
started = true;
// Take over scroll restoration so our push (top) and pop (browser) behave.
if ("scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual";
}
// Seed the current entry so the first Back lands on a router-owned state.
window.history.replaceState({ spa: true }, "", window.location.href);
document.addEventListener("click", onClick);
window.addEventListener("popstate", onPopState);
}
window.SpaRouter = {
navigate: function (url) {
return navigate(url, "push");
},
start: start,
shouldHandle: shouldHandle
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();