diff --git a/AGENTS.md b/AGENTS.md index 55ed548..68e69c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,11 @@ not for the public pages. See `docs/design-principles.md` for the design. and embedded in page ETags so nav-affecting changes invalidate caches. `Data.brand` is the site name (header link + `` suffix), editable in the site editor via `/_api/settings`; empty = no header link and - no `<title>` suffix. + no `<title>` suffix. `Data.theme` is the active theme name (empty = + none/base only); themes live in `frontend/src/assets/themes/{theme}`. + `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. - `markdown.py` — markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists plugins). Custom image rule: relative srcs resolve against the page path, titled images become figures. @@ -83,8 +87,8 @@ not for the public pages. See `docs/design-principles.md` for the design. - `assets/` — shared styles and data files built by Vite and served hashed under `/_assets/`: `pagerite.css` (base layout + conservative variables), `themes/purple/theme.css` (the purple/dark theme override, including its - own `banner.svg`), `pygments.css`, and `fonts/` (self-hosted - Fraunces/Literata/Fira Code variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from + own `banner.svg`), `pygments.css`, and `fonts/` (self-hosted Source + Sans 3/Source Serif 4/Inter/Montserrat/Fira Code 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 type="module">` for them (module scripts defer by default). @@ -97,13 +101,16 @@ not for the public pages. See `docs/design-principles.md` for the design. (CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, 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 + banner HTML edited in - a small CodeMirror window and previewed into `#page-banner` + vue-draggable structure tree with - always-editable title/slug inputs per row) opened by - the banner pen — everything saves immediately as you edit (brand/title - debounced, slug on commit since it renames the path), tree rows navigate - in place without transitions when focused, and the front page is a - root-only row whose empty slug is editable like any other. Every + never the path — and `SiteEditor.vue` (site brand + theme selector + + site-wide custom CSS + 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 — + everything saves immediately as you edit (brand/title/CSS debounced, + slug on commit since it renames the path), theme change swaps the + stylesheet in place, tree rows navigate in place without transitions when + focused, and the front page is a root-only row whose empty slug is + editable like any other. Every non-empty list (and the root) ends with a non-draggable ➕ footer row (vuedraggable `#footer` slot): clicking it starts a new pending page at that level, and while dragging it is the list's "end of list" drop @@ -125,7 +132,15 @@ not for the public pages. See `docs/design-principles.md` for the design. `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`; there is no `index.html` source (it would shadow `/` and turn missing dev paths - into an empty Vue shell). All outputs are ES modules. + 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). vite-plugin-fastapi.js has an auto-upgrade marker — edit `vite.config.js`, not the plugin. - `docs/` — design documentation. diff --git a/docs/design-principles.md b/docs/design-principles.md index 99d0499..b25b3e4 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -82,8 +82,9 @@ evolves. ships a `banner.svg`; the base stylesheet falls back to a plain gradient). - **Fetch-navigation.** Links are plain `<a href>`; a small script (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the - page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions - and the document title, keeping `<head>` and the layout chrome. Without JS + page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions, + the document title, and the site-wide custom CSS (`<style id="pagerite-user">` + in `<head>`), keeping the rest of `<head>` and the layout chrome. Without JS everything works as normal page loads. Scripts inside fetched banner and content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for a rotating cube page transition (CSS adapted from termotohtori.fi — the @@ -132,14 +133,16 @@ 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/purple/theme.css` by default) overrides those - variables and adds the visual styling. Vue may add per-component styles on top + (`frontend/src/assets/themes/purple/theme.css`) 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 where needed. - Fonts, the shared stylesheet, pygments styles and the theme's banner SVG live under `frontend/src/assets/` (the banner SVG under `themes/purple/`) and are emitted as hashed assets under `/_assets/` - (Fraunces for headings, Literata for body, Fira Code for code — variable - woff2 files with local `@font-face`). No third-party requests. + (Source Serif 4 for headings, Source Sans 3 for body, Fira Code for code + by default; Inter and Montserrat kept as variable woff2 options with + local `@font-face`). No third-party requests. ## Editing @@ -151,10 +154,13 @@ evolves. sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) straight into the visible article's heading and body. - **Site mode** — the 🖊️ on the banner opens a panel with the site - **brand** (applied to the header live), the page's **banner HTML** - field (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. + **brand** (applied to the header live), a **theme** selector (swapping + the theme stylesheet in place), 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 + 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. - Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket @@ -191,7 +197,7 @@ evolves. avoiding REST polling and races. Rendering always stays server-side. - A REST API also exists for scripting, all under `/_api/`: `GET pages` (the full tree), `PUT/DELETE pages/{path}`, - `GET/PUT settings` (site brand), `POST structure` (reorder/move/ - retitle), file upload/removal via `PUT/DELETE files/{name}`. + `GET/PUT settings` (site brand, theme and custom CSS), `POST structure` + (reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`. - On startup, seed pages from `pagerite/seed.py` are added **only if missing** — existing user content is never overwritten. diff --git a/frontend/package.json b/frontend/package.json index 3054016..3d44584 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "build": "vite build" }, "dependencies": { + "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-markdown": "^6.5.2", "@codemirror/language": "^6.12.4", diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index a71fb95..bfdf2b3 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -15,6 +15,7 @@ import StructureTree from './StructureTree.vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState, Compartment } from '@codemirror/state' import { placeholder } from '@codemirror/view' +import { css } from '@codemirror/lang-css' import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' import { slugify } from './slugify' @@ -41,6 +42,11 @@ let view = null // CodeMirror for the banner HTML let syncing = false // set while replacing the document programmatically const bannerPh = new Compartment() // placeholder shows the inherited source +let cssView = null // CodeMirror for the site-wide custom CSS +let cssSyncing = false // set while replacing the CSS document programmatically +const customCss = ref('') +const cssEl = ref(null) + // path -> node, for quick lookups (current title, delete checks). const flatMap = computed(() => { const map = {} @@ -115,6 +121,33 @@ function swapRegions(doc) { } else if (freshBrand) { document.getElementById('nav')?.before(document.importNode(freshBrand, true)) } + // Site-wide custom CSS is in <head> and must be swapped too. + const freshUserStyle = doc.getElementById('pagerite-user') + const curUserStyle = document.getElementById('pagerite-user') + if (freshUserStyle && curUserStyle) { + curUserStyle.textContent = freshUserStyle.textContent + } else if (freshUserStyle) { + document.head.appendChild(document.importNode(freshUserStyle, true)) + } else if (curUserStyle) { + curUserStyle.remove() + } + // Theme and other public stylesheets live in <head> and must be kept in + // sync; editor-only stylesheets are preserved. Diff-based: unchanged + // sheets keep their elements, so their @keyframes are never torn down + // (re-creating keyframes would replay the editor's slide-in animation). + const curLinks = [...document.head.querySelectorAll('link[rel="stylesheet"]')] + .filter((l) => !l.dataset.pagerite) + const freshHrefs = [...doc.head.querySelectorAll('link[rel="stylesheet"]')] + .map((l) => l.href) + for (const link of curLinks) { + if (!freshHrefs.includes(link.href)) link.remove() + } + const have = new Set( + [...document.head.querySelectorAll('link[rel="stylesheet"]')].map((l) => l.href), + ) + for (const link of doc.head.querySelectorAll('link[rel="stylesheet"]')) { + if (!have.has(link.href)) document.head.appendChild(document.importNode(link, true)) + } document.title = doc.title } @@ -243,10 +276,18 @@ async function commitPending() { // Edits apply to the live page immediately and save while typing. An // 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' }, +] async function loadSettings() { try { - brand.value = (await (await fetch('/_api/settings')).json()).brand + const s = await (await fetch('/_api/settings')).json() + brand.value = s.brand + theme.value = s.theme || '' + customCss.value = s.custom_css || '' } catch { /* keep default */ } } @@ -277,11 +318,16 @@ function onBrandInput() { debounce('brand', saveBrand) } -async function saveBrand() { +async function saveSettings(opts = {}) { const res = await fetch('/_api/settings', { method: 'PUT', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ brand: brand.value }), + body: JSON.stringify({ + brand: brand.value, + theme: theme.value, + custom_css: customCss.value, + ...opts, + }), }) if (res.ok) { saveError.value = '' @@ -290,6 +336,58 @@ async function saveBrand() { } } +function saveBrand() { + saveSettings() +} + +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() + } + if (theme.value) { + await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`) + } + } + loadPlain(path.value) +} + +// --- Site-wide custom CSS -------------------------------------------------- +// Edits apply to the live page immediately and save while typing. +function applyCustomCss(css) { + let el = document.getElementById('pagerite-user') + if (css.trim()) { + if (!el) { + el = document.createElement('style') + el.id = 'pagerite-user' + document.head.append(el) + } + el.textContent = css + } else if (el) { + el.remove() + } +} + +function onCustomCssInput() { + applyCustomCss(customCss.value) + debounce('custom-css', saveCustomCss, 400) +} + +function saveCustomCss() { + saveSettings() +} + +function setCssDocument(text) { + cssSyncing = true + cssView.dispatch({ changes: { from: 0, to: cssView.state.doc.length, insert: text } }) + cssSyncing = false + customCss.value = text +} + // Two-step delete (no dialogs): the first click arms the row's button for // a few seconds, the second actually deletes. const arming = ref(null) @@ -548,9 +646,8 @@ function connect() { } } -onMounted(() => { +onMounted(async () => { refreshPages() - loadSettings() connect() view = new EditorView({ state: EditorState.create({ @@ -572,7 +669,30 @@ onMounted(() => { }), parent: bannerEl.value, }) + cssView = new EditorView({ + state: EditorState.create({ + doc: '', + extensions: [ + basicSetup, + css(), + cmTheme, + cmHighlight, + EditorView.lineWrapping, + placeholder('Site styling CSS'), + EditorView.updateListener.of((u) => { + if (u.docChanged && !cssSyncing) { + customCss.value = cssView.state.doc.toString() + onCustomCssInput() + } + }), + ], + }), + parent: cssEl.value, + }) addEventListener('keydown', onKeydown) + await loadSettings() + setCssDocument(customCss.value) + applyCustomCss(customCss.value) }) onUnmounted(() => { @@ -584,6 +704,7 @@ onUnmounted(() => { ws.close() } view?.destroy() + cssView?.destroy() removeEventListener('keydown', onKeydown) }) </script> @@ -605,7 +726,18 @@ onUnmounted(() => { placeholder="Site name (header link and window title)" @input="onBrandInput" /> + <select + v-model="theme" + class="text-input theme-select" + title="Theme" + @change="onThemeChange" + > + <option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value"> + {{ opt.label }} + </option> + </select> </label> + <div ref="cssEl" class="css-cm" /> </section> <section class="block" @paste="onBannerPaste"> @@ -727,6 +859,11 @@ onUnmounted(() => { border-radius: 4px; } +.theme-select { + flex: 0 0 auto; + width: auto; +} + /* Small CodeMirror window for the banner HTML; scrolls internally. */ .banner-cm { border: 1px solid var(--line); @@ -748,6 +885,26 @@ onUnmounted(() => { display: none; } +/* CodeMirror window for site-wide custom CSS. */ +.css-cm { + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; +} + +.css-cm :deep(.cm-editor) { + max-height: 12rem; + font-size: 0.85rem; +} + +.css-cm :deep(.cm-scroller) { + overflow: auto; +} + +.css-cm :deep(.cm-gutters) { + display: none; +} + .structure { flex: 1; overflow-y: auto; diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue index a45aed1..68b9e2a 100644 --- a/frontend/src/StructureTree.vue +++ b/frontend/src/StructureTree.vue @@ -274,7 +274,7 @@ body.tree-dragging .treelist { } .slug-edit { - font-family: "Fira Code", monospace; + font-family: var(--font-code); color: var(--muted); } diff --git a/frontend/src/assets/fonts/fonts.css b/frontend/src/assets/fonts/fonts.css index 3c68159..4a9b568 100644 --- a/frontend/src/assets/fonts/fonts.css +++ b/frontend/src/assets/fonts/fonts.css @@ -1,3 +1,10 @@ -@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('fraunces.woff2') format('woff2'); } -@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('literata.woff2') format('woff2'); } +/* Variable fonts: only downloaded when referenced by the page CSS. */ +@font-face { font-family: 'Inter'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('inter.woff2') format('woff2'); } +@font-face { font-family: 'Inter'; font-weight: 100 900; font-style: italic; font-display: swap; src: url('inter-italic.woff2') format('woff2'); } +@font-face { font-family: 'Montserrat'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('montserrat.woff2') format('woff2'); } +@font-face { font-family: 'Montserrat'; font-weight: 100 900; font-style: italic; font-display: swap; src: url('montserrat-italic.woff2') format('woff2'); } +@font-face { font-family: 'Source Sans 3'; font-weight: 200 900; font-style: normal; font-display: swap; src: url('sourcesans3.woff2') format('woff2'); } +@font-face { font-family: 'Source Sans 3'; font-weight: 200 900; font-style: italic; font-display: swap; src: url('sourcesans3-italic.woff2') format('woff2'); } +@font-face { font-family: 'Source Serif 4'; font-weight: 200 900; font-style: normal; font-display: swap; src: url('sourceserif4.woff2') format('woff2'); } +@font-face { font-family: 'Source Serif 4'; font-weight: 200 900; font-style: italic; font-display: swap; src: url('sourceserif4-italic.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('firacode.woff2') format('woff2'); } diff --git a/frontend/src/assets/fonts/fraunces.woff2 b/frontend/src/assets/fonts/fraunces.woff2 deleted file mode 100644 index cb295bf..0000000 Binary files a/frontend/src/assets/fonts/fraunces.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/inter-OFL.txt b/frontend/src/assets/fonts/inter-OFL.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/frontend/src/assets/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/inter-italic.woff2 b/frontend/src/assets/fonts/inter-italic.woff2 new file mode 100644 index 0000000..2f06c65 Binary files /dev/null and b/frontend/src/assets/fonts/inter-italic.woff2 differ diff --git a/frontend/src/assets/fonts/inter.woff2 b/frontend/src/assets/fonts/inter.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/frontend/src/assets/fonts/inter.woff2 differ diff --git a/frontend/src/assets/fonts/literata.woff2 b/frontend/src/assets/fonts/literata.woff2 deleted file mode 100644 index cae7062..0000000 Binary files a/frontend/src/assets/fonts/literata.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/montserrat-OFL.txt b/frontend/src/assets/fonts/montserrat-OFL.txt new file mode 100644 index 0000000..35ae34c --- /dev/null +++ b/frontend/src/assets/fonts/montserrat-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) Montserrat-Italic[wght].ttf: Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/montserrat-italic.woff2 b/frontend/src/assets/fonts/montserrat-italic.woff2 new file mode 100644 index 0000000..becdfd5 Binary files /dev/null and b/frontend/src/assets/fonts/montserrat-italic.woff2 differ diff --git a/frontend/src/assets/fonts/montserrat.woff2 b/frontend/src/assets/fonts/montserrat.woff2 new file mode 100644 index 0000000..3b2eaa9 Binary files /dev/null and b/frontend/src/assets/fonts/montserrat.woff2 differ diff --git a/frontend/src/assets/fonts/sourcesans3-OFL.txt b/frontend/src/assets/fonts/sourcesans3-OFL.txt new file mode 100644 index 0000000..046fc66 --- /dev/null +++ b/frontend/src/assets/fonts/sourcesans3-OFL.txt @@ -0,0 +1,93 @@ +Google Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/sourcesans3-italic.woff2 b/frontend/src/assets/fonts/sourcesans3-italic.woff2 new file mode 100644 index 0000000..4b43ac3 Binary files /dev/null and b/frontend/src/assets/fonts/sourcesans3-italic.woff2 differ diff --git a/frontend/src/assets/fonts/sourcesans3.woff2 b/frontend/src/assets/fonts/sourcesans3.woff2 new file mode 100644 index 0000000..864cc41 Binary files /dev/null and b/frontend/src/assets/fonts/sourcesans3.woff2 differ diff --git a/frontend/src/assets/fonts/sourceserif4-OFL.txt b/frontend/src/assets/fonts/sourceserif4-OFL.txt new file mode 100644 index 0000000..046fc66 --- /dev/null +++ b/frontend/src/assets/fonts/sourceserif4-OFL.txt @@ -0,0 +1,93 @@ +Google Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/sourceserif4-italic.woff2 b/frontend/src/assets/fonts/sourceserif4-italic.woff2 new file mode 100644 index 0000000..9425030 Binary files /dev/null and b/frontend/src/assets/fonts/sourceserif4-italic.woff2 differ diff --git a/frontend/src/assets/fonts/sourceserif4.woff2 b/frontend/src/assets/fonts/sourceserif4.woff2 new file mode 100644 index 0000000..30dbe2d Binary files /dev/null and b/frontend/src/assets/fonts/sourceserif4.woff2 differ diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 3c77383..2fb541a 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -12,6 +12,9 @@ --accent: #0056b3; --accent2: #003d80; --line: #0000001a; + --font-body: "Source Sans 3", system-ui, sans-serif; + --font-heading: "Source Serif 4", Georgia, serif; + --font-code: "Fira Code", ui-monospace, monospace; /* Width of the docked editor panel (used both here for shifting the page and in the Vue editor's own styles). */ --editor-w: min(46rem, 50vw); @@ -26,9 +29,12 @@ html { } body { - font-family: "Literata", Georgia, serif; + font-family: var(--font-body); font-size: 1.05rem; line-height: 1.65; + /* Tabular numerals wherever the active font supports them; avoids + numbers jumping in width as counters/values change. */ + font-variant-numeric: tabular-nums; margin: 0; background: var(--bg); color: var(--text); @@ -79,7 +85,7 @@ body { } #brand { - font-family: "Fraunces", serif; + font-family: var(--font-heading); font-weight: 700; font-size: 2.4rem; text-decoration: none; @@ -257,7 +263,7 @@ main { article h1, article h2, article h3 { - font-family: "Fraunces", serif; + font-family: var(--font-heading); font-weight: 600; line-height: 1.25; } @@ -463,7 +469,7 @@ pre:hover .copy, } code { - font-family: "Fira Code", ui-monospace, monospace; + font-family: var(--font-code); font-size: 0.88em; } diff --git a/frontend/src/assets/themes/purple/banner.svg b/frontend/src/assets/themes/purple/banner.svg new file mode 100644 index 0000000..e93bc03 --- /dev/null +++ b/frontend/src/assets/themes/purple/banner.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360"> + <rect width="1600" height="360" fill="#1b1830"/> +</svg> diff --git a/frontend/src/main.js b/frontend/src/main.js index 25220a1..f3df462 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -5,8 +5,9 @@ // - SiteEditor ("site" mode): pen on the banner — banner HTML editing // (previewed into the real banner) and the site structure tree. if (import.meta.env.DEV) { + // Base styles only; the theme CSS is imported by pagerite.js (which + // always runs first — the editor opens from public pages). import("./assets/pagerite.css"); - import("./assets/themes/purple/theme.css"); } import { createApp } from 'vue' @@ -29,6 +30,15 @@ export function openEditor(path, { mode = 'page' } = {}) { pagePath: path, onClose: closeEditor, }).mount(host) + // The slide-in (editor-slide-in in pagerite.css) is a one-shot open + // effect; once finished, drop it so that later stylesheet swaps (theme + // change re-creating @keyframes) cannot restart it. + const root = host.firstElementChild + root?.addEventListener('animationend', function done(e) { + if (e.target !== root) return + root.removeEventListener('animationend', done) + root.style.animation = 'none' + }) } export function closeEditor() { diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index e430076..5996c8f 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -7,7 +7,10 @@ (() => { if (import.meta.env.DEV) { import("./assets/pagerite.css"); - import("./assets/themes/purple/theme.css"); + // 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; + if (theme) import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`); } const REGIONS = ["page-banner", "nav", "sidebar", "main"]; @@ -188,6 +191,17 @@ const el = document.getElementById(id); el.replaceWith(document.importNode(doc.getElementById(id), true)); } + // Site-wide custom CSS lives in <head id="pagerite-user"> and must be + // kept in sync across fetch-navigations. + const oldUserStyle = document.getElementById("pagerite-user"); + const newUserStyle = doc.getElementById("pagerite-user"); + if (oldUserStyle && newUserStyle) { + oldUserStyle.textContent = newUserStyle.textContent; + } else if (newUserStyle) { + document.head.appendChild(document.importNode(newUserStyle, true)); + } else if (oldUserStyle) { + oldUserStyle.remove(); + } document.title = doc.title; // Banners may contain scripts (canvas etc.), content pages may too. runScripts(document.getElementById("page-banner")); @@ -235,6 +249,7 @@ const link = document.createElement("link"); link.rel = "stylesheet"; link.href = css; + link.dataset.pagerite = "editor-css"; document.head.append(link); } } @@ -242,7 +257,7 @@ editorModule = import(/* @vite-ignore */ editBtn.dataset.editorSrc); editorModule .then((m) => m.openEditor(path, { mode })) - .catch(() => {}); + .catch((e) => console.error("editor load failed:", e)); return; } const a = ev.target.closest("a[href]"); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a0dcf4e..36be43a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -32,6 +32,10 @@ export default defineConfig({ manifest: true, assetsDir: '_assets', rollupOptions: { + // main.js is dynamic-imported by the public page (pagerite.js) for + // its openEditor/closeEditor exports — keep them in the bundle + // (app builds strip unused entry exports by default). + preserveEntrySignatures: 'exports-only', input: { main: fileURLToPath(new URL('./src/main.js', import.meta.url)), pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)), diff --git a/pagerite/app.py b/pagerite/app.py index daa5fc8..ec33f42 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -281,14 +281,16 @@ async def update_structure(op: StructureOp) -> None: @app.get("/_api/settings") async def get_settings() -> dict[str, str]: - """Site-wide settings (the brand text).""" - return {"brand": data.brand} + """Site-wide settings (brand, theme and custom CSS).""" + return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css} class SettingsIn(BaseModel): """Payload for updating site-wide settings.""" brand: str + theme: str + custom_css: str @app.put("/_api/settings", status_code=204) @@ -296,6 +298,8 @@ async def put_settings(settings: SettingsIn) -> None: """Update site-wide settings; bumps the version so ETags invalidate.""" with kanta.transaction("update settings"): data.brand = settings.brand + data.theme = settings.theme + data.custom_css = settings.custom_css data.version += 1 @@ -570,7 +574,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: path = path.strip("/") if path and _is_reserved(path): # Reserved slug shape: never content — no tree lookup. - return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404) + return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404) chain = resolve(data.menu, path) node = chain[-1] if chain else None if node is not None and node.published and node.content is not None: @@ -580,17 +584,17 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: if request.headers.get("if-none-match") == etag: return Response(status_code=304) return HTMLResponse( - views.render_page(data.menu, path, data.brand), + views.render_page(data.menu, path, data.brand, data.custom_css, data.theme), headers={"etag": etag}, ) if node is not None and node.published and node.content is None: # Category label without a landing page: placeholder with the pen # to create it (404 — no page here, but the node is real). - return HTMLResponse(views.render_category(data.menu, path, data.brand), 404) + return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme), 404) if node is None and not path: # No front page (no top-level node with slug ""): "/" opens the # first item of the navigation instead. for slug, item in sorted_nodes(data.menu): if item.published: return RedirectResponse(f"/{slug}") - return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404) + return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404) diff --git a/pagerite/data.py b/pagerite/data.py index 10a2aa6..78e3ed3 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -79,6 +79,12 @@ class Data(msgspec.Struct): #: Site name shown in the header and <title> suffix; editable in the #: site editor. Empty = no brand link in the header, no title suffix. brand: str = "Pagerite" + #: Active theme name (empty = none/base only). Themes live in + #: frontend/src/assets/themes/{theme}/theme.css. + theme: str = "purple" + #: Raw site-wide custom CSS, injected inline in every page <head>. + #: Trusted author content; not sanitized. + custom_css: str = "" #: Legacy flat page store (pre-tree databases); migrated into `menu` #: on startup, then cleared. Never written otherwise. pages: dict[str, Page] = {} diff --git a/pagerite/views.py b/pagerite/views.py index 50071da..d9e2ed3 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -27,10 +27,8 @@ BUILD = Path(__file__).with_name("frontend-build") # Shared CSS built as separate entries so the backend can link base and theme # independently. Order matters: base first, theme overrides it. -_SHARED_CSS = { - "src/assets/pagerite.css": "pagerite", - "src/assets/themes/purple/theme.css": "pagerite-theme", -} +_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] = {} @@ -43,7 +41,15 @@ def _manifest() -> dict: return _manifest_cache -def _shared_css_urls(vite_url: str | None) -> list[str]: +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 _shared_css_urls(vite_url: str | None, theme: str) -> list[str]: """URLs for the base and theme stylesheets. In dev the JS entries import these files, so Vite injects them; the @@ -52,10 +58,10 @@ def _shared_css_urls(vite_url: str | None) -> list[str]: if vite_url: return [] manifest = _manifest() - return [f"/{manifest[key]['file']}" for key in _SHARED_CSS] + return [f"/{manifest[key]['file']}" for key in _css_keys(theme)] -def _editor_css_url(vite_url: str | None) -> str | None: +def _editor_css_url(vite_url: str | None, theme: str) -> 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 @@ -65,24 +71,37 @@ def _editor_css_url(vite_url: str | None) -> str | None: return None manifest = _manifest() entry = manifest["src/main.js"] - shared_files = {manifest[key]["file"] for key in _SHARED_CSS} + shared_files = {manifest[key]["file"] for key in _css_keys(theme)} for css in entry.get("css", []): if css not in shared_files: return f"/{css}" return None -def _layout(urls: list[str], modules: list[str] = ()) -> Template: +def _layout( + urls: list[str], + modules: list[str] = (), + custom_css: str = "", + theme: 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. + + 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. """ doc = Document(E.Title, lang="en") + if theme: + doc.meta(name="pagerite:theme", content=theme) for url in urls: doc.link(rel="stylesheet", href=url, blocking="render") for src in modules: doc.script(src=src, type="module") + if custom_css.strip(): + doc.style(custom_css, id="pagerite-user") return Template( doc .header( @@ -212,7 +231,7 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None: return None -def _edit_attrs(path: str, mode: str = "page") -> dict: +def _edit_attrs(path: str, mode: str = "page", theme: str = "") -> dict: """Attributes for a 🖊️ edit button. pagerite.js wires these buttons to dynamic-import the editor app @@ -221,7 +240,7 @@ def _edit_attrs(path: str, mode: str = "page") -> dict: (the pen on the banner) edits the banner and site structure. They are buttons, not links: editing is an action, not a navigation. """ - script, editor_css = _editor_assets() + script, editor_css = _editor_assets(theme) return { "type": "button", "class": "edit-link" if mode == "page" else "edit-link banner-edit-link", @@ -247,25 +266,37 @@ def page_content(menu: dict[str, Node], path: str) -> HTML: return HTML(str(doc)) -def render_page(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str: +def render_page( + menu: dict[str, Node], + path: str, + brand: str = SITE_NAME, + custom_css: str = "", + theme: str = "", +) -> str: """Render a full HTML page for the slug path.""" node = resolve(menu, path)[-1] title = _title(path.rpartition("/")[2], node) - scripts, styles = _page_assets() + scripts, styles = _page_assets(theme) return str( - _layout(styles, scripts)( + _layout(styles, scripts, custom_css, theme)( Title=f"{title} – {brand}" if brand else title, Brand=_brand_link(brand), Nav=nav_html(menu, path), Sidebar=sidebar_html(menu, path), Banner=banner_html(menu, path), - BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))), + BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))), Main=page_content(menu, path), ), ) -def render_category(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str: +def render_category( + menu: dict[str, Node], + path: str, + brand: str = SITE_NAME, + custom_css: str = "", + theme: str = "", +) -> str: """Render the placeholder for a content-less category label (404). The node exists in the tree but has no page of its own. Nav links @@ -278,47 +309,53 @@ def render_category(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> with doc: doc.h1(title) # Editing works here too: the pen creates this category's page. - doc.button("🖊️", **_edit_attrs(path)) + doc.button("🖊️", **_edit_attrs(path, "page", theme)) doc.p( "Pages in this section are listed in the menu on the left." ) - scripts, styles = _page_assets() + scripts, styles = _page_assets(theme) return str( - _layout(styles, scripts)( + _layout(styles, scripts, custom_css, theme)( Title=f"{title} – {brand}" if brand else title, Brand=_brand_link(brand), Nav=nav_html(menu, path), Sidebar=sidebar_html(menu, path), Banner=banner_html(menu, path), - BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))), + BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))), Main=HTML(str(doc)), ), ) -def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str: +def render_not_found( + menu: dict[str, Node], + path: str, + brand: str = SITE_NAME, + custom_css: str = "", + theme: str = "", +) -> str: """Render a 404 page within the normal layout.""" doc = E.article with doc: doc.h1("Not Found") # Editing works here too: this is how brand new pages get created. - doc.button("🖊️", **_edit_attrs(path)) + doc.button("🖊️", **_edit_attrs(path, "page", theme)) doc.p(f"No page at /{path}.") - scripts, styles = _page_assets() + scripts, styles = _page_assets(theme) return str( - _layout(styles, scripts)( + _layout(styles, scripts, custom_css, theme)( Title=f"Not Found – {brand}" if brand else "Not Found", Brand=_brand_link(brand), Nav=nav_html(menu, path), Sidebar=sidebar_html(menu, path), Banner=banner_html(menu, path), - BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))), + BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))), Main=HTML(str(doc)), ), ) -def _page_assets() -> tuple[list[str], list[str]]: +def _page_assets(theme: str) -> tuple[list[str], list[str]]: """Script and CSS URLs for public pages (pagerite entry). Dev mode loads the entry from the Vite dev server; production uses @@ -326,18 +363,19 @@ def _page_assets() -> tuple[list[str], list[str]]: """ vite_url = os.environ.get("PAGERITE_VITE_URL") if vite_url: - return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url) - if "page" not in _asset_cache: + return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url, theme) + key = f"page:{theme}" + if key not in _asset_cache: manifest = _manifest() entry = manifest["src/pagerite.js"] - _asset_cache["page"] = ( + _asset_cache[key] = ( [f"/{entry['file']}"], - _shared_css_urls(None), + _shared_css_urls(None, theme), ) - return _asset_cache["page"] + return _asset_cache[key] -def _editor_assets() -> tuple[list[str], str | None]: +def _editor_assets(theme: str) -> 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 @@ -346,8 +384,9 @@ def _editor_assets() -> 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 - if "editor" not in _asset_cache: + key = f"editor:{theme}" + if key not in _asset_cache: manifest = _manifest() entry = manifest["src/main.js"] - _asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None) - return _asset_cache["editor"] + _asset_cache[key] = [f"/{entry['file']}"], _editor_css_url(None, theme) + return _asset_cache[key]