Look up themes and theme per-file overrides in system, user and site directories.

This commit is contained in:
2026-08-29 02:41:29 +00:00
parent d21c3edbfc
commit 775dc3f65a
7 changed files with 221 additions and 70 deletions
+3 -2
View File
@@ -14,7 +14,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `data.py` — msgspec Structs for the kanta database.
- `migrations.py` — kanta schema migrations (`migrate_vN`), e.g. v1 moves legacy in-db file blobs to the on-disk store.
- `markdown.py` — markdown-it-py renderer.
- `views.py` — shared page layout and rendering.
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
- `seed.py` — demo content, written only on first database creation.
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
- `frontend/src/` — Vue editor and public-page JS entries.
@@ -28,7 +28,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
## 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`).
- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `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
@@ -46,6 +46,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
- 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.
- **platformdirs** — platform user/system data dirs for the theme and font search roots (`views.THEME_DIRS` / `views.FONT_DIRS`; use `site_data_dir(..., multipath=True)`, not `site_data_path`, which collapses multipath).
- **markdown-it-py** — Markdown rendering with `html=True` raw passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs; in-body h1/h2 headings get auto slug ids + self-links when the body has 3+ of them (`python-slugify`, mirroring `slugify.js`); **Pygments** for server-side code highlighting (`nowrap` spans, styled by `frontend/src/assets/pygments.css` which maps token classes 1:1 onto the `--code-*` variables; light/dark palette sets live in `pagerite.css` and resolve via `light-dark()` from the theme's `color-scheme` — themes pick a set, not individual colors).
## Conventions
+21
View File
@@ -14,6 +14,18 @@ The `::view-transition*` rules are not in the base stylesheet: they live in the
Themes are folders in `pagerite/themes/{name}/` containing `theme.css` and/or `banner.css` (+ `banner.svg` artwork and any extra assets the CSS references, like summer's `grass.svg`). They are served by the backend at `/_themes/{name}/...` — read from disk per request (etag by mtime), never built, so on-disk edits show on the next page load even in prod.
Themes are searched across several roots, most specific first (see `views.THEME_DIRS`):
1. `themes/` under the current working directory
2. `<site-dir>/themes/` (the site's own folder, e.g. `localhost/themes/`)
3. The platform user data dir's `pagerite/themes/` (Linux: `~/.local/share/pagerite/themes/`, Windows: `%LOCALAPPDATA%\pagerite\themes\`)
4. The platform system data dirs' `pagerite/themes/` (Linux: `/usr/local/share/pagerite/themes/`, `/usr/share/pagerite/themes/`, ...; Windows: `%PROGRAMDATA%\pagerite\themes\`) — several combine
5. `pagerite/themes/` (built-in package dir, fallback)
The data dirs come from `platformdirs` (`views._data_roots`).
All roots combine: listings are the union of folder names, and each file resolves from the first root that has it. So users can add completely new themes in any root, shadow a built-in file with their own (`<site>/themes/corporate/theme.css`), or extend a built-in theme with extra files (any files the user folder doesn't provide still come from the built-in). Since everything is read per request, new or changed folders take effect without a server restart.
`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 site editor shows a light/dark-mode indicator in front of each theme name, read from the theme's `color-scheme` declaration in `theme.css`: ☀️ for light-only, 🌙 for dark-only, and 🌓 for themes that support both. The base theme (`none`) is light-only.
@@ -25,6 +37,15 @@ Current themes:
- `nitro` — racing/HUD style following `prefers-color-scheme` (warm light-grey page, deep violet in dark), Montserrat/Literata, black as an accent only, a straight orange blade under the banner, and an orange racing-tab nav clipped with a bezier `shape()`.
- `summer` — light playful meadow, one palette sampled from its illustrated `banner.svg` (sky/grass/sun/flower pink), Fraunces/Literata, a tilted gradient brand, flower bullets, and a layered-parallax banner (sun rises, clouds drift, nearer hills move less) with idle animations (swaying flowers, floating clouds, breathing sun glow) wrapped in `prefers-reduced-motion: no-preference`.
## User fonts
Fonts are shared across themes, so user fonts live in `fonts/` folders next to the theme roots (same list minus the built-in fallback: `views.FONT_DIRS`, e.g. `localhost/fonts/` for the site; built-in fonts ship with the Vite build). A font is a folder `fonts/{name}/` with:
- `font.css``@font-face` rules with URLs relative to the folder (files served at `/_fonts/{name}/...`, per request like themes), plus a `:root { --font-{name}: "Family Name", serif; }` stack variable so themes and custom CSS reference it like the built-in `--font-*` variables.
- the font files the CSS references (e.g. `{name}.woff2`).
Every font.css is linked on all pages (after the base stylesheet, before the theme). The site editor's font picker lists user fonts too, with label and serif/sans grouping parsed from the `--font-{name}` stack. Everything is read per request, so new fonts appear without a restart.
## Banner designs
A theme folder may also ship a banner design (`banner.css` + `banner.html` arbitrary markup or `banner.svg`), selectable per page independently of the active theme. Standalone banner designs (no theme.css) ship as:
+15 -3
View File
@@ -85,6 +85,14 @@ async function loadSettings() {
]
transition.value = s.transition || 'cube'
transitionOptions.value = s.transitions || []
fontOptions.value = [
...BASE_FONT_OPTIONS,
...(s.fonts || []).map((f) => ({
value: `var(--font-${f.name})`,
label: f.label,
serif: f.serif,
})),
]
} catch { /* keep default */ }
}
@@ -374,7 +382,7 @@ function setCssDocument(text) {
// inline with other content, and those must be stripped/parsed too or
// re-picking a font would insert a duplicate row.
const FONT_DECL = /--font-(?:body|heading|brand)\s*:\s*var\(--font-[a-z0-9-]+\)\s*;/g
const FONT_OPTIONS = [
const BASE_FONT_OPTIONS = [
{ value: 'var(--font-source-serif)', label: 'Source Serif 4', serif: true },
{ value: 'var(--font-fraunces)', label: 'Fraunces', serif: true },
{ value: 'var(--font-literata)', label: 'Literata', serif: true },
@@ -388,6 +396,10 @@ const FONT_OPTIONS = [
{ value: 'var(--font-exo2)', label: 'Exo 2', serif: false },
{ value: 'var(--font-fira-code)', label: 'Fira Code', serif: false },
]
// Built-in options plus user fonts reported by the backend (fonts/
// folders on disk, see GET /_api/settings), so added fonts need no
// frontend changes.
const fontOptions = ref(BASE_FONT_OPTIONS)
const fontHeading = ref('')
const fontBody = ref('')
const fontBrand = ref('')
@@ -397,8 +409,8 @@ const fontBrand = ref('')
// the candidate font at the size and weight of the element being styled.
const fontPicker = ref(null) // open tab: 'heading' | 'body' | 'brand' | null
let fontTabLast = 'body'
const serifFonts = computed(() => FONT_OPTIONS.filter((o) => o.serif))
const sansFonts = computed(() => FONT_OPTIONS.filter((o) => !o.serif))
const serifFonts = computed(() => fontOptions.value.filter((o) => o.serif))
const sansFonts = computed(() => fontOptions.value.filter((o) => !o.serif))
function toggleFontPanel() {
if (fontPicker.value) {
+4 -3
View File
@@ -8,14 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools'
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
// Proxy everything except Vite's own dev-time paths and the backend machinery
// to the FastAPI backend in dev. /_api, /_f, /_themes and /_a are handled by
// the fastapi-vue plugin, and /@..., /src, /node_modules, /__... stay with Vite.
// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are
// handled by the fastapi-vue plugin, and /@..., /src, /node_modules, /__...
// stay with Vite.
const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
// https://vite.dev/config/
export default defineConfig({
plugins: [
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_a"] }),
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a"] }),
vue(),
vueDevTools(),
],
+31 -22
View File
@@ -550,7 +550,8 @@ async def update_structure(op: StructureOp) -> None:
@app.get("/_api/settings")
async def get_settings() -> dict:
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
the themes and banner designs available on disk for the selectors."""
the themes, banner designs and user fonts available on disk for the
selectors."""
return {
"brand": data.brand,
"brand_html": data.brand_html,
@@ -559,6 +560,7 @@ async def get_settings() -> dict:
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
"themes": views._theme_info(),
"banner_designs": views._banner_design_names(),
"fonts": views._user_fonts(),
"transition": data.transition,
"transitions": views._transition_names(),
}
@@ -678,33 +680,20 @@ async def delete_file(name: str) -> None:
file_store.delete(name)
@app.get("/_themes/{name}/{filename}")
async def theme_file(name: str, filename: str, request: Request) -> Response:
"""Serve a theme/banner-design file from pagerite/themes/{name}/.
async def _serve_user_file(path: Path | None, request: Request) -> Response:
"""Serve a user-asset file resolved on disk, with mtime etag.
Stylesheets plus any extra assets the CSS references (like summer's
grass.svg). Read from disk on every request (etag by mtime+size):
theme files are never built or content-hashed, so edits on disk show
on the next page load, in prod as well as dev.
Read from disk on every request (etag by mtime+size): user assets are
never built or content-hashed, so edits on disk show on the next page
load, in prod as well as dev.
"""
if (
"/" in filename
or filename.startswith(".")
or "/" in name
or name.startswith(".")
):
raise HTTPException(404)
path = views.THEMES / name / filename
try:
stat = path.stat()
except FileNotFoundError:
raise HTTPException(404) from None
if not path.is_file():
if path is None:
raise HTTPException(404)
stat = path.stat()
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
return Response(
path.read_bytes(),
media_type=mime,
@@ -712,6 +701,26 @@ async def theme_file(name: str, filename: str, request: Request) -> Response:
)
@app.get("/_themes/{name}/{filename}")
async def theme_file(name: str, filename: str, request: Request) -> Response:
"""Serve a theme/banner-design file, resolved across views.THEME_DIRS.
Stylesheets plus any extra assets the CSS references (like summer's
grass.svg).
"""
return await _serve_user_file(views.theme_file(name, filename), request)
@app.get("/_fonts/{name}/{filename}")
async def user_font_file(name: str, filename: str, request: Request) -> Response:
"""Serve a user font file, resolved across views.FONT_DIRS.
The folder's font.css (@font-face rules + --font-{name} stack variable)
is linked on every page; the woff2 files it references come from here.
"""
return await _serve_user_file(views.font_file(name, filename), request)
@app.get("/_f/{name}")
async def stored_file(name: str, request: Request) -> Response:
"""Serve a file from the content-addressed store (immutable: the name
+146 -40
View File
@@ -22,18 +22,62 @@ import os
import re
from html5tagger import HTML, Document, E, Template
from platformdirs import site_data_dir, user_data_path
from pagerite.data import Node, prettify, resolve, sorted_nodes
from pagerite.markdown import render
SITE_NAME = "Pagerite"
BUILD = Path(__file__).with_name("frontend-build")
THEMES = Path(__file__).parent / "themes"
def _data_roots() -> list[Path]:
"""Platform data dirs for user-provided assets (platformdirs), most
specific first: user data dir, then the system-wide data dirs."""
roots = [user_data_path("pagerite", appauthor=False)]
# site_data_dir keeps the multipath (site_data_path collapses it to the
# first entry, since a Path cannot hold several).
roots += site_data_dir("pagerite", appauthor=False, multipath=True).split(os.pathsep)
return [Path(r) for r in roots]
def _theme_dirs() -> list[Path]:
"""Theme search roots, most specific first; first match wins per file.
Users can add new themes or override/extend built-in ones file by file
by placing folders in any of these roots (all combine into one listing):
cwd, the site folder, and the platform user/system data dirs
(e.g. ~/.local/share/pagerite, /usr/share/pagerite on Linux;
%LOCALAPPDATA%\\pagerite, %PROGRAMDATA%\\pagerite on Windows).
The built-in package themes are the final fallback.
"""
return [
Path("themes"),
Path(os.getenv("PAGERITE_HOSTNAME", "localhost")) / "themes",
*(root / "themes" for root in _data_roots()),
Path(__file__).parent / "themes",
]
THEME_DIRS = _theme_dirs()
# User fonts are shared across themes, so they live in fonts/ folders next
# to the themes/ roots (no built-in fallback: built-in fonts ship with the
# Vite build). A font folder {name}/ carries font.css with @font-face rules
# (relative URLs resolve under /_fonts/{name}/) and a :root --font-{name}
# stack variable, so themes and custom CSS can reference it like the
# built-in --font-* variables.
FONT_DIRS = [
Path("fonts"),
Path(os.getenv("PAGERITE_HOSTNAME", "localhost")) / "fonts",
*(root / "fonts" for root in _data_roots()),
]
# The base CSS is built by Vite as a separate entry so the backend can link
# it independently of the theme. Themes and banner designs are plain .css
# files in THEMES/{name}/, served by the backend at /_themes/{name}/... and
# re-read from disk on every request (see app.py), so they are never built.
# files in {THEME_DIRS}/{name}/, served by the backend at /_themes/{name}/...
# and re-read from disk on every request (see app.py), so they are never
# built and edits/new folders show without a restart.
_BASE_CSS_KEY = "src/assets/pagerite.css"
_manifest_cache: dict | None = None
@@ -53,7 +97,9 @@ def _theme_color_schemes(theme: str) -> set[str]:
Reads the first ``color-scheme:`` declaration in the file. An empty set
means the theme did not declare one.
"""
path = THEMES / theme / "theme.css"
path = theme_file(theme, "theme.css")
if path is None:
return set()
try:
css = path.read_text()
except (OSError, ValueError):
@@ -82,26 +128,68 @@ def _theme_mode(theme: str) -> str:
def _theme_info() -> list[dict[str, str]]:
"""Theme folders on disk, each with its name and supported color mode."""
return [
{"name": d.name, "mode": _theme_mode(d.name)}
for d in sorted(THEMES.iterdir(), key=lambda d: d.name)
if d.is_dir() and (d / "theme.css").exists()
{"name": name, "mode": _theme_mode(name)}
for name in _theme_folder_names(("theme.css",))
]
def _banner_design_names() -> list[str]:
"""Available banner designs: theme folders with artwork and/or styles."""
return sorted(
d.name
for d in THEMES.iterdir()
if d.is_dir()
and any((d / f).exists() for f in ("banner.css", "banner.svg", "banner.html"))
)
return _theme_folder_names(("banner.css", "banner.svg", "banner.html"))
def _transition_names() -> list[str]:
"""Available page-transition designs: theme folders with transition.css."""
return _theme_folder_names(("transition.css",))
def _theme_folder_names(required: tuple[str, ...]) -> list[str]:
"""Sorted union of theme folder names across THEME_DIRS containing any
of the required files."""
return sorted(
d.name for d in THEMES.iterdir() if d.is_dir() and (d / "transition.css").exists()
name
for root in THEME_DIRS
if root.is_dir()
for name in {d.name: d for d in root.iterdir() if d.is_dir()}
if any(theme_file(name, f) for f in required)
)
def _user_fonts() -> list[dict]:
"""User font folders on disk: FONT_DIRS/{name}/ with a font.css.
The label and serif flag are read from the font.css ``--font-{name}``
stack variable; the stylesheet itself is linked on every page (see
_layout) so the variable and @font-face rules just exist.
"""
fonts = []
for name in _font_folder_names():
path = font_file(name, "font.css")
css = path.read_text(errors="replace") if path else ""
m = re.search(
rf"--font-{re.escape(name)}\s*:\s*([^;]+);",
re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL),
)
stack = m.group(1) if m else ""
family = re.search(r'"([^"]+)"|\'([^\']+)\'', stack)
fonts.append(
{
"name": name,
"label": (family.group(1) or family.group(2)) if family else name,
"serif": bool(re.search(r"(^|,\s*)serif\s*$", stack)),
}
)
return fonts
def _font_folder_names() -> list[str]:
"""Sorted union of font folder names across FONT_DIRS with a font.css."""
return sorted(
d.name
for root in FONT_DIRS
if root.is_dir()
for d in root.iterdir()
if d.is_dir() and (d / "font.css").is_file()
)
@@ -110,6 +198,27 @@ def _valid_name(name: str) -> bool:
return bool(name) and "/" not in name and not name.startswith(".")
def theme_file(name: str, filename: str) -> Path | None:
"""Resolve a theme file across THEME_DIRS; the first root with the file
wins, so users can override or extend built-in themes file by file."""
return _user_file(THEME_DIRS, name, filename)
def font_file(name: str, filename: str) -> Path | None:
"""Resolve a user font file across FONT_DIRS (first match wins)."""
return _user_file(FONT_DIRS, name, filename)
def _user_file(dirs: list[Path], name: str, filename: str) -> Path | None:
if not _valid_name(name) or not _valid_name(filename):
return None
for root in dirs:
path = root / name / filename
if path.is_file():
return path
return None
def _base_css_url(vite_url: str | None) -> str | None:
"""URL for the base stylesheet (None in dev: Vite injects it from JS,
avoiding the HMR-wrapped module output)."""
@@ -123,25 +232,21 @@ def _base_css_url(vite_url: str | None) -> str | None:
def _theme_css_url(theme: str) -> str | None:
"""URL for the theme stylesheet, served by the backend (dev and prod)."""
if theme and _valid_name(theme) and (THEMES / theme / "theme.css").exists():
if theme and theme_file(theme, "theme.css"):
return f"/_themes/{theme}/theme.css"
return None
def _banner_css_url(design: str) -> str | None:
"""URL for a banner design's stylesheet, served by the backend."""
if design and _valid_name(design) and (THEMES / design / "banner.css").exists():
if design and theme_file(design, "banner.css"):
return f"/_themes/{design}/banner.css"
return None
def _transition_css_url(transition: str) -> str | None:
"""URL for a page-transition stylesheet, served by the backend."""
if (
transition
and _valid_name(transition)
and (THEMES / transition / "transition.css").exists()
):
if transition and theme_file(transition, "transition.css"):
return f"/_themes/{transition}/transition.css"
return None
@@ -167,13 +272,16 @@ def _inline_asset(url: str) -> str:
"""Read a served asset's content for inlining into the page (prod only).
Handles build assets (``/_assets/...`` from the Vite build) and theme
files (``/_themes/{name}/...`` from pagerite/themes/).
or user-font files (``/_themes/{name}/...``, ``/_fonts/{name}/...``,
resolved across THEME_DIRS / FONT_DIRS).
"""
if url.startswith("/_themes/"):
name, _, file = url.removeprefix("/_themes/").partition("/")
if _valid_name(name) and _valid_name(file):
return (THEMES / name / file).read_text()
raise ValueError(f"not a theme asset: {url}")
for prefix, resolver in (("/_themes/", theme_file), ("/_fonts/", font_file)):
if url.startswith(prefix):
name, _, file = url.removeprefix(prefix).partition("/")
path = resolver(name, file)
if path is None:
raise ValueError(f"not a theme/font asset: {url}")
return path.read_text()
return (BUILD / url.lstrip("/")).read_text()
@@ -213,8 +321,9 @@ def _layout(
analytics) stay external in both modes.
Order matters and is fixed: base (Vite build, absent in dev where Vite
injects it from JS), theme, banner design and page transition (from
pagerite/themes/),
injects it from JS), user fonts (from FONT_DIRS, so themes and custom
CSS can reference their --font-* variables), theme, banner design and
page transition (from THEME_DIRS),
entry-specific stylesheets (e.g. overlayscrollbars.css), then the user's
custom CSS last so it always wins.
@@ -272,6 +381,10 @@ def _layout(
# per sheet, and fetch-navigation can carry them across swaps whole.
sheets = [
("pagerite-base", _base_css_url(vite_url)),
*[
(f"pagerite-font-{name}", f"/_fonts/{name}/font.css")
for name in _font_folder_names()
],
("pagerite-theme", _theme_css_url(theme)),
("pagerite-banner", _banner_css_url(banner_design)),
("pagerite-transition", _transition_css_url(transition)),
@@ -489,14 +602,10 @@ def _design_banner(design: str) -> HTML:
"""
if not _valid_name(design):
return HTML("")
html = THEMES / design / "banner.html"
svg = THEMES / design / "banner.svg"
if html.exists():
body = html.read_text()
elif svg.exists():
body = svg.read_text()
else:
path = theme_file(design, "banner.html") or theme_file(design, "banner.svg")
if path is None:
return HTML("")
body = path.read_text()
return HTML(f'<div data-design="{design}">{body}</div>')
@@ -504,10 +613,7 @@ def theme_banner_design(theme: str) -> str:
"""The theme's own banner design (a theme folder doubles as a banner
design when it ships banner.css, banner.svg or banner.html), "" if it
has none."""
if _valid_name(theme) and any(
(THEMES / theme / f).exists()
for f in ("banner.css", "banner.svg", "banner.html")
):
if any(theme_file(theme, f) for f in ("banner.css", "banner.svg", "banner.html")):
return theme
return ""
+1
View File
@@ -25,6 +25,7 @@ dependencies = [
"markdown-it-py>=4.2.0",
"maxminddb>=3.1.1",
"mdit-py-plugins>=0.6.1",
"platformdirs>=4.11.5",
"pygments>=2.20.0",
"python-slugify>=8.0.4",
"ua-parser>=1.0.2",