130 lines
5.2 KiB
TypeScript
130 lines
5.2 KiB
TypeScript
/**
|
|
* Client-side navigation runtime, served at `/__wrnexus/nav.js`.
|
|
*
|
|
* Progressive enhancement over normal links: intercepts same-origin `<a>`
|
|
* clicks, fetches the target page's HTML, swaps the `#app` container in place,
|
|
* updates history/title/scroll, ensures any framework runtimes the new page
|
|
* needs are present, and re-hydrates. Anything unexpected (cross-origin,
|
|
* modified click, non-HTML response, missing `#app`) falls back to a full
|
|
* browser navigation, so behaviour degrades safely.
|
|
*
|
|
* Data "loaders": pages load their data on the server (SSR `api` bindings), so
|
|
* the fetched HTML already contains fresh data — no separate client loader is
|
|
* needed. Client-side (`csr`) bindings and reactive scopes re-hydrate after the
|
|
* swap. Programmatic navigation is exposed as `window.__wrnexusNavigate(url)`.
|
|
*/
|
|
|
|
export const NAV_RUNTIME = String.raw`
|
|
(function () {
|
|
if (!window.history || !history.pushState || !window.fetch || !window.DOMParser) return;
|
|
if (window.__wrnexusNavInstalled) return;
|
|
window.__wrnexusNavInstalled = true;
|
|
|
|
var APP_ID = "app";
|
|
|
|
function pathOf(src) { return String(src).split("?")[0]; }
|
|
|
|
function isLocalLink(a) {
|
|
if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-nav")) return false;
|
|
if (a.target && a.target !== "_self") return false;
|
|
if (a.origin !== location.origin) return false;
|
|
var href = a.getAttribute("href");
|
|
if (!href || href.charAt(0) === "#") return false;
|
|
var rel = (a.getAttribute("rel") || "").toLowerCase();
|
|
return rel.indexOf("external") === -1;
|
|
}
|
|
|
|
function loadedScriptPaths() {
|
|
var set = {};
|
|
document.querySelectorAll("script[src]").forEach(function (s) {
|
|
var src = s.getAttribute("src");
|
|
if (src) set[pathOf(src)] = true;
|
|
});
|
|
return set;
|
|
}
|
|
|
|
// Append any /__wrnexus/* runtime the incoming page declares but the current
|
|
// document has not loaded yet. Fresh scripts self-initialise on load.
|
|
function ensureScripts(doc) {
|
|
var loaded = loadedScriptPaths();
|
|
doc.querySelectorAll("script[src]").forEach(function (s) {
|
|
var src = s.getAttribute("src");
|
|
if (!src || loaded[pathOf(src)]) return;
|
|
loaded[pathOf(src)] = true;
|
|
var el = document.createElement("script");
|
|
el.src = src;
|
|
el.async = false; // preserve execution order (e.g. schemas.js before validate.js)
|
|
document.body.appendChild(el);
|
|
});
|
|
}
|
|
|
|
// Re-hydrate already-loaded runtimes against the swapped-in DOM. Every
|
|
// entrypoint is idempotent, so this is safe even when a fresh script also
|
|
// self-initialises the same nodes.
|
|
function rehydrate() {
|
|
try { if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); } catch (e) {}
|
|
try { if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document); } catch (e) {}
|
|
try { if (window.__wireValidate) window.__wireValidate.init(document); } catch (e) {}
|
|
}
|
|
|
|
function render(html, url, isPop) {
|
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
var to = doc.getElementById(APP_ID);
|
|
var from = document.getElementById(APP_ID);
|
|
if (!to || !from) { location.href = url; return; } // structure mismatch → hard nav
|
|
if (doc.title) document.title = doc.title;
|
|
// Swap #app by importing nodes — NOT innerHTML — so it works under a strict
|
|
// Trusted-Types CSP (require-trusted-types-for 'script').
|
|
var imported = [];
|
|
for (var i = 0; i < to.childNodes.length; i++) imported.push(document.importNode(to.childNodes[i], true));
|
|
from.replaceChildren.apply(from, imported);
|
|
ensureScripts(doc);
|
|
rehydrate();
|
|
if (!isPop) { history.pushState({ wrnexusNav: true }, "", url); window.scrollTo(0, 0); }
|
|
try {
|
|
window.dispatchEvent(new CustomEvent("wrnexus:navigated", { detail: { url: url } }));
|
|
} catch (e) {}
|
|
}
|
|
|
|
var inFlight = null;
|
|
|
|
function navigate(url, isPop) {
|
|
var token = {};
|
|
inFlight = token;
|
|
fetch(url, { headers: { "x-wrnexus-nav": "1", accept: "text/html" }, credentials: "same-origin" })
|
|
.then(function (r) {
|
|
if (inFlight !== token) return null; // superseded by a newer navigation
|
|
if (r.redirected && r.url) url = r.url; // follow server redirects (e.g. auth)
|
|
var ct = r.headers.get("content-type") || "";
|
|
if (ct.indexOf("text/html") === -1) { location.href = url; return null; }
|
|
return r.text().then(function (t) {
|
|
if (inFlight === token) render(t, url, isPop);
|
|
});
|
|
})
|
|
.catch(function () { location.href = url; });
|
|
}
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
function (e) {
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
var a = e.target && e.target.closest ? e.target.closest("a") : null;
|
|
if (!isLocalLink(a)) return;
|
|
if (a.href === location.href) { e.preventDefault(); return; }
|
|
e.preventDefault();
|
|
navigate(a.href, false);
|
|
},
|
|
false,
|
|
);
|
|
|
|
window.addEventListener("popstate", function () {
|
|
navigate(location.href, true);
|
|
});
|
|
|
|
// Programmatic navigation for forms/actions and app code.
|
|
window.__wrnexusNavigate = function (url) {
|
|
navigate(new URL(url, location.href).href, false);
|
|
};
|
|
})();
|
|
`.trim();
|