Backend-served themes and selectable, inheritable banner designs

Themes move from Vite-built frontend assets to pagerite/themes/{name}/
folders holding theme.css and/or banner.css (+ banner.svg), served by the
backend at /_themes/{name}/... and re-read from disk per request (etag by
mtime), so on-disk edits show on the next page load even in prod and new
themes need no build or config. The theme and banner-design selectors
enumerate these folders via GET /_api/settings.

Banner designs: Node.banner_design picks a design per page (None inherits
from ancestors, then the front page, then the active theme's own design;
"" = none). The design's banner.css is linked in <head> (id
pagerite-banner, between theme and custom CSS) and its banner.svg inlined
into #page-banner first (marked svg[data-design]); the page's own
Node.banner HTML renders after it, so author code always wins. #page-banner
is now a stacking grid so artwork and author code overlay.

Dev/prod hot loading unified: the backend renders the theme/design links
in both modes; in dev pagerite.js only re-appends them (and the custom
CSS) after the Vite-injected base styles. Theme switches just swap the
link href. The pagerite:theme meta and Vite theme build entries are gone.
This commit is contained in:
2026-08-18 05:46:02 +00:00
parent c0817330e7
commit 30947df16b
15 changed files with 560 additions and 348 deletions
+42 -28
View File
@@ -53,18 +53,27 @@ not for the public pages. See `docs/design-principles.md` for the design.
caching; pages reference files by absolute `/_f/` URLs so hierarchy caching; pages reference files by absolute `/_f/` URLs so hierarchy
moves never break them. `Node.banner` is a raw trusted HTML snippet moves never break them. `Node.banner` is a raw trusted HTML snippet
for the header banner (img, styled div, canvas+script...); empty for the header banner (img, styled div, canvas+script...); empty
inherits from the node's ancestors (front page last), then the active inherits from the node's ancestors (front page last). It is rendered
theme's banner artwork: an inline SVG from AFTER the banner design's artwork, so author code (e.g. a `<style>`
`pagerite/themes/{theme}/banner.svg`, inlined into `#page-banner` by override) always wins over the design's own styles.
the backend only when no user banner applies (so it is recolorable `Node.banner_design` picks a banner design: a theme folder name whose
from the theme CSS via `var(...)` and never fights user designs; the `banner.css`/`banner.svg` supply the design's styles and inline SVG
base stylesheet falls back to a plain gradient). artwork (marked `svg[data-design]`); "" = explicitly no design, None =
inherit (nearest ancestor, front page last, then the active theme's
own design if it ships banner.css/banner.svg). The design's banner.css
is linked in `<head>` (id `pagerite-banner`) between the theme and the
custom CSS.
`Data.version` is bumped on every write `Data.version` is bumped on every write
and embedded in page ETags so nav-affecting changes invalidate caches. and embedded in page ETags so nav-affecting changes invalidate caches.
`Data.brand` is the site name (header link + `<title>` suffix), editable `Data.brand` is the site name (header link + `<title>` suffix), editable
in the site editor via `/_api/settings`; empty = no header link and in the site editor via `/_api/settings`; empty = no header link and
no `<title>` suffix. `Data.theme` is the active theme name (empty = no `<title>` suffix. `Data.theme` is the active theme name (empty =
none/base only); themes live in `frontend/src/assets/themes/{theme}`. none/base only); themes are folders in `pagerite/themes/{name}`
containing `theme.css` and/or `banner.css` (+ `banner.svg` artwork),
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. The theme selector and banner-design
selector enumerate these folders via `GET /_api/settings`.
`Data.custom_css` is raw trusted CSS injected inline in every page `Data.custom_css` is raw trusted CSS injected inline in every page
`<head>` (id `pagerite-user`) and swapped during fetch-navigation; `<head>` (id `pagerite-user`) and swapped during fetch-navigation;
editable in the site editor. Font picks (heading/body/brand) in the editable in the site editor. Font picks (heading/body/brand) in the
@@ -117,23 +126,26 @@ not for the public pages. See `docs/design-principles.md` for the design.
from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is
detected (dev/no proxy), editing is left open. Pages themselves render detected (dev/no proxy), editing is left open. Pages themselves render
identically for everyone; the real gate is the auth proxy in front of identically for everyone; the real gate is the auth proxy in front of
all of `/_api`. The backend links the shared CSS as two separate all of `/_api`. The backend links the stylesheets in a fixed order —
stylesheets (base and theme) so they can be swapped or augmented. base (Vite build), theme, banner design, custom CSS last — each with
a stable id so the site editor can swap them in place.
- `assets/` — shared styles and data files built by Vite and served hashed - `assets/` — shared styles and data files built by Vite and served hashed
under `/_assets/`: `pagerite.css` (base layout + conservative variables), under `/_assets/`: `pagerite.css` (base layout + conservative
`themes/{purple,corporate,nitro}/theme.css` (theme overrides and font variables), `pygments.css`,
picks: `purple` = dark dusk palette with Fraunces/Literata and a tilted and `fonts/` (self-hosted Source
Sans 3/Source Serif 4/Fraunces/Literata/Cormorant/Playfair
Display/Inter/Montserrat/Fira Code/Cause/Exo 2/New Rocker
variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
termotohtori.fi) is fragile — do not tweak. Themes are NOT built:
`pagerite/themes/{name}/theme.css` (theme overrides and font picks:
`purple` = dark dusk palette with Fraunces/Literata and a tilted
oversized gradient brand; `corporate` = light-first with automatic oversized gradient brand; `corporate` = light-first with automatic
`prefers-color-scheme` dark mode, Montserrat/Inter and a huge solid `prefers-color-scheme` dark mode, Montserrat/Inter and a huge solid
brand; `nitro` = racing/HUD style following `prefers-color-scheme` brand; `nitro` = racing/HUD style following `prefers-color-scheme`
(warm light-grey page, deep violet in dark), Montserrat/Literata, (warm light-grey page, deep violet in dark), Montserrat/Literata,
black as an accent only, a straight orange blade under the banner, and black as an accent only, a straight orange blade under the banner, and
an orange racing-tab nav clipped with a bezier `shape()`), `pygments.css`, an orange racing-tab nav clipped with a bezier `shape()`) and the
and `fonts/` (self-hosted Source companion `banner.css` banner designs are served by the backend.
Sans 3/Source Serif 4/Fraunces/Literata/Cormorant/Playfair
Display/Inter/Montserrat/Fira Code/Cause/Exo 2/New Rocker
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 - Vite builds ES-module `.js` outputs; the backend renders `<script
type="module">` for them (module scripts defer by default). type="module">` for them (module scripts defer by default).
- The database file is `pagerite.kantadb` in the cwd (`PAGERITE_DB` - The database file is `pagerite.kantadb` in the cwd (`PAGERITE_DB`
@@ -146,8 +158,9 @@ not for the public pages. See `docs/design-principles.md` for the design.
previewing into the visible article; editor scroll drives document previewing into the visible article; editor scroll drives document
scroll) opened by the article pen — it edits content and title only, scroll) opened by the article pen — it edits content and title only,
never the path — and `SiteEditor.vue` (site brand + theme selector + never the path — and `SiteEditor.vue` (site brand + theme selector +
favicon upload/remove + site-wide custom CSS + banner HTML edited in favicon upload/remove + site-wide custom CSS + per-page banner design
small CodeMirror windows; selector (inherit/none/named design, inherited by children) + banner
HTML edited in small CodeMirror windows;
banner previewed into `#page-banner`, CSS injected into banner previewed into `#page-banner`, CSS injected into
`<head id="pagerite-user">`) + vue-draggable structure tree with `<head id="pagerite-user">`) + vue-draggable structure tree with
always-editable title/slug inputs per row, opened by the banner pen — always-editable title/slug inputs per row, opened by the banner pen —
@@ -183,19 +196,20 @@ not for the public pages. See `docs/design-principles.md` for the design.
`assetsDir: '_/assets'` (so the build mirrors the URL space; `assetsDir: '_/assets'` (so the build mirrors the URL space;
`frontend/public/favicon.ico` lands at the build root and is served at `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`, plus `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus
`src/assets/pagerite.css` and every `src/assets/themes/*/theme.css` as `src/assets/pagerite.css` as a separate stylesheet entry; theme and
separate stylesheet entries (enumerated from the themes directory, so new banner-design CSS are NOT built — they live in `pagerite/themes/{name}/`
themes need no config change); there and are served by the backend. There
is no `index.html` source (it would shadow `/` and turn missing dev paths 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 into an empty Vue shell). All outputs are ES modules. The build sets
`preserveEntrySignatures: 'exports-only'` because main.js is consumed `preserveEntrySignatures: 'exports-only'` because main.js is consumed
via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite
app builds otherwise strip unused entry exports, leaving dead edit pens. app builds otherwise strip unused entry exports, leaving dead edit pens.
In dev the backend links no stylesheets (Vite injects them from JS); the In dev the backend links theme/banner-design stylesheets like in prod
active theme reaches the page as `<meta name="pagerite:theme">` and (`/_themes/...`); only the base CSS is Vite-injected from JS, and
pagerite.js imports that theme's CSS, while a theme switch in the site pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/
editor swaps the Vite-injected `<style data-vite-dev-id>` tags (the `#pagerite-user` elements to restore the canonical order (base < theme <
`<link>` sync used in prod is a no-op in dev). design < custom CSS). Theme switches in the site editor simply swap the
`#pagerite-theme` link href, identically in dev and prod.
vite-plugin-fastapi.js has an vite-plugin-fastapi.js has an
auto-upgrade marker — edit `vite.config.js`, not the plugin. auto-upgrade marker — edit `vite.config.js`, not the plugin.
- `docs/` — design documentation. - `docs/` — design documentation.
+24 -14
View File
@@ -86,15 +86,19 @@ evolves.
`Sidebar`, `Main`) filled per request. The dynamic regions carry stable `Sidebar`, `Main`) filled per request. The dynamic regions carry stable
ids (`#page-banner`, `#nav`, `#sidebar`, `#main`). ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
- The page top is a **full-width banner header** with the site name and the - 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 is navigation bar overlaid on it — no separate chrome header. The banner
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML 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` + `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 an **inline SVG** (marked `svg[data-design]`): as
markup it 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 snippet (an image, a styled div, canvas + script — anything), resolved by
walking up the node's ancestors to the front page; when nothing in the walking up the node's ancestors to the front page and rendered **after**
chain sets one, the active theme's banner artwork shows. That artwork is the design artwork, so author styles always win over the design's own.
an **inline SVG** (`pagerite/themes/{name}/banner.svg`) the backend
inlines into `#page-banner`: as markup it can be recolored from the theme
stylesheet (corporate's single SVG serves both light and dark mode via
`var()`-driven stops) and it is never rendered underneath a user banner.
The base stylesheet falls back to a plain gradient. There is deliberately 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 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 ruin user-supplied designs; themes that want one bake it into their SVG
@@ -154,20 +158,24 @@ evolves.
- The base stylesheet `frontend/src/assets/pagerite.css` provides the layout, - The base stylesheet `frontend/src/assets/pagerite.css` provides the layout,
typography and interaction rules with conservative CSS variables. A theme layer typography and interaction rules with conservative CSS variables. A theme layer
(`frontend/src/assets/themes/{name}/theme.css` — currently `purple`, `corporate` (`pagerite/themes/{name}/theme.css` — currently `purple`, `corporate`
and `nitro`) overrides those variables and 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 adds the visual styling; `Data.theme` selects the active theme (empty = none/base
only) and the site editor can switch it. Vue may add per-component styles on top 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 where needed. The corporate and nitro themes switch palettes automatically via
`prefers-color-scheme` (corporate is light-first with a matching dark palette; `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 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 banner and orange accents carry over unchanged); purple (dusk) uses one
fixed palette for everyone. Themes may restyle structural details the base fixed palette for everyone. Themes may restyle structural details the base
leaves plain — heading colors and underlines, list markers, nav treatment, leaves plain — heading colors and underlines, list markers, nav treatment,
brand sizing. The banner artwork has scroll parallax: pagerite.js sets the 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 `--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 page is idle), the banner contents drift within their window (with scale
overscan so no edge shows), and themes may key their own effects off the overscan so no edge shows), and designs may key their own effects off the
same parameter — purple's sun rises as you scroll. same parameter — purple's sun rises as you scroll.
- Fonts, the shared stylesheet and pygments styles - Fonts, the shared stylesheet and pygments styles
live under `frontend/src/assets/` and are emitted as hashed assets under live under `frontend/src/assets/` and are emitted as hashed assets under
@@ -193,7 +201,9 @@ evolves.
stored as plain `:root` rows inside the custom CSS, referencing the base 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 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 into `<style id="pagerite-user">` in the live page head and swapped during
fetch-navigation), the page's **banner HTML** field (previewed into the 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 real banner region, so you see exactly which banner you're editing) and
the **structure tree**. Everything saves immediately as you edit — no the **structure tree**. Everything saves immediately as you edit — no
save button, no edit mode. save button, no edit mode.
+84 -24
View File
@@ -302,12 +302,10 @@ async function commitPending() {
// empty brand removes the header link and the title suffix entirely. // empty brand removes the header link and the title suffix entirely.
const brand = ref('') const brand = ref('')
const theme = ref('purple') const theme = ref('purple')
const THEME_OPTIONS = [ // Theme and banner-design options come from the backend (theme folders on
{ value: '', label: 'none' }, // disk, see GET /_api/settings), so added themes need no frontend changes.
{ value: 'purple', label: 'purple' }, const themeOptions = ref([{ value: '', label: 'none' }])
{ value: 'corporate', label: 'corporate' }, const bannerDesigns = ref([])
{ value: 'nitro', label: 'nitro' },
]
async function loadSettings() { async function loadSettings() {
try { try {
@@ -316,6 +314,11 @@ async function loadSettings() {
theme.value = s.theme || '' theme.value = s.theme || ''
customCss.value = s.custom_css || '' customCss.value = s.custom_css || ''
favicon.value = s.favicon || '' favicon.value = s.favicon || ''
themeOptions.value = [
{ value: '', label: 'none' },
...(s.themes || []).map((t) => ({ value: t, label: t })),
]
bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ } } catch { /* keep default */ }
} }
@@ -420,19 +423,23 @@ function saveBrand() {
async function onThemeChange() { async function onThemeChange() {
await saveSettings() await saveSettings()
if (import.meta.env.DEV) { // Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
// Dev: styles are Vite-injected <style> tags, not <link>s, so the // and prod: swap the link in place, then re-render (the theme's default
// stylesheet sync in swapRegions can't switch themes. Drop the old // banner design and the page's stylesheet links may change with it).
// theme's injected styles and import the new theme module instead. let link = document.getElementById('pagerite-theme')
for (const el of document.head.querySelectorAll('style[data-vite-dev-id]')) { if (theme.value) {
if (el.dataset.viteDevId.includes('/themes/')) el.remove() const href = `/_themes/${theme.value}/theme.css`
if (link) {
link.href = href
} else {
link = document.createElement('link')
link.rel = 'stylesheet'
link.id = 'pagerite-theme'
document.getElementById('pagerite-base')?.after(link)
?? document.head.prepend(link)
} }
if (theme.value) { } else if (link) {
await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`) link.remove()
}
// The freshly injected theme style now sits after the custom CSS;
// move the custom CSS back to the end so it keeps winning.
applyCustomCss(customCss.value)
} }
loadPlain(path.value) loadPlain(path.value)
} }
@@ -707,6 +714,34 @@ provide('structureHandlers', {
newPage, newPage,
}) })
// --- Banner design ---------------------------------------------------------
// The page's banner design: null = inherit (nearest ancestor's setting,
// then the theme's default), '' = explicitly none, otherwise a design
// name. bannerDesignFrom tells where an inherited setting comes from
// (null = the theme default), shown in the selector's inherit option.
const bannerDesign = ref(null)
const bannerDesignFrom = ref(null)
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
return `inherit (theme: ${theme.value || 'none'})`
}
return `inherit (/${bannerDesignFrom.value})`
})
function onBannerDesignChange() {
// Saves immediately; the preview needs a server re-render (the design's
// inline SVG and its stylesheet link both change).
const msg = {
type: 'save',
path: normPath(path.value),
banner_design: bannerDesign.value,
}
pendingSave = msg
send(msg)
loadPlain(path.value)
}
// --- Banner editing ------------------------------------------------------ // --- Banner editing ------------------------------------------------------
// The banner HTML is edited in a small CodeMirror window (HTML syntax), // The banner HTML is edited in a small CodeMirror window (HTML syntax),
// previewed into the real #page-banner region on every keystroke. // previewed into the real #page-banner region on every keystroke.
@@ -731,11 +766,15 @@ function previewBanner() {
const el = document.getElementById('page-banner') const el = document.getElementById('page-banner')
if (!el) return if (!el) return
if (banner.value.trim()) { if (banner.value.trim()) {
// Own banner: preview it live over the region. // Own banner code supplements the design: the inlined design artwork
// (marked svg[data-design]) stays in place, the author code goes after
// it so its styles win.
const artwork = [...el.querySelectorAll('svg[data-design]')]
el.innerHTML = banner.value el.innerHTML = banner.value
el.prepend(...artwork)
runScripts(el) runScripts(el)
} else { } else {
// No banner of its own: the region must show the inherited/default // No banner code of its own: the region must show the inherited/design
// banner — re-render from the server (an empty write here would wipe it). // banner — re-render from the server (an empty write here would wipe it).
loadPlain(path.value) loadPlain(path.value)
} }
@@ -785,12 +824,15 @@ function onMessage(ev) {
const msg = JSON.parse(ev.data) const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) { if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '') setDocument(msg.banner ?? '')
// Placeholder tells where an empty banner falls back to. bannerDesign.value = msg.banner_design ?? null
bannerDesignFrom.value = msg.banner_design_from ?? null
// Placeholder tells where an empty banner code field falls back to;
// the design artwork renders regardless (this code supplements it).
view.dispatch({ view.dispatch({
effects: bannerPh.reconfigure(placeholder( effects: bannerPh.reconfigure(placeholder(
msg.banner_from == null msg.banner_from == null
? 'using default artwork' ? 'own banner code (added after the design)'
: `inherited from /${msg.banner_from}`, : `code inherited from /${msg.banner_from}`,
)), )),
}) })
// Overlay this page's own banner on the swapped region. Empty means // Overlay this page's own banner on the swapped region. Empty means
@@ -924,7 +966,7 @@ onUnmounted(() => {
title="Theme" title="Theme"
@change="onThemeChange" @change="onThemeChange"
> >
<option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value"> <option v-for="opt in themeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }} {{ opt.label }}
</option> </option>
</select> </select>
@@ -1007,6 +1049,16 @@ onUnmounted(() => {
<section class="block" @paste="onBannerPaste"> <section class="block" @paste="onBannerPaste">
<div class="block-head"> <div class="block-head">
<span class="field-label">Banner on /{{ path }}</span> <span class="field-label">Banner on /{{ path }}</span>
<select
v-model="bannerDesign"
class="text-input design-select"
title="Banner design (artwork + its own styles)"
@change="onBannerDesignChange"
>
<option :value="null">{{ inheritLabel }}</option>
<option value="">none</option>
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
</select>
<button <button
type="button" type="button"
title="upload banner image/video (replaces existing media) — pasting works too" title="upload banner image/video (replaces existing media) — pasting works too"
@@ -1147,6 +1199,14 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
/* The banner design selector sits between the label and the upload button
(which stays pushed right by its auto margin). */
.design-select {
flex: 0 1 auto;
width: auto;
font-size: 0.85rem;
}
.text-input { .text-input {
flex: 1; flex: 1;
min-width: 4rem; min-width: 4rem;
+9 -3
View File
@@ -131,18 +131,24 @@ body {
} }
/* Per-page banner content (img, styled div, inline SVG...) fills the /* Per-page banner content (img, styled div, inline SVG...) fills the
banner; swapped along with #nav/#main on fetch-navigation. When the page banner; swapped along with #nav/#main on fetch-navigation. The backend
sets no banner of its own, the backend inlines the active theme's SVG inlines the effective banner design's SVG artwork here first
artwork here instead (pagerite/themes/{theme}/banner.svg). */ (pagerite/themes/{design}/banner.svg, marked svg[data-design]), then the
page's own banner code after it. */
#page-banner { #page-banner {
position: absolute; position: absolute;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
/* Stack the design artwork and the page's own banner code on top of
each other (artwork first): the banner is a background layer, author
code overlays it. A single child behaves exactly as before. */
display: grid;
} }
/* :not(style, script): author-level display:block would override the UA's /* :not(style, script): author-level display:block would override the UA's
display:none on those and render their source as banner text. */ display:none on those and render their source as banner text. */
#page-banner>*:not(style, script) { #page-banner>*:not(style, script) {
grid-area: 1 / 1;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
+12 -11
View File
@@ -8,17 +8,18 @@ import { showAuthIframe } from 'paskia'
(() => { (() => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
// The theme is selectable; the backend names the active one in a meta // In dev the base stylesheet is injected by Vite from JS (linking the
// tag (dev links no stylesheets — Vite injects them from JS). // raw module would pull in its HMR wrapper). Theme and banner-design
const theme = document.querySelector('meta[name="pagerite:theme"]')?.content; // stylesheets are plain files served by the backend (/_themes/...), so
const sheets = [import("./assets/pagerite.css")]; // the backend renders their <link>s in both dev and prod. The injected
if (theme) sheets.push(import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`)); // base styles land at the end of <head> — after them, restore the
// The injected styles land after the server-rendered custom CSS in // canonical order: base < theme < banner design < custom CSS (whose
// <head>; move the custom CSS back to the end so its equal-specificity // equal-specificity :root rules — font variables — must win by order).
// :root rules (font variables) win. import("./assets/pagerite.css").then(() => {
Promise.all(sheets).then(() => { for (const id of ["pagerite-theme", "pagerite-banner", "pagerite-user"]) {
const el = document.getElementById("pagerite-user"); const el = document.getElementById(id);
if (el) document.head.append(el); if (el) document.head.append(el);
}
}); });
} }
+2 -11
View File
@@ -1,5 +1,4 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { readdirSync } from 'node:fs'
import fastapiVue from './vite-plugin-fastapi.js' import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@@ -8,15 +7,6 @@ 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'
// Every theme directory ships its theme.css as a separate build entry, so
// the backend can link base and theme stylesheets independently.
const themesDir = fileURLToPath(new URL('./src/assets/themes', import.meta.url))
const themeInputs = Object.fromEntries(
readdirSync(themesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => [`theme_${d.name}`, `${themesDir}/${d.name}/theme.css`]),
)
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev. // Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the // Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
// backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin. // backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin.
@@ -49,8 +39,9 @@ export default defineConfig({
input: { input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)), main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)), pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
// Only the base CSS is built; theme/banner-design stylesheets live
// in pagerite/themes/{name}/ and are served by the backend as-is.
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)), pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
...themeInputs,
}, },
}, },
}, },
+47 -5
View File
@@ -273,13 +273,16 @@ async def update_structure(op: StructureOp) -> None:
@app.get("/_api/settings") @app.get("/_api/settings")
async def get_settings() -> dict[str, str]: async def get_settings() -> dict:
"""Site-wide settings (brand, theme, custom CSS and favicon URL).""" """Site-wide settings (brand, theme, custom CSS and favicon URL), plus
the themes and banner designs available on disk for the selectors."""
return { return {
"brand": data.brand, "brand": data.brand,
"theme": data.theme, "theme": data.theme,
"custom_css": data.custom_css, "custom_css": data.custom_css,
"favicon": f"/_f/{data.favicon}" if data.favicon else "", "favicon": f"/_f/{data.favicon}" if data.favicon else "",
"themes": views._theme_names(),
"banner_designs": views._banner_design_names(),
} }
@@ -397,6 +400,35 @@ async def delete_file(name: str) -> None:
data.version += 1 data.version += 1
@app.get("/_themes/{name}/{filename}")
async def theme_file(name: str, filename: str, request: Request) -> Response:
"""Serve a theme/banner-design stylesheet from pagerite/themes/{name}/.
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.
"""
if (
filename not in {"theme.css", "banner.css"}
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
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
return Response(
path.read_bytes(),
media_type="text/css",
headers={"etag": etag, "cache-control": "no-cache"},
)
@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
@@ -465,12 +497,12 @@ async def editor_ws(ws: WebSocket) -> None:
Stateless protocol (each message carries the path): Stateless protocol (each message carries the path):
<- {"type": "open", "path"} <- {"type": "open", "path"}
-> {"type": "doc", "path", "exists", "title", "markdown", "published", -> {"type": "doc", "path", "exists", "title", "markdown", "published",
"banner"} "banner", "banner_design"}
<- {"type": "render", "path", "markdown"} <- {"type": "render", "path", "markdown"}
-> {"type": "html", "path", "html"} -> {"type": "html", "path", "html"}
<- {"type": "save", "path", "title"?, "markdown"?, "published"?, <- {"type": "save", "path", "title"?, "markdown"?, "published"?,
"banner"?, "move_from"?} (absent fields keep their old values; "banner"?, "banner_design"?, "move_from"?} (absent fields keep
move_from: rename/move a page, subtree included) their old values; move_from: rename/move a page, subtree included)
-> {"type": "saved", "path"} | {"type": "error", "detail"} -> {"type": "saved", "path"} | {"type": "error", "detail"}
""" """
await ws.accept() await ws.accept()
@@ -495,10 +527,18 @@ async def editor_ws(ws: WebSocket) -> None:
"markdown": node.content if node and node.content is not None else "", "markdown": node.content if node and node.content is not None else "",
"published": node.published if node else True, "published": node.published if node else True,
"banner": node.banner if node else "", "banner": node.banner if node else "",
# Own banner design setting: null = inherit,
# "" = none, otherwise a design name.
"banner_design": node.banner_design if node else None,
# Which node's banner applies here ("" = front page, # Which node's banner applies here ("" = front page,
# null = default artwork); the site editor shows it # null = default artwork); the site editor shows it
# as the banner field's placeholder. # as the banner field's placeholder.
"banner_from": views.banner_source(data.menu, path), "banner_from": views.banner_source(data.menu, path),
# Which node's banner-design setting applies here
# (null = the active theme's default design).
"banner_design_from": views.banner_design_source(
data.menu, path, data.theme
),
}) })
case "render": case "render":
markdown = msg.get("markdown", "") markdown = msg.get("markdown", "")
@@ -571,6 +611,8 @@ async def editor_ws(ws: WebSocket) -> None:
node.published = bool(msg["published"]) node.published = bool(msg["published"])
if "banner" in msg: if "banner" in msg:
node.banner = msg["banner"] node.banner = msg["banner"]
if "banner_design" in msg:
node.banner_design = msg["banner_design"]
node.modified = datetime.now(UTC) node.modified = datetime.now(UTC)
data.version += 1 data.version += 1
await ws.send_json({"type": "saved", "path": path}) await ws.send_json({"type": "saved", "path": path})
+7 -1
View File
@@ -31,9 +31,15 @@ class Node(msgspec.Struct, omit_defaults=True):
#: Markdown source of the node's page; None = pure category label #: Markdown source of the node's page; None = pure category label
#: (its URL renders a placeholder page). #: (its URL renders a placeholder page).
content: str | None = None content: str | None = None
#: Raw HTML for the header banner (img, styled div, canvas+script...). #: Raw HTML for the header banner (img, styled div, canvas+script...),
#: rendered after the banner design's artwork so author code always
#: wins over the design's own styles.
#: Empty inherits the nearest ancestor's banner, front page last. #: Empty inherits the nearest ancestor's banner, front page last.
banner: str = "" banner: str = ""
#: Banner design: a theme folder name (its banner.css/banner.svg),
#: "" = explicitly no design, None = inherit (nearest ancestor, front
#: page last, then the active theme's own design).
banner_design: str | None = None
published: bool = True published: bool = True
children: dict[str, "Node"] = {} children: dict[str, "Node"] = {}
created: datetime = msgspec.field( created: datetime = msgspec.field(
+79
View File
@@ -0,0 +1,79 @@
/* Corporate banner design: sizing and colors for the geometric artwork
(inlined by the backend into #page-banner). The cb-* classes recolor the
SVG from the active palette (var(--accent)), so one SVG serves light
and dark — and other themes too. */
#banner {
min-height: 15rem;
border-bottom: none;
}
/* Artwork colors, light mode */
.cb-bg0 {
stop-color: #ffffff;
}
.cb-bg1 {
stop-color: #e6eefe;
}
.cb-r0 {
stop-color: var(--accent);
}
.cb-r1 {
stop-color: #00b3ff;
}
.cb-g0,
.cb-g1 {
stop-color: var(--accent);
}
.cb-dot {
fill: var(--accent);
}
.cb-orbit {
stroke: var(--accent);
}
.cb-spark {
fill: var(--accent);
}
/* Artwork colors, dark mode */
@media (prefers-color-scheme: dark) {
.cb-bg0 {
stop-color: #0d1830;
}
.cb-bg1 {
stop-color: #0a1122;
}
.cb-r0 {
stop-color: #2f7bff;
}
.cb-r1 {
stop-color: #00d0ff;
}
.cb-g0,
.cb-g1 {
stop-color: #2f7bff;
}
.cb-dot {
fill: #4d8dff;
}
.cb-orbit {
stroke: #4d8dff;
}
.cb-spark {
fill: #6ea8ff;
}
}
@@ -1,9 +1,9 @@
/* Corporate theme: bright and bold professional. Saturated royal-blue /* Corporate theme: bright and bold professional. Saturated royal-blue
gradients on white, geometric Montserrat display type over Inter body, gradients on white, geometric Montserrat display type over Inter body,
and a genuinely large brand with a soft blue overlap shadow. Automatic and a genuinely large brand with a soft blue overlap shadow. Automatic
dark mode keeps the same saturated blue identity on deep navy; the dark mode keeps the same saturated blue identity on deep navy. The
banner artwork (inlined by the backend) is recolored from here via the companion banner design (banner.css, artwork inlined by the backend)
cb-* classes, so one SVG serves both modes. */ recolors its SVG from the active palette via the cb-* classes. */
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@@ -18,40 +18,6 @@
--font-heading: var(--font-montserrat); --font-heading: var(--font-montserrat);
} }
/* Banner artwork colors, light mode */
.cb-bg0 {
stop-color: #ffffff;
}
.cb-bg1 {
stop-color: #e6eefe;
}
.cb-r0 {
stop-color: var(--accent);
}
.cb-r1 {
stop-color: #00b3ff;
}
.cb-g0,
.cb-g1 {
stop-color: var(--accent);
}
.cb-dot {
fill: var(--accent);
}
.cb-orbit {
stroke: var(--accent);
}
.cb-spark {
fill: var(--accent);
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0b1428; --bg: #0b1428;
@@ -64,40 +30,6 @@
/* Code wells stay navy in dark mode (light mode uses --surface). */ /* Code wells stay navy in dark mode (light mode uses --surface). */
--code-bg: #0d1b3e; --code-bg: #0d1b3e;
} }
/* Banner artwork colors, dark mode */
.cb-bg0 {
stop-color: #0d1830;
}
.cb-bg1 {
stop-color: #0a1122;
}
.cb-r0 {
stop-color: #2f7bff;
}
.cb-r1 {
stop-color: #00d0ff;
}
.cb-g0,
.cb-g1 {
stop-color: #2f7bff;
}
.cb-dot {
fill: #4d8dff;
}
.cb-orbit {
stroke: #4d8dff;
}
.cb-spark {
fill: #6ea8ff;
}
} }
::selection { ::selection {
@@ -124,11 +56,6 @@
} }
} }
#banner {
min-height: 15rem;
border-bottom: none;
}
#nav { #nav {
font-size: 1.05em; font-size: 1.05em;
font-weight: 600; font-weight: 600;
+79
View File
@@ -0,0 +1,79 @@
/* Nitro banner design: the bezier-swept artwork with wide orange stripes
(inlined by the backend into #page-banner), in neutral dark greys that
follow the page's color scheme. */
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
from the page by a straight orange blade. */
#banner {
height: 13rem;
border-bottom: 4px solid var(--accent);
}
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
page's violet family by the dark-scheme block below). */
.nb-base {
fill: #0b0b0d;
}
.nb-s1a {
stop-color: #242428;
}
.nb-s1b {
stop-color: #0b0b0d;
}
.nb-s2a {
stop-color: #19191d;
}
.nb-s2b {
stop-color: #060607;
}
.nb-c0 {
stop-color: #2a2a2f;
}
.nb-c1 {
stop-color: #131315;
}
.nb-c2 {
stop-color: #0b0b0d;
}
@media (prefers-color-scheme: dark) {
/* Banner dark tones tinted to the same violet family as the page. */
.nb-base {
fill: #100d18;
}
.nb-s1a {
stop-color: #292536;
}
.nb-s1b {
stop-color: #100d18;
}
.nb-s2a {
stop-color: #1e1a2b;
}
.nb-s2b {
stop-color: #090811;
}
.nb-c0 {
stop-color: #322d44;
}
.nb-c1 {
stop-color: #171422;
}
.nb-c2 {
stop-color: #100d18;
}
}
@@ -59,39 +59,6 @@
--code-bg: #12101b; --code-bg: #12101b;
/* code wells join the violet family */ /* code wells join the violet family */
} }
/* Banner dark tones tinted to the same violet family as the page. */
.nb-base {
fill: #100d18;
}
.nb-s1a {
stop-color: #292536;
}
.nb-s1b {
stop-color: #100d18;
}
.nb-s2a {
stop-color: #1e1a2b;
}
.nb-s2b {
stop-color: #090811;
}
.nb-c0 {
stop-color: #322d44;
}
.nb-c1 {
stop-color: #171422;
}
.nb-c2 {
stop-color: #100d18;
}
} }
::selection { ::selection {
@@ -112,47 +79,6 @@
text-shadow: 0 0 0.1em black; text-shadow: 0 0 0.1em black;
} }
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
from the page by a straight orange blade. */
#banner {
height: 13rem;
border-bottom: 4px solid var(--accent);
}
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
page's violet family by the dark-scheme block above). */
.nb-base {
fill: #0b0b0d;
}
.nb-s1a {
stop-color: #242428;
}
.nb-s1b {
stop-color: #0b0b0d;
}
.nb-s2a {
stop-color: #19191d;
}
.nb-s2b {
stop-color: #060607;
}
.nb-c0 {
stop-color: #2a2a2f;
}
.nb-c1 {
stop-color: #131315;
}
.nb-c2 {
stop-color: #0b0b0d;
}
#nav { #nav {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 0.95em; font-size: 0.95em;
+21
View File
@@ -0,0 +1,21 @@
/* Purple banner design: the sunrise artwork (inlined by the backend into
#page-banner) with parallax sun and a fade into the page background. */
/* Sunrise parallax: the sun and its glow rise faster than the artwork
drift (pagerite.js sets --pry on <html>), so scrolling the page makes
the sun come up. */
#page-banner .sun,
#page-banner .sun-glow {
transform-box: fill-box;
transform: translateY(calc(var(--pry, 0px) * -2));
}
/* The banner artwork fades into the page background at its bottom edge
(baked into the SVG, so a user banner replaces it cleanly). */
.banner-fade {
stop-color: var(--bg);
}
#banner {
min-height: 13rem;
}
@@ -41,25 +41,6 @@
filter: drop-shadow(0 0.15rem 0.6rem #9b6bff55); filter: drop-shadow(0 0.15rem 0.6rem #9b6bff55);
} }
/* Sunrise parallax: the sun and its glow rise faster than the artwork
drift (pagerite.js sets --pry on <html>), so scrolling the page makes
the sun come up. */
#page-banner .sun,
#page-banner .sun-glow {
transform-box: fill-box;
transform: translateY(calc(var(--pry, 0px) * -2));
}
/* The banner artwork fades into the page background at its bottom edge
(baked into the SVG, so a user banner replaces it cleanly). */
.banner-fade {
stop-color: var(--bg);
}
#banner {
min-height: 13rem;
}
/* Dark artwork: keep the nav readable with a shadow. */ /* Dark artwork: keep the nav readable with a shadow. */
#nav { #nav {
text-shadow: 0 0 0.15em black; text-shadow: 0 0 0.15em black;
+151 -82
View File
@@ -25,11 +25,13 @@ from pagerite.markdown import has_h1, 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"
# Shared CSS built as separate entries so the backend can link base and theme # The base CSS is built by Vite as a separate entry so the backend can link
# independently. Order matters: base first, theme overrides it. # 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.
_BASE_CSS_KEY = "src/assets/pagerite.css" _BASE_CSS_KEY = "src/assets/pagerite.css"
_THEME_CSS_KEY = "src/assets/themes/{theme}/theme.css"
_manifest_cache: dict | None = None _manifest_cache: dict | None = None
_asset_cache: dict[str, tuple] = {} _asset_cache: dict[str, tuple] = {}
@@ -42,29 +44,53 @@ def _manifest() -> dict:
return _manifest_cache return _manifest_cache
def _css_keys(theme: str) -> list[str]: def _theme_names() -> list[str]:
"""Manifest keys for the stylesheets to load for ``theme`` (empty = none).""" """Theme folders on disk (a folder is a theme when it has theme.css)."""
keys = [_BASE_CSS_KEY] return sorted(
if theme: d.name for d in THEMES.iterdir() if d.is_dir() and (d / "theme.css").exists()
keys.append(_THEME_CSS_KEY.format(theme=theme)) )
return keys
def _shared_css_urls(vite_url: str | None, theme: str) -> list[str]: def _banner_design_names() -> list[str]:
"""URLs for the base and theme stylesheets. """Available banner designs: theme folders with banner.css or banner.svg."""
return sorted(
d.name
for d in THEMES.iterdir()
if d.is_dir() and ((d / "banner.css").exists() or (d / "banner.svg").exists())
)
In dev the JS entries import these files, so Vite injects them; the
backend does not link them, avoiding the HMR-wrapped module output. def _valid_name(name: str) -> bool:
Themes added after the last frontend build are missing from the """Guard against path traversal in theme/design names."""
manifest — fall back to the base stylesheet rather than failing. return bool(name) and "/" not in name and not name.startswith(".")
"""
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)."""
if vite_url: if vite_url:
return [] return None
manifest = _manifest() manifest = _manifest()
return [f"/{manifest[key]['file']}" for key in _css_keys(theme) if key in manifest] if _BASE_CSS_KEY in manifest:
return f"/{manifest[_BASE_CSS_KEY]['file']}"
return None
def _editor_css_url(vite_url: str | None, theme: str) -> 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():
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():
return f"/_themes/{design}/banner.css"
return None
def _editor_css_url(vite_url: str | None) -> str | None:
"""URL for the editor-specific stylesheet (Vue component styles). """URL for the editor-specific stylesheet (Vue component styles).
This is linked by the public-page edit pen so the editor styles are This is linked by the public-page edit pen so the editor styles are
@@ -74,32 +100,33 @@ def _editor_css_url(vite_url: str | None, theme: str) -> str | None:
return None return None
manifest = _manifest() manifest = _manifest()
entry = manifest["src/main.js"] entry = manifest["src/main.js"]
shared_files = {manifest[key]["file"] for key in _css_keys(theme)} base = manifest.get(_BASE_CSS_KEY, {}).get("file")
for css in entry.get("css", []): for css in entry.get("css", []):
if css not in shared_files: if css != base:
return f"/{css}" return f"/{css}"
return None return None
def _layout( def _layout(
urls: list[str],
modules: list[str] = (), modules: list[str] = (),
custom_css: str = "", custom_css: str = "",
theme: str = "", theme: str = "",
banner_design: str = "",
favicon: str = "", favicon: str = "",
) -> Template: ) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts. """Page layout template with standard asset URLs and ES-module scripts.
Stylesheets use ``blocking="render"`` so the browser waits for them before Stylesheets use ``blocking="render"`` so the browser waits for them before
showing the page, avoiding a flash of unstyled content. showing the page, avoiding a flash of unstyled content. Order matters and
is fixed: base (Vite build, absent in dev where Vite injects it from JS),
theme and banner design (backend-served from pagerite/themes/), then the
user's custom CSS last so it always wins.
The active theme is named in a meta tag so that in dev (where the In dev, pagerite.js re-appends the backend-rendered theme/design links
backend links no stylesheets and Vite injects them from JS) the (and the custom CSS) after the Vite-injected base styles, keeping this
frontend entries know which theme CSS module to import. order intact.
""" """
doc = Document(E.Title, lang="en") doc = Document(E.Title, lang="en")
if theme:
doc.meta(name="pagerite:theme", content=theme)
# A custom favicon (from the site editor) is linked explicitly; without # A custom favicon (from the site editor) is linked explicitly; without
# one, browsers fall back to the build's /favicon.ico by convention. # one, browsers fall back to the build's /favicon.ico by convention.
if favicon: if favicon:
@@ -107,19 +134,21 @@ def _layout(
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens # Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
# itself once it has validated the session (pages render identically # itself once it has validated the session (pages render identically
# for everyone; editing is gated by the auth proxy in front of /_api). # for everyone; editing is gated by the auth proxy in front of /_api).
script, editor_css = _editor_assets(theme) script, editor_css = _editor_assets()
doc.meta(name="pagerite:editor-src", content=script[-1]) doc.meta(name="pagerite:editor-src", content=script[-1])
if editor_css: if editor_css:
doc.meta(name="pagerite:editor-css", content=editor_css) doc.meta(name="pagerite:editor-css", content=editor_css)
# Stylesheet links carry stable ids so the site editor's hot swap can # Stylesheet links carry stable ids so the site editor's hot swap can
# keep each sheet at its rendered position (see swapRegions). # keep each sheet at its rendered position (see swapRegions).
for i, url in enumerate(urls): vite_url = os.environ.get("PAGERITE_VITE_URL")
doc.link( sheets = [
rel="stylesheet", ("pagerite-base", _base_css_url(vite_url)),
href=url, ("pagerite-theme", _theme_css_url(theme)),
blocking="render", ("pagerite-banner", _banner_css_url(banner_design)),
id="pagerite-base" if i == 0 else "pagerite-theme", ]
) for id_, url in sheets:
if url:
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
for src in modules: for src in modules:
doc.script(src=src, type="module") doc.script(src=src, type="module")
if custom_css.strip(): if custom_css.strip():
@@ -233,42 +262,90 @@ def _first_leaf(node: Node, path: str) -> str | None:
def banner_html(menu: dict[str, Node], path: str, theme: str = "") -> HTML: def banner_html(menu: dict[str, Node], path: str, theme: str = "") -> HTML:
"""Resolve the banner for a path: the nearest node on the ancestor """Resolve the banner for a path: the effective banner design's inline
chain (the node itself first), then the front page, then the theme SVG artwork first, then the user's own banner HTML — always last, so
artwork. The front page is a top-level *sibling* of the other author code (e.g. <style> overrides) wins over the design's own styles.
main-level nodes, not their parent, so it never appears in the chain
and is consulted explicitly, last. The snippet is raw trusted HTML,
so a banner can be anything — an img, a styled div, canvas + script.
With no user banner anywhere in the chain, the active theme's inline The design comes from banner_design(); the user banner from the nearest
SVG artwork is inlined instead: as markup it can be recolored from the node on the ancestor chain (the node itself first), then the front page.
theme stylesheet (``var(--accent)`` etc.) and animated, and it is not The front page is a top-level *sibling* of the other main-level nodes,
rendered at all when the user supplies their own banner. not their parent, so it never appears in the chain and is consulted
explicitly, last. The snippet is raw trusted HTML, so a banner can be
anything — an img, a styled div, canvas + script.
""" """
parts = []
design = banner_design(menu, path, theme)
if design:
parts.append(_design_banner(design))
source = banner_source(menu, path) source = banner_source(menu, path)
if source is not None: if source is not None:
return HTML(resolve(menu, source)[-1].banner) parts.append(HTML(resolve(menu, source)[-1].banner))
return _theme_banner(theme) return HTML("".join(str(p) for p in parts))
_banner_cache: dict[str, HTML] = {} def _design_banner(design: str) -> HTML:
"""The design's inline banner SVG (empty for none/unknown designs).
Read from disk on every request: design files are never built/cached,
def _theme_banner(theme: str) -> HTML: so editing them on disk shows on the next page load, even in prod."""
"""The theme's inline banner SVG (empty for none/unknown themes).""" if not _valid_name(design):
if not theme or "/" in theme:
return HTML("") return HTML("")
if theme not in _banner_cache: path = THEMES / design / "banner.svg"
path = Path(__file__).parent / "themes" / theme / "banner.svg" if not path.exists():
_banner_cache[theme] = HTML(path.read_text()) if path.exists() else HTML("") return HTML("")
return _banner_cache[theme] # data-design marks the artwork as the design's (not author code), so
# the site editor's live banner preview keeps it in place.
return HTML(path.read_text().replace("<svg", "<svg data-design", 1))
def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
"""The effective banner design name at ``path`` ("" = no design).
Nodes set ``banner_design`` to a design name, "" (explicitly none) or
None (inherit). Resolution walks the ancestor chain from the node
upwards, then the front page, then falls back to the active theme's own
design (a theme folder doubles as a banner design when it ships
banner.css or banner.svg).
"""
chain = resolve(menu, path) or []
for node in reversed(chain):
if node.banner_design is not None:
return node.banner_design
front = menu.get("")
if front and front.banner_design is not None:
return front.banner_design
if (
_valid_name(theme)
and ((THEMES / theme / "banner.css").exists()
or (THEMES / theme / "banner.svg").exists())
):
return theme
return ""
def banner_design_source(
menu: dict[str, Node], path: str, theme: str = ""
) -> str | None:
"""Which node's banner-design setting applies at ``path`` (like
banner_source), or None when the active theme's default applies.
Used by the site editor for the design selector's inherit label."""
chain = resolve(menu, path) or []
segs = path.split("/")
for i in range(len(chain) - 1, -1, -1):
if chain[i].banner_design is not None:
return "/".join(segs[: i + 1])
front = menu.get("")
if front and front.banner_design is not None:
return ""
return None
def banner_source(menu: dict[str, Node], path: str) -> str | None: def banner_source(menu: dict[str, Node], path: str) -> str | None:
"""Which node's banner applies at ``path``: the nearest ancestor with """Which node's banner HTML applies at ``path``: the nearest ancestor with
one set (the front page, a top-level sibling of the chain, last). one set (the front page, a top-level sibling of the chain, last). None =
None = the default artwork. Whitespace-only banners count as empty: no user banner anywhere (only the design artwork renders, if any).
clearing the editor can leave a stray newline behind.""" Whitespace-only banners count as empty: clearing the editor can leave a
stray newline behind."""
chain = resolve(menu, path) or [] chain = resolve(menu, path) or []
segs = path.split("/") segs = path.split("/")
for i in range(len(chain) - 1, -1, -1): for i in range(len(chain) - 1, -1, -1):
@@ -304,9 +381,8 @@ def render_page(
"""Render a full HTML page for the slug path.""" """Render a full HTML page for the slug path."""
node = resolve(menu, path)[-1] node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node) title = _title(path.rpartition("/")[2], node)
scripts, styles = _page_assets(theme)
return str( return str(
_layout(styles, scripts, custom_css, theme, favicon)( _layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"{title} {brand}" if brand else title, Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand), Brand=_brand_link(brand),
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
@@ -341,9 +417,8 @@ def render_category(
doc.p("Pages in this section are listed in the menu on the left.") doc.p("Pages in this section are listed in the menu on the left.")
else: else:
doc.p("This section has no page of its own yet.") doc.p("This section has no page of its own yet.")
scripts, styles = _page_assets(theme)
return str( return str(
_layout(styles, scripts, custom_css, theme, favicon)( _layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"{title} {brand}" if brand else title, Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand), Brand=_brand_link(brand),
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
@@ -367,9 +442,8 @@ def render_not_found(
with doc: with doc:
doc.h1("Not Found") doc.h1("Not Found")
doc.p(f"No article at /{path}. If there was before, it may have been deleted.") doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
scripts, styles = _page_assets(theme)
return str( return str(
_layout(styles, scripts, custom_css, theme, favicon)( _layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"Not Found {brand}" if brand else "Not Found", Title=f"Not Found {brand}" if brand else "Not Found",
Brand=_brand_link(brand), Brand=_brand_link(brand),
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
@@ -380,27 +454,23 @@ def render_not_found(
) )
def _page_assets(theme: str) -> tuple[list[str], list[str]]: def _page_assets() -> list[str]:
"""Script and CSS URLs for public pages (pagerite entry). """Script URLs for public pages (pagerite entry).
Dev mode loads the entry from the Vite dev server; production uses Dev mode loads the entry from the Vite dev server; production uses
the Vite build manifest to resolve the hashed asset names. the Vite build manifest to resolve the hashed asset names.
""" """
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url: if vite_url:
return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url, theme) return [f"{vite_url}/src/pagerite.js"]
key = f"page:{theme}" if "page" not in _asset_cache:
if key not in _asset_cache:
manifest = _manifest() manifest = _manifest()
entry = manifest["src/pagerite.js"] entry = manifest["src/pagerite.js"]
_asset_cache[key] = ( _asset_cache["page"] = [f"/{entry['file']}"]
[f"/{entry['file']}"], return _asset_cache["page"]
_shared_css_urls(None, theme),
)
return _asset_cache[key]
def _editor_assets(theme: str) -> tuple[list[str], str | None]: def _editor_assets() -> tuple[list[str], str | None]:
"""Script URL and editor-specific CSS URL for the public-page edit pen. """Script URL and editor-specific CSS URL for the public-page edit pen.
The shared CSS is already linked on the page, so the pen only needs the The shared CSS is already linked on the page, so the pen only needs the
@@ -409,9 +479,8 @@ def _editor_assets(theme: str) -> tuple[list[str], str | None]:
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url: if vite_url:
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
key = f"editor:{theme}" if "editor" not in _asset_cache:
if key not in _asset_cache:
manifest = _manifest() manifest = _manifest()
entry = manifest["src/main.js"] entry = manifest["src/main.js"]
_asset_cache[key] = [f"/{entry['file']}"], _editor_css_url(None, theme) _asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
return _asset_cache[key] return _asset_cache["editor"]