From 36b250a15c9ef80cb84152349521561c7baed2a1 Mon Sep 17 00:00:00 2001
From: Leo Vasanko
Date: Sun, 16 Aug 2026 05:49:38 +0000
Subject: [PATCH] Pagerite: single-user CMS/blog
FastAPI backend rendering HTML with html5tagger, content persisted in a
kanta database and rendered per request. Vue only for the editing tools
(page editor over a WebSocket, site/structure editor); public pages are
plain HTML with fetch navigation. No auth: single trusted author.
---
.gitignore | 13 +
AGENTS.md | 160 ++
README.md | 27 +
docs/design-principles.md | 183 ++
frontend/.gitignore | 39 +
frontend/.vscode/extensions.json | 3 +
frontend/index.html | 12 +
frontend/package-lock.json | 2845 ++++++++++++++++++++++++++
frontend/package.json | 28 +
frontend/public/favicon.ico | Bin 0 -> 4286 bytes
frontend/src/PageEditor.vue | 423 ++++
frontend/src/SiteEditor.vue | 767 +++++++
frontend/src/StructureTree.vue | 251 +++
frontend/src/cmtheme.js | 41 +
frontend/src/main.js | 55 +
frontend/vite-plugin-fastapi.js | 36 +
frontend/vite.config.js | 23 +
pagerite/__init__.py | 1 +
pagerite/__main__.py | 33 +
pagerite/app.py | 525 +++++
pagerite/data.py | 133 ++
pagerite/markdown.py | 125 ++
pagerite/seed.py | 449 ++++
pagerite/static/banner.svg | 34 +
pagerite/static/fonts/firacode.woff2 | Bin 0 -> 36276 bytes
pagerite/static/fonts/fonts.css | 3 +
pagerite/static/fonts/fraunces.woff2 | Bin 0 -> 67304 bytes
pagerite/static/fonts/literata.woff2 | Bin 0 -> 85672 bytes
pagerite/static/pagerite.js | 258 +++
pagerite/static/pygments.css | 86 +
pagerite/static/style.css | 695 +++++++
pagerite/views.py | 253 +++
pyproject.toml | 37 +
scripts/devserver.py | 88 +
scripts/fastapi-vue/buildhook.py | 19 +
scripts/fastapi-vue/buildutil.py | 231 +++
scripts/fastapi-vue/devutil.py | 226 ++
uv.lock | 983 +++++++++
38 files changed, 9085 insertions(+)
create mode 100644 .gitignore
create mode 100644 AGENTS.md
create mode 100644 README.md
create mode 100644 docs/design-principles.md
create mode 100644 frontend/.gitignore
create mode 100644 frontend/.vscode/extensions.json
create mode 100644 frontend/index.html
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/public/favicon.ico
create mode 100644 frontend/src/PageEditor.vue
create mode 100644 frontend/src/SiteEditor.vue
create mode 100644 frontend/src/StructureTree.vue
create mode 100644 frontend/src/cmtheme.js
create mode 100644 frontend/src/main.js
create mode 100644 frontend/vite-plugin-fastapi.js
create mode 100644 frontend/vite.config.js
create mode 100644 pagerite/__init__.py
create mode 100644 pagerite/__main__.py
create mode 100644 pagerite/app.py
create mode 100644 pagerite/data.py
create mode 100644 pagerite/markdown.py
create mode 100644 pagerite/seed.py
create mode 100644 pagerite/static/banner.svg
create mode 100644 pagerite/static/fonts/firacode.woff2
create mode 100644 pagerite/static/fonts/fonts.css
create mode 100644 pagerite/static/fonts/fraunces.woff2
create mode 100644 pagerite/static/fonts/literata.woff2
create mode 100644 pagerite/static/pagerite.js
create mode 100644 pagerite/static/pygments.css
create mode 100644 pagerite/static/style.css
create mode 100644 pagerite/views.py
create mode 100644 pyproject.toml
create mode 100755 scripts/devserver.py
create mode 100644 scripts/fastapi-vue/buildhook.py
create mode 100644 scripts/fastapi-vue/buildutil.py
create mode 100644 scripts/fastapi-vue/devutil.py
create mode 100644 uv.lock
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..483cd46
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+# Python-generated files
+__pycache__/
+*.py[oc]
+build/
+dist/
+wheels/
+*.egg-info
+
+# Virtual environments
+.venv
+.ruff_cache/
+/pagerite/frontend-build
+pagerite.kanta
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..38fb8b6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,160 @@
+# AGENTS.md
+
+- Do NOT test, run the server, write tests etc.
+- ESPECIALLY DO NOT make repros, do NOT install Playwright etc.
+
+Please instead ask the user to see from dev tools what you need, e.g. to look up something in DOM or log. Use console.log for debugging where needed (and otherwise for permanently kept useful messages in the app).
+
+## What this is
+
+Pagerite: a single-user CMS/blog. FastAPI serves HTML rendered in Python
+with html5tagger; content is persisted in a kanta database and rendered on
+the fly per request. Vue is used only for interactive bits (editing tools),
+not for the public pages. See `docs/design-principles.md` for the design.
+
+## Layout
+
+- `pagerite/` — the Python backend package (hatchling build target).
+ - Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed)
+ - Dev mode `scripts/devserver.py` (which the user mostly uses for auto reloads, no build needed)
+ - Avoid running the server yourself, ask the user to test
+ - `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled
+ (`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to
+ our content. Our own routes (content pages, `/_/api/...`, `/_/f/...`,
+ `/static/...`) are registered 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
+ defined earlier wins. The one exception is the content catch-all
+ `/{path:path}`, registered 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
+ asset files at root without a catch-all; an `index.html` in the build
+ would become a `/` route, so leave it out of the build to keep `/` ours.
+ - `data.py` — msgspec Structs for the kanta database. The site structure
+ is a tree: `Data.menu` maps top-level slugs to `Node`s, each with
+ `children` keyed by slug — the URL path is the slug chain. The front
+ page is whichever top-level node has slug "" (parallel to the other
+ main level pages, not their parent); it cannot have children, and
+ renaming its slug away leaves no front page ("/" redirects to the
+ first nav item). `Node.content` is
+ the Markdown page, or None for a pure category label whose URL
+ redirects to its first child; every label's title and slug are
+ editable. Siblings order by the fractional `Node.order` key: a moved
+ item gets a fresh key relative to its new siblings, all others keep
+ theirs. `resolve`/`find_slot` walk the tree by path; moves are slot
+ detach/attach carrying the whole subtree. Legacy flat `Data.pages`
+ (pre-tree databases) migrates into `menu` on startup. The app owns
+ the `Data` object; reads are plain attribute access, writes in
+ `kanta.transaction(...)`.
+ `Data.files` is a content-addressed store (blake3[:12] + extension)
+ mapping file names to bytes, served at `/_/f/{name}` with immutable
+ caching; pages reference files by absolute `/_/f/` URLs so hierarchy
+ moves never break them. `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), then the default
+ banner.svg artwork. `Data.version` is bumped on every write
+ and embedded in page ETags so nav-affecting changes invalidate caches.
+ `Data.brand` is the site name (header link + `` suffix), editable
+ in the site editor via `/_/api/settings`; empty = no header link and
+ no `` suffix.
+ - `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
+ footnote, deflist, tasklists plugins). Custom image rule: relative srcs
+ resolve against the page path, titled images become figures.
+ - `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`; content-less labels redirect to their first child via
+ `first_leaf`), and page/404 rendering. If the markdown contains its own h1, the page title
+ is NOT rendered as an additional h1 (it still supplies and nav
+ labels). The navbar holds
+ top-level items only; the current section's subitems go to a left
+ `#sidebar` (empty and hidden elsewhere). Dynamic regions have stable ids
+ (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps.
+ - `seed.py` — demo content written on startup for paths missing from the
+ database (never overwrites existing pages).
+ - `static/` — our own assets served at `/static/`: `style.css` (shared
+ with Vue later), `pagerite.js` (fetch-navigation with rotating-cube
+ `startViewTransition`, scroll-reveal), `banner.svg`
+ (full-width header art) and `fonts/` (self-hosted Fraunces/Literata/
+ Fira Code variable woff2). The `::view-transition*` block at the end of
+ `style.css` (from termotohtori.fi) is fragile — do not tweak.
+ - The database file is `pagerite.kanta` in the cwd (`PAGERITE_DB`
+ overrides); gitignored. Do not delete it without asking.
+- `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template
+ (build hook etc.), do not edit.
+- `frontend/` — the Vue editor as **two separate apps** mounted in their
+ own host divs created inside the static document: `PageEditor.vue`
+ (CodeMirror + server-rendered preview over WebSocket `/_/api/ws/editor`,
+ previewing into the visible article; editor scroll drives document
+ scroll) opened by the article pen — it edits content and title only,
+ never the path — and `SiteEditor.vue` (site brand + banner HTML edited in
+ a small CodeMirror window and previewed into `#page-banner` + vue-draggable structure tree with
+ always-editable title/slug inputs per row) opened by
+ the banner pen — everything saves immediately as you edit (brand/title
+ debounced, slug on commit since it renames the path), tree rows navigate
+ in place without transitions when focused, and the front page is a
+ root-only row whose empty slug is editable like any other (empty child
+ lists become drop zones while dragging). The two pens swap the docked
+ panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by
+ pagerite.js when a 🖊️ edit link is clicked (the link carries
+ `data-editor-src`/`data-editor-css`/`data-editor-mode`); the `/admin`
+ route (page selected by location hash) is the no-JS-import fallback shell
+ rendered by `views.render_editor` and keeps its own preview pane.
+ 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` builds with
+ `manifest: true` and a JS-only input (`src/main.js`) so no `index.html`
+ ends up in the build (it would shadow `/`). vite-plugin-fastapi.js has an
+ auto-upgrade marker — edit `vite.config.js`, not the plugin.
+- `docs/` — design documentation.
+
+## Toolchain
+
+- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`,
+ `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`,
+ `pygments`, `tracerite`; dev group has `httpx`. Run anything via
+ `uv run ...` (the venv is `.venv`).
+- Key libraries:
+ - **html5tagger** — all HTML generation (`E`, `Document`, `Template`,
+ `HTML` for trusted/raw HTML).
+ - To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header
+ - Chain with `doc.p("text").br`: every attribute access creates element to doc (returning self), calls add content to current element.
+ - Closing tags are not used where optional, e.g. no `