11 KiB
Backend
The Python backend lives in pagerite/.
app.py
Thin FastAPI assembly: lifespan (open the kanta database, load the file store, the frontend build and GeoIP), the FastAPI(...) instance with built-in API docs disabled (docs_url/redoc_url/openapi_url=None) because /docs belongs to our content, the server header middleware, and router includes. The routes themselves live in specialized modules:
state.py— shared core, no routes: the environment-derived site constants (HOSTNAME,SITE_URL,DB_PATH,FILES_DIR, image/favicon tunables), thedataroot and itskantahandle (Kanta(..., migrations="pagerite.migrations")), theanalytics_store, the fastapi-vuefrontend, the page render cache and_html_response, the translatordispatcher, the slug charset helpers, and the@kanta.bootstraphooks (demo seed, translator defaults).files.py— theFileStoreand image derivative helpers, and the file routes:/_api/files,/_f/,/_themes/,/_fonts/, the favicon settings endpoints.api.py— the editor REST API and WebSockets:/_api/pages,/_api/structure,/_api/settings,/_api/toggle-task,/_api/translations,/_api/ws/editor, and the translator channel/_translate/{clientkey}.tracking.py— visit analytics: GeoIP, client enrichment, favicon fetching, debounced broadcasts, the/_wsactivity socket, the admin stream/_api/ws/analytics, and the/_aviewer page.pages.py— the public content pages:/,/sitemap.xml,/robots.txtand the/{path:path}catch-all.
Route ordering is load-bearing and lives in app.py: the api/tracking/files routers are included BEFORE frontend.route(app, "/") is called — fastapi-vue inserts its file routes at the position where route() was called (during load() in the lifespan), so anything registered earlier wins. The content catch-all /{path:path} is included AFTER frontend.route() so that built frontend assets still take priority over content slugs. The Frontend is constructed with spa=False explicitly: it only serves the built files without a catch-all.
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 state.py's _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 cleared wholesale by _invalidate_pages() on every content/settings change, which also bumps the in-memory render generation. 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 the render generation; /_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 (in files.py): content-addressed files on disk under <hostname>/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 <hash>.orig<ext> (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. migrate_v2 rewrites old /_f/{hash}.avif article links to the bare form, backfills missing derivatives on disk, and drops the obsolete version field. Legacy databases that still carry blobs in a files kanta field or a flat pages store are migrated by pagerite/migrations.py::migrate_v1 (kanta's migrate_vN mechanism, wired via Kanta(..., migrations="pagerite.migrations")), which rewrites the raw state before struct decoding — all schema/storage upgrades live in that module, none in the app lifespan.
data.py
msgspec Structs for the kanta database. See docs/content-model.md for the full data model.
markdown.py
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside ::: aside, never participate), each gets a slug id (python-slugify, mirroring the editor's slugify.js — unicode folds to ASCII, separators become single hyphens) unless the author set {#id}, and their text is wrapped in a self-link (a.anchor) so section links are copyable; anchored headings also carry data-line with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, render(title=...) injects it as # {title} so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is href="" (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get -2/-3 suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw <img> HTML stay plain. A lone {name} / {name: args} line is a block directive: a core rule turns it into a directive token (render instance only — the verbatim parser keeps the plain paragraph so segments/chunks see the placeholder source), and the render rule delegates to the resolvers passed as render(directives=...), leaving the source literal where no resolver applies (e.g. the editor preview). Built in: {dates} expands to the article's published/updated dateline (p.dateline, from Node.created/modified, registered by render() when created is given); views.py resolves {cards} — the page's published children — and {cards: path path/* ...} (space-separated: a path's subtree as one stack, path/* its children as one stack each) into the same card-row markup as category pages (.cards.wide, a boundary block outside the column segments). A page with any {cards} tag drops the automatic end-of-page child cards; multiple tags each render their own row. Code fences take pandoc-style brace attributes on the info line (```{.python .wide #id key=val} — the first class is the language when no bare language word precedes the braces) as well as a trailing {...} line; both land on the <pre>, the <code> keeps only the language class.
render() returns a Rendered(html, multicol): the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct <article> children) — h1/h2 headings and .wide blocks stand bare, the runs between them become <div class="colseg"> (margin-breakout boxes — .margin, ::: aside — stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus .cols on segments with enough text in at least two paragraphs or one long enough to split across columns, ::: nocols opting out; in column segments, paragraphs past BREAKABLE_TEXT visible characters are marked .breakable so they may split across columns), and multicol flags bodies long enough to columnize (visible-text thresholds, code excluded). views.py puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
views.py
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 site origin (SITE_URL — https://<hostname> from the CLI hostname argument; on localhost the request's own base URL is the fallback); article:published/modified_time come from Node.created/modified. Additionally twitter:image pins extension-less /_f/{hash} share images to the .webp variant — X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. The page title is injected as # {title} when the markdown has no h1 of its own, so it never appears twice (it always 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), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders 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, main-level pages, 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).
Any page with published children — a category page — lists them as a card grid (nav.cards) after the markdown content, as does the content-less category 404. Each card links to the child page (a content-less child to its first leaf) and shows the child's share image (the same hero → first raster → first SVG heuristics as og:image) as a full-card cover with the title overlaid.
seed.py
Demo content written only when the database is first created, via a @kanta.bootstrap handler in state.py.