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
|
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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
/* 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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user