Look up themes and theme per-file overrides in system, user and site directories.
This commit is contained in:
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
- `seed.py` — demo content, written only on first database creation.
|
||||||
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
|
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
|
||||||
- `frontend/src/` — Vue editor and public-page JS entries.
|
- `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
|
## 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:
|
- Key libraries:
|
||||||
- **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML).
|
- **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
|
- 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
|
- 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
|
- 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.
|
- **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).
|
- **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
|
## Conventions
|
||||||
|
|||||||
@@ -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 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.
|
`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.
|
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()`.
|
- `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`.
|
- `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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ async function loadSettings() {
|
|||||||
]
|
]
|
||||||
transition.value = s.transition || 'cube'
|
transition.value = s.transition || 'cube'
|
||||||
transitionOptions.value = s.transitions || []
|
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 */ }
|
} catch { /* keep default */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +382,7 @@ function setCssDocument(text) {
|
|||||||
// inline with other content, and those must be stripped/parsed too or
|
// inline with other content, and those must be stripped/parsed too or
|
||||||
// re-picking a font would insert a duplicate row.
|
// 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_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-source-serif)', label: 'Source Serif 4', serif: true },
|
||||||
{ value: 'var(--font-fraunces)', label: 'Fraunces', serif: true },
|
{ value: 'var(--font-fraunces)', label: 'Fraunces', serif: true },
|
||||||
{ value: 'var(--font-literata)', label: 'Literata', 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-exo2)', label: 'Exo 2', serif: false },
|
||||||
{ value: 'var(--font-fira-code)', label: 'Fira Code', 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 fontHeading = ref('')
|
||||||
const fontBody = ref('')
|
const fontBody = ref('')
|
||||||
const fontBrand = ref('')
|
const fontBrand = ref('')
|
||||||
@@ -397,8 +409,8 @@ const fontBrand = ref('')
|
|||||||
// the candidate font at the size and weight of the element being styled.
|
// the candidate font at the size and weight of the element being styled.
|
||||||
const fontPicker = ref(null) // open tab: 'heading' | 'body' | 'brand' | null
|
const fontPicker = ref(null) // open tab: 'heading' | 'body' | 'brand' | null
|
||||||
let fontTabLast = 'body'
|
let fontTabLast = 'body'
|
||||||
const serifFonts = computed(() => FONT_OPTIONS.filter((o) => o.serif))
|
const serifFonts = computed(() => fontOptions.value.filter((o) => o.serif))
|
||||||
const sansFonts = computed(() => FONT_OPTIONS.filter((o) => !o.serif))
|
const sansFonts = computed(() => fontOptions.value.filter((o) => !o.serif))
|
||||||
|
|
||||||
function toggleFontPanel() {
|
function toggleFontPanel() {
|
||||||
if (fontPicker.value) {
|
if (fontPicker.value) {
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
|||||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
||||||
|
|
||||||
// Proxy everything except Vite's own dev-time paths and the backend machinery
|
// 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
|
// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are
|
||||||
// the fastapi-vue plugin, and /@..., /src, /node_modules, /__... stay with Vite.
|
// handled by the fastapi-vue plugin, and /@..., /src, /node_modules, /__...
|
||||||
|
// stay with Vite.
|
||||||
const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_a"] }),
|
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a"] }),
|
||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
|
|||||||
+31
-22
@@ -550,7 +550,8 @@ async def update_structure(op: StructureOp) -> None:
|
|||||||
@app.get("/_api/settings")
|
@app.get("/_api/settings")
|
||||||
async def get_settings() -> dict:
|
async def get_settings() -> dict:
|
||||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
"""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 {
|
return {
|
||||||
"brand": data.brand,
|
"brand": data.brand,
|
||||||
"brand_html": data.brand_html,
|
"brand_html": data.brand_html,
|
||||||
@@ -559,6 +560,7 @@ async def get_settings() -> dict:
|
|||||||
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
|
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
|
||||||
"themes": views._theme_info(),
|
"themes": views._theme_info(),
|
||||||
"banner_designs": views._banner_design_names(),
|
"banner_designs": views._banner_design_names(),
|
||||||
|
"fonts": views._user_fonts(),
|
||||||
"transition": data.transition,
|
"transition": data.transition,
|
||||||
"transitions": views._transition_names(),
|
"transitions": views._transition_names(),
|
||||||
}
|
}
|
||||||
@@ -678,33 +680,20 @@ async def delete_file(name: str) -> None:
|
|||||||
file_store.delete(name)
|
file_store.delete(name)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/_themes/{name}/{filename}")
|
async def _serve_user_file(path: Path | None, request: Request) -> Response:
|
||||||
async def theme_file(name: str, filename: str, request: Request) -> Response:
|
"""Serve a user-asset file resolved on disk, with mtime etag.
|
||||||
"""Serve a theme/banner-design file from pagerite/themes/{name}/.
|
|
||||||
|
|
||||||
Stylesheets plus any extra assets the CSS references (like summer's
|
Read from disk on every request (etag by mtime+size): user assets are
|
||||||
grass.svg). Read from disk on every request (etag by mtime+size):
|
never built or content-hashed, so edits on disk show on the next page
|
||||||
theme files are never built or content-hashed, so edits on disk show
|
load, in prod as well as dev.
|
||||||
on the next page load, in prod as well as dev.
|
|
||||||
"""
|
"""
|
||||||
if (
|
if path is None:
|
||||||
"/" 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():
|
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
|
stat = path.stat()
|
||||||
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
|
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
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(
|
return Response(
|
||||||
path.read_bytes(),
|
path.read_bytes(),
|
||||||
media_type=mime,
|
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}")
|
@app.get("/_f/{name}")
|
||||||
async def stored_file(name: str, request: Request) -> Response:
|
async def stored_file(name: str, request: Request) -> Response:
|
||||||
"""Serve a file from the content-addressed store (immutable: the name
|
"""Serve a file from the content-addressed store (immutable: the name
|
||||||
|
|||||||
+146
-40
@@ -22,18 +22,62 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from html5tagger import HTML, Document, E, Template
|
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.data import Node, prettify, resolve, sorted_nodes
|
||||||
from pagerite.markdown import render
|
from pagerite.markdown import render
|
||||||
|
|
||||||
SITE_NAME = "Pagerite"
|
SITE_NAME = "Pagerite"
|
||||||
BUILD = Path(__file__).with_name("frontend-build")
|
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
|
# 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
|
# it independently of the theme. Themes and banner designs are plain .css
|
||||||
# files in THEMES/{name}/, served by the backend at /_themes/{name}/... and
|
# files in {THEME_DIRS}/{name}/, served by the backend at /_themes/{name}/...
|
||||||
# re-read from disk on every request (see app.py), so they are never built.
|
# 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"
|
_BASE_CSS_KEY = "src/assets/pagerite.css"
|
||||||
|
|
||||||
_manifest_cache: dict | None = None
|
_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
|
Reads the first ``color-scheme:`` declaration in the file. An empty set
|
||||||
means the theme did not declare one.
|
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:
|
try:
|
||||||
css = path.read_text()
|
css = path.read_text()
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -82,26 +128,68 @@ def _theme_mode(theme: str) -> str:
|
|||||||
def _theme_info() -> list[dict[str, str]]:
|
def _theme_info() -> list[dict[str, str]]:
|
||||||
"""Theme folders on disk, each with its name and supported color mode."""
|
"""Theme folders on disk, each with its name and supported color mode."""
|
||||||
return [
|
return [
|
||||||
{"name": d.name, "mode": _theme_mode(d.name)}
|
{"name": name, "mode": _theme_mode(name)}
|
||||||
for d in sorted(THEMES.iterdir(), key=lambda d: d.name)
|
for name in _theme_folder_names(("theme.css",))
|
||||||
if d.is_dir() and (d / "theme.css").exists()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _banner_design_names() -> list[str]:
|
def _banner_design_names() -> list[str]:
|
||||||
"""Available banner designs: theme folders with artwork and/or styles."""
|
"""Available banner designs: theme folders with artwork and/or styles."""
|
||||||
return sorted(
|
return _theme_folder_names(("banner.css", "banner.svg", "banner.html"))
|
||||||
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"))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _transition_names() -> list[str]:
|
def _transition_names() -> list[str]:
|
||||||
"""Available page-transition designs: theme folders with transition.css."""
|
"""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(
|
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(".")
|
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:
|
def _base_css_url(vite_url: str | None) -> str | None:
|
||||||
"""URL for the base stylesheet (None in dev: Vite injects it from JS,
|
"""URL for the base stylesheet (None in dev: Vite injects it from JS,
|
||||||
avoiding the HMR-wrapped module output)."""
|
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:
|
def _theme_css_url(theme: str) -> str | None:
|
||||||
"""URL for the theme stylesheet, served by the backend (dev and prod)."""
|
"""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 f"/_themes/{theme}/theme.css"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _banner_css_url(design: str) -> str | None:
|
def _banner_css_url(design: str) -> str | None:
|
||||||
"""URL for a banner design's stylesheet, served by the backend."""
|
"""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 f"/_themes/{design}/banner.css"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _transition_css_url(transition: str) -> str | None:
|
def _transition_css_url(transition: str) -> str | None:
|
||||||
"""URL for a page-transition stylesheet, served by the backend."""
|
"""URL for a page-transition stylesheet, served by the backend."""
|
||||||
if (
|
if transition and theme_file(transition, "transition.css"):
|
||||||
transition
|
|
||||||
and _valid_name(transition)
|
|
||||||
and (THEMES / transition / "transition.css").exists()
|
|
||||||
):
|
|
||||||
return f"/_themes/{transition}/transition.css"
|
return f"/_themes/{transition}/transition.css"
|
||||||
return None
|
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).
|
"""Read a served asset's content for inlining into the page (prod only).
|
||||||
|
|
||||||
Handles build assets (``/_assets/...`` from the Vite build) and theme
|
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/"):
|
for prefix, resolver in (("/_themes/", theme_file), ("/_fonts/", font_file)):
|
||||||
name, _, file = url.removeprefix("/_themes/").partition("/")
|
if url.startswith(prefix):
|
||||||
if _valid_name(name) and _valid_name(file):
|
name, _, file = url.removeprefix(prefix).partition("/")
|
||||||
return (THEMES / name / file).read_text()
|
path = resolver(name, file)
|
||||||
raise ValueError(f"not a theme asset: {url}")
|
if path is None:
|
||||||
|
raise ValueError(f"not a theme/font asset: {url}")
|
||||||
|
return path.read_text()
|
||||||
return (BUILD / url.lstrip("/")).read_text()
|
return (BUILD / url.lstrip("/")).read_text()
|
||||||
|
|
||||||
|
|
||||||
@@ -213,8 +321,9 @@ def _layout(
|
|||||||
analytics) stay external in both modes.
|
analytics) stay external in both modes.
|
||||||
|
|
||||||
Order matters and is fixed: base (Vite build, absent in dev where Vite
|
Order matters and is fixed: base (Vite build, absent in dev where Vite
|
||||||
injects it from JS), theme, banner design and page transition (from
|
injects it from JS), user fonts (from FONT_DIRS, so themes and custom
|
||||||
pagerite/themes/),
|
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
|
entry-specific stylesheets (e.g. overlayscrollbars.css), then the user's
|
||||||
custom CSS last so it always wins.
|
custom CSS last so it always wins.
|
||||||
|
|
||||||
@@ -272,6 +381,10 @@ def _layout(
|
|||||||
# per sheet, and fetch-navigation can carry them across swaps whole.
|
# per sheet, and fetch-navigation can carry them across swaps whole.
|
||||||
sheets = [
|
sheets = [
|
||||||
("pagerite-base", _base_css_url(vite_url)),
|
("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-theme", _theme_css_url(theme)),
|
||||||
("pagerite-banner", _banner_css_url(banner_design)),
|
("pagerite-banner", _banner_css_url(banner_design)),
|
||||||
("pagerite-transition", _transition_css_url(transition)),
|
("pagerite-transition", _transition_css_url(transition)),
|
||||||
@@ -489,14 +602,10 @@ def _design_banner(design: str) -> HTML:
|
|||||||
"""
|
"""
|
||||||
if not _valid_name(design):
|
if not _valid_name(design):
|
||||||
return HTML("")
|
return HTML("")
|
||||||
html = THEMES / design / "banner.html"
|
path = theme_file(design, "banner.html") or theme_file(design, "banner.svg")
|
||||||
svg = THEMES / design / "banner.svg"
|
if path is None:
|
||||||
if html.exists():
|
|
||||||
body = html.read_text()
|
|
||||||
elif svg.exists():
|
|
||||||
body = svg.read_text()
|
|
||||||
else:
|
|
||||||
return HTML("")
|
return HTML("")
|
||||||
|
body = path.read_text()
|
||||||
return HTML(f'<div data-design="{design}">{body}</div>')
|
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
|
"""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
|
design when it ships banner.css, banner.svg or banner.html), "" if it
|
||||||
has none."""
|
has none."""
|
||||||
if _valid_name(theme) and any(
|
if any(theme_file(theme, f) for f in ("banner.css", "banner.svg", "banner.html")):
|
||||||
(THEMES / theme / f).exists()
|
|
||||||
for f in ("banner.css", "banner.svg", "banner.html")
|
|
||||||
):
|
|
||||||
return theme
|
return theme
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"markdown-it-py>=4.2.0",
|
"markdown-it-py>=4.2.0",
|
||||||
"maxminddb>=3.1.1",
|
"maxminddb>=3.1.1",
|
||||||
"mdit-py-plugins>=0.6.1",
|
"mdit-py-plugins>=0.6.1",
|
||||||
|
"platformdirs>=4.11.5",
|
||||||
"pygments>=2.20.0",
|
"pygments>=2.20.0",
|
||||||
"python-slugify>=8.0.4",
|
"python-slugify>=8.0.4",
|
||||||
"ua-parser>=1.0.2",
|
"ua-parser>=1.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user