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:
@@ -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
|
||||
moves never break them. `Node.banner` is a raw trusted HTML snippet
|
||||
for the header banner (img, styled div, canvas+script...); empty
|
||||
inherits from the node's ancestors (front page last), then the active
|
||||
theme's banner artwork: an inline SVG from
|
||||
`pagerite/themes/{theme}/banner.svg`, inlined into `#page-banner` by
|
||||
the backend only when no user banner applies (so it is recolorable
|
||||
from the theme CSS via `var(...)` and never fights user designs; the
|
||||
base stylesheet falls back to a plain gradient).
|
||||
inherits from the node's ancestors (front page last). It is rendered
|
||||
AFTER the banner design's artwork, so author code (e.g. a `<style>`
|
||||
override) always wins over the design's own styles.
|
||||
`Node.banner_design` picks a banner design: a theme folder name whose
|
||||
`banner.css`/`banner.svg` supply the design's styles and inline SVG
|
||||
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
|
||||
and embedded in page ETags so nav-affecting changes invalidate caches.
|
||||
`Data.brand` is the site name (header link + `<title>` suffix), editable
|
||||
in the site editor via `/_api/settings`; empty = no header link and
|
||||
no `<title>` suffix. `Data.theme` is the active theme name (empty =
|
||||
none/base only); themes live in `frontend/src/assets/themes/{theme}`.
|
||||
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
|
||||
`<head>` (id `pagerite-user`) and swapped during fetch-navigation;
|
||||
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
|
||||
detected (dev/no proxy), editing is left open. Pages themselves render
|
||||
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
|
||||
stylesheets (base and theme) so they can be swapped or augmented.
|
||||
all of `/_api`. The backend links the stylesheets in a fixed order —
|
||||
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
|
||||
under `/_assets/`: `pagerite.css` (base layout + conservative variables),
|
||||
`themes/{purple,corporate,nitro}/theme.css` (theme overrides and font
|
||||
picks: `purple` = dark dusk palette with Fraunces/Literata and a tilted
|
||||
under `/_assets/`: `pagerite.css` (base layout + conservative
|
||||
variables), `pygments.css`,
|
||||
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
|
||||
`prefers-color-scheme` dark mode, Montserrat/Inter and a huge solid
|
||||
brand; `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()`), `pygments.css`,
|
||||
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.
|
||||
an orange racing-tab nav clipped with a bezier `shape()`) and the
|
||||
companion `banner.css` banner designs are served by the backend.
|
||||
- Vite builds ES-module `.js` outputs; the backend renders `<script
|
||||
type="module">` for them (module scripts defer by default).
|
||||
- The database file is `pagerite.kantadb` in the cwd (`PAGERITE_DB`
|
||||
@@ -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
|
||||
scroll) opened by the article pen — it edits content and title only,
|
||||
never the path — and `SiteEditor.vue` (site brand + theme selector +
|
||||
favicon upload/remove + site-wide custom CSS + banner HTML edited in
|
||||
small CodeMirror windows;
|
||||
favicon upload/remove + site-wide custom CSS + per-page banner design
|
||||
selector (inherit/none/named design, inherited by children) + banner
|
||||
HTML edited in small CodeMirror windows;
|
||||
banner previewed into `#page-banner`, CSS injected into
|
||||
`<head id="pagerite-user">`) + vue-draggable structure tree with
|
||||
always-editable title/slug inputs per row, opened by the banner pen —
|
||||
@@ -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;
|
||||
`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
|
||||
`src/assets/pagerite.css` and every `src/assets/themes/*/theme.css` as
|
||||
separate stylesheet entries (enumerated from the themes directory, so new
|
||||
themes need no config change); there
|
||||
`src/assets/pagerite.css` as a separate stylesheet entry; theme and
|
||||
banner-design CSS are NOT built — they live in `pagerite/themes/{name}/`
|
||||
and are served by the backend. There
|
||||
is no `index.html` source (it would shadow `/` and turn missing dev paths
|
||||
into an empty Vue shell). All outputs are ES modules. The build sets
|
||||
`preserveEntrySignatures: 'exports-only'` because main.js is consumed
|
||||
via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite
|
||||
app builds otherwise strip unused entry exports, leaving dead edit pens.
|
||||
In dev the backend links no stylesheets (Vite injects them from JS); the
|
||||
active theme reaches the page as `<meta name="pagerite:theme">` and
|
||||
pagerite.js imports that theme's CSS, while a theme switch in the site
|
||||
editor swaps the Vite-injected `<style data-vite-dev-id>` tags (the
|
||||
`<link>` sync used in prod is a no-op in dev).
|
||||
In dev the backend links theme/banner-design stylesheets like in prod
|
||||
(`/_themes/...`); only the base CSS is Vite-injected from JS, and
|
||||
pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/
|
||||
`#pagerite-user` elements to restore the canonical order (base < theme <
|
||||
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
|
||||
auto-upgrade marker — edit `vite.config.js`, not the plugin.
|
||||
- `docs/` — design documentation.
|
||||
|
||||
+24
-14
@@ -86,15 +86,19 @@ evolves.
|
||||
`Sidebar`, `Main`) filled per request. The dynamic regions carry stable
|
||||
ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
|
||||
- The page top is a **full-width banner header** with the site name and the
|
||||
navigation bar overlaid on it — no separate chrome header. The banner is
|
||||
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
|
||||
navigation bar overlaid on it — no separate chrome header. The banner
|
||||
combines two layers, stacked in `#page-banner` (a grid, so they overlay):
|
||||
first the **banner design** — a named design living in a theme folder
|
||||
(`pagerite/themes/{name}/banner.css` + `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
|
||||
walking up the node's ancestors to the front page; when nothing in the
|
||||
chain sets one, the active theme's banner artwork shows. That artwork is
|
||||
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.
|
||||
walking up the node's ancestors to the front page and rendered **after**
|
||||
the design artwork, so author styles always win over the design's own.
|
||||
The base stylesheet falls back to a plain gradient. There is deliberately
|
||||
no scrim fading the banner into the page background — any such fade would
|
||||
ruin user-supplied designs; themes that want one bake it into their SVG
|
||||
@@ -154,20 +158,24 @@ evolves.
|
||||
|
||||
- The base stylesheet `frontend/src/assets/pagerite.css` provides the layout,
|
||||
typography and interaction rules with conservative CSS variables. A theme layer
|
||||
(`frontend/src/assets/themes/{name}/theme.css` — currently `purple`, `corporate`
|
||||
and `nitro`) overrides those variables and
|
||||
(`pagerite/themes/{name}/theme.css` — currently `purple`, `corporate`
|
||||
and `nitro`, served by the backend at `/_themes/{name}/theme.css` straight
|
||||
from disk, never built) overrides those variables and
|
||||
adds the visual styling; `Data.theme` selects the active theme (empty = none/base
|
||||
only) and the site editor can switch it. 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
|
||||
`prefers-color-scheme` (corporate is light-first with a matching dark palette;
|
||||
nitro a warm light-grey page or, in dark mode, a deep violet one — its dark
|
||||
banner and orange accents carry over unchanged); purple (dusk) uses one
|
||||
fixed palette for everyone. Themes may restyle structural details the base
|
||||
leaves plain — heading colors and underlines, list markers, nav treatment,
|
||||
brand sizing. 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
|
||||
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.
|
||||
- Fonts, the shared stylesheet and pygments styles
|
||||
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
|
||||
stylesheet's per-family font variables), a **site-wide custom CSS** field (injected
|
||||
into `<style id="pagerite-user">` in the live page head and swapped during
|
||||
fetch-navigation), the page's **banner 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
|
||||
the **structure tree**. Everything saves immediately as you edit — no
|
||||
save button, no edit mode.
|
||||
|
||||
+84
-24
@@ -302,12 +302,10 @@ async function commitPending() {
|
||||
// empty brand removes the header link and the title suffix entirely.
|
||||
const brand = ref('')
|
||||
const theme = ref('purple')
|
||||
const THEME_OPTIONS = [
|
||||
{ value: '', label: 'none' },
|
||||
{ value: 'purple', label: 'purple' },
|
||||
{ value: 'corporate', label: 'corporate' },
|
||||
{ value: 'nitro', label: 'nitro' },
|
||||
]
|
||||
// Theme and banner-design options come from the backend (theme folders on
|
||||
// disk, see GET /_api/settings), so added themes need no frontend changes.
|
||||
const themeOptions = ref([{ value: '', label: 'none' }])
|
||||
const bannerDesigns = ref([])
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -316,6 +314,11 @@ async function loadSettings() {
|
||||
theme.value = s.theme || ''
|
||||
customCss.value = s.custom_css || ''
|
||||
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 */ }
|
||||
}
|
||||
|
||||
@@ -420,19 +423,23 @@ function saveBrand() {
|
||||
|
||||
async function onThemeChange() {
|
||||
await saveSettings()
|
||||
if (import.meta.env.DEV) {
|
||||
// Dev: styles are Vite-injected <style> tags, not <link>s, so the
|
||||
// stylesheet sync in swapRegions can't switch themes. Drop the old
|
||||
// theme's injected styles and import the new theme module instead.
|
||||
for (const el of document.head.querySelectorAll('style[data-vite-dev-id]')) {
|
||||
if (el.dataset.viteDevId.includes('/themes/')) el.remove()
|
||||
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
|
||||
// and prod: swap the link in place, then re-render (the theme's default
|
||||
// banner design and the page's stylesheet links may change with it).
|
||||
let link = document.getElementById('pagerite-theme')
|
||||
if (theme.value) {
|
||||
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) {
|
||||
await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`)
|
||||
}
|
||||
// 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)
|
||||
} else if (link) {
|
||||
link.remove()
|
||||
}
|
||||
loadPlain(path.value)
|
||||
}
|
||||
@@ -707,6 +714,34 @@ provide('structureHandlers', {
|
||||
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 ------------------------------------------------------
|
||||
// The banner HTML is edited in a small CodeMirror window (HTML syntax),
|
||||
// previewed into the real #page-banner region on every keystroke.
|
||||
@@ -731,11 +766,15 @@ function previewBanner() {
|
||||
const el = document.getElementById('page-banner')
|
||||
if (!el) return
|
||||
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.prepend(...artwork)
|
||||
runScripts(el)
|
||||
} 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).
|
||||
loadPlain(path.value)
|
||||
}
|
||||
@@ -785,12 +824,15 @@ function onMessage(ev) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg.type === 'doc' && msg.path === path.value) {
|
||||
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({
|
||||
effects: bannerPh.reconfigure(placeholder(
|
||||
msg.banner_from == null
|
||||
? 'using default artwork'
|
||||
: `inherited from /${msg.banner_from}`,
|
||||
? 'own banner code (added after the design)'
|
||||
: `code inherited from /${msg.banner_from}`,
|
||||
)),
|
||||
})
|
||||
// Overlay this page's own banner on the swapped region. Empty means
|
||||
@@ -924,7 +966,7 @@ onUnmounted(() => {
|
||||
title="Theme"
|
||||
@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 }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -1007,6 +1049,16 @@ onUnmounted(() => {
|
||||
<section class="block" @paste="onBannerPaste">
|
||||
<div class="block-head">
|
||||
<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
|
||||
type="button"
|
||||
title="upload banner image/video (replaces existing media) — pasting works too"
|
||||
@@ -1147,6 +1199,14 @@ onUnmounted(() => {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
|
||||
@@ -131,18 +131,24 @@ body {
|
||||
}
|
||||
|
||||
/* Per-page banner content (img, styled div, inline SVG...) fills the
|
||||
banner; swapped along with #nav/#main on fetch-navigation. When the page
|
||||
sets no banner of its own, the backend inlines the active theme's SVG
|
||||
artwork here instead (pagerite/themes/{theme}/banner.svg). */
|
||||
banner; swapped along with #nav/#main on fetch-navigation. The backend
|
||||
inlines the effective banner design's SVG artwork here first
|
||||
(pagerite/themes/{design}/banner.svg, marked svg[data-design]), then the
|
||||
page's own banner code after it. */
|
||||
#page-banner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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
|
||||
display:none on those and render their source as banner text. */
|
||||
#page-banner>*:not(style, script) {
|
||||
grid-area: 1 / 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
+12
-11
@@ -8,17 +8,18 @@ import { showAuthIframe } from 'paskia'
|
||||
|
||||
(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
// The theme is selectable; the backend names the active one in a meta
|
||||
// tag (dev links no stylesheets — Vite injects them from JS).
|
||||
const theme = document.querySelector('meta[name="pagerite:theme"]')?.content;
|
||||
const sheets = [import("./assets/pagerite.css")];
|
||||
if (theme) sheets.push(import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`));
|
||||
// The injected styles land after the server-rendered custom CSS in
|
||||
// <head>; move the custom CSS back to the end so its equal-specificity
|
||||
// :root rules (font variables) win.
|
||||
Promise.all(sheets).then(() => {
|
||||
const el = document.getElementById("pagerite-user");
|
||||
if (el) document.head.append(el);
|
||||
// In dev the base stylesheet is injected by Vite from JS (linking the
|
||||
// raw module would pull in its HMR wrapper). Theme and banner-design
|
||||
// stylesheets are plain files served by the backend (/_themes/...), so
|
||||
// the backend renders their <link>s in both dev and prod. The injected
|
||||
// base styles land at the end of <head> — after them, restore the
|
||||
// canonical order: base < theme < banner design < custom CSS (whose
|
||||
// equal-specificity :root rules — font variables — must win by order).
|
||||
import("./assets/pagerite.css").then(() => {
|
||||
for (const id of ["pagerite-theme", "pagerite-banner", "pagerite-user"]) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) document.head.append(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-11
@@ -1,5 +1,4 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
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'
|
||||
|
||||
// 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.
|
||||
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
|
||||
// backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin.
|
||||
@@ -49,8 +39,9 @@ export default defineConfig({
|
||||
input: {
|
||||
main: fileURLToPath(new URL('./src/main.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)),
|
||||
...themeInputs,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+47
-5
@@ -273,13 +273,16 @@ async def update_structure(op: StructureOp) -> None:
|
||||
|
||||
|
||||
@app.get("/_api/settings")
|
||||
async def get_settings() -> dict[str, str]:
|
||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL)."""
|
||||
async def get_settings() -> dict:
|
||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||
the themes and banner designs available on disk for the selectors."""
|
||||
return {
|
||||
"brand": data.brand,
|
||||
"theme": data.theme,
|
||||
"custom_css": data.custom_css,
|
||||
"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
|
||||
|
||||
|
||||
@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}")
|
||||
async def stored_file(name: str, request: Request) -> Response:
|
||||
"""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):
|
||||
<- {"type": "open", "path"}
|
||||
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
||||
"banner"}
|
||||
"banner", "banner_design"}
|
||||
<- {"type": "render", "path", "markdown"}
|
||||
-> {"type": "html", "path", "html"}
|
||||
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
||||
"banner"?, "move_from"?} (absent fields keep their old values;
|
||||
move_from: rename/move a page, subtree included)
|
||||
"banner"?, "banner_design"?, "move_from"?} (absent fields keep
|
||||
their old values; move_from: rename/move a page, subtree included)
|
||||
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
||||
"""
|
||||
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 "",
|
||||
"published": node.published if node else True,
|
||||
"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,
|
||||
# null = default artwork); the site editor shows it
|
||||
# as the banner field's placeholder.
|
||||
"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":
|
||||
markdown = msg.get("markdown", "")
|
||||
@@ -571,6 +611,8 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
node.published = bool(msg["published"])
|
||||
if "banner" in msg:
|
||||
node.banner = msg["banner"]
|
||||
if "banner_design" in msg:
|
||||
node.banner_design = msg["banner_design"]
|
||||
node.modified = datetime.now(UTC)
|
||||
data.version += 1
|
||||
await ws.send_json({"type": "saved", "path": path})
|
||||
|
||||
+7
-1
@@ -31,9 +31,15 @@ class Node(msgspec.Struct, omit_defaults=True):
|
||||
#: Markdown source of the node's page; None = pure category label
|
||||
#: (its URL renders a placeholder page).
|
||||
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.
|
||||
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
|
||||
children: dict[str, "Node"] = {}
|
||||
created: datetime = msgspec.field(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-76
@@ -1,9 +1,9 @@
|
||||
/* Corporate theme: bright and bold professional. Saturated royal-blue
|
||||
gradients on white, geometric Montserrat display type over Inter body,
|
||||
and a genuinely large brand with a soft blue overlap shadow. Automatic
|
||||
dark mode keeps the same saturated blue identity on deep navy; the
|
||||
banner artwork (inlined by the backend) is recolored from here via the
|
||||
cb-* classes, so one SVG serves both modes. */
|
||||
dark mode keeps the same saturated blue identity on deep navy. The
|
||||
companion banner design (banner.css, artwork inlined by the backend)
|
||||
recolors its SVG from the active palette via the cb-* classes. */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -18,40 +18,6 @@
|
||||
--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) {
|
||||
:root {
|
||||
--bg: #0b1428;
|
||||
@@ -64,40 +30,6 @@
|
||||
/* Code wells stay navy in dark mode (light mode uses --surface). */
|
||||
--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 {
|
||||
@@ -124,11 +56,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
#banner {
|
||||
min-height: 15rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#nav {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
@@ -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 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 {
|
||||
@@ -112,47 +79,6 @@
|
||||
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 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.95em;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
#nav {
|
||||
text-shadow: 0 0 0.15em black;
|
||||
+151
-82
@@ -25,11 +25,13 @@ from pagerite.markdown import has_h1, render
|
||||
|
||||
SITE_NAME = "Pagerite"
|
||||
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
|
||||
# independently. Order matters: base first, theme overrides it.
|
||||
# The base CSS is built by Vite as a separate entry so the backend can link
|
||||
# it independently of the theme. Themes and banner designs are plain .css
|
||||
# files in THEMES/{name}/, served by the backend at /_themes/{name}/... and
|
||||
# re-read from disk on every request (see app.py), so they are never built.
|
||||
_BASE_CSS_KEY = "src/assets/pagerite.css"
|
||||
_THEME_CSS_KEY = "src/assets/themes/{theme}/theme.css"
|
||||
|
||||
_manifest_cache: dict | None = None
|
||||
_asset_cache: dict[str, tuple] = {}
|
||||
@@ -42,29 +44,53 @@ def _manifest() -> dict:
|
||||
return _manifest_cache
|
||||
|
||||
|
||||
def _css_keys(theme: str) -> list[str]:
|
||||
"""Manifest keys for the stylesheets to load for ``theme`` (empty = none)."""
|
||||
keys = [_BASE_CSS_KEY]
|
||||
if theme:
|
||||
keys.append(_THEME_CSS_KEY.format(theme=theme))
|
||||
return keys
|
||||
def _theme_names() -> list[str]:
|
||||
"""Theme folders on disk (a folder is a theme when it has theme.css)."""
|
||||
return sorted(
|
||||
d.name for d in THEMES.iterdir() if d.is_dir() and (d / "theme.css").exists()
|
||||
)
|
||||
|
||||
|
||||
def _shared_css_urls(vite_url: str | None, theme: str) -> list[str]:
|
||||
"""URLs for the base and theme stylesheets.
|
||||
def _banner_design_names() -> list[str]:
|
||||
"""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.
|
||||
Themes added after the last frontend build are missing from the
|
||||
manifest — fall back to the base stylesheet rather than failing.
|
||||
"""
|
||||
|
||||
def _valid_name(name: str) -> bool:
|
||||
"""Guard against path traversal in theme/design names."""
|
||||
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:
|
||||
return []
|
||||
return None
|
||||
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).
|
||||
|
||||
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
|
||||
manifest = _manifest()
|
||||
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", []):
|
||||
if css not in shared_files:
|
||||
if css != base:
|
||||
return f"/{css}"
|
||||
return None
|
||||
|
||||
|
||||
def _layout(
|
||||
urls: list[str],
|
||||
modules: list[str] = (),
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
banner_design: str = "",
|
||||
favicon: str = "",
|
||||
) -> Template:
|
||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||
|
||||
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
|
||||
backend links no stylesheets and Vite injects them from JS) the
|
||||
frontend entries know which theme CSS module to import.
|
||||
In dev, pagerite.js re-appends the backend-rendered theme/design links
|
||||
(and the custom CSS) after the Vite-injected base styles, keeping this
|
||||
order intact.
|
||||
"""
|
||||
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
|
||||
# one, browsers fall back to the build's /favicon.ico by convention.
|
||||
if favicon:
|
||||
@@ -107,19 +134,21 @@ def _layout(
|
||||
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
|
||||
# itself once it has validated the session (pages render identically
|
||||
# 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])
|
||||
if editor_css:
|
||||
doc.meta(name="pagerite:editor-css", content=editor_css)
|
||||
# Stylesheet links carry stable ids so the site editor's hot swap can
|
||||
# keep each sheet at its rendered position (see swapRegions).
|
||||
for i, url in enumerate(urls):
|
||||
doc.link(
|
||||
rel="stylesheet",
|
||||
href=url,
|
||||
blocking="render",
|
||||
id="pagerite-base" if i == 0 else "pagerite-theme",
|
||||
)
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
sheets = [
|
||||
("pagerite-base", _base_css_url(vite_url)),
|
||||
("pagerite-theme", _theme_css_url(theme)),
|
||||
("pagerite-banner", _banner_css_url(banner_design)),
|
||||
]
|
||||
for id_, url in sheets:
|
||||
if url:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
|
||||
for src in modules:
|
||||
doc.script(src=src, type="module")
|
||||
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:
|
||||
"""Resolve the banner for a path: the nearest node on the ancestor
|
||||
chain (the node itself first), then the front page, then the theme
|
||||
artwork. The front page is a top-level *sibling* of the other
|
||||
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.
|
||||
"""Resolve the banner for a path: the effective banner design's inline
|
||||
SVG artwork first, then the user's own banner HTML — always last, so
|
||||
author code (e.g. <style> overrides) wins over the design's own styles.
|
||||
|
||||
With no user banner anywhere in the chain, the active theme's inline
|
||||
SVG artwork is inlined instead: as markup it can be recolored from the
|
||||
theme stylesheet (``var(--accent)`` etc.) and animated, and it is not
|
||||
rendered at all when the user supplies their own banner.
|
||||
The design comes from banner_design(); the user banner from the nearest
|
||||
node on the ancestor chain (the node itself first), then the front page.
|
||||
The front page is a top-level *sibling* of the other 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.
|
||||
"""
|
||||
parts = []
|
||||
design = banner_design(menu, path, theme)
|
||||
if design:
|
||||
parts.append(_design_banner(design))
|
||||
source = banner_source(menu, path)
|
||||
if source is not None:
|
||||
return HTML(resolve(menu, source)[-1].banner)
|
||||
return _theme_banner(theme)
|
||||
parts.append(HTML(resolve(menu, source)[-1].banner))
|
||||
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).
|
||||
|
||||
|
||||
def _theme_banner(theme: str) -> HTML:
|
||||
"""The theme's inline banner SVG (empty for none/unknown themes)."""
|
||||
if not theme or "/" in theme:
|
||||
Read from disk on every request: design files are never built/cached,
|
||||
so editing them on disk shows on the next page load, even in prod."""
|
||||
if not _valid_name(design):
|
||||
return HTML("")
|
||||
if theme not in _banner_cache:
|
||||
path = Path(__file__).parent / "themes" / theme / "banner.svg"
|
||||
_banner_cache[theme] = HTML(path.read_text()) if path.exists() else HTML("")
|
||||
return _banner_cache[theme]
|
||||
path = THEMES / design / "banner.svg"
|
||||
if not path.exists():
|
||||
return HTML("")
|
||||
# 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:
|
||||
"""Which node's banner applies at ``path``: the nearest ancestor with
|
||||
one set (the front page, a top-level sibling of the chain, last).
|
||||
None = the default artwork. Whitespace-only banners count as empty:
|
||||
clearing the editor can leave a stray newline behind."""
|
||||
"""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). None =
|
||||
no user banner anywhere (only the design artwork renders, if any).
|
||||
Whitespace-only banners count as empty: clearing the editor can leave a
|
||||
stray newline behind."""
|
||||
chain = resolve(menu, path) or []
|
||||
segs = path.split("/")
|
||||
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."""
|
||||
node = resolve(menu, path)[-1]
|
||||
title = _title(path.rpartition("/")[2], node)
|
||||
scripts, styles = _page_assets(theme)
|
||||
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,
|
||||
Brand=_brand_link(brand),
|
||||
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.")
|
||||
else:
|
||||
doc.p("This section has no page of its own yet.")
|
||||
scripts, styles = _page_assets(theme)
|
||||
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,
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
@@ -367,9 +442,8 @@ def render_not_found(
|
||||
with doc:
|
||||
doc.h1("Not Found")
|
||||
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
|
||||
scripts, styles = _page_assets(theme)
|
||||
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",
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
@@ -380,27 +454,23 @@ def render_not_found(
|
||||
)
|
||||
|
||||
|
||||
def _page_assets(theme: str) -> tuple[list[str], list[str]]:
|
||||
"""Script and CSS URLs for public pages (pagerite entry).
|
||||
def _page_assets() -> list[str]:
|
||||
"""Script URLs for public pages (pagerite entry).
|
||||
|
||||
Dev mode loads the entry from the Vite dev server; production uses
|
||||
the Vite build manifest to resolve the hashed asset names.
|
||||
"""
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url, theme)
|
||||
key = f"page:{theme}"
|
||||
if key not in _asset_cache:
|
||||
return [f"{vite_url}/src/pagerite.js"]
|
||||
if "page" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/pagerite.js"]
|
||||
_asset_cache[key] = (
|
||||
[f"/{entry['file']}"],
|
||||
_shared_css_urls(None, theme),
|
||||
)
|
||||
return _asset_cache[key]
|
||||
_asset_cache["page"] = [f"/{entry['file']}"]
|
||||
return _asset_cache["page"]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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")
|
||||
if vite_url:
|
||||
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
|
||||
key = f"editor:{theme}"
|
||||
if key not in _asset_cache:
|
||||
if "editor" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/main.js"]
|
||||
_asset_cache[key] = [f"/{entry['file']}"], _editor_css_url(None, theme)
|
||||
return _asset_cache[key]
|
||||
_asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
|
||||
return _asset_cache["editor"]
|
||||
|
||||
Reference in New Issue
Block a user