Files
pagerite/docs/design-principles.md
LeoVasanko 4d6735f609 Fix card image priority: own > mined from article > inherited
The resolved (inheriting) Node.image was checked before mining the
article, so a front-page banner image shadowed the articles' own
extracted figures everywhere: card covers, og:image and the editor's
card previews.
2026-09-23 06:10:01 +00:00

20 KiB
Raw Permalink Blame History

Pagerite Design Principles

Pagerite is a single-user CMS/blog. This document records the initial high-level design decisions; it will be refined as the implementation evolves.

Architecture

  • Server-side rendered. FastAPI serves complete HTML pages, generated in Python with html5tagger. There is no client-side templating or SPA for the public site.
  • Vue only where interactivity demands it. Small interactive islands (editing tools mainly) are Vue components mounted into specific elements of the server-rendered pages. The public reading experience has no scripting requirement.
  • Persistence via kanta. Content is stored in an asyncio-friendly kanta database. Rendering happens on the fly on each request — there are no pre-built static artifacts.

Content model

  • Pages and blog articles are fundamentally the same kind of thing: named pieces of content. The blog/website distinction is blurred; an article is just a page (possibly with metadata such as a publication date and listing in a feed).
  • Pretty URLs. Content is addressed by its name (slug), not by technical constructs — no /cms/... or /blog/post1 prefixes. Slugs usually live directly at the site root; structured content may nest (/docs/design-principles-style). The URL space is the author's, so reserved prefixes must be kept few and deliberate: everything internal lives under /_ (/_api/, /_f/, /_assets/). The only other reserved root path is /favicon.ico, served from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores ([a-z0-9_-]; input is transliterated and filtered as you type, and a new page's empty slug is derived from its title), may not begin with _ or ., and such URLs are never looked up as content.
  • Single user, trusted author. No auth concerns in the core design. Everything published is public; only editing tools will later sit behind access control (external SSO when that time comes). The author is trusted to create well-meaning slugs and content — no sanitization for safety, only for correctness.
  • Commenting is not planned now but the model should not preclude it later.

Authoring format

  • Content is written in Markdown with powerful extensions (tables, footnotes, code highlighting, etc.).
  • Embedded HTML is passed through unfiltered, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
  • Renderer: markdown-it-py with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and ::: name containers — generic <div class="name"> wrappers (the name may be followed by brace attributes: ::: aside {.right}), of which ::: aside floats as a muted side box and {.margin} / ::: margin marks any block a margin note — on all but phone widths they are taken out of flow into the side zone at the article's start edge (left in LTR, right in RTL — the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and ::: nocols opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (> [!NOTE] / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with html=True for raw passthrough, typographer=True for SmartyPants-style replacements in body text (curly quotes, -- / --- → en / em dashes, ... → ellipsis, (c) → ©, etc.), and breaks=True so single line breaks inside paragraphs become <br> — including inside blockquotes, where every newline is kept and a blank > line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with Pygments (nowrap spans styled by /_assets/pygments-*.css, which maps every token class onto the --code-* variables; the base stylesheet defines light and dark palette sets resolved via light-dark(), so each theme gets the set matching its color-scheme and may only retint --code-bg to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
  • Files are content-addressed. Uploads (PUT /_api/files/{filename}) are stored on disk (<hostname>/files/, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from /_f/…. Raster images (not GIF) and SVGs (rasterized) are recompressed via mediapreview: the original is kept as {hash}.orig{ext} (internal only, never served — it may carry EXIF data; SVG originals stay servable as {hash}.svg) while pages link the extension-less /_f/{hash} and the server picks from the derivatives ({hash}.avif / {hash}.webp / {hash}.jpg) by Accept header — a format only when listed explicitly (image/avif → AVIF, image/webp → WebP, otherwise JPEG), with vary: accept; an explicit extension in the URL pins the format. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block <figure> — with <figcaption> when it has a title; images inline with text and raw <img> HTML stay plain inline images. Positioning is by attribute classes: ![alt](/_f/… "Caption"){.right}{.right}, {.left} float at 30% of the text column, to its end/start edge following the text direction (the caption wraps within it; an explicit width=300 makes the figure shrink-wrap the image instead), {.margin} makes it a margin note, placed in the side zone at the text's start edge on all but phone widths, {.wide} goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like width=300 work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with {.wide} becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (some text {.small}) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes {.small} / {.large} / {.huge} (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, ::: containers and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.

Page structure and navigation

  • All pages share one static layout, defined once as an html5tagger Template with capitalized placeholders (Title, Banner, Nav, Sidebar, Main) filled per request. The dynamic regions carry stable ids (#page-banner, #nav, #sidebar, #main).
  • The page top is a full-width banner header with the site name and the navigation bar overlaid on it — no separate chrome header. The banner combines two layers, stacked in #page-banner (a grid, so they overlay): first the banner design — a named design living in a theme folder (pagerite/themes/{name}/banner.css plus artwork as banner.html — arbitrary markup like canvas + style + script — or banner.svg), chosen per page via Node.banner_design (a design name, "" for none, None to inherit from the nearest ancestor, then the front page, then the active theme's own design). The artwork is inlined into a div[data-design] wrapper: SVG artwork can be recolored from the theme stylesheet (corporate's single SVG serves both light and dark mode via var()-driven stops). Second, per-page author code: Node.banner holds an arbitrary trusted HTML snippet (an image, a styled div, canvas + script — anything), resolved by walking up the node's ancestors to the front page and rendered after the design artwork, so author styles always win over the design's own. The base stylesheet falls back to a plain gradient. There is deliberately no scrim fading the banner into the page background — any such fade would ruin user-supplied designs; themes that want one bake it into their SVG (purple does).
  • Fetch-navigation. Links are plain <a href>; a small script (frontend/src/pagerite.js) intercepts same-origin clicks, fetches the page, and swaps the #page-banner, #nav, #sidebar and #main regions, the document title, and the site-wide custom CSS (<style id="pagerite-user"> in <head>), keeping the rest of <head> and the layout chrome. Without JS everything works as normal page loads. Scripts inside fetched banner and content regions are re-created so they execute. Swaps run inside document.startViewTransition for the page transition selected in the site settings (Data.transition; the cube design — CSS adapted from termotohtori.fi, fragile, do not tweak — rotates, mirrored on browser back; crossfade fades; both skipped under prefers-reduced-motion). With cube, navigation within the same top-level section crossfades instead of rotating.
  • The site structure is a tree of labels. Data.menu holds the top-level items by slug, each with children keyed by slug — the URL path is the slug chain. The front page is a top-level node with slug "" (an item parallel to the other main level pages, not their parent) and cannot have children. The header navbar holds only the top level; a top-level item is highlighted when viewing any of its subpages. A page with published children lists them as cards after its content (the child page's card image as the cover — its own Node.image when set, else mined like the og tags, else the inherited one — laid out by the child's card-mode selection: full-card cover with the title overlaid, or a golden-ratio split with a square image and the title in the top part, the description below it on a translucent band); a left sidebar (#sidebar) with the section's sub-navigation appears only from the second level down, when there is something to navigate — main-level pages, sections with fewer than two published items, leaf pages and the front page render no aside element at all. Other sections' subitems are never shown without navigating into them first.
  • Landing pages are optional. Every label can either have content (Node.content, a Markdown page) or none — a content-less label renders a 404 page listing its children as cards (with a pen to create the landing page) instead of redirecting, while nav links to it point straight at its first child, so categories need no filler content and normal navigation never sees the 404. Title and slug of every label are editable; renaming a slug moves the whole subtree. The sidebar never lists the section itself, avoiding title duplication with the navbar.
  • Menu order is manual. Each node has a fractional order key among its siblings; reordering/moving writes only the moved node (it takes a fresh value halfway between its new siblings; all other items keep theirs). New pages append at the end of their menu. Structure edits (reorder, move/rename with the whole subtree, retitle) go through POST /_api/structure and the editor's structure panel.
  • Unpublished pages are hidden from both nav and URL access (404).

Reading experience

  • The article column is sized by the viewport, never by content: a symmetric grid (1fr minmax(0, 78rem) 1fr) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged .multicol by the backend render) lift the cap and become a bounded composition, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem side zone at the article's left — the region the nav sidebar overlays — which hosts margin boxes (.margin, ::: aside, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in main), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in main (container queries + cqw units, so the docked editor's inset is automatic). .wide figures on multicol pages bleed to the viewport edges measured from main (cqw), sliding under the sidebar. The backend splits the body into .colseg segments at h1/h2 headings and .wide elements (full-width separators, never inside columns); margin boxes stay inside the segment at their anchor point and the CSS takes them out of flow — absolutely positioned off the article's left border into the zone, the columns flowing through unaffected — tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with .cols — code blocks are excluded from that measure, a ::: nocols container opts its whole section out, and column-filling paragraphs are marked .breakable so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem.
  • A gentle scroll-reveal of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and prefers-reduced-motion disables all motion.

Styling

  • The base stylesheet frontend/src/assets/pagerite.css provides the layout, typography and interaction rules with conservative CSS variables. A theme layer (pagerite/themes/{name}/theme.css — currently purple, corporate and nitro, served by the backend at /_themes/{name}/theme.css straight from disk, never built) overrides those variables and adds the visual styling; Data.theme selects the active theme (empty = none/base only) and the site editor can switch it, choosing from the theme folders found on disk. Vue may add per-component styles on top where needed. The corporate and nitro themes switch palettes automatically via prefers-color-scheme (corporate is light-first with a matching dark palette; nitro a warm light-grey page or, in dark mode, a deep violet one — its dark banner and orange accents carry over unchanged); purple (dusk) uses one fixed palette for everyone. Themes may restyle structural details the base leaves plain — heading colors and underlines, list markers, nav treatment, brand sizing. A theme folder may also ship a banner design (banner.css + banner.svg), selectable per page independently of the active theme. The banner artwork has scroll parallax: pagerite.js sets the --pry scroll parameter on <html> (event-driven, so it is still when the page is idle), the banner contents drift within their window (with scale overscan so no edge shows), and designs may key their own effects off the same parameter — purple's sun rises as you scroll.
  • Fonts, the shared stylesheet and pygments styles live under frontend/src/assets/ and are emitted as hashed assets under /_assets/ (Source Serif 4 for headings, Source Sans 3 for body, Fira Code for code by default; Fraunces, Literata, Cormorant, Playfair Display, Inter, Montserrat, Cause, Exo 2 and New Rocker kept as woff2 options with local @font-face, variable-weight where available). No third-party requests.

Editing

  • Editing happens in place, in two modes opened by two pens:
    • Page mode — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap).
    • Site mode — the ⚙️ at the top right (after the 📊 analytics link, before login) opens a panel with the site brand (applied to the header live), a theme selector (swapping the theme stylesheet in place), a page transition selector (cube/crossfade, swapping #pagerite-transition in place), font picks (heading/body/brand — stored as plain :root rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a site-wide custom CSS field (injected into <style id="pagerite-user"> in the live page head and swapped during fetch-navigation), the page's banner design selector (inherit / none / any design found on disk, inherited by children), the page's banner HTML field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the structure tree. Everything saves immediately as you edit — no save button, no edit mode.
  • Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are <button>s wired up by pagerite.js — editing is an action, not a navigation. The editor's WebSocket reconnects automatically with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
  • CodeMirror 6 for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (PUT /_api/files/...) and insert ![alt](/_f/hash) at the cursor.
  • The structure panel (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page.
  • Preview and saving go over a WebSocket (/_api/ws/editor) with a stateless JSON protocol (open/render/save; on save all fields are optional and absent ones keep their old values, move_from renames), avoiding REST polling and races. Rendering always stays server-side.
  • A REST API also exists for scripting, all under /_api/: GET pages (the full tree), PUT/DELETE pages/{path}, GET/PUT settings (site brand, theme and custom CSS), POST structure (reorder/move/retitle), file upload/removal via PUT/DELETE files/{name}.
  • On startup, seed pages from pagerite/seed.py are added only if missing — existing user content is never overwritten.