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 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 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 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` range can be shared. When the URL has no hash, the client derives the
if analytics history spans less than 24 hours, otherwise `week`. 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` `AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
page-chrome hiding and `#/analytics/<range>` hash routing have been removed. 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. 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` ## `data.py`
msgspec Structs for the kanta database. See `docs/content-model.md` for the full data model. 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. 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). 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` 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 ## 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`. 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. `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` ## `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/` ## `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. 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 ## 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 ## 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 TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue' import VisitorCharts from './VisitorCharts.vue'
const props = defineProps({
initialRange: { type: String, default: 'week' },
})
const ABUSE_MAX_LINES = 5 const ABUSE_MAX_LINES = 5
const data = ref(null) const data = ref(null)
@@ -35,6 +31,13 @@ let ws = null
let reconnectTimeout = null let reconnectTimeout = null
let timeInterval = 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() { function connectAnalytics() {
if (ws) return if (ws) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -43,6 +46,15 @@ function connectAnalytics() {
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
data.value = JSON.parse(event.data) 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 { } catch {
error.value = 'analytics data could not be loaded' 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 totalViews = computed(() => calcTotalViews(data.value?.views))
const readStats = computed(() => calcReadStats(visits.value)) const readStats = computed(() => calcReadStats(visits.value))
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
// Keep the URL shareable when the range changes. // Keep the URL shareable when the range changes.
watch(range, (r) => { watch(range, (r) => {
const url = new URL(location.href) const url = new URL(location.href)
+29 -16
View File
@@ -242,30 +242,43 @@ async function saveSettings(opts = {}) {
async function onThemeChange() { async function onThemeChange() {
await saveSettings() await saveSettings()
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev // 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 // and prod, but rendered differently: a <link> in dev, an inline <style>
// banner design and the page's stylesheet links may change with it). // in prod. Swap it in place, then re-render (the theme's default banner
let link = document.getElementById('pagerite-theme') // 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) { if (theme.value) {
const href = `/_themes/${theme.value}/theme.css` if (el?.tagName === 'STYLE') {
if (link) { el.textContent = await (await fetch(url)).text()
link.href = href } else if (el) {
} else { el.href = url
} else if (import.meta.env.DEV) {
// Re-create after "none": keep base < theme < design < custom CSS. // 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 // Vite-injected <style>), so anchor to the next sheet instead of
// prepending before the base styles. // prepending before the base styles.
link = document.createElement('link') el = document.createElement('link')
link.rel = 'stylesheet' el.rel = 'stylesheet'
link.id = 'pagerite-theme' el.id = 'pagerite-theme'
link.href = href el.href = url
const before = document.getElementById('pagerite-base')?.nextSibling const before = document.getElementById('pagerite-base')?.nextSibling
?? document.getElementById('pagerite-banner') ?? document.getElementById('pagerite-banner')
?? document.getElementById('pagerite-user') ?? document.getElementById('pagerite-user')
if (before) before.before(link) if (before) before.before(el)
else document.head.append(link) 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) { } else if (el) {
link.remove() el.remove()
} }
loadPlain(path.value) loadPlain(path.value)
} }
+12 -8
View File
@@ -1,6 +1,9 @@
// Analytics page entry: mounts AnalyticsView inside the normal page layout. // Analytics page entry: mounts AnalyticsView inside the normal page layout.
// The backend renders #analytics-app inside #main and links this module for // In production the backend inlines this module into the /_a page (and
// the initial load; pagerite.js also imports it on fetch-navigation to /_a. // 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 { createApp } from 'vue'
import AnalyticsView from './AnalyticsView.vue' import AnalyticsView from './AnalyticsView.vue'
@@ -8,11 +11,7 @@ let app = null
export function mount(container) { export function mount(container) {
if (app) return if (app) return
app = createApp(AnalyticsView, { app = createApp(AnalyticsView)
initialRange: location.hash.slice(1)
|| container.dataset.initialRange
|| 'week',
})
app.mount(container) app.mount(container)
} }
@@ -21,6 +20,11 @@ export function unmount() {
app = null 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') const container = document.getElementById('analytics-app')
if (container) mount(container) if (container) mount(container)
+3 -1
View File
@@ -912,7 +912,9 @@ article h2 {
padding: 0.5rem 1rem; 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-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem 1.2rem; gap: 0.5rem 1.2rem;
+141 -36
View File
@@ -55,6 +55,19 @@ import "overlayscrollbars/overlayscrollbars.css";
let isAdmin = false; let isAdmin = false;
let editorMeta = null; 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) { function makePen(mode) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
@@ -124,11 +137,11 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
async function setupAuth() { async function setupAuth() {
const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content; const src = assets["pagerite:editor-src"];
if (!src) { pingEntryOnce(); return; } if (!src) { pingEntryOnce(); return; }
editorMeta = { editorMeta = {
src, src,
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content, css: assets["pagerite:editor-css"],
}; };
// Detect whether Paskia SSO is available on this site. // Detect whether Paskia SSO is available on this site.
@@ -147,6 +160,26 @@ import "overlayscrollbars/overlayscrollbars.css";
// No auth proxy / dev. // 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(); renderAuthUi();
pingEntryOnce(); pingEntryOnce();
} }
@@ -230,6 +263,7 @@ import "overlayscrollbars/overlayscrollbars.css";
// buttons; re-add whichever auth UI is appropriate for this session. // buttons; re-add whichever auth UI is appropriate for this session.
renderAuthUi(); renderAuthUi();
placeEditPen(); placeEditPen();
fitNav();
// Multi-column layout only when there is enough text to justify it. // Multi-column layout only when there is enough text to justify it.
// Split the body into columned segments: h1s, h2s and wide figures are // Split the body into columned segments: h1s, h2s and wide figures are
// full-width separators and never go inside columns. // 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 // internal link is fetched exactly once, and navigation is served from
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain) // memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
// announce their fresh copies via pagerite:page-fetched, keeping the // 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 const pageCache = new Map(); // pathname -> HTML text
addEventListener("pagerite:page-fetched", (ev) => { addEventListener("pagerite:page-fetched", (ev) => {
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html); pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
}); });
function preload() { function preload() {
const urls = new Set([location.pathname]); const urls = new Set();
for (const a of document.querySelectorAll( for (const a of document.querySelectorAll(
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]', '#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
)) { )) {
@@ -455,29 +492,39 @@ import "overlayscrollbars/overlayscrollbars.css";
// --- Analytics page mount/unmount -------------------------------------- // --- Analytics page mount/unmount --------------------------------------
// The analytics page is a normal page whose body is rendered by the server // 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 // but whose content is a Vue app. In dev the entry module is imported from
// analytics bundle is only fetched when visiting /_a, and unmount the app // the Vite dev server on demand; in production it is inlined into the /_a
// before swapping away so Vue teardown runs cleanly. // page as script#pagerite-js-analytics, which a fetch-navigation swap does
let analyticsUnmount = null; // 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() { function teardownAnalytics() {
analyticsUnmount?.(); // Remove even the server-rendered script element so a later return to
analyticsUnmount = null; // /_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) { async function mountAnalytics(doc) {
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content; if (!doc.getElementById("analytics-app")) return;
if (!src) { // Already mounted: on a full /_a load the inline script has run.
teardownAnalytics(); 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; return;
} }
try { 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"); const container = document.getElementById("analytics-app");
if (container) { if (container) mod.mount(container);
mod.mount(container);
analyticsUnmount = mod.unmount;
}
} catch (e) { } catch (e) {
console.error("analytics mount failed:", e); console.error("analytics mount failed:", e);
} }
@@ -504,8 +551,8 @@ import "overlayscrollbars/overlayscrollbars.css";
// Reflect any redirect the server issued. // Reflect any redirect the server issued.
if (res.redirected) finalUrl = res.url; if (res.redirected) finalUrl = res.url;
const html = await res.text(); const html = await res.text();
// Populate the cache too, or the post-swap preload (which includes // Populate the cache too, so returning here (back/forward, or a
// location.pathname) would fetch the very page we just loaded again. // self-link in the nav) is served from memory.
pageCache.set(new URL(finalUrl, location.href).pathname, html); pageCache.set(new URL(finalUrl, location.href).pathname, html);
doc = new DOMParser().parseFromString(html, "text/html"); doc = new DOMParser().parseFromString(html, "text/html");
} catch { } catch {
@@ -534,27 +581,47 @@ import "overlayscrollbars/overlayscrollbars.css";
} else if (oldSidebar) { } else if (oldSidebar) {
oldSidebar.remove(); oldSidebar.remove();
} }
// Site-wide custom CSS lives in <head id="pagerite-user"> and must be // Stylesheets live in <head> with stable ids — links in dev, inline
// kept in sync across fetch-navigations. It is kept last in <head>: // <style> elements in production — and must follow the swap: the
// in dev Vite injects the base stylesheet after the server-rendered // analytics sheet exists on /_a only, and theme/banner/custom CSS
// tag, and equal-specificity :root rules are decided by order. // may have changed since this page was loaded. Diff by id, keeping
const oldUserStyle = document.getElementById("pagerite-user"); // the fresh document's order; unchanged sheets keep their elements
const newUserStyle = doc.getElementById("pagerite-user"); // so their @keyframes are never torn down. Editor-injected sheets
if (oldUserStyle && newUserStyle) { // (data-pagerite, no id) and Vite's dev styles (no id) are left
oldUserStyle.textContent = newUserStyle.textContent; // alone. Mirrors the head sync in swapdoc.js.
document.head.appendChild(oldUserStyle); const sel = 'link[rel="stylesheet"][id], style[id]';
} else if (newUserStyle) { const fresh = [...doc.head.querySelectorAll(sel)];
document.head.appendChild(document.importNode(newUserStyle, true)); const freshIds = new Set(fresh.map((el) => el.id));
} else if (oldUserStyle) { for (const el of [...document.head.querySelectorAll(sel)]) {
oldUserStyle.remove(); 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; document.title = doc.title;
// Banners may contain scripts (canvas etc.), content pages may too. // Banners may contain scripts (canvas etc.), content pages may too.
runScripts(document.getElementById("page-banner")); runScripts(document.getElementById("page-banner"));
runScripts(document.getElementById("main")); runScripts(document.getElementById("main"));
applyEffects(); 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); mountAnalytics(doc);
}; };
// Rotating cube page transition (see the FRAGILE block in pagerite.css); // Rotating cube page transition (see the FRAGILE block in pagerite.css);
@@ -707,6 +774,44 @@ import "overlayscrollbars/overlayscrollbars.css";
fit(); 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(); setupAuth();
applyEffects(); applyEffects();
mountAnalytics(document); mountAnalytics(document);
+26 -19
View File
@@ -59,32 +59,39 @@ function swapRegions(doc) {
curUserStyle.remove() curUserStyle.remove()
} }
// Theme and other public stylesheets live in <head>, rendered with stable // Theme and other public stylesheets live in <head>, rendered with stable
// ids by the backend; sync them positionally so the custom CSS (rendered // ids by the backend (links in dev, inline <style> elements in prod);
// last) always keeps winning by order. Diff-based: unchanged sheets keep // sync them positionally so the custom CSS (rendered last) always keeps
// their elements, so their @keyframes are never torn down (re-creating // winning by order. Diff-based: unchanged sheets keep their elements, so
// keyframes would replay the editor's slide-in animation). // their @keyframes are never torn down (re-creating keyframes would
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')] // replay the editor's slide-in animation).
const freshIds = new Set(freshLinks.map((l) => l.id)) const sel = 'link[rel="stylesheet"][id], style[id]'
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) { const freshEls = [...doc.head.querySelectorAll(sel)]
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove() 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 // Insert missing sheets in the fresh document's order, each right after
// its predecessor's element. The first sheet rendered is always the base // 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 // CSS, so its element doubles as the fallback anchor when nothing matched
// (e.g. no theme was selected before and the position is otherwise lost). // yet (e.g. no theme was selected before and the position is otherwise
// lost).
let anchor = null let anchor = null
for (const link of freshLinks) { for (const el of freshEls) {
const cur = link.id && document.getElementById(link.id) const cur = el.id && document.getElementById(el.id)
if (cur && cur.href === link.href) { if (cur && cur.outerHTML === el.outerHTML) {
anchor = cur anchor = cur
continue continue
} }
const el = document.importNode(link, true) const imported = document.importNode(el, true)
// Same id, new URL (theme switch): replace in place, keeping position. // Same id, new content (theme switch): replace in place, keeping position.
if (cur) cur.replaceWith(el) if (cur) cur.replaceWith(imported)
else if (anchor) anchor.after(el) else if (anchor) anchor.after(imported)
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el) else {
anchor = el 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 // The editor keeps its own title while open; only inherit the server title
// when navigating outside the editor (e.g. fetch-navigation swaps). // when navigating outside the editor (e.g. fetch-navigation swaps).
+119 -30
View File
@@ -22,7 +22,7 @@ import shutil
import socket import socket
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime
from email.utils import format_datetime from email.utils import format_datetime
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@@ -32,10 +32,11 @@ from xml.sax.saxutils import escape as xml_escape
import blake3 import blake3
import msgspec import msgspec
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect 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 fastapi_vue import Frontend
from kanta import Kanta from kanta import Kanta
from pydantic import BaseModel from pydantic import BaseModel
from zstandard import ZstdCompressor
from pagerite import analytics, seed, views from pagerite import analytics, seed, views
from pagerite.__main__ import DEVMODE from pagerite.__main__ import DEVMODE
@@ -282,6 +283,79 @@ async def _headers(request: Request, call_next) -> Response:
return 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): class PageIn(BaseModel):
"""Payload for creating or replacing a page.""" """Payload for creating or replacing a page."""
@@ -435,6 +509,33 @@ async def put_settings(settings: SettingsIn) -> None:
data.version += 1 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") @app.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]: async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it. """Upload a favicon into the content-addressed store and activate it.
@@ -712,36 +813,20 @@ class AnalyticsPing(BaseModel):
read: int = 0 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) @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. """Render the analytics viewer as a normal site page at /_a.
The page itself is public, but the data stream (/_api/ws/analytics) stays 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 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. statistics; others get the viewer with a "could not be loaded" message.
""" """
return HTMLResponse( return _html_response(
views.render_analytics( request,
data.menu, "analytics",
data.brand, "",
data.custom_css,
data.theme,
data.favicon,
data.brand_html,
initial_range=_analytics_initial_range(),
),
headers={"cache-control": "no-cache"}, headers={"cache-control": "no-cache"},
etag=True,
) )
@@ -1085,7 +1170,7 @@ frontend.route(app, "/")
@app.get("/{path:path}", response_model=None) @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. """Render the content page at a slug path, or 404.
A node without content is a category label: its URL renders a 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): if _is_trackable_path(path):
flushed = _track_entry(path, request) flushed = _track_entry(path, request)
_schedule_client_enrichment(flushed) _schedule_client_enrichment(flushed)
return HTMLResponse( return _html_response(
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, str(request.base_url).rstrip("/")), request,
"page",
path,
headers={ headers={
"etag": etag, "etag": etag,
"last-modified": _http_date(node.modified), "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): if _is_trackable_path(path):
flushed = _track_entry(path, request) flushed = _track_entry(path, request)
_schedule_client_enrichment(flushed) _schedule_client_enrichment(flushed)
return HTMLResponse( return _html_response(
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), request,
"category",
path,
404, 404,
headers={ headers={
"last-modified": _http_date(node.modified), "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)) asyncio.create_task(_enrich_client(client_hash))
flushed = _track_entry(path, request) flushed = _track_entry(path, request)
_schedule_client_enrichment(flushed) _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 #: Favicon: name of a file in `files` (content-addressed), linked as
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico. #: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
favicon: str = "" 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` #: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise. #: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {} pages: dict[str, Page] = {}
+112 -31
View File
@@ -110,6 +110,35 @@ def _editor_css_url(vite_url: str | None) -> str | None:
return 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( def _layout(
modules: list[str] = (), modules: list[str] = (),
stylesheets: list[str] = (), stylesheets: list[str] = (),
@@ -118,16 +147,21 @@ def _layout(
banner_design: str = "", banner_design: str = "",
favicon: str = "", favicon: str = "",
social: dict[str, str] | None = None, social: dict[str, str] | None = None,
extra_meta: dict[str, str] | None = None,
) -> Template: ) -> 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 In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev
showing the page, avoiding a flash of unstyled content. Order matters and server and stylesheets use ``blocking="render"`` so the browser waits
is fixed: base (Vite build, absent in dev where Vite injects it from JS), for them before showing the page, avoiding a flash of unstyled content.
theme and banner design (backend-served from pagerite/themes/), entry- In production all page assets are inlined into the document: stylesheets
specific stylesheets (e.g. overlayscrollbars.css), then the user's custom become ``<style>`` elements and module scripts inline ``<script>``s, so
CSS last so it always wins. 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 In dev, pagerite.js re-appends the backend-rendered theme/design links
(and the custom CSS) after the Vite-injected base styles, keeping this (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 ``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as
property attributes, everything else (description, twitter:*) as name. 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") doc = Document(E.Title, lang="en")
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs # 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. # one, browsers fall back to the build's /favicon.ico by convention.
if favicon: if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon") doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens # Asset URLs for the on-demand bundles (editor, analytics) for
# itself once it has validated the session (pages render identically # pagerite.js, which injects the 🖊️ edit pens itself once it has
# for everyone; editing is gated by the auth proxy in front of /_api). # validated the session (pages render identically for everyone; editing
script, editor_css = _editor_assets() # is gated by the auth proxy in front of /_api). Dev passes the Vite
doc.meta(name="pagerite:editor-src", content=script[-1]) # dev-server URLs as meta tags (Vite serves the modules and injects
if editor_css: # their CSS for hot reloads); production inlines all page assets and
doc.meta(name="pagerite:editor-css", content=editor_css) # carries the on-demand URLs in one JSON script instead.
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).
vite_url = os.environ.get("PAGERITE_VITE_URL") 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 = [ sheets = [
("pagerite-base", _base_css_url(vite_url)), ("pagerite-base", _base_css_url(vite_url)),
("pagerite-theme", _theme_css_url(theme)), ("pagerite-theme", _theme_css_url(theme)),
("pagerite-banner", _banner_css_url(banner_design)), ("pagerite-banner", _banner_css_url(banner_design)),
] ]
for id_, url in sheets: for id_, url in sheets:
if url: if not url:
continue
if vite_url:
doc.link(rel="stylesheet", href=url, blocking="render", id=id_) doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
else:
doc.style(HTML(_inline_asset(url)), id=id_)
for url in stylesheets: for url in stylesheets:
doc.link(rel="stylesheet", href=url, blocking="render") 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: for src in modules:
doc.script(src=src, type="module") if vite_url:
doc.script(src=src, type="module")
if custom_css.strip(): if custom_css.strip():
doc.style(custom_css, id="pagerite-user") doc.style(custom_css, id="pagerite-user")
return Template( body = (
doc doc
.header( .header(
E.div(E.Banner, id="page-banner"), E.div(E.Banner, id="page-banner"),
@@ -194,8 +255,23 @@ def _layout(
E.main(E.Main, id="main"), E.main(E.Main, id="main"),
id="content", 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: def _brand_link(brand: str, brand_html: str = "") -> HTML:
@@ -681,16 +757,22 @@ def render_analytics(
theme: str = "", theme: str = "",
favicon: str = "", favicon: str = "",
brand_html: str = "", brand_html: str = "",
initial_range: str = "week",
) -> str: ) -> 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() page_scripts, page_stylesheets = _page_assets()
analytics_scripts, analytics_stylesheets = _analytics_assets() analytics_scripts, analytics_stylesheets = _analytics_assets()
scripts = page_scripts + analytics_scripts scripts = page_scripts + analytics_scripts
stylesheets = page_stylesheets + analytics_stylesheets stylesheets = page_stylesheets + analytics_stylesheets
doc = E.article doc = E.article
with doc: with doc:
doc.div(id="analytics-app", **{"data-initial-range": initial_range}) doc.div(id="analytics-app")
return str( return str(
_layout( _layout(
scripts, scripts,
@@ -699,7 +781,6 @@ def render_analytics(
theme, theme,
banner_design(menu, "_a", theme), banner_design(menu, "_a", theme),
favicon, favicon,
extra_meta={"pagerite:analytics-src": analytics_scripts[0]},
)( )(
Title=f"Analytics {brand}" if brand else "Analytics", Title=f"Analytics {brand}" if brand else "Analytics",
Brand=_brand_link(brand, brand_html), Brand=_brand_link(brand, brand_html),
+1
View File
@@ -27,6 +27,7 @@ dependencies = [
"mdit-py-plugins>=0.6.1", "mdit-py-plugins>=0.6.1",
"pygments>=2.20.0", "pygments>=2.20.0",
"ua-parser>=1.0.2", "ua-parser>=1.0.2",
"zstandard>=0.25.0",
] ]
[project.scripts] [project.scripts]