- Data.theme/Data.custom_css site settings with /_api/settings support; theme stylesheet linked per page, custom CSS inline as #pagerite-user, swapped during fetch-navigation - Site editor: theme selector, custom CSS CodeMirror field; in-place stylesheet swap (diff-based, preserves editor stylesheets); dev swaps Vite-injected style tags since no <link>s exist there - Dev theme delivery via <meta name="pagerite:theme"> + dynamic import in pagerite.js instead of a hardcoded purple import - Fix dead edit pens in prod: Vite app builds strip entry exports, so the dynamic-imported main.js had no openEditor/closeEditor; build with preserveEntrySignatures 'exports-only' and log editor load failures instead of swallowing them - Editor slide-in is one-shot: cleared on animationend so stylesheet swaps cannot restart a finished animation - Replace Fraunces/Literata with Source Sans 3/Serif 4, Inter and Montserrat variable fonts; font stacks as CSS variables
203 lines
14 KiB
Markdown
203 lines
14 KiB
Markdown
# 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/...`) 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
|
||
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.
|
||
- `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 renders
|
||
a placeholder page (while nav links to it point at 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 active
|
||
theme's banner artwork (or a plain gradient in the base stylesheet).
|
||
`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 + `<title>` suffix), editable
|
||
in the site editor via `/_api/settings`; empty = no header link and
|
||
no `<title>` suffix. `Data.theme` is the active theme name (empty =
|
||
none/base only); themes live in `frontend/src/assets/themes/{theme}`.
|
||
`Data.custom_css` is raw trusted CSS injected inline in every page
|
||
`<head>` (id `pagerite-user`) and swapped during fetch-navigation;
|
||
editable in the site editor.
|
||
- `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`; nav links to content-less labels point at 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 <title> 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).
|
||
- `frontend/src/` — the Vue editor and public-page entries.
|
||
- `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
|
||
- `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal and
|
||
code copy buttons. The backend links the shared CSS as two separate
|
||
stylesheets (base and theme) so they can be swapped or augmented.
|
||
- `assets/` — shared styles and data files built by Vite and served hashed
|
||
under `/_assets/`: `pagerite.css` (base layout + conservative variables),
|
||
`themes/purple/theme.css` (the purple/dark theme override, including its
|
||
own `banner.svg`), `pygments.css`, and `fonts/` (self-hosted Source
|
||
Sans 3/Source Serif 4/Inter/Montserrat/Fira Code variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
|
||
termotohtori.fi) is fragile — do not tweak.
|
||
- Vite builds ES-module `.js` outputs; the backend renders `<script
|
||
type="module">` for them (module scripts defer by default).
|
||
- The database file is `pagerite.kantadb` 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 + theme selector +
|
||
site-wide custom CSS + banner HTML edited in small CodeMirror windows;
|
||
banner previewed into `#page-banner`, CSS injected into
|
||
`<head id="pagerite-user">`) + 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/CSS debounced,
|
||
slug on commit since it renames the path), theme change swaps the
|
||
stylesheet in place, 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. Every
|
||
non-empty list (and the root) ends with a non-draggable ➕ footer row
|
||
(vuedraggable `#footer` slot): clicking it starts a new pending page at
|
||
that level, and while dragging it is the list's "end of list" drop
|
||
target. Dropping ON the lower part of a row moves the page under that
|
||
row (the child list's container invisibly overlaps its own row's bottom
|
||
via negative margin — Sortable inserts it as the first child natively),
|
||
while a row's exposed top edge inserts a sibling before it. Row
|
||
indentation is structural (each nested list margin-indents itself), so a
|
||
dragged row previews its whole subtree at the target list's depth. 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`).
|
||
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`; 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 no stylesheets (Vite injects them from JS); the
|
||
active theme reaches the page as `<meta name="pagerite:theme">` and
|
||
pagerite.js imports that theme's CSS, while a theme switch in the site
|
||
editor swaps the Vite-injected `<style data-vite-dev-id>` tags (the
|
||
`<link>` sync used in prod is a no-op in dev).
|
||
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 `</p>` or `</li>` is ever included in output. Due to this proper "nesting" of content is NOT required and should be avoided. Where needed, () directly after tag define attributes and content INSIDE the element, then close the element. `with doc.ul:` and such may be used for larger chunks.
|
||
- Prefer building directly on one builder with `with` blocks (recursing
|
||
inside a with block for hierarchies) over preparing `E.` snippets into
|
||
variables and composing them. Note `with doc.li:` alone fails (`li`
|
||
has an optional end tag) — use `with doc.li.ul:` style chains, or
|
||
`doc.li.a(...)` followed by a nested `with doc.ul:` block.
|
||
- `Template(builder)` freezes a builder with **Capitalized** attribute
|
||
placeholders (e.g. `E.Title`, `doc.main(E.Main, id="main")`); calling
|
||
it fills the slots with escaping — pass `HTML(...)` for raw HTML.
|
||
Passing a list to a template slot expands it; passing a list to a
|
||
normal builder call does NOT (spread it: `E.ul(*items)`).
|
||
- To create plain HTML snippets use `E.div(E.p("content"))` etc using the `E` empty builder.
|
||
- **kanta** — asyncio-native embedded database: `Kanta(filename, data)`
|
||
root object, `transaction`, `flush`, snapshot/replay-log persistence.
|
||
- `async with Kanta(Data(),...) as kanta:` (or await kanta.open/close)
|
||
- `with kanta.transaction(...) as data:` - transactions only for writes
|
||
- `data` may be referenced directly to read anywhere and to modify in transactions (`as data` is just a shorthand access)
|
||
- Data structures should be msgspec.Structs, where JSON restrictions do not apply (we can use `bytes`, `UUID`, `datetime` etc. even as dict keys)
|
||
- We prefer objects rather than lists, as this works better in change diffs. E.g. `dict[str, True]` where the keys indicate presence and always have value `True`.
|
||
- Maintaining and owning the app's own `Data` object is preferable; Kanta never copies this, only edits in place
|
||
- Note: besides opening it every access is immediate direct variable access: no `await`, no locks, no delays
|
||
- **fastapi-vue** — template glue for serving/building the Vue frontend;
|
||
keep its integration points (`Frontend`, build hook) intact.
|
||
- **markdown-it-py** — Markdown rendering with `html=True` raw
|
||
passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs;
|
||
**Pygments** for server-side code highlighting (`nowrap` spans, styles
|
||
in `frontend/src/assets/pygments.css` scoped to "pre code").
|
||
|
||
## Conventions
|
||
|
||
- Keep dependencies minimal; add via `uv add` and mention it.
|
||
- The public URL space belongs to content (pretty slugs at root). Reserve
|
||
only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus
|
||
`/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits,
|
||
hyphens and underscores `[a-z0-9_-]` (the site editor filters input live
|
||
via `slugify.js`, built on the `transliteration` npm package — unicode
|
||
folds to ASCII, spaces become hyphens; an empty slug on a new page is
|
||
derived from its title), may not begin with `_` or `.`, and such URLs are
|
||
never looked up as content.
|
||
- No auth in core code; trusted single author. Never add output
|
||
sanitization "for safety" against the author — embedded HTML/scripts in
|
||
Markdown are passed through deliberately.
|
||
- Update this file and `docs/design-principles.md` when architecture,
|
||
tooling, or conventions change.
|