From f793d21c5e0d6c1e10f279ee72661644f8760f5b Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 29 Aug 2026 19:46:47 +0000 Subject: [PATCH] Serve images extension-less at /_f/{hash} with Accept-negotiated AVIF/WebP/JPEG Uploaded images (SVGs rasterized, GIFs excepted) are stored as the original (hash.orig.ext, internal only, never served) plus AVIF primary and WebP/JPEG fallback derivatives re-encoded from it. Pages link the bare hash; the server serves a format only when Accept lists it explicitly (image/avif -> AVIF, image/webp -> WebP, else JPEG) with vary: accept, while an explicit extension pins the format. Favicons go through the same pipeline at 192px. migrate_v2 rewrites old /_f/{hash}.avif article links, a startup backfill creates missing derivatives, and twitter:image pins the .webp variant for X's scraper. --- docs/backend.md | 4 +- docs/content-model.md | 2 +- docs/design-principles.md | 4 +- pagerite/app.py | 218 ++++++++++++++++++++++++++++++++------ pagerite/migrations.py | 27 +++++ pagerite/seed.py | 2 +- pagerite/views.py | 13 ++- 7 files changed, 231 insertions(+), 39 deletions(-) diff --git a/docs/backend.md b/docs/backend.md index 51e9a6e..c7839eb 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -10,7 +10,7 @@ The build mirrors the URL space — hashed immutable assets under `/_assets/`, ` 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. -Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Legacy databases that still carry blobs in a `files` kanta field are migrated to disk by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which pops the field from the raw state before struct decoding. +Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Uploaded raster images (and rasterized SVGs) are stored as `.orig` (internal only, never served) plus AVIF, WebP and JPEG derivatives, and pages link the extension-less `/_f/{hash}`: the server serves a format only when the Accept header lists it explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise — including `*/*` — JPEG), with `vary: accept`; an explicit extension pins the format. Missing derivatives of older uploads are backfilled at startup; `migrate_v2` rewrites old `/_f/{hash}.avif` article links to the bare form. Legacy databases that still carry blobs in a `files` kanta field are migrated to disk by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which pops the field from the raw state before struct decoding. ## `data.py` @@ -26,7 +26,7 @@ markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering. -Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster ``, then the first SVG; the first `