// 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"; (() => { // 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-user"]) { const el = document.getElementById(id); if (el) document.head.append(el); } }); } // 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) { const btn = document.createElement("button"); btn.type = "button"; btn.dataset.editorSrc = editorMeta.src; btn.dataset.editorCss = editorMeta.css || ""; btn.dataset.editorMode = mode; if (mode === "page") { btn.className = "edit-link"; btn.title = "edit page"; btn.textContent = "🖊️"; } else if (mode === "banner") { btn.className = "edit-link"; btn.title = "edit banner"; 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 && !article.querySelector("button.edit-link")) { article.prepend(makePen("page")); } } 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) { pens.append(makePen("banner")); pens.append(makePen("site")); // 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); } 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) { // Teach the backend the site's public origin (used for absolute // social/canonical URLs): unlike request headers, location.origin // reflects the real scheme and host even behind reverse proxies. fetch("/_api/site-url", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ url: location.origin }), }).catch(() => {}); // 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"); await navigator.clipboard.writeText( (code || pre).textContent.replace(/\n$/, ""), ); btn.textContent = "copied"; btn.classList.add("copied"); setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("copied"); }, 1500); }); pre.append(btn); } } // 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 body, since that wipes elements inside it. function placeEditPen() { const article = document.querySelector("#main article"); const btn = article?.querySelector("button.edit-link"); // First visible h1: the title h1 may be display:none when the // markdown owns its heading (editor preview state). const h1 = [...(article?.querySelectorAll("h1") || [])] .find((h) => h.offsetParent !== null); 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(); // Preview swaps also wipe the .colseg wrappers (the server render has // none), which would drop the multi-column layout until a full reload; // re-split so columns survive both live editing and closing the editor. const main = document.getElementById("main"); if (main) applyMulticol(main); }); // Multi-column layout only when there is enough text to justify it. // Split the body into columned segments: h1s, h2s and wide figures are // full-width separators and never go inside columns. function applyMulticol(main) { const article = main.querySelector("article"); if (!article) return; const body = article.querySelector(".body"); article.classList.toggle( "multicol", !!body && body.textContent.trim().length > 1800, ); if (body && article.classList.contains("multicol") && !body.querySelector(".colseg")) { // h1s, h2s and anything holding a wide image are full-width // separators const isSeparator = (el) => el.tagName === "H1" || el.tagName === "H2" || el.querySelector("img.wide") !== null; let seg = null; for (const el of [...body.children]) { if (isSeparator(el)) { seg = null; body.append(el); } else { if (!seg) { seg = document.createElement("div"); seg.className = "colseg"; body.append(seg); } seg.append(el); } } // Columns are per section: only segments with enough text get them, // so a short ingress or a brief section stays single-column. for (const s of body.querySelectorAll(".colseg")) { s.classList.toggle("cols", s.textContent.trim().length > 600); } } } 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(); applyMulticol(main); 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(); // pathname -> HTML text addEventListener("pagerite:page-fetched", (ev) => { pageCache.set(new URL(ev.detail.url, location.href).pathname, 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 Set(); for (const a of document.querySelectorAll( '#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]', )) { urls.add(a.pathname); } for (const url of urls) { if (pageCache.has(url)) continue; // x-pagerite-preload: idle cache warm-up, not a page view — the // server excludes these GETs from analytics (the ping sent on actual // navigation does the counting). fetch(url, { 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(url, 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; }); // --- 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) { let ticking = false; const drift = () => { ticking = false; document.documentElement.style.setProperty( "--pry", `${Math.min(scrollY * 0.1, 30)}px`, ); }; addEventListener("scroll", () => { if (!ticking) { ticking = true; requestAnimationFrame(drift); } }, { passive: true }); } // --- Analytics pings --------------------------------------------------- // Fire-and-forget POSTs to /_a with the fields as query parameters (a // beacon can carry no body, and query args show in server logs next to // the document GET they refer to): on the initial page load (starts the // visit — the server counts nothing from the document GET alone), for // internal fetch-navigations, for external https exits, and on window // close. ``read`` is the active time (ms) spent on ``fr``. // Reading time pauses after 1 minute of inactivity and resumes on the // next mouse/touch/scroll/keyboard event. // Excluded: back/forward (popstate never pings), everything while the // editor is open (body.editing — admin noise, not visits), and // navigations TO the analytics page (/_a — admin machinery, and the // server rejects it as a ping target anyway). Navigations AWAY from /_a // must ping: load() already fetched the target page without the preload // header, and without the ping that GET would flush to the crawler list. // Admins (when SSO is actually in use — with no auth proxy "admin" is // everyone's state) ping normally but with hide=1: the server then // records nothing and scrubs any session the same browser accumulated // before logging in, so admins never show up as visits or crawlers. // See docs/analytics.md. // fetch wrapper: every key of ``params`` becomes a query arg on /_a // (falsy values are omitted). Admins get hide=1. ``beacon`` uses // sendBeacon when available, for unload-time pings. function pingFetch(params, { beacon = false } = {}) { const query = new URLSearchParams(); if (ssoAvailable && isAdmin) params = { ...params, hide: 1 }; for (const [key, value] of Object.entries(params)) { if (value) query.set(key, value); } const url = `/_a?${query}`; try { if (beacon && navigator.sendBeacon) { navigator.sendBeacon(url); } else { fetch(url, { method: "POST", keepalive: true }); } } catch { /* analytics must never break navigation */ } } function ping({ to, fr = currentPath, read = 0, beacon = false } = {}) { if (document.body.classList.contains("editing")) return; if (to === "/_a") return; pingFetch({ fr, to, read: Math.round(read / 1000) }, { beacon }); } // 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. const INACTIVE_MS = 60_000; let readStart = performance.now(); let readElapsed = 0; let reading = true; let readInactivityTimer = null; let closePingedFor = null; function markReadActivity() { if (!reading) { reading = true; readStart = performance.now(); } clearTimeout(readInactivityTimer); readInactivityTimer = setTimeout(() => { if (reading) { readElapsed += performance.now() - readStart; reading = false; } }, INACTIVE_MS); } 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); } function sendClosePing() { if (closePingedFor === currentPath) return; closePingedFor = currentPath; const read = takeReadTime(); if (Math.round(read / 1000) <= 0) return; ping({ read, beacon: true }); } for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) { addEventListener(ev, markReadActivity, { passive: true }); } addEventListener("pagehide", sendClosePing); // The initial page load pings 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 ping again. Reloads are not visits: // pinging 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(new URL(url, location.href).pathname); if (cached) { doc = new DOMParser().parseFromString(cached, "text/html"); } else { try { const res = await fetch(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(new URL(finalUrl, location.href).pathname, html); doc = new DOMParser().parseFromString(html, "text/html"); } catch { location.href = url; // fall back to a normal navigation return false; } } if (REGIONS.some((id) => !doc.getElementById(id))) { location.href = 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 //