// 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 { profile, apiJson, fetchJson } from "paskia"; 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: the user's explicit pick (initial ?lang=, public // selector, editor dropdown) is kept in chosenLang; while the editor is // open its selection overrides it (swapdoc.setLangOverride), and closing // falls back to chosenLang. JS state only — pretty URLs, no reloads. // window.__pageriteLang is the pin for swapdoc.loadPlain's fetches. let chosenLang = langParam; let sessionLang = langParam; window.__pageriteLang = sessionLang; addEventListener("pagerite:session-lang", (ev) => { if (ev.detail?.lang) chosenLang = ev.detail.lang; sessionLang = ev.detail?.lang || chosenLang; window.__pageriteLang = sessionLang; }); // 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 an auth button — 🔑 log in for anonymous visitors, // 🔐 profile when logged in. The click opens paskia-js's profile() dialog // (an iframe overlay; Paskia does not support being iframed by others, but // serves this dialog itself), which handles the login flow too. On close we // re-probe auth: login/logout inside the dialog changes the session. 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 icon-btn"; btn.title = "edit section"; btn.textContent = "🖊️"; btn.dataset.editorLine = line; } else if (mode === "page") { btn.className = "edit-link edit-page icon-btn"; btn.title = "edit page"; btn.textContent = "🖊️"; } else { btn.className = "edit-link site-edit-link icon-btn"; 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 makeAuthButton(admin) { const btn = document.createElement("button"); btn.type = "button"; btn.className = (admin ? "profile-link" : "login-link") + " icon-btn"; btn.title = admin ? "profile" : "log in"; btn.textContent = admin ? "\u{1F510}" : "\u{1F511}"; btn.addEventListener("click", async () => { // Resolves when the dialog closes ("login"/"logout"/"back"); whatever // happened, the session may have changed — re-probe and re-render. try { await profile(); } catch { /* dialog closed without completing */ } setupAuth(); }); return btn; } // The corner ✉️ (for everyone): opens the messaging overlay — the // visitor's own thread, or the admin inbox for editors (the {contact:} // trigger buttons always open the visitor side, so admins can test it). // The unread marker is seeded without any socket: for visitors from // localStorage (the newest admin reply vs. the last look, both written // by the contact bundle), for editors from the /_api/settings probe's // contact_unread. Once the bundle has loaded, its refreshBadges keeps // the marker live. let adminUnreadSeed = 0; function makeContactIcon(admin) { const b = document.createElement("button"); b.type = "button"; b.id = "contact-icon"; b.className = "contact-icon icon-btn"; b.title = admin ? "messages" : "contact us"; b.textContent = "✉️"; if (admin) { if (adminUnreadSeed > 0) { const badge = document.createElement("span"); badge.className = "n"; badge.textContent = adminUnreadSeed; b.append(badge); } } else { const seen = Number(localStorage.getItem("pagerite-contact-seen") || 0); const last = localStorage.getItem("pagerite-contact-lastadmin") || ""; if (last && Date.parse(last) > seen) b.classList.add("has-unread"); } b.addEventListener("click", () => openContactPanel({ admin })); return b; } // The banner top-right corner container: the language selector (first // item) plus the admin pens and auth links. renderAuthUi rebuilds it from // scratch; the selector's state lives in the shared store, not the DOM, // so the langselect bundle re-mounts it into the fresh container. function pensContainer() { let pens = document.querySelector(".editor-pens"); if (!pens) { const banner = document.getElementById("page-banner"); if (!banner) return null; pens = document.createElement("div"); pens.className = "editor-pens"; banner.after(pens); } return pens; } 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) { // 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 onToolPage = currentPath === "/_a"; if (document.getElementById("page-banner")) { const pens = pensContainer(); // The contact icon is there for everyone. pens.append(makeContactIcon(canEdit)); if (canEdit && !onToolPage) { // Analytics viewer is a normal page at /_a. const a = document.createElement("a"); a.className = "edit-link analytics-link icon-btn"; a.href = "/_a"; a.title = "analytics"; a.textContent = "📊"; pens.append(a); pens.append(makePen("site")); } if (ssoAvailable) pens.append(makeAuthButton(isAdmin)); if (!pens.firstElementChild) pens.remove(); } if (canEdit && !onToolPage) injectPagePen(); } // Re-mount the selector into the fresh container (no-op until the // bundle has been loaded once). langselectMod?.ensureMounted(document.querySelector(".editor-pens")); } 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, and whether the // current session has pagerite:admin. fetchJson (paskia-js) is plain // fetch with JSON handling and an error on non-OK — it never opens the // login dialog (that is apiFetch/apiJson's job), so these probes are // safe to run for anonymous visitors. try { await fetchJson("/auth/api/settings"); ssoAvailable = true; } catch { ssoAvailable = false; } isAdmin = false; try { const settings = await fetchJson("/_api/settings"); isAdmin = true; // Seeds the contact icon's admin badge (docs/messages.md). adminUnreadSeed = settings.contact_unread || 0; } catch { // Anonymous, no pagerite:admin, or 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(); }); // paskia's auth iframe announces its result as a window message: a login // or logout completed anywhere in the page (e.g. the analytics view's // login flow) re-probes auth and re-renders the pens. setupAuth is // idempotent, so the profile button's own re-probe after the same login // is a harmless duplicate. addEventListener("message", (ev) => { const type = ev.data?.type; if (type === "auth-success" || type === "auth-logout") 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); // Editor-driven swaps don't go through load(): re-evaluate the // language selector from the fresh copy too. mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html")); contactMod?.refreshBadges(); }); // 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)); // Re-registering resyncs the contact thread after a reconnect. if (contactToken) ws.send(JSON.stringify({ token: contactToken })); }; ws.onmessage = (ev) => { // Contact thread pushes ride this socket (docs/messages.md). if (!contactListener) return; let msg = null; try { msg = JSON.parse(ev.data); } catch { return; } if (msg?.type === "thread") contactListener(msg); else if (msg?.type === "pow") contactPowListener?.(msg); else if (msg?.type === "contact-error") console.warn("contact:", msg.detail); }; 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 (isToolPage(msg.to)) 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, lang } = {}) { // Reading-time updates from the tool pages themselves are not tracked // (admin machinery; the server would reject the path anyway). if (!to && isToolPage(currentPath)) 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; // The rendered language: normally the live , but a language // switch passes it explicitly — the swap that updates runs inside // the view-transition callback, after the switch ping goes out. lang = lang || document.documentElement.lang; if (lang) msg.lang = lang; 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; let lastActivityAt = performance.now(); function markReadActivity() { const t = performance.now(); if (reading) { // A gap longer than the inactivity timeout means the cutoff timer // could not run on time (the computer slept, or the tab was throttled // hard): close the span at its intended cutoff — the gap itself was // not reading time, and performance.now() keeps running through sleep // on some platforms. const cutoff = lastActivityAt + INACTIVE_MS; if (t > cutoff) { readElapsed += Math.max(0, cutoff - readStart); readStart = t; } } else { reading = true; readStart = t; } lastActivityAt = t; clearTimeout(readInactivityTimer); // Same sleep guard, other ordering: when this timer itself fires late // (waking from sleep before any user event), clamp the accumulation to // its intended fire time. const stopAt = t + INACTIVE_MS; readInactivityTimer = setTimeout(() => { if (reading) { readElapsed += Math.max(0, Math.min(performance.now(), stopAt) - 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: "" }); } // --- Admin tool pages mount/unmount ------------------------------------ // /_a (analytics) 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 page as a script element, which a // fetch-navigation swap does not execute — re-create the element so the // fresh module auto-mounts on its container. Each module exposes its // unmount as a window global (see analytics-main.js). const TOOL_PAGES = { "/_a": { container: "analytics-app", script: "pagerite-js-analytics", asset: "pagerite:analytics-src", unmount: "__pageriteAnalyticsUnmount" }, }; function isToolPage(path) { return Object.hasOwn(TOOL_PAGES, path || ""); } function teardownTools() { // Remove even the server-rendered script element so a later return // re-mounts from a fresh copy (the module has torn itself down). for (const p of Object.values(TOOL_PAGES)) { document.getElementById(p.script)?.remove(); window[p.unmount]?.(); window[p.unmount] = null; } } async function mountTool(doc) { const page = Object.values(TOOL_PAGES).find((p) => doc.getElementById(p.container)); if (!page) return; // Already mounted: on a full page load the inline script has run. if (document.getElementById(page.script)) return; const inline = doc.getElementById(page.script); 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[page.asset]); const container = document.getElementById(page.container); if (container) mod.mount(container); } catch (e) { console.error("tool page mount failed:", e); } } // --- Public language selector ------------------------------------------ // Pages translated into more than one language advertise it via hreflang // alternates (x-default + one link per language). Those pages get the // editors' flag dropdown as the first item of the corner container; its // bundle (Vue + the flag SVG set) loads on demand. Re-evaluated from the // fresh document on every swap (the head's own alternates stay stale). let langselectMod = null; async function mountLangselect(doc) { const links = [...doc.head.querySelectorAll('link[rel="alternate"][hreflang]')]; const dflt = links.find((l) => l.hreflang === "x-default"); const langs = links.filter((l) => l.hreflang && l.hreflang !== "x-default"); if (!dflt || langs.length <= 1) return langselectMod?.hide(); try { langselectMod ??= await import(/* @vite-ignore */ assets["pagerite:langselect-src"]); for (const css of (assets["pagerite:langselect-css"] || "").split(",")) { if (css && !document.querySelector(`link[href="${css}"]`)) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = css; link.dataset.pagerite = "langselect-css"; document.head.append(link); } } langselectMod.setLanguages( // The original's alternate is the plain URL — x-default's href — // which also marks it as the primary option. langs.map((l) => ({ tag: l.hreflang, href: l.href, primary: l.href === dflt.href })), doc.documentElement.lang, ); langselectMod.ensureMounted(pensContainer()); } catch (e) { console.error("language selector mount failed:", e); } } // The selector's pick (LangSelector dispatches this): make it the // session language and swap the page in place. With the editor open the // pick already landed in the shared store — the editor's watch re-renders // the page itself, so there is nothing to do here. addEventListener("pagerite:set-session-lang", async (ev) => { const tag = ev.detail?.lang; if (!tag || tag === sessionLang) return; if (document.body.classList.contains("editing")) return; chosenLang = sessionLang = tag; window.__pageriteLang = tag; const y = scrollY; // a language switch is not a navigation: keep scroll await load(currentPath, false); scrollTo(0, y); // Log the switch as a trail event in the new language (load() updated // , but possibly inside a still-pending view transition, so // pass the tag explicitly): the ping matches the switch's GET // server-side, so it is not misclassified as a crawler hit. ping({ to: currentPath, lang: tag }); }); // --- Messaging overlay --------------------------------------------------- // {contact: Label} directives in articles render .contact-trigger buttons, // and the corner ✉️ icon (rendered by renderAuthUi for everyone) opens the // same overlay: the visitor's thread, or the admin inbox for editors // (docs/messages.md). The bundle (Vue overlay + composer) loads on first // click and rides the activity socket for realtime thread state — no extra // connection for visitors. Registration re-sends on every socket // (re)connect, and the server pushes the thread back as the resync. let contactMod = null; let contactToken = ""; let contactListener = null; let contactPowListener = null; const contactBridge = { register(token, onThread, onPow) { contactToken = token; contactListener = onThread; contactPowListener = onPow; contactBridge.send({ token }); }, send(msg) { msg.fr = currentPath; activityWs(); if (ws?.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(msg)); return; } catch { /* fall through to queueing */ } } wsQueue.push(msg); }, }; async function openContactPanel(opts) { try { contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]); for (const css of (assets["pagerite:contact-css"] || "").split(",")) { if (css && !document.querySelector(`link[href="${css}"]`)) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = css; link.dataset.pagerite = "contact-css"; document.head.append(link); } } contactMod.open(contactBridge, opts); } catch (e) { console.error("contact panel load failed:", e); } } addEventListener("click", (ev) => { const btn = ev.target.closest?.(".contact-trigger"); if (!btn) return; ev.preventDefault(); // In the editor preview the button is inert. if (document.body.classList.contains("editing")) return; // Always the visitor side, even for admins (testing both sides). openContactPanel({ admin: false }); }); // --- Fetch navigation ------------------------------------------------ async function load(url, push = true, back = false) { // Navigating with the editor open keeps it open: the shell retargets to // the new page once the swap lands (below). The exception is /_a // (analytics), where the panel does not apply — close it there, with // navigating: true so the close does not re-swap/re-title the page it // was previewing (this navigation is already swapping). The cache is // bypassed while editing because the editor may have invalidated the // prefetched copies of other pages. const editing = document.body.classList.contains("editing"); if (editing && isToolPage(new URL(url, location.href).pathname)) { await editorModule?.then((m) => m.closeEditor({ navigating: true })); } teardownTools(); 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 //