Page caching and zstd compression. Avoid useless fetching. Mobile layouts of navigation menus improved.
This commit is contained in:
+3
-2
@@ -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
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)`.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user