Backend-served themes and selectable, inheritable banner designs

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

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

Dev/prod hot loading unified: the backend renders the theme/design links
in both modes; in dev pagerite.js only re-appends them (and the custom
CSS) after the Vite-injected base styles. Theme switches just swap the
link href. The pagerite:theme meta and Vite theme build entries are gone.
This commit is contained in:
2026-08-18 05:46:02 +00:00
parent c0817330e7
commit 30947df16b
15 changed files with 560 additions and 348 deletions
+84 -24
View File
@@ -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;