// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full // page loads. Real links are used throughout, so this is pure // progressive enhancement - without JS every link does a normal load. // // Also: scroll-reveal effects and code copy buttons. These need no // support from the article itself and are re-applied after each swap. import { OverlayScrollbars } from "overlayscrollbars"; import "overlayscrollbars/overlayscrollbars.css"; import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; (() => { // Overlay scrollbars: the native kind reserves a strip of layout (or // shifts the layout when it appears; overflow: overlay is dead), so we // replace it with floating ones that cover content instead. The body // remains the native viewport scroller — window scroll events, scrollY // and scroll restoration are unaffected; only the scrollbar UI is // custom. Theme variables are in pagerite.css. OverlayScrollbars(document.body, { scrollbars: { autoHide: "scroll" }, }); if (import.meta.env.DEV) { // In dev the base stylesheet is injected by Vite from JS (linking the // raw module would pull in its HMR wrapper). Theme and banner-design // stylesheets are plain files served by the backend (/_themes/...), so // the backend renders their s in both dev and prod. The injected // base styles land at the end of — after them, restore the // canonical order: base < theme < banner design < custom CSS (whose // equal-specificity :root rules — font variables — must win by order). import("./assets/pagerite.css").then(() => { for (const id of ["pagerite-theme", "pagerite-banner", "pagerite-transition", "pagerite-user"]) { const el = document.getElementById(id); if (el) document.head.append(el); } }); } // --- Language override (?lang=) --------------------------------------- // /page?lang=fi serves a translated, indexable version (each language is // its own canonical). The chosen language sticks for the session of // clicks: the server replicates ?lang= onto the navigation links it // renders (nav, sidebar, cards — in-article links are content and stay // as authored), and pageUrl adds it to internal fetches that lack one. // The address bar keeps the pretty URL: the query is stripped on load // and never pushed into history. A full refresh or a shared link resets // to automatic selection (the browser's own Accept-Language — every // plain fetch carries it by default). See docs/localization.md. const langParam = new URL(location.href).searchParams.get("lang"); if (langParam) { const url = new URL(location.href); url.searchParams.delete("lang"); history.replaceState(history.state, "", url); } // The session language. While the editor panel is open, its language // selection overrides the normal preference (swapdoc.setLangOverride): // internal fetches and prefetches follow it until the panel closes and // the override clears (null restores the initial ?lang=, if any). let sessionLang = langParam; addEventListener("pagerite:session-lang", (ev) => { sessionLang = ev.detail?.lang || langParam; }); // An internal URL as fetched: carries the session's ?lang= unless the // link already pins a language of its own. With no ?lang= on the initial // load nothing is ever added. const pageUrl = (url) => { const u = new URL(url, location.href); if (sessionLang && u.origin === location.origin && !u.searchParams.has("lang")) { u.searchParams.set("lang", sessionLang); } return u; }; // The in-memory page cache is keyed by path + query: the same pathname // holds different HTML for each language version. const rawKey = (url) => { const u = new URL(url, location.href); return u.pathname + u.search; }; const cacheKey = (url) => rawKey(pageUrl(url)); // What goes into the address bar and history: the pretty URL, no ?lang=. const prettyUrl = (url) => { const u = pageUrl(url); u.searchParams.delete("lang"); return u; }; // Regions every page has. #sidebar is NOT among them: it is omitted // entirely when the section has no sub-navigation, and handled below. const REGIONS = ["page-banner", "nav", "main"]; const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); let editorModule = null; // --- Auth-gated edit pens --------------------------------------------- // Pages render identically for everyone; the 🖊️ pens are injected by JS // only after we know the user has pagerite:admin access. We probe our own // /_api/settings endpoint: the same reverse proxy that gates /_api returns // 401/403 here, and a 200 means the permission is present. // // When Paskia SSO is in use (probed via /auth/api/settings), the banner // corner gets a plain link to /auth/ — 🔑 log in for anonymous visitors, // 🔐 profile when logged in. Normal navigation: Paskia does not support // being iframed, and history.back() returns to the page as-is (the // pageshow handler below re-probes auth to refresh the pens). let ssoAvailable = false; let isAdmin = false; let authReady = false; let editorMeta = null; // Asset URLs for the on-demand bundles. Dev renders them as // pagerite:* meta tags (Vite dev-server URLs); production inlines all // page assets and carries the on-demand URLs in a JSON script instead. const assets = (() => { const el = document.getElementById("pagerite-assets"); if (el) return JSON.parse(el.textContent); const map = {}; for (const m of document.querySelectorAll('meta[name^="pagerite:"]')) { map[m.name] = m.content; } return map; })(); function makePen(mode, line) { const btn = document.createElement("button"); btn.type = "button"; btn.dataset.editorSrc = editorMeta.src; btn.dataset.editorCss = editorMeta.css || ""; btn.dataset.editorMode = mode; if (line != null) { // Section pen on an anchored h2: opens the page editor at the // section's markdown source line (data-line, from the backend). btn.className = "edit-link edit-section"; btn.title = "edit section"; btn.textContent = "🖊️"; btn.dataset.editorLine = line; } else if (mode === "page") { btn.className = "edit-link edit-page"; btn.title = "edit page"; btn.textContent = "🖊️"; } else { btn.className = "edit-link site-edit-link"; btn.title = "site settings"; btn.textContent = "⚙️"; } return btn; } function injectPagePen() { const article = document.querySelector("#main article"); if (!article) return; if (!article.querySelector("button.edit-page")) { article.prepend(makePen("page")); } // Section pens on the anchored h2s (long articles only — the backend // adds data-line to those headings), for editor access mid-document. for (const h2 of article.querySelectorAll("h2[data-line]")) { if (!h2.querySelector("button.edit-section")) { h2.append(makePen("page", h2.dataset.line)); } } } function makeAuthLink(admin) { const a = document.createElement("a"); a.className = admin ? "profile-link" : "login-link"; a.href = "/auth/"; a.title = admin ? "profile" : "log in"; a.textContent = admin ? "\u{1F510}" : "\u{1F511}"; return a; } function removePens() { document.querySelectorAll(".editor-pens, #main article button.edit-link") .forEach((el) => el.remove()); } function renderAuthUi() { // Always start from a clean slate: if the auth probe is still running we // must not show any admin UI, and if it came back negative we must drop // pens that may have been injected while the browser cache made us look // authenticated. removePens(); if (!authReady) return; // Editing is open for admins and, as a dev/no-proxy fallback, when no // Paskia SSO is detected at all. const canEdit = isAdmin || !ssoAvailable; // The analytics page is a read-only dashboard: editing pens and the side // panel do not apply there. Login/logout links are still useful. const onAnalytics = currentPath === "/_a"; const banner = document.getElementById("page-banner"); if (banner) { const pens = document.createElement("div"); pens.className = "editor-pens"; if (canEdit && !onAnalytics) { // Analytics viewer is now a normal page at /_a. const a = document.createElement("a"); a.className = "edit-link analytics-link"; a.href = "/_a"; a.title = "analytics"; a.textContent = "📊"; pens.append(a); pens.append(makePen("site")); } if (ssoAvailable) pens.append(makeAuthLink(isAdmin)); banner.after(pens); } if (canEdit && !onAnalytics) injectPagePen(); } async function setupAuth() { authReady = false; renderAuthUi(); const src = assets["pagerite:editor-src"]; if (!src) { authReady = true; renderAuthUi(); pingEntryOnce(); return; } editorMeta = { src, css: assets["pagerite:editor-css"], }; // Detect whether Paskia SSO is available on this site. try { const ssoRes = await fetch("/auth/api/settings"); ssoAvailable = ssoRes.ok; } catch { ssoAvailable = false; } // Check whether the current session has pagerite:admin. isAdmin = false; try { isAdmin = (await fetch("/_api/settings")).status === 200; } catch { // No auth proxy / dev. } if (isAdmin) { // Warm the cache with the editor bundle: the hashed asset is // immutable, so preloading costs nothing and the pens then open // instantly. The analytics page has no editor. if (currentPath !== "/_a" && !import.meta.env.DEV) { const preload = document.createElement("link"); preload.rel = "modulepreload"; preload.href = src; document.head.append(preload); } } authReady = true; renderAuthUi(); placeEditPen(); pingEntryOnce(); } // Returning to the page via history back/forward may restore a cached // copy whose auth UI predates a login/logout — re-probe and re-render. addEventListener("pageshow", (ev) => { if (ev.persisted) setupAuth(); }); function runScripts(root) { // Scripts inserted via DOM swapping do not execute; re-create them. for (const old of root.querySelectorAll("script")) { const s = document.createElement("script"); for (const a of old.attributes) s.setAttribute(a.name, a.value); s.textContent = old.textContent; old.replaceWith(s); } } // --- Scroll reveal + code block copy buttons ------------------------- const observer = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { e.target.classList.add("in"); observer.unobserve(e.target); } } }, { rootMargin: "0px 0px -8% 0px" }); function addCopyButtons(main) { for (const pre of main.querySelectorAll("pre")) { if (pre.querySelector(".copy")) continue; const btn = document.createElement("button"); btn.className = "copy"; btn.type = "button"; btn.textContent = "copy"; btn.addEventListener("click", async () => { const code = pre.querySelector("code"); const text = (code || pre).textContent.replace(/\n$/, ""); // navigator.clipboard exists only in secure contexts (https or // localhost); viewing over plain http needs the textarea fallback. try { if (navigator.clipboard) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement("textarea"); ta.value = text; ta.style.cssText = "position:fixed;opacity:0"; document.body.append(ta); ta.select(); document.execCommand("copy"); ta.remove(); } } catch { btn.textContent = "failed"; setTimeout(() => (btn.textContent = "copy"), 1500); return; } btn.textContent = "copied"; btn.classList.add("copied"); setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("copied"); }, 1500); }); pre.append(btn); } } // --- Figure lightbox (click to enlarge) -------------------------------- // Clicking an article figure's image opens it in a full-viewport box: // the image as large as fits with its caption below; click or Esc // closes. The zoomed reuses the source URL — /_f/ is immutable // and content-negotiated, so the large view comes from the browser // cache at no extra cost. let lightbox = null; function closeLightbox() { lightbox?.remove(); lightbox = null; } function openLightbox(figure) { const img = figure.querySelector("img"); if (!img) return; closeLightbox(); lightbox = document.createElement("div"); lightbox.id = "lightbox"; const big = document.createElement("img"); big.src = img.currentSrc || img.src; big.alt = img.alt; lightbox.append(big); const cap = figure.querySelector("figcaption"); if (cap) { const c = document.createElement("div"); c.className = "caption"; c.textContent = cap.textContent; lightbox.append(c); } lightbox.addEventListener("click", closeLightbox); document.body.append(lightbox); } // Any key dismisses the lightbox — Esc included, and the rest would // only scroll the page behind it anyway. addEventListener("keydown", () => closeLightbox()); // Tuck the article edit pen at the end of the first h1 (which may come // from the markdown itself). Re-runs when the editor replaces the // previewed article, since that wipes elements inside it. function placeEditPen() { const article = document.querySelector("#main article"); const btn = article?.querySelector("button.edit-page"); const h1 = article?.querySelector("h1"); if (btn && h1 && btn.parentElement !== h1) h1.append(btn); } addEventListener("pagerite:preview", () => { // The editors' in-place swaps (SiteEditor.loadPlain) replace #main // without applyEffects, discarding the injected pens; re-add them // before tucking the article pen into the h1. Without this a freshly // created page has no pen for commitPending's handover click. renderAuthUi(); placeEditPen(); }); function applyEffects() { (window.requestIdleCallback || setTimeout)(preload); const main = document.getElementById("main"); addCopyButtons(main); // Fetch-navigation swaps #main, discarding the article pen and corner // buttons; re-add whichever auth UI is appropriate for this session. renderAuthUi(); placeEditPen(); fitNav(); if (reduceMotion.matches) return; for (const el of main.querySelectorAll( "h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item", )) { if (!el.classList.contains("reveal")) { el.classList.add("reveal"); observer.observe(el); } } } // --- Page cache / preloading ------------------------------------------ // Articles are deliberately NOT HTTP-cacheable, so speed comes from an // in-memory cache instead: at load (and after each swap) every visible // internal link is fetched exactly once, and navigation is served from // memory with no fetch at all. Editor re-renders (swapdoc.loadPlain) // announce their fresh copies via pagerite:page-fetched, keeping the // cache in sync after edits. The current page is NOT preloaded: we just // received it as the document (re-fetching would be redundant, and // browser heuristics may send it without if-none-match, defeating the // conditional request); it enters the cache when navigated to. const pageCache = new Map(); // rawKey/cacheKey(url) -> HTML text addEventListener("pagerite:page-fetched", (ev) => { // Key by the URL as announced, exactly as the editor fetched it: a // copy pinned to a language (?lang=) caches under its own key, where // navigation with the same session language finds it. pageCache.set(rawKey(ev.detail.url), ev.detail.html); }); // Editors mutate site-wide state (theme, structure, headings, banners), // which can change the rendered HTML of every cached page. Drop the whole // cache so stale prefetches are never served; the current page is re-fetched // by the editor's own loadPlain and re-cached afterwards. Re-preloading is // deferred until the editor panel closes to avoid hammering the server. addEventListener("pagerite:drop-page-cache", () => { pageCache.clear(); }); // When the editor panel closes, warm the cache again for the visible links // on the (now final) page so subsequent navigation stays instant. addEventListener("pagerite:preload-pages", () => { preload(); }); function preload() { const urls = new Map(); // cache key -> URL, deduped (hashes collapse) for (const a of document.querySelectorAll( '#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]', )) { const u = pageUrl(a.href); urls.set(rawKey(u), u); } for (const [key, u] of urls) { if (pageCache.has(key)) continue; // x-pagerite-preload: idle cache warm-up, not a page view — the // server excludes these GETs from analytics (the navigation message // sent on actual navigation does the counting). fetch(u, { headers: { "x-pagerite-preload": "1" } }) .then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html") ? r.text() : "")) .then((html) => { if (html) pageCache.set(key, html); }) .catch(() => {}); } } // The path we are currently showing. location.pathname is unusable for // this on popstate (it has already changed to the target); the editors // signal their replaceState navigation with pagerite:preview. let currentPath = location.pathname; addEventListener("pagerite:preview", () => { currentPath = location.pathname; }); // History position marker: every entry we create carries an incrementing // idx so popstate can tell forward navigation from back (needed for the // mirrored cube transition). replaceState calls below must preserve this // state object instead of passing null. let historyIdx = history.state?.idx ?? 0; history.replaceState({ idx: historyIdx }, ""); // --- Banner parallax ---------------------------------------------------- // The banner artwork stays windowed in place while its contents drift // against the scroll. The --pry scroll parameter is also available to // themes for their own effects (e.g. the purple sun rising faster than // the drift). Event-driven only: perfectly still when the page is idle. if (!reduceMotion.matches) { // rAF loop that eases the value toward the live scroll position. Reading // scrollY every frame (rather than only on scroll events) also picks up // the in-between positions of Chrome/macOS momentum scrolling, whose // scroll events fire late and coarsely. The loop idles once settled. let value = Math.min(scrollY * 0.1, 30); let running = false; const drift = () => { const target = Math.min(scrollY * 0.1, 30); value += (target - value) * 0.12; if (Math.abs(target - value) < 0.05) { value = target; running = false; } document.documentElement.style.setProperty("--pry", `${value}px`); if (running) requestAnimationFrame(drift); }; addEventListener("scroll", () => { if (!running) { running = true; requestAnimationFrame(drift); } }, { passive: true }); } // --- Section hash ------------------------------------------------------- // Reflect the section being read in the location hash: the last h1/h2 // above the middle of the viewport is current, even when already // scrolled out of view. Above the first tagged heading the hash is // removed — including at the very top of the document, where an early // heading may sit in the top half. Pages too short to scroll never get // a hash, and articles with few headings have no ids at all (the // backend only anchors h1/h2 in bodies of 3+ such headings). // replaceState keeps this out of history; fetch-navigation already // handles anchor scrolling itself. let hashQueued = false; addEventListener("scroll", () => { if (hashQueued) return; hashQueued = true; requestAnimationFrame(() => { hashQueued = false; const mid = innerHeight / 2; let current = null; for (const h of document.querySelectorAll("article :is(h1, h2)[id]")) { if (h.getBoundingClientRect().top < mid) current = h; else break; } const scrollable = document.documentElement.scrollHeight > innerHeight; const want = !scrollable || scrollY === 0 || !current ? "" : `#${current.id}`; if (want !== location.hash) { history.replaceState(history.state, "", want || location.pathname + location.search); } }); }, { passive: true }); // --- Analytics over WebSocket ------------------------------------------ // One /_ws connection follows the whole browsing session: the initial page // load (starts the visit — the server counts nothing from the document GET // alone), internal fetch-navigations, external https exits, and frequent // active reading-time updates. Messages are JSON text frames matching the // server's msgspec Ping struct: {fr?, to?, read?, hide?} — falsy fields // are omitted. ``read`` is the active time (ms) accumulated on ``fr`` since // the last report; reading time pauses after 1 minute of inactivity and // resumes on the next mouse/touch/scroll/keyboard event. While the user is // active, accumulated reading time is flushed every few seconds, so a // disconnect simply leaves the last reported time on the server — no close // beacon is needed. After 5 minutes without any activity the client closes // the channel itself (a sleeping tab would lose it anyway); the next // activity reconnects and the server sees a new session. // Excluded: back/forward (popstate never reports), everything while the // editor is open (body.editing — admin noise, not visits), and // navigations TO the analytics page (/_a — admin machinery). Navigations // AWAY from /_a must report: load() already fetched the target page // without the preload header, and without the message that GET would flush // to the crawler list. // Admins (when SSO is actually in use — with no auth proxy "admin" is // everyone's state) report normally but with hide: the server then flags // the client record, scrubbing everything it ever did from the statistics, // so admins never show up as visits or crawlers. // See docs/analytics.md. // The activity WebSocket. Messages sent before the connection opens are // queued (the queue keeps the interim activity). Reconnects are driven by // user activity only — never by timers while the page sits idle — with an // exponential falloff between attempts so a failing endpoint cannot make // us hammer the server (or trip its security limits). After a longer // stretch without any activity we close the socket proactively: the user // has moved on and left the tab open (a sleeping browser tab would lose // the connection anyway), so the next activity reconnects and registers // as a fresh session. Analytics must never break navigation: every send // is wrapped, and a server without the endpoint just leaves the socket // failing in the background. let ws = null; const wsQueue = []; const wsPolicy = reconnectPolicy({ min: 1000 }); // The first attempt is staggered too: page load opens several sockets at // once (Vite's HMR socket, the editors), and the burst trips the browser's // WebSocket throttling (sockets then sit "pending" for minutes). let wsNotBefore = Date.now() + socketSlot(); let wsWatchdog = null; function activityWs() { if (ws || Date.now() < wsNotBefore) return; const url = new URL("/_ws", location.href); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; try { ws = new WebSocket(url); } catch { return; } clearTimeout(wsWatchdog); wsWatchdog = watchConnecting(ws, "activity"); ws.onopen = () => { wsPolicy.opened(); for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg)); }; ws.onclose = () => { ws = null; // No timer here: the next user activity retries, after the backoff. wsNotBefore = Date.now() + wsPolicy.closed(); }; ws.onerror = () => ws.close(); } function report(msg) { if (document.body.classList.contains("editing")) return; if (msg.to === "/_a") return; if (ssoAvailable && isAdmin) msg.hide = true; activityWs(); if (ws?.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(msg)); return; } catch { /* fall through to queueing */ } } wsQueue.push(msg); } function ping({ to, fr = currentPath, read = 0 } = {}) { // Reading-time updates from the analytics page itself are not tracked // (/_a is admin machinery; the server would reject the path anyway). if (!to && currentPath === "/_a") return; const msg = {}; if (fr) msg.fr = fr; if (to) msg.to = to; const secs = Math.round(read / 1000); if (secs > 0) msg.read = secs; if (!msg.to && !msg.read) return; report(msg); } // Active reading time for the current page. The clock stops after 1 minute // without activity and restarts on the next mouse/touch/scroll/keyboard // event. Every READ_FLUSH_MS of accumulated activity is reported. After // IDLE_MS with no activity at all, the remaining read time is flushed and // the WebSocket is closed: the user has moved on, and the next activity // reconnects as a new session. const INACTIVE_MS = 60_000; const READ_FLUSH_MS = 5_000; const IDLE_MS = 5 * 60_000; let readStart = performance.now(); let readElapsed = 0; let reading = true; let readInactivityTimer = null; let idleTimer = null; function markReadActivity() { if (!reading) { reading = true; readStart = performance.now(); } clearTimeout(readInactivityTimer); readInactivityTimer = setTimeout(() => { if (reading) { readElapsed += performance.now() - readStart; reading = false; } }, INACTIVE_MS); // Any activity is a sign of life: (re)connect the channel if it was // dropped or idle-closed (not while editing — admin noise), and push // the idle disconnect forward. if (!document.body.classList.contains("editing")) activityWs(); clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (ws) { // Flush what is left unsent, then hang up. report() would try to // reconnect a dead socket, which is exactly what we avoid here. const left = takeReadTime(); if (Math.round(left / 1000) > 0) ping({ read: left }); ws.close(); } }, IDLE_MS); // Frequently update the article read time on the server. if (readElapsed + performance.now() - readStart >= READ_FLUSH_MS) { ping({ read: takeReadTime() }); } } function takeReadTime() { if (reading) { readElapsed += performance.now() - readStart; readStart = performance.now(); } const ms = Math.max(0, Math.round(readElapsed)); readElapsed = 0; return ms; } function resetReadTime() { readElapsed = 0; reading = true; readStart = performance.now(); clearTimeout(readInactivityTimer); } for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) { addEventListener(ev, markReadActivity, { passive: true }); } // The initial page load reports too — it is what starts the visit and // counts the entry page view (the document GET alone records nothing). // It carries only ``to``: the server attributes the entry to the referer // it saw on the document GET (unavailable to JS once loaded), and an // ``fr`` equal to ``to`` would log a bogus self-transition when a // session already exists (e.g. a second tab). // Sent once per load, after the auth probes so the admin gate applies; // the pageshow re-probe must not report again. Reloads are not visits: // reporting them would double-count the view and log a self-transition. let entryPinged = false; function pingEntryOnce() { if (entryPinged) return; entryPinged = true; const nav = performance.getEntriesByType?.("navigation")[0]; if (nav ? nav.type === "reload" : performance.navigation?.type === 1) return; ping({ to: currentPath, fr: "" }); } // --- Analytics page mount/unmount -------------------------------------- // The analytics page is a normal page whose body is rendered by the server // but whose content is a Vue app. In dev the entry module is imported from // the Vite dev server on demand; in production it is inlined into the /_a // page as script#pagerite-js-analytics, which a fetch-navigation swap does // not execute — re-create the element so the fresh module auto-mounts on // #analytics-app (see analytics-main.js). The module exposes its unmount // as window.__pageriteAnalyticsUnmount. function teardownAnalytics() { // Remove even the server-rendered script element so a later return to // /_a re-mounts from a fresh copy (the module has torn itself down). document.getElementById("pagerite-js-analytics")?.remove(); window.__pageriteAnalyticsUnmount?.(); window.__pageriteAnalyticsUnmount = null; } async function mountAnalytics(doc) { if (!doc.getElementById("analytics-app")) return; // Already mounted: on a full /_a load the inline script has run. if (document.getElementById("pagerite-js-analytics")) return; const inline = doc.getElementById("pagerite-js-analytics"); if (inline) { const s = document.createElement("script"); for (const a of inline.attributes) s.setAttribute(a.name, a.value); s.textContent = inline.textContent; document.body.append(s); return; } try { // Dev: the cached module auto-mounts only on its first evaluation, // so call mount() explicitly for repeat visits (it no-ops when the // app is already up). const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]); const container = document.getElementById("analytics-app"); if (container) mod.mount(container); } catch (e) { console.error("analytics mount failed:", e); } } // --- Fetch navigation ------------------------------------------------ async function load(url, push = true, back = false) { // Navigating with the editor open closes it; unsaved edits are lost // (the region swap discards the previewed changes anyway). Cache must be // bypassed for this navigation because the editor may have invalidated // the prefetched copies of other pages. const editing = document.body.classList.contains("editing"); if (editing) { editorModule?.then((m) => m.closeEditor()); } teardownAnalytics(); let doc; let finalUrl = url; const cached = !editing && pageCache.get(cacheKey(url)); if (cached) { doc = new DOMParser().parseFromString(cached, "text/html"); } else { try { const res = await fetch(pageUrl(url)); const type = res.headers.get("content-type") || ""; if (!res.ok || !type.includes("text/html")) throw new Error("not a page"); // Reflect any redirect the server issued. if (res.redirected) finalUrl = res.url; const html = await res.text(); // Populate the cache too, so returning here (back/forward, or a // self-link in the nav) is served from memory. pageCache.set(cacheKey(finalUrl), html); doc = new DOMParser().parseFromString(html, "text/html"); } catch { location.href = pageUrl(url); // fall back to a normal navigation return false; } } if (REGIONS.some((id) => !doc.getElementById(id))) { location.href = pageUrl(url); return false; } const doit = () => { for (const id of REGIONS) { const el = document.getElementById(id); el.replaceWith(document.importNode(doc.getElementById(id), true)); } // #sidebar is omitted entirely when the section has no // sub-navigation, so it may be absent on either side of the swap: // replace, insert (as #main's preceding sibling), or remove. const oldSidebar = document.getElementById("sidebar"); const newSidebar = doc.getElementById("sidebar"); if (oldSidebar && newSidebar) { oldSidebar.replaceWith(document.importNode(newSidebar, true)); } else if (newSidebar) { document.getElementById("main").before(document.importNode(newSidebar, true)); } else if (oldSidebar) { oldSidebar.remove(); } // Stylesheets live in with stable ids — links in dev, inline //