Page caching and zstd compression. Avoid useless fetching. Mobile layouts of navigation menus improved.

This commit is contained in:
2026-08-22 14:05:12 +00:00
parent 87b16b7144
commit 319163ee7e
16 changed files with 479 additions and 156 deletions
+3 -2
View File
@@ -197,8 +197,9 @@ Because it is a real page, fetch-navigation handles it like any other internal
link: clicking the 📊 pen (or any link to `/_a`) fetches the server-rendered
HTML, swaps the dynamic regions and mounts the Vue analytics app in place. The
range selector updates the URL hash (`#week` etc.) so links to a specific
range can be shared. When the URL has no hash, the server defaults to `day`
if analytics history spans less than 24 hours, otherwise `week`.
range can be shared. When the URL has no hash, the client derives the
default from the first analytics snapshot: `day` if the recorded history
spans less than 24 hours, otherwise `week`.
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
+3 -1
View File
@@ -8,6 +8,8 @@ The FastAPI app. FastAPI's built-in API docs are disabled (`docs_url`/`redoc_url
The build mirrors the URL space — hashed immutable assets under `/_assets/`, `favicon.ico` at the site root — and an `index.html` in the build would become a `/` route, so leave it out of the build to keep `/` ours.
Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding, and `data.version`, which bumps on every content/settings change and so transparently invalidates the whole cache. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and `data.version`; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304.
## `data.py`
msgspec Structs for the kanta database. See `docs/content-model.md` for the full data model.
@@ -20,7 +22,7 @@ markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists,
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the request base URL; `article:published/modified_time` come from `Node.created`/`modified`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still supplies `<title>` and nav labels).
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`Data.site_url` — learned from admin browsers reporting their `location.origin` via `POST /_api/site-url`, correct even behind reverse proxies; until learned, the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still supplies `<title>` and nav labels).
The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), which is rendered when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap).
+1 -1
View File
@@ -20,7 +20,7 @@ Siblings order by the fractional `Node.order` key: a moved item gets a fresh key
`Node.banner` is a raw trusted HTML snippet for the header banner (img, styled div, canvas+script...); empty inherits from the node's ancestors (front page last). It is rendered AFTER the banner design's artwork, so author code (e.g. a `<style>` override) always wins over the design's own styles.
`Node.banner_design` picks a banner design: a theme folder name whose `banner.css` styles it and whose `banner.html` (arbitrary markup: canvas + style + script) or `banner.svg` supplies the inline artwork (wrapped in `div[data-design]`); "" = explicitly no design, None = inherit (nearest ancestor, front page last, then the active theme's own design if it ships banner.css/banner.svg/banner.html). The design's banner.css is linked in `<head>` (id `pagerite-banner`) between the theme and the custom CSS.
`Node.banner_design` picks a banner design: a theme folder name whose `banner.css` styles it and whose `banner.html` (arbitrary markup: canvas + style + script) or `banner.svg` supplies the inline artwork (wrapped in `div[data-design]`); "" = explicitly no design, None = inherit (nearest ancestor, front page last, then the active theme's own design if it ships banner.css/banner.svg/banner.html). The design's banner.css lives in `<head>` (id `pagerite-banner`) between the theme and the custom CSS — a `<link>` in dev, an inline `<style>` in production.
## Site settings
+1 -1
View File
@@ -25,6 +25,6 @@ Dropping ON the lower part of a row moves the page under that row (the child lis
The shell is dynamic-imported onto the content page by pagerite.js when an edit pen is clicked (the pens are injected by pagerite.js after the session validates; they carry `data-editor-src`/`data-editor-css`/`data-editor-mode`). In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), in prod from the hashed build assets resolved via `frontend-build/.vite/manifest.json`.
`vite.config.js` sets `appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL space; `frontend/public/favicon.ico` lands at the build root and is served at `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus `src/assets/pagerite.css` as a separate stylesheet entry; theme and banner-design CSS are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. There is no `index.html` source (it would shadow `/` and turn missing dev paths into an empty Vue shell). All outputs are ES modules. The build sets `preserveEntrySignatures: 'exports-only'` because main.js is consumed via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite app builds otherwise strip unused entry exports, leaving dead edit pens. In dev the backend links theme/banner-design stylesheets like in prod (`/_themes/...`); only the base CSS is Vite-injected from JS, and pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/`#pagerite-user` elements to restore the canonical order (base < theme < design < custom CSS). Theme switches in the site editor simply swap the `#pagerite-theme` link href, identically in dev and prod.
`vite.config.js` sets `appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL space; `frontend/public/favicon.ico` lands at the build root and is served at `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus `src/assets/pagerite.css` as a separate stylesheet entry; theme and banner-design CSS are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. There is no `index.html` source (it would shadow `/` and turn missing dev paths into an empty Vue shell). All outputs are ES modules. The build sets `preserveEntrySignatures: 'exports-only'` because main.js is consumed via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite app builds otherwise strip unused entry exports, leaving dead edit pens. In dev the backend links theme/banner-design stylesheets like in prod (`/_themes/...`); only the base CSS is Vite-injected from JS, and pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/`#pagerite-user` elements to restore the canonical order (base < theme < design < custom CSS). In production all page assets are inlined instead (styles as `<style id="pagerite-…">` in `<head>`, scripts at the end of the body). Theme switches in the site editor swap the `#pagerite-theme` element in place — the link href in dev, the inline style's text (fetched from `/_themes/...`) in prod.
`vite-plugin-fastapi.js` has an auto-upgrade marker — edit `vite.config.js`, not the plugin.
+5 -3
View File
@@ -8,9 +8,11 @@ Vue editor app entry, mounts the tabbed `EditorShell`. See `docs/editing.md` for
## `pagerite.js`
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link — and the current page — is fetched once at load, clicks are then served from JS with no fetch, and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire), scroll-reveal, OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), code copy buttons, and the auth check.
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire), scroll-reveal, OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, and the auth check.
It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen (asset URLs from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`. The backend links the stylesheets in a fixed order — base (Vite build), theme, banner design, custom CSS last — each with a stable id so the site editor can swap them in place.
It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`.
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
## `assets/`
@@ -18,7 +20,7 @@ Shared styles and data files built by Vite and served hashed under `/_assets/`:
The `::view-transition*` block at the end of `pagerite.css` (from termotohtori.fi) is fragile — do not tweak. Themes and banner designs are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. See `docs/themes-and-assets.md` for details.
Vite builds ES-module `.js` outputs; the backend renders `<script type="module">` for them (module scripts defer by default).
Vite builds ES-module `.js` outputs; in dev the backend links them as `<script type="module">` (module scripts defer by default), in production it inlines them at the end of the body.
## Database file
+1 -1
View File
@@ -34,4 +34,4 @@ The banner artwork has scroll parallax: pagerite.js sets the `--pry` scroll para
## Stylesheet order
The backend links the stylesheets in a fixed order — base (Vite build), theme, banner design, custom CSS last — each with a stable id so the site editor can swap them in place. The base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
The backend emits the stylesheets in a fixed order — base (Vite build), theme, banner design, entry sheets, custom CSS last — each with a stable id so fetch-navigation and the site editor can sync them in place. In dev they are `<link>`s (the base is Vite-injected from JS instead); in production they are inlined as `<style>` elements. The base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
+16 -6
View File
@@ -21,10 +21,6 @@ import VisitorCell from './VisitorCell.vue'
import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue'
const props = defineProps({
initialRange: { type: String, default: 'week' },
})
const ABUSE_MAX_LINES = 5
const data = ref(null)
@@ -35,6 +31,13 @@ let ws = null
let reconnectTimeout = null
let timeInterval = null
// The initial range comes from the URL hash (shareable links); without one,
// it is derived from the first analytics snapshot: day when the recorded
// history is shorter than 24 h, week otherwise.
const hashRange = location.hash.slice(1)
const range = ref(RANGES[hashRange] ? hashRange : 'week')
let rangePinned = Boolean(RANGES[hashRange])
function connectAnalytics() {
if (ws) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -43,6 +46,15 @@ function connectAnalytics() {
ws.onmessage = (event) => {
try {
data.value = JSON.parse(event.data)
if (!rangePinned) {
rangePinned = true
const starts = (data.value?.visits || [])
.map((v) => Date.parse(v.start))
.filter((t) => !Number.isNaN(t))
if (starts.length && Date.now() - Math.min(...starts) < 24 * 3600 * 1000) {
range.value = 'day'
}
}
} catch {
error.value = 'analytics data could not be loaded'
}
@@ -82,8 +94,6 @@ const visits = computed(() => data.value?.visits || [])
const totalViews = computed(() => calcTotalViews(data.value?.views))
const readStats = computed(() => calcReadStats(visits.value))
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
// Keep the URL shareable when the range changes.
watch(range, (r) => {
const url = new URL(location.href)
+29 -16
View File
@@ -242,30 +242,43 @@ async function saveSettings(opts = {}) {
async function onThemeChange() {
await saveSettings()
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
// and prod: swap the link in place, then re-render (the theme's default
// banner design and the page's stylesheet links may change with it).
let link = document.getElementById('pagerite-theme')
// and prod, but rendered differently: a <link> in dev, an inline <style>
// in prod. Swap it in place, then re-render (the theme's default banner
// design and the page's stylesheets may change with it).
let el = document.getElementById('pagerite-theme')
const url = `/_themes/${theme.value}/theme.css`
if (theme.value) {
const href = `/_themes/${theme.value}/theme.css`
if (link) {
link.href = href
} else {
if (el?.tagName === 'STYLE') {
el.textContent = await (await fetch(url)).text()
} else if (el) {
el.href = url
} else if (import.meta.env.DEV) {
// Re-create after "none": keep base < theme < design < custom CSS.
// In dev there is no #pagerite-base link (the base is a
// In dev there is no #pagerite-base element (the base is a
// Vite-injected <style>), so anchor to the next sheet instead of
// prepending before the base styles.
link = document.createElement('link')
link.rel = 'stylesheet'
link.id = 'pagerite-theme'
link.href = href
el = document.createElement('link')
el.rel = 'stylesheet'
el.id = 'pagerite-theme'
el.href = url
const before = document.getElementById('pagerite-base')?.nextSibling
?? document.getElementById('pagerite-banner')
?? document.getElementById('pagerite-user')
if (before) before.before(link)
else document.head.append(link)
if (before) before.before(el)
else document.head.append(el)
} else {
// Prod: inline <style>, fetched from the backend-served URL.
el = document.createElement('style')
el.id = 'pagerite-theme'
el.textContent = await (await fetch(url)).text()
const before = document.getElementById('pagerite-base')?.nextSibling
?? document.getElementById('pagerite-banner')
?? document.getElementById('pagerite-user')
if (before) before.before(el)
else document.head.append(el)
}
} else if (link) {
link.remove()
} else if (el) {
el.remove()
}
loadPlain(path.value)
}
+12 -8
View File
@@ -1,6 +1,9 @@
// Analytics page entry: mounts AnalyticsView inside the normal page layout.
// The backend renders #analytics-app inside #main and links this module for
// the initial load; pagerite.js also imports it on fetch-navigation to /_a.
// In production the backend inlines this module into the /_a page (and
// pagerite.js re-creates the script element after fetch-navigations there);
// in dev pagerite.js imports it from the Vite dev server on demand. Either
// way it auto-mounts on #analytics-app when it evaluates, and unmounts when
// pagerite.js announces a swap away from /_a.
import { createApp } from 'vue'
import AnalyticsView from './AnalyticsView.vue'
@@ -8,11 +11,7 @@ let app = null
export function mount(container) {
if (app) return
app = createApp(AnalyticsView, {
initialRange: location.hash.slice(1)
|| container.dataset.initialRange
|| 'week',
})
app = createApp(AnalyticsView)
app.mount(container)
}
@@ -21,6 +20,11 @@ export function unmount() {
app = null
}
// Auto-mount on a normal (non-fetch) page load.
// pagerite.js calls this before swapping away from /_a; each evaluation
// (the inlined production module evaluates fresh on every visit) replaces
// the handle.
window.__pageriteAnalyticsUnmount = unmount
// Auto-mount when the page holding #analytics-app is present.
const container = document.getElementById('analytics-app')
if (container) mount(container)
+3 -1
View File
@@ -912,7 +912,9 @@ article h2 {
padding: 0.5rem 1rem;
}
#sidebar ul {
/* Only the main level becomes a horizontal wrapping strip; submenus stay
vertical blocks attached under their parent item. */
#sidebar > ul {
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem 1.2rem;
+141 -36
View File
@@ -55,6 +55,19 @@ import "overlayscrollbars/overlayscrollbars.css";
let isAdmin = 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";
@@ -124,11 +137,11 @@ import "overlayscrollbars/overlayscrollbars.css";
}
async function setupAuth() {
const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content;
const src = assets["pagerite:editor-src"];
if (!src) { pingEntryOnce(); return; }
editorMeta = {
src,
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
css: assets["pagerite:editor-css"],
};
// Detect whether Paskia SSO is available on this site.
@@ -147,6 +160,26 @@ import "overlayscrollbars/overlayscrollbars.css";
// 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);
}
}
renderAuthUi();
pingEntryOnce();
}
@@ -230,6 +263,7 @@ import "overlayscrollbars/overlayscrollbars.css";
// buttons; re-add whichever auth UI is appropriate for this session.
renderAuthUi();
placeEditPen();
fitNav();
// 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.
@@ -285,14 +319,17 @@ import "overlayscrollbars/overlayscrollbars.css";
// 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.
// 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);
});
function preload() {
const urls = new Set([location.pathname]);
const urls = new Set();
for (const a of document.querySelectorAll(
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
)) {
@@ -455,29 +492,39 @@ import "overlayscrollbars/overlayscrollbars.css";
// --- 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. We load the entry module on demand so the
// analytics bundle is only fetched when visiting /_a, and unmount the app
// before swapping away so Vue teardown runs cleanly.
let analyticsUnmount = null;
// 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() {
analyticsUnmount?.();
analyticsUnmount = null;
// 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) {
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content;
if (!src) {
teardownAnalytics();
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 {
const mod = await import(/* @vite-ignore */ src);
// 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);
analyticsUnmount = mod.unmount;
}
if (container) mod.mount(container);
} catch (e) {
console.error("analytics mount failed:", e);
}
@@ -504,8 +551,8 @@ import "overlayscrollbars/overlayscrollbars.css";
// Reflect any redirect the server issued.
if (res.redirected) finalUrl = res.url;
const html = await res.text();
// Populate the cache too, or the post-swap preload (which includes
// location.pathname) would fetch the very page we just loaded again.
// 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 {
@@ -534,27 +581,47 @@ import "overlayscrollbars/overlayscrollbars.css";
} else if (oldSidebar) {
oldSidebar.remove();
}
// Site-wide custom CSS lives in <head id="pagerite-user"> and must be
// kept in sync across fetch-navigations. It is kept last in <head>:
// in dev Vite injects the base stylesheet after the server-rendered
// tag, and equal-specificity :root rules are decided by order.
const oldUserStyle = document.getElementById("pagerite-user");
const newUserStyle = doc.getElementById("pagerite-user");
if (oldUserStyle && newUserStyle) {
oldUserStyle.textContent = newUserStyle.textContent;
document.head.appendChild(oldUserStyle);
} else if (newUserStyle) {
document.head.appendChild(document.importNode(newUserStyle, true));
} else if (oldUserStyle) {
oldUserStyle.remove();
// Stylesheets live in <head> with stable ids — links in dev, inline
// <style> elements in production — and must follow the swap: the
// analytics sheet exists on /_a only, and theme/banner/custom CSS
// may have changed since this page was loaded. Diff by id, keeping
// the fresh document's order; unchanged sheets keep their elements
// so their @keyframes are never torn down. Editor-injected sheets
// (data-pagerite, no id) and Vite's dev styles (no id) are left
// alone. Mirrors the head sync in swapdoc.js.
const sel = 'link[rel="stylesheet"][id], style[id]';
const fresh = [...doc.head.querySelectorAll(sel)];
const freshIds = new Set(fresh.map((el) => el.id));
for (const el of [...document.head.querySelectorAll(sel)]) {
if (!freshIds.has(el.id)) el.remove();
}
let anchor = null;
for (const el of fresh) {
const cur = document.getElementById(el.id);
if (cur && cur.outerHTML === el.outerHTML) {
anchor = cur;
continue;
}
const imported = document.importNode(el, true);
if (cur) cur.replaceWith(imported);
else if (anchor) anchor.after(imported);
else {
const base = document.getElementById("pagerite-base");
if (base) base.after(imported);
else document.head.append(imported);
}
anchor = imported;
}
// Custom CSS must stay last: equal-specificity :root rules (font
// variables) are decided by order, and in dev Vite injects the base
// stylesheet after the server-rendered tag.
const userStyle = document.getElementById("pagerite-user");
if (userStyle) document.head.appendChild(userStyle);
document.title = doc.title;
// Banners may contain scripts (canvas etc.), content pages may too.
runScripts(document.getElementById("page-banner"));
runScripts(document.getElementById("main"));
applyEffects();
// The fetched doc carries the analytics meta; the live document's
// <head> is never swapped, so querying it would never find the entry.
mountAnalytics(doc);
};
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
@@ -707,6 +774,44 @@ import "overlayscrollbars/overlayscrollbars.css";
fit();
}
// --- Nav condense-to-fit -------------------------------------------------
// The top nav stays on one row even on too-narrow screens: first the link
// gaps shrink, then the nav's side padding, and only in extreme cases the
// font size. #nav is replaced on fetch-navigation swaps, so this re-runs
// from applyEffects (fresh elements each time); CSS keeps flex-wrap: wrap
// as the no-JS fallback.
function fitNav() {
const nav = document.getElementById("nav");
const ul = nav?.querySelector("ul");
if (!ul) return;
// Restore the themed defaults before measuring.
nav.style.fontSize = "";
nav.style.paddingInline = "";
ul.style.columnGap = "";
ul.style.flexWrap = "nowrap";
const overflow = () => ul.scrollWidth - ul.clientWidth;
if (overflow() <= 0) return;
// 1) shrink the gaps between items (down to a fifth of the themed gap)
const gap = parseFloat(getComputedStyle(ul).columnGap) || 0;
const joints = Math.max(ul.children.length - 1, 1);
if (gap > 0) {
ul.style.columnGap = `${Math.max(0.2 * gap, gap - overflow() / joints)}px`;
}
// 2) shrink the nav's side padding (down to 0.4x)
if (overflow() > 0) {
const pad = parseFloat(getComputedStyle(nav).paddingInlineStart) || 0;
nav.style.paddingInline = `${Math.max(0.4 * pad, pad - overflow() / 2)}px`;
}
// 3) shrink the font to fit what remains
if (overflow() > 0) {
const fs = parseFloat(getComputedStyle(nav).fontSize);
nav.style.fontSize = `${fs * ul.clientWidth / ul.scrollWidth}px`;
}
}
addEventListener("resize", fitNav);
document.fonts?.ready.then(fitNav);
setupAuth();
applyEffects();
mountAnalytics(document);
+26 -19
View File
@@ -59,32 +59,39 @@ function swapRegions(doc) {
curUserStyle.remove()
}
// Theme and other public stylesheets live in <head>, rendered with stable
// ids by the backend; sync them positionally so the custom CSS (rendered
// last) always keeps winning by order. Diff-based: unchanged sheets keep
// their elements, so their @keyframes are never torn down (re-creating
// keyframes would replay the editor's slide-in animation).
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
const freshIds = new Set(freshLinks.map((l) => l.id))
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
// ids by the backend (links in dev, inline <style> elements in prod);
// sync them positionally so the custom CSS (rendered last) always keeps
// winning by order. Diff-based: unchanged sheets keep their elements, so
// their @keyframes are never torn down (re-creating keyframes would
// replay the editor's slide-in animation).
const sel = 'link[rel="stylesheet"][id], style[id]'
const freshEls = [...doc.head.querySelectorAll(sel)]
const freshIds = new Set(freshEls.map((el) => el.id))
for (const el of [...document.head.querySelectorAll(sel)]) {
if (!freshIds.has(el.id)) el.remove()
}
// Insert missing sheets in the fresh document's order, each right after
// its predecessor's element. The first sheet rendered is always the base
// CSS, so its link doubles as the fallback anchor when nothing matched yet
// (e.g. no theme was selected before and the position is otherwise lost).
// CSS, so its element doubles as the fallback anchor when nothing matched
// yet (e.g. no theme was selected before and the position is otherwise
// lost).
let anchor = null
for (const link of freshLinks) {
const cur = link.id && document.getElementById(link.id)
if (cur && cur.href === link.href) {
for (const el of freshEls) {
const cur = el.id && document.getElementById(el.id)
if (cur && cur.outerHTML === el.outerHTML) {
anchor = cur
continue
}
const el = document.importNode(link, true)
// Same id, new URL (theme switch): replace in place, keeping position.
if (cur) cur.replaceWith(el)
else if (anchor) anchor.after(el)
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
anchor = el
const imported = document.importNode(el, true)
// Same id, new content (theme switch): replace in place, keeping position.
if (cur) cur.replaceWith(imported)
else if (anchor) anchor.after(imported)
else {
const base = document.getElementById('pagerite-base')
if (base) base.after(imported)
else document.head.append(imported)
}
anchor = imported
}
// The editor keeps its own title while open; only inherit the server title
// when navigating outside the editor (e.g. fetch-navigation swaps).
+119 -30
View File
@@ -22,7 +22,7 @@ import shutil
import socket
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from email.utils import format_datetime
from functools import lru_cache
from pathlib import Path
@@ -32,10 +32,11 @@ from xml.sax.saxutils import escape as xml_escape
import blake3
import msgspec
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.responses import RedirectResponse, Response
from fastapi_vue import Frontend
from kanta import Kanta
from pydantic import BaseModel
from zstandard import ZstdCompressor
from pagerite import analytics, seed, views
from pagerite.__main__ import DEVMODE
@@ -282,6 +283,79 @@ async def _headers(request: Request, call_next) -> Response:
return response
# Dynamic HTML is compressed per request at level 9 (static assets are
# already pre-compressed by fastapi-vue's Frontend).
_zstd = ZstdCompressor(9)
def _render_html(kind: str, path: str, base_url: str) -> str:
"""Render one of the generated pages (see _html_response)."""
if kind == "page":
return views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url)
if kind == "category":
return views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html)
if kind == "not-found":
return views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html)
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html)
@lru_cache(maxsize=128)
def _cached_body(kind: str, path: str, base_url: str, version: int, zstd: bool) -> bytes:
"""Rendered page body. Every input the output depends on is in the key:
data.version bumps on any content/settings change, base_url feeds the
social meta URLs, and zstd selects the stored encoding (both variants
are cached rather than re-compressed).
"""
body = _render_html(kind, path, base_url).encode()
return _zstd.compress(body) if zstd else body
def _html_response(
request: Request,
kind: str,
path: str,
status_code: int = 200,
headers: dict | None = None,
etag: bool = False,
) -> Response:
"""Response for a generated page, zstd-compressed when the client
accepts it (no gzip fallback).
Done per handler rather than in middleware so that Frontend's
already-compressed asset responses are never touched. The ETag stays
identical across encodings (revalidation compares it before
compression); ``vary: accept-encoding`` keeps caches from mixing the
representations. In dev the cache is bypassed so theme/design edits on
disk apply immediately.
``etag=True`` derives the validator from a blake3 hash of the
(uncompressed) body — for pages like /_a that have no Node whose
modified timestamp could serve as one — and answers matching
if-none-match revalidations with a 304.
"""
zstd = "zstd" in request.headers.get("accept-encoding", "")
# Absolute social/canonical URLs use the learned public origin; until
# an admin visit teaches it, fall back to the request's own base URL.
base_url = data.site_url or str(request.base_url).rstrip("/")
if DEVMODE:
identity = _render_html(kind, path, base_url).encode()
body = _zstd.compress(identity) if zstd else identity
else:
identity = _cached_body(kind, path, base_url, data.version, False)
body = _cached_body(kind, path, base_url, data.version, True) if zstd else identity
h = dict(headers or {})
if zstd:
h["vary"] = "accept-encoding"
if etag:
tag = f'"{blake3.blake3(identity).hexdigest()[:32]}"'
h["etag"] = tag
if request.headers.get("if-none-match") == tag:
return Response(status_code=304, headers=h)
if zstd:
h["content-encoding"] = "zstd"
return Response(body, status_code, h, media_type="text/html")
class PageIn(BaseModel):
"""Payload for creating or replacing a page."""
@@ -435,6 +509,33 @@ async def put_settings(settings: SettingsIn) -> None:
data.version += 1
class SiteUrlIn(BaseModel):
"""Payload for learning the site's public origin."""
url: str
@app.post("/_api/site-url", status_code=204)
async def learn_site_url(payload: SiteUrlIn) -> None:
"""Learn the site's public origin (scheme + host) from an admin browser.
pagerite.js reports location.origin once an admin session is detected:
unlike request Host headers it reflects the real public scheme and host
even behind reverse proxies, with zero manual configuration. Stored in
the database with a version bump so cached pages re-render with correct
absolute social/canonical URLs.
"""
url = payload.url.rstrip("/")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc or parsed.path:
raise HTTPException(400, "not an origin")
if url == data.site_url:
return
with kanta.transaction("learn site url"):
data.site_url = url
data.version += 1
@app.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it.
@@ -712,36 +813,20 @@ class AnalyticsPing(BaseModel):
read: int = 0
def _analytics_initial_range() -> str:
"""Default analytics range: day when history is shorter than 24 h."""
visits = analytics_store.data.visits
if not visits:
return "week"
earliest = min(v.start for v in visits)
if datetime.now(UTC) - earliest < timedelta(hours=24):
return "day"
return "week"
@app.get("/_a", response_model=None)
async def analytics_page(request: Request) -> HTMLResponse:
async def analytics_page(request: Request) -> Response:
"""Render the analytics viewer as a normal site page at /_a.
The page itself is public, but the data stream (/_api/ws/analytics) stays
admin-gated like the rest of /_api, so only authorized users see the
statistics; others get the viewer with a "could not be loaded" message.
"""
return HTMLResponse(
views.render_analytics(
data.menu,
data.brand,
data.custom_css,
data.theme,
data.favicon,
data.brand_html,
initial_range=_analytics_initial_range(),
),
return _html_response(
request,
"analytics",
"",
headers={"cache-control": "no-cache"},
etag=True,
)
@@ -1085,7 +1170,7 @@ frontend.route(app, "/")
@app.get("/{path:path}", response_model=None)
async def show_page(request: Request, path: str) -> HTMLResponse | Response:
async def show_page(request: Request, path: str) -> Response:
"""Render the content page at a slug path, or 404.
A node without content is a category label: its URL renders a
@@ -1122,8 +1207,10 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
if _is_trackable_path(path):
flushed = _track_entry(path, request)
_schedule_client_enrichment(flushed)
return HTMLResponse(
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, str(request.base_url).rstrip("/")),
return _html_response(
request,
"page",
path,
headers={
"etag": etag,
"last-modified": _http_date(node.modified),
@@ -1136,8 +1223,10 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
if _is_trackable_path(path):
flushed = _track_entry(path, request)
_schedule_client_enrichment(flushed)
return HTMLResponse(
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
return _html_response(
request,
"category",
path,
404,
headers={
"last-modified": _http_date(node.modified),
@@ -1160,4 +1249,4 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
asyncio.create_task(_enrich_client(client_hash))
flushed = _track_entry(path, request)
_schedule_client_enrichment(flushed)
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404)
return _html_response(request, "not-found", path, 404)
+6
View File
@@ -100,6 +100,12 @@ class Data(msgspec.Struct):
#: Favicon: name of a file in `files` (content-addressed), linked as
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
favicon: str = ""
#: Public origin (scheme + host) of the site, learned from admin
#: browsers (POST /_api/site-url — location.origin is correct even
#: behind reverse proxies, unlike request Host headers). Used for
#: absolute social/canonical URLs; empty = fall back to the request's
#: own base URL.
site_url: str = ""
#: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {}
+110 -29
View File
@@ -110,6 +110,35 @@ def _editor_css_url(vite_url: str | None) -> str | None:
return None
def _inline_asset(url: str) -> str:
"""Read a served asset's content for inlining into the page (prod only).
Handles build assets (``/_assets/...`` from the Vite build) and theme
files (``/_themes/{name}/...`` from pagerite/themes/).
"""
if url.startswith("/_themes/"):
name, _, file = url.removeprefix("/_themes/").partition("/")
if _valid_name(name) and _valid_name(file):
return (THEMES / name / file).read_text()
raise ValueError(f"not a theme asset: {url}")
return (BUILD / url.lstrip("/")).read_text()
def _inline_script(url: str) -> str:
"""Read a built JS bundle for inlining (prod only).
Inline modules resolve relative imports against the document URL, not
the bundle's directory, so rewrite the build's relative chunk
specifiers ("./chunk.js") to absolute /_assets/ paths.
"""
js = _inline_asset(url)
for chunk in _manifest().values():
file = chunk.get("file", "")
if file.endswith(".js"):
js = js.replace(f'"./{file.rsplit("/", 1)[-1]}"', f'"/{file}"')
return js
def _layout(
modules: list[str] = (),
stylesheets: list[str] = (),
@@ -118,16 +147,21 @@ def _layout(
banner_design: str = "",
favicon: str = "",
social: dict[str, str] | None = None,
extra_meta: dict[str, str] | None = None,
) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts.
"""Page layout template with standard assets and ES-module scripts.
Stylesheets use ``blocking="render"`` so the browser waits for them before
showing the page, avoiding a flash of unstyled content. Order matters and
is fixed: base (Vite build, absent in dev where Vite injects it from JS),
theme and banner design (backend-served from pagerite/themes/), entry-
specific stylesheets (e.g. overlayscrollbars.css), then the user's custom
CSS last so it always wins.
In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev
server and stylesheets use ``blocking="render"`` so the browser waits
for them before showing the page, avoiding a flash of unstyled content.
In production all page assets are inlined into the document: stylesheets
become ``<style>`` elements and module scripts inline ``<script>``s, so
a page loads with no asset round trips. The on-demand bundles (editor,
analytics) stay external in both modes.
Order matters and is fixed: base (Vite build, absent in dev where Vite
injects it from JS), theme and banner design (from pagerite/themes/),
entry-specific stylesheets (e.g. overlayscrollbars.css), then the user's
custom CSS last so it always wins.
In dev, pagerite.js re-appends the backend-rendered theme/design links
(and the custom CSS) after the Vite-injected base styles, keeping this
@@ -135,9 +169,6 @@ def _layout(
``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as
property attributes, everything else (description, twitter:*) as name.
``extra_meta`` is emitted as plain ``<meta name="..." content="...">``
tags after the editor meta tags; used for page-specific import hints.
"""
doc = Document(E.Title, lang="en")
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
@@ -155,33 +186,63 @@ def _layout(
# one, browsers fall back to the build's /favicon.ico by convention.
if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
# itself once it has validated the session (pages render identically
# for everyone; editing is gated by the auth proxy in front of /_api).
script, editor_css = _editor_assets()
doc.meta(name="pagerite:editor-src", content=script[-1])
if editor_css:
doc.meta(name="pagerite:editor-css", content=editor_css)
for key, value in (extra_meta or {}).items():
doc.meta(name=key, content=value)
# Stylesheet links carry stable ids so the site editor's hot swap can
# keep each sheet at its rendered position (see swapRegions).
# Asset URLs for the on-demand bundles (editor, analytics) for
# pagerite.js, which injects the 🖊️ edit pens itself once it has
# validated the session (pages render identically for everyone; editing
# is gated by the auth proxy in front of /_api). Dev passes the Vite
# dev-server URLs as meta tags (Vite serves the modules and injects
# their CSS for hot reloads); production inlines all page assets and
# carries the on-demand URLs in one JSON script instead.
vite_url = os.environ.get("PAGERITE_VITE_URL")
editor_scripts, editor_css = _editor_assets()
config = {
"pagerite:editor-src": editor_scripts[-1],
"pagerite:analytics-src": _analytics_assets()[0][0],
}
if editor_css:
config["pagerite:editor-css"] = editor_css
if vite_url:
for key, value in config.items():
doc.meta(name=key, content=value)
else:
# Inert JSON script; URLs never contain "</", but stay safe.
doc.script(
HTML(json.dumps(config).replace("</", "<\\/")),
type="application/json",
id="pagerite-assets",
)
# Stylesheets carry stable ids so the fetch-navigation and the site
# editor's hot swap can sync <head> positionally (see swapdoc.js).
# Production inlines the CSS as <style> elements: one less round trip
# per sheet, and fetch-navigation can carry them across swaps whole.
sheets = [
("pagerite-base", _base_css_url(vite_url)),
("pagerite-theme", _theme_css_url(theme)),
("pagerite-banner", _banner_css_url(banner_design)),
]
for id_, url in sheets:
if url:
if not url:
continue
if vite_url:
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
else:
doc.style(HTML(_inline_asset(url)), id=id_)
for url in stylesheets:
if vite_url:
doc.link(rel="stylesheet", href=url, blocking="render")
else:
# Id from the file stem minus the content hash, so the head
# sync can match sheets across pages (e.g. the analytics sheet
# exists on /_a only and is added/removed on swaps).
stem = url.rsplit("/", 1)[-1].removesuffix(".css")
name = re.sub(r"-[A-Za-z0-9_-]{8}$", "", stem)
doc.style(HTML(_inline_asset(url)), id=f"pagerite-css-{name}")
for src in modules:
if vite_url:
doc.script(src=src, type="module")
if custom_css.strip():
doc.style(custom_css, id="pagerite-user")
return Template(
body = (
doc
.header(
E.div(E.Banner, id="page-banner"),
@@ -194,8 +255,23 @@ def _layout(
E.main(E.Main, id="main"),
id="content",
)
.footer(None), # kept empty for now; zero-height (see pagerite.css)
.footer(None) # kept empty for now; zero-height (see pagerite.css)
)
if not vite_url:
# Inline the bundles at the end of the body: module scripts are
# deferred anyway, and the page can render before they execute.
# Escape "</script" so it cannot terminate the element early (only
# ever occurs inside string literals, where the backslash escape is
# a no-op).
for src in modules:
js = re.sub(r"</script", r"<\\/script", _inline_script(src), flags=re.I)
# Stable id from the file stem minus the content hash; the
# analytics page's script (pagerite-js-analytics) is found and
# re-created by pagerite.js on fetch-navigations to /_a.
stem = src.rsplit("/", 1)[-1].removesuffix(".js")
name = re.sub(r"-[A-Za-z0-9_-]{8}$", "", stem)
body.script(HTML(js), type="module", id=f"pagerite-js-{name}")
return Template(body)
def _brand_link(brand: str, brand_html: str = "") -> HTML:
@@ -681,16 +757,22 @@ def render_analytics(
theme: str = "",
favicon: str = "",
brand_html: str = "",
initial_range: str = "week",
) -> str:
"""Render the analytics viewer as a normal page at /_a."""
"""Render the analytics viewer as a normal page at /_a.
The analytics entry is inlined into this page only (prod) or loaded
from the Vite dev server (dev); its stylesheet rides along in <head>
so fetch-navigations can sync it into the live document. The initial
range is not rendered in: the client takes it from the URL hash or
derives it from the analytics data itself.
"""
page_scripts, page_stylesheets = _page_assets()
analytics_scripts, analytics_stylesheets = _analytics_assets()
scripts = page_scripts + analytics_scripts
stylesheets = page_stylesheets + analytics_stylesheets
doc = E.article
with doc:
doc.div(id="analytics-app", **{"data-initial-range": initial_range})
doc.div(id="analytics-app")
return str(
_layout(
scripts,
@@ -699,7 +781,6 @@ def render_analytics(
theme,
banner_design(menu, "_a", theme),
favicon,
extra_meta={"pagerite:analytics-src": analytics_scripts[0]},
)(
Title=f"Analytics {brand}" if brand else "Analytics",
Brand=_brand_link(brand, brand_html),
+1
View File
@@ -27,6 +27,7 @@ dependencies = [
"mdit-py-plugins>=0.6.1",
"pygments>=2.20.0",
"ua-parser>=1.0.2",
"zstandard>=0.25.0",
]
[project.scripts]