Compare commits

..
23 Commits
Author SHA1 Message Date
LeoVasanko 14655ee4d7 Ship pagerite/themes in the built package
hatchling's only-packages=true drops directories without an __init__.py,
so pagerite/themes never made it into the wheel/sdist (this also means
the banner.svg artwork was never packaged). Force-include it as a build
artifact like frontend-build.
2026-08-18 06:47:58 +00:00
LeoVasanko 0ec9be330e Add the 'eyes' banner design; seed banners only on select sub pages
Banner designs can now ship arbitrary markup as banner.html (canvas +
style + script), taking precedence over banner.svg; the artwork is
inlined in a div[data-design] wrapper either way, which the editor's
live preview preserves (and never re-runs its scripts).

The bundled 'eyes' design (themes/eyes/banner.html + banner.css sizing
the stage) replaces the eyes canvas script that was embedded in the
notes-on-urls page's own banner — the page now just picks the design.
Seeds set banners only on select sub pages (the-long-read gradient,
canvas-nights stars): the front page no longer gets a banner image and
shows the theme's default design. Seed entries carry a banner_design
field.
2026-08-18 06:36:05 +00:00
LeoVasanko c5a91edac7 Fix theme re-selection after switching to none
Re-creating the #pagerite-theme link anchored to #pagerite-base, which
does not exist in dev (base CSS is a Vite-injected <style>), so the
fallback prepended it before the base styles and the theme lost the
cascade. Anchor to the following sheet (banner design / custom CSS) or
append at the end instead, keeping base < theme < design < custom order.
2026-08-18 06:23:49 +00:00
LeoVasanko 1c0ef315ff Refine dateline format; fix stale page caching breaking theme hot swap
Dateline is now '1 Jan 2026', with ' – edited 3 Jan 2026' appended only
when the last edit came at least 24h after publishing.

The Last-Modified header added heuristic browser caching of pages (no
Cache-Control was sent), so the site editor's re-fetch after a theme
change served the cached page with the old stylesheet link. Pages now
send 'Cache-Control: no-cache' — always revalidate, still cheap via the
ETag.
2026-08-18 06:20:38 +00:00
LeoVasanko 87c0aac3c0 Add Server/Last-Modified headers and a {dates} dateline tag
An http middleware sets 'Server: pagerite' (dropping uvicorn's versioned
default) and content pages return Last-Modified from Node.modified. In
markdown, a {dates} line expands to the article's published/updated
dateline (updated shown only when it falls on a later day); the editor
preview resolves it for pages that exist, unsaved pages show it literal.
Demonstrated in the long-read seed page.
2026-08-18 06:15:45 +00:00
LeoVasanko 3cefdec56e Scrap the right gutter on fluid (multicol) articles
Long articles now span from the left gutter (which holds the overlaying
sidebar) to the right viewport edge instead of reserving a symmetric
right gutter. The .wide breakout is re-anchored accordingly: left margin
is the gutter share (20vw of the 1fr+4fr grid, 1/5 of the post-editor
width while editing) plus main's padding; the <=102rem sidebar case keeps
its constant -13.25rem margin.
2026-08-18 06:09:03 +00:00
LeoVasanko e044ab86ee Unwrap seed markdown paragraphs to single lines
With markdown-it breaks:True, the 80ch hard wraps rendered as forced
<br>s: frozen wrapping at the source's line breaks, ragged unjustified
lines and stale manual hyphenation. One line per paragraph lets the
browser reflow, justify and hyphenate normally.
2026-08-18 06:04:39 +00:00
LeoVasanko 6303d5ae6d Fix multicol layout pinning two fixed columns at narrow widths
The 78rem minimum on the center track prevented the article from ever
shrinking, locking long articles into two fixed-width columns that
overflowed the window. Drop the floor: the 4fr center share now scales
with the window in both directions, so column widths flex continuously
and the count falls to 1 when space runs out.
2026-08-18 05:53:56 +00:00
LeoVasanko 7ec0d10155 Proxy /_themes to the backend in the Vite dev server 2026-08-18 05:49:00 +00:00
LeoVasanko 30947df16b 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.
2026-08-18 05:46:02 +00:00
LeoVasanko c0817330e7 Add favicon upload to the site editor
Data.favicon names a blob in the content-addressed files store (no
migration: msgspec default). PUT/DELETE /_api/settings/favicon upload and
clear it; when set, every page links it as <link rel="icon">, otherwise
browsers fall back to the build's /favicon.ico. SiteEditor shows a preview
with upload/replace/remove and applies the change to the live page head.
2026-08-18 05:22:25 +00:00
LeoVasanko d03ee7fa8f Make article layout fluid: CSS-driven column count, uncapped width
Replace the fixed 100rem two-column breakpoint with 'columns: 30rem' so
CSS fits as many >=30rem columns as the article's width allows, and let
long (.multicol) articles grow past the 78rem cap (4:1 share against the
gutters, keeping them symmetric for the .wide breakout math). Also treat
mid-article h1s as full-width column separators like h2s.
2026-08-18 05:14:21 +00:00
LeoVasanko f9e314e5ea Enable markdown-it typographer and breaks; remove custom dash rule 2026-08-18 04:32:54 +00:00
LeoVasanko 0fcd7b16f5 Remove Vue favicon. 2026-08-18 04:08:47 +00:00
LeoVasanko f125966bb7 Fix various inconsistencies of sidebar and link handling on categories with only one child. 2026-08-18 03:57:41 +00:00
LeoVasanko 32f30dbdaa Don't let whitespace be considered a custom page banner. 2026-08-18 03:14:04 +00:00
LeoVasanko 124ef62b5f Fix flow from new page creation to page editor. 2026-08-18 03:10:26 +00:00
LeoVasanko a0990fc3f6 Fix brand text placing issue with purple theme. 2026-08-18 03:09:35 +00:00
LeoVasanko 87deaa380b Exclude script and style tags from page banner styling to avoid them becoming visible on page. 2026-08-18 02:59:49 +00:00
LeoVasanko f11b772493 Integrate Paskia auth UI flows, hide delete for empty pages. 2026-08-17 22:32:18 +00:00
LeoVasanko a45a575f72 Login button styling 2026-08-17 22:09:48 +00:00
LeoVasanko 9fcf4ee8e0 Stricter slug path processing, only serve site on 404 of missing article paths, not any other path. 2026-08-17 22:02:48 +00:00
LeoVasanko a4fab3dcc9 Ping our own API for auth check, not auth backend (forward auth). 2026-08-17 21:54:26 +00:00
23 changed files with 1551 additions and 662 deletions
+71 -38
View File
@@ -53,18 +53,28 @@ 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` styles it and whose `banner.html` (arbitrary markup:
base stylesheet falls back to a plain gradient). canvas + style + script) or `banner.svg` supplies the inline artwork
(wrapped in `div[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/banner.html). 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
@@ -74,9 +84,17 @@ not for the public pages. See `docs/design-principles.md` for the design.
referencing the per-family variables (`--font-source-sans` etc.) from referencing the per-family variables (`--font-source-sans` etc.) from
pagerite.css; pagerite.css;
the base stylesheet's `--font-brand` defaults to `var(--font-heading)`. the base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
`Data.favicon` names a file in the content-addressed `files` store,
uploaded/cleared in the site editor via `PUT`/`DELETE
/_api/settings/favicon`; when set it is linked as `<link rel="icon">`
on every page, otherwise browsers fall back to the build's
`/favicon.ico` by convention.
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs, - `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
footnote, deflist, tasklists plugins). Custom image rule: relative srcs footnote, deflist, tasklists plugins; typographer + breaks on). Custom
resolve against the page path, titled images become figures. image rule: relative srcs resolve against the page path, titled images
become figures. A `{dates}` line expands to the article's
published/updated dateline (`p.dateline`, from `Node.created`/
`modified`; left literal in previews of unsaved pages).
- `views.py` — the shared page layout as an html5tagger `Template` with - `views.py` — the shared page layout as an html5tagger `Template` with
placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav
rendering straight from the `Data.menu` tree (siblings sorted by rendering straight from the `Data.menu` tree (siblings sorted by
@@ -85,9 +103,13 @@ not for the public pages. See `docs/design-principles.md` for the design.
is NOT rendered as an additional h1 (it still supplies <title> and nav is NOT rendered as an additional h1 (it still supplies <title> and nav
labels). The navbar holds labels). The navbar holds
top-level items only; the current section's subitems go to a left top-level items only; the current section's subitems go to a left
`#sidebar`, which is rendered only when the section offers at least two `#sidebar`, which is rendered when the section offers at least two
published items (no aside element at all on the front page, leaf pages published items, or exactly one while viewing anything other than that
and one-page sections). Dynamic regions have stable ids only page — the section index, a 404, a grandchild (so those pages can
reach the child); no aside element at all on the front page, leaf
pages and the sole page of a one-page section. Also,
category labels are nodes without content — None *or* empty markdown —
and their nav links point at their first child page. Dynamic regions have stable ids
(`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps
(`#sidebar` may be absent on either side of a swap). (`#sidebar` may be absent on either side of a swap).
- `seed.py` — demo content written on startup for paths missing from the - `seed.py` — demo content written on startup for paths missing from the
@@ -97,29 +119,37 @@ not for the public pages. See `docs/design-principles.md` for the design.
- `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal, - `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal,
brand shrink-to-fit (the themed size is the maximum; JS reduces the brand shrink-to-fit (the themed size is the maximum; JS reduces the
font-size so a long brand or narrow viewport still fits one line), font-size so a long brand or narrow viewport still fits one line),
code copy buttons, and the auth check: it fetches code copy buttons, and the auth check. It first probes `GET /auth/api/settings`
`/auth/api/validate?perm=pagerite:admin` and only then injects the 🖊️ to detect whether Paskia SSO is available, then `GET /_api/settings` to
edit pens (asset URLs from the `pagerite:editor-src`/`-css` meta tags); learn the current session's admin status. The same reverse proxy that
a 401 adds a "log in" link to `/auth/` in the banner corner, a 403 gates `/_api` returns 401 for anonymous users, 403 for users without
nothing, and any other result (no auth server, e.g. dev) leaves the admin permission, and 200 for admins. When Paskia is detected, a
editing open. Pages themselves render identically for everyone; the 🔑 login button (anonymous) or 👤 profile button (logged in) is shown in
real gate is the auth proxy in front of all of `/_api`. The backend links the shared CSS as two separate the banner corner; both open Paskia's iframe dialog via `showAuthIframe`
stylesheets (base and theme) so they can be swapped or augmented. instead of navigating away. Admins also get the 🖊️ edit pens (asset URLs
from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is
detected (dev/no proxy), editing is left open. Pages themselves render
identically for everyone; the real gate is the auth proxy in front of
all of `/_api`. The backend links the stylesheets in a fixed order —
base (Vite build), theme, banner design, custom CSS last — each with
a stable id so the site editor can swap them in place.
- `assets/` — shared styles and data files built by Vite and served hashed - `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`
@@ -132,7 +162,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 +
site-wide custom CSS + banner HTML edited in small CodeMirror windows; favicon upload/remove + site-wide custom CSS + per-page banner design
selector (inherit/none/named design, inherited by children) + banner
HTML edited in small CodeMirror windows;
banner previewed into `#page-banner`, CSS injected into 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 —
@@ -168,19 +200,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.
+33 -16
View File
@@ -51,8 +51,13 @@ evolves.
single-trusted-author assumption above. single-trusted-author assumption above.
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition - Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition
lists, task lists, brace-attributes; tables and strikethrough from the lists, task lists, brace-attributes; tables and strikethrough from the
default preset), with `html=True` for raw passthrough. Fenced code blocks default preset), with `html=True` for raw passthrough,
are highlighted server-side with **Pygments** (`nowrap` spans styled by `typographer=True` for SmartyPants-style replacements in body text (curly
quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.),
and `breaks=True` so single line breaks inside paragraphs become `<br>`.
Code spans/blocks and raw HTML are left untouched. Fenced code blocks are
highlighted server-side with
**Pygments** (`nowrap` spans styled by
`/_assets/pygments-*.css`, which maps every token class onto the `--code-*` `/_assets/pygments-*.css`, which maps every token class onto the `--code-*`
variables; the base stylesheet defines light and dark palette sets resolved variables; the base stylesheet defines light and dark palette sets resolved
via `light-dark()`, so each theme gets the set matching its `color-scheme` via `light-dark()`, so each theme gets the set matching its `color-scheme`
@@ -81,15 +86,21 @@ 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` plus artwork as `banner.html`
arbitrary markup like canvas + style + script — or `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 inlined into a `div[data-design]` wrapper: SVG
artwork 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
@@ -149,20 +160,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
@@ -188,7 +203,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.
+1
View File
@@ -18,6 +18,7 @@
"@codemirror/view": "^6.43.8", "@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"paskia": "file:../../paskia/paskia-js",
"transliteration": "^2.6.1", "transliteration": "^2.6.1",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

+203 -24
View File
@@ -190,7 +190,7 @@ async function loadPlain(p) {
history.replaceState(null, '', finalUrl) history.replaceState(null, '', finalUrl)
runScripts(document.getElementById('page-banner')) runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main')) runScripts(document.getElementById('main'))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-tuck the edit pen dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
// The swap brought in the server-rendered (inherited) banner; overlay // The swap brought in the server-rendered (inherited) banner; overlay
// the page's own banner if one is being edited. // the page's own banner if one is being edited.
if (banner.value.trim()) previewBanner() if (banner.value.trim()) previewBanner()
@@ -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 {
@@ -315,9 +313,65 @@ async function loadSettings() {
brand.value = s.brand brand.value = s.brand
theme.value = s.theme || '' theme.value = s.theme || ''
customCss.value = s.custom_css || '' 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 */ } } catch { /* keep default */ }
} }
// --- Favicon ---------------------------------------------------------------
// Uploaded into the content-addressed file store (PUT /_api/settings/favicon)
// and linked on every page as <link rel="icon">; empty falls back to the
// build's /favicon.ico. Applies to the live page immediately.
const favicon = ref('')
const faviconInput = ref(null)
function applyFavicon(url) {
let link = document.querySelector('link[rel="icon"]')
if (url) {
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
link.id = 'pagerite-favicon'
document.head.append(link)
}
link.href = url
} else if (link?.id === 'pagerite-favicon') {
link.remove()
}
}
async function uploadFavicon(file) {
if (!file || !file.type.startsWith('image/')) return
const res = await fetch('/_api/settings/favicon', {
method: 'PUT',
headers: { 'x-filename': file.name.replace(/[^\w.-]/g, '-') },
body: file,
})
if (res.ok) {
saveError.value = ''
const { path: url } = await res.json()
favicon.value = url
applyFavicon(url)
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
}
async function removeFavicon() {
const res = await fetch('/_api/settings/favicon', { method: 'DELETE' })
if (res.ok) {
saveError.value = ''
favicon.value = ''
applyFavicon('')
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
}
function currentTitle() { function currentTitle() {
return flatMap.value[path.value]?.title return flatMap.value[path.value]?.title
|| document.title.replace(/ [^]*$/, '') || document.title.replace(/ [^]*$/, '')
@@ -369,19 +423,31 @@ 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 (el.dataset.viteDevId.includes('/themes/')) el.remove()
}
if (theme.value) { if (theme.value) {
await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`) const href = `/_themes/${theme.value}/theme.css`
if (link) {
link.href = href
} else {
// Re-create after "none": keep base < theme < design < custom CSS.
// In dev there is no #pagerite-base link (the base is a
// Vite-injected <style>), so anchor to the next sheet instead of
// prepending before the base styles.
link = document.createElement('link')
link.rel = 'stylesheet'
link.id = 'pagerite-theme'
link.href = href
const before = document.getElementById('pagerite-base')?.nextSibling
?? document.getElementById('pagerite-banner')
?? document.getElementById('pagerite-user')
if (before) before.before(link)
else document.head.append(link)
} }
// The freshly injected theme style now sits after the custom CSS; } else if (link) {
// move the custom CSS back to the end so it keeps winning. link.remove()
applyCustomCss(customCss.value)
} }
loadPlain(path.value) loadPlain(path.value)
} }
@@ -656,6 +722,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.
@@ -680,11 +774,17 @@ 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 with [data-design]) is detached while the author code is
// swapped in (so runScripts never re-runs the design's own scripts),
// then put back first — author code stays last so its styles win.
const artwork = [...el.querySelectorAll('[data-design]')]
for (const a of artwork) a.remove()
el.innerHTML = banner.value el.innerHTML = banner.value
runScripts(el) runScripts(el)
el.prepend(...artwork)
} 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)
} }
@@ -734,12 +834,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
@@ -873,7 +976,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>
@@ -887,6 +990,28 @@ onUnmounted(() => {
A A
</button> </button>
</label> </label>
<div class="favicon-row">
<img v-if="favicon" :src="favicon" class="favicon-preview" alt="" />
<span v-else class="favicon-preview favicon-empty">?</span>
<button
type="button"
title="upload favicon (ico, png, svg...)"
@click="faviconInput.click()"
>{{ favicon ? 'replace favicon' : 'upload favicon' }}</button>
<button
v-if="favicon"
type="button"
title="remove favicon (back to the default)"
@click="removeFavicon"
>remove</button>
<input
ref="faviconInput"
type="file"
accept="image/*"
hidden
@change="(ev) => { uploadFavicon(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div v-if="fontPicker" class="font-picker"> <div v-if="fontPicker" class="font-picker">
<div class="font-tabs"> <div class="font-tabs">
<button <button
@@ -934,6 +1059,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"
@@ -1025,6 +1160,42 @@ onUnmounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Favicon row: tiny preview (or placeholder tile) + upload/remove buttons. */
.favicon-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.favicon-preview {
width: 1.4rem;
height: 1.4rem;
object-fit: contain;
border-radius: 3px;
background: var(--bg);
border: 1px solid var(--line);
}
.favicon-empty {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 0.85rem;
}
.favicon-row button {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.6rem;
background: var(--accent2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.block-head button { .block-head button {
margin-left: auto; margin-left: auto;
font: inherit; font: inherit;
@@ -1038,6 +1209,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;
+1
View File
@@ -142,6 +142,7 @@ function onEnd() {
<span class="acts"> <span class="acts">
<span v-if="!element.published" class="draft">draft</span> <span v-if="!element.published" class="draft">draft</span>
<button <button
v-if="element.has_content || !element.children.length"
type="button" type="button"
class="act del" class="act del"
:class="{ armed: handlers.arming() === element.path }" :class="{ armed: handlers.arming() === element.path }"
+89 -20
View File
@@ -131,16 +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;
} }
#page-banner>* { /* :not(style, script): author-level display:block would override the UA's
display:none on those and render their source as banner text. */
#page-banner>*:not(style, script) {
grid-area: 1 / 1;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -152,6 +160,15 @@ body {
will-change: transform; will-change: transform;
} }
/* The design artwork wrapper (div[data-design]) takes the sizing and
parallax above; an svg inside it fills it (it is what used to be the
direct child before designs got a wrapper). */
#page-banner [data-design]>svg {
display: block;
width: 100%;
height: 100%;
}
#brand, #brand,
#nav { #nav {
position: relative; position: relative;
@@ -226,6 +243,16 @@ body {
transition: margin-left 0.25s ease; transition: margin-left 0.25s ease;
} }
/* Long articles (.multicol is added by pagerite.js based on content length)
lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which
holds the overlaying sidebar) and the article taking all the rest, out
to the right viewport edge. The column count follows the width (see the
`columns: 30rem` rule below). The .wide breakout is re-anchored to the
left gutter below (the article is no longer viewport-centered). */
body:has(.multicol) #content {
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
}
body.editing #content { body.editing #content {
margin-left: var(--editor-w); margin-left: var(--editor-w);
padding-left: 1rem; padding-left: 1rem;
@@ -432,18 +459,26 @@ article h1 .edit-link {
opacity: 1; opacity: 1;
} }
/* Login link injected by pagerite.js for anonymous visitors when an auth /* Login/profile buttons injected by pagerite.js when Paskia SSO is in use.
server gates /_api (validate answered 401). Same corner as the site pen. */ Both sit in the banner's top-right corner, to the right of the site pen,
a.login-link { and are styled like the edit pens. */
button.login-link,
button.profile-link {
position: absolute; position: absolute;
top: 0.6rem; top: 0.6rem;
right: 1.25rem; right: 0.5rem;
z-index: 10; z-index: 10;
font-size: 0.85rem; font: inherit;
border: none;
cursor: pointer;
background: none;
padding: 0;
opacity: 0.7; opacity: 0.7;
text-shadow: 0 0 0.1em black;
} }
a.login-link:hover { button.login-link:hover,
button.profile-link:hover {
opacity: 1; opacity: 1;
} }
@@ -454,17 +489,17 @@ article dd {
hyphens: auto; hyphens: auto;
} }
/* Multi-column reading on wide displays, but only for long articles /* Multi-column reading, but only for long articles (pagerite.js adds
(pagerite.js adds .multicol based on content length and splits the body .multicol based on content length and splits the body into .colseg segments
into .colseg segments separated by full-width h2s and wide figures; separated by full-width h2s and wide figures; only segments with enough
only segments with enough text get .cols and thus columns). Columns only text get .cols). No fixed breakpoint: `columns: 30rem` lets CSS fit as
reflow text inside the article; the article's width never changes. */ many columns of at least 30rem as the article's current width allows —
@media (min-width: 100rem) { since .multicol also uncaps the article width (see #content above), a
.multicol .colseg.cols { wider window simply yields more columns. */
columns: 2; .multicol .colseg.cols {
columns: 30rem;
column-gap: 3.5rem; column-gap: 3.5rem;
column-rule: 1px solid var(--line); column-rule: 1px solid var(--line);
}
} }
.multicol .colseg { .multicol .colseg {
@@ -654,7 +689,7 @@ figure:has(.wide) img {
/* Floats take a fixed share of the text column rather than sizing by the /* Floats take a fixed share of the text column rather than sizing by the
image's intrinsic width, which varies wildly (SVGs have none, and image's intrinsic width, which varies wildly (SVGs have none, and
min-content collapses them). A percentage also scales correctly inside min-content collapses them). A percentage also scales correctly inside
two-column segments, where the column is the containing block. The multi-column segments, where the column is the containing block. The
caption wraps within that width. A width attribute ({.right width=300}) caption wraps within that width. A width attribute ({.right width=300})
overrides the default on uncaptioned images. */ overrides the default on uncaptioned images. */
figure:has(.right), figure:has(.right),
@@ -682,12 +717,30 @@ article h2 {
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr); grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
} }
/* Long articles stay fluid here too: same 12rem reservation for the
sidebar, then the article takes everything to the right viewport
edge. The article's left edge stays at 12rem either way, so the
constant .wide breakout margin below remains correct. */
body:has(#sidebar):has(.multicol):not(.editing) #content {
grid-template-columns: 12rem minmax(0, 1fr);
}
body:has(#sidebar) figure:has(.wide), body:has(#sidebar) figure:has(.wide),
body:has(#sidebar) img.wide:not(figure img) { body:has(#sidebar) img.wide:not(figure img) {
margin-inline: -13.25rem 0; margin-inline: -13.25rem 0;
} }
} }
/* .wide on multicol pages: the article is not viewport-centered (no right
gutter), so the bleed anchors at the left gutter — the 1fr share of the
1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
right viewport edge. Loses to the sidebar rule above (id in :has) and to
the editing rules below (same specificity, later in the file). */
body:has(.multicol) figure:has(.wide),
body:has(.multicol) img.wide:not(figure img) {
margin-inline: calc(-20vw - 1.25rem) 0;
}
/* Editing: shrink the bleed to the space right of the docked editor. */ /* Editing: shrink the bleed to the space right of the docked editor. */
body.editing figure:has(.wide), body.editing figure:has(.wide),
body.editing img.wide:not(figure img) { body.editing img.wide:not(figure img) {
@@ -695,6 +748,14 @@ body.editing img.wide:not(figure img) {
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2); margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
} }
/* Editing + multicol: the left gutter is 1/5 of the space right of the
editor. */
body.editing:has(.multicol) figure:has(.wide),
body.editing:has(.multicol) img.wide:not(figure img) {
width: calc(100vw - var(--editor-w));
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
}
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */ /* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
.reveal { .reveal {
opacity: 0; opacity: 0;
@@ -725,6 +786,14 @@ body.editing img.wide:not(figure img) {
list-style: none; list-style: none;
} }
/* Published/updated line, expanded from the {dates} tag in the markdown
(typically placed right after the article's h1). */
.dateline {
color: var(--muted);
font-size: 0.85rem;
text-align: left;
}
.footnote { .footnote {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted); color: var(--muted);
+115 -36
View File
@@ -4,19 +4,22 @@
// //
// Also: scroll-reveal effects and code copy buttons. These need no // Also: scroll-reveal effects and code copy buttons. These need no
// support from the article itself and are re-applied after each swap. // support from the article itself and are re-applied after each swap.
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);
}
}); });
} }
@@ -28,12 +31,18 @@
// --- Auth-gated edit pens --------------------------------------------- // --- Auth-gated edit pens ---------------------------------------------
// Pages render identically for everyone; the 🖊️ pens are injected by JS // Pages render identically for everyone; the 🖊️ pens are injected by JS
// only after the auth server validates the session (perm pagerite:admin). // only after we know the user has pagerite:admin access. We probe our own
// 401 = anonymous: show a small login link in the banner corner instead. // /_api/settings endpoint: the same reverse proxy that gates /_api returns
// 403 = logged in without the permission: no pens. Any other outcome // 401/403 here, and a 200 means the permission is present.
// (404, network error — i.e. no auth server deployed, as in dev) leaves //
// editing open as before: the real gate is the proxy in front of /_api. // When Paskia SSO is in use, 401/403 responses carry `auth.iframe`, which
let authorized = false; // we use to open the login/profile dialogs inline instead of navigating
// away. A separate probe to /auth/api/settings tells us whether Paskia is
// available at all; if it isn't, we treat the site as dev/no-proxy and
// leave editing open.
let ssoAvailable = false;
let isAdmin = false;
let loginIframeUrl = null;
let editorMeta = null; let editorMeta = null;
function makePen(mode) { function makePen(mode) {
@@ -59,14 +68,60 @@
} }
} }
function addLoginLink() { function addLoginButton(url) {
const banner = document.getElementById("page-banner"); const banner = document.getElementById("page-banner");
if (!banner || banner.parentElement.querySelector(".login-link")) return; if (!banner || banner.parentElement.querySelector(".login-link")) return;
const a = document.createElement("a"); const btn = document.createElement("button");
a.className = "login-link"; btn.type = "button";
a.href = "/auth/"; btn.className = "login-link";
a.textContent = "log in"; btn.title = "log in";
banner.after(a); btn.textContent = "🔑";
btn.addEventListener("click", async () => {
try {
await showAuthIframe(url);
// Successful login: refresh the auth UI (may now show edit pens).
setupAuth();
} catch {
// Cancelled or error: leave the button in place.
}
});
banner.after(btn);
}
function injectProfileButton() {
const banner = document.getElementById("page-banner");
if (!banner || banner.parentElement.querySelector(".profile-link")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "profile-link";
btn.title = "profile";
btn.textContent = "👤";
btn.addEventListener("click", () => {
showAuthIframe("/auth/").catch(() => {});
});
banner.after(btn);
}
function renderAuthUi() {
const banner = document.getElementById("page-banner");
if (banner) {
for (const el of banner.parentElement.querySelectorAll(
".banner-edit-link, .login-link, .profile-link",
)) {
el.remove();
}
}
if (isAdmin) {
injectPens();
if (ssoAvailable) injectProfileButton();
} else if (ssoAvailable && loginIframeUrl) {
addLoginButton(loginIframeUrl);
} else if (ssoAvailable) {
injectProfileButton();
} else {
// No Paskia SSO: dev/no-proxy fallback, leave editing open.
injectPens();
}
} }
async function setupAuth() { async function setupAuth() {
@@ -76,18 +131,32 @@
src, src,
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content, css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
}; };
// Detect whether Paskia SSO is available on this site.
try {
const ssoRes = await fetch("/auth/api/settings");
ssoAvailable = ssoRes.ok;
} catch {
ssoAvailable = false;
}
// Check whether the current session has pagerite:admin.
isAdmin = false;
loginIframeUrl = null;
let status = 0; let status = 0;
try { try {
status = (await fetch("/auth/api/validate?perm=pagerite:admin")).status; const res = await fetch("/_api/settings");
status = res.status;
if (status === 401) {
const data = await res.json().catch(() => ({}));
loginIframeUrl = data.auth?.iframe || null;
}
} catch { } catch {
// Auth server unreachable: treat as not deployed. // No auth proxy / dev.
}
if (status === 401) addLoginLink();
else if (status !== 403) {
authorized = true;
injectPens();
placeEditPen();
} }
if (status === 200) isAdmin = true;
renderAuthUi();
} }
function runScripts(root) { function runScripts(root) {
@@ -146,17 +215,25 @@
if (btn && h1 && btn.parentElement !== h1) h1.append(btn); if (btn && h1 && btn.parentElement !== h1) h1.append(btn);
} }
addEventListener("pagerite:preview", placeEditPen); addEventListener("pagerite:preview", () => {
// The editors' in-place swaps (SiteEditor.loadPlain) replace #main
// without applyEffects, discarding the injected pens; re-add them
// before tucking the article pen into the h1. Without this a freshly
// created page has no pen for commitPending's handover click.
renderAuthUi();
placeEditPen();
});
function applyEffects() { function applyEffects() {
(window.requestIdleCallback || setTimeout)(preload); (window.requestIdleCallback || setTimeout)(preload);
const main = document.getElementById("main"); const main = document.getElementById("main");
addCopyButtons(main); addCopyButtons(main);
// Fetch-navigation swaps #main, discarding the article pen; re-add it. // Fetch-navigation swaps #main, discarding the article pen and corner
if (authorized) injectPens(); // buttons; re-add whichever auth UI is appropriate for this session.
renderAuthUi();
placeEditPen(); placeEditPen();
// Multi-column layout only when there is enough text to justify it. // Multi-column layout only when there is enough text to justify it.
// Split the body into columned segments: h2s and wide figures are // Split the body into columned segments: h1s, h2s and wide figures are
// full-width separators and never go inside columns. // full-width separators and never go inside columns.
const article = main.querySelector("article"); const article = main.querySelector("article");
if (article) { if (article) {
@@ -167,9 +244,11 @@
); );
if (body && article.classList.contains("multicol") if (body && article.classList.contains("multicol")
&& !body.querySelector(".colseg")) { && !body.querySelector(".colseg")) {
// h2s and anything holding a wide image are full-width separators // h1s, h2s and anything holding a wide image are full-width
// separators
const isSeparator = (el) => const isSeparator = (el) =>
el.tagName === "H2" || el.querySelector("img.wide") !== null; el.tagName === "H1" || el.tagName === "H2"
|| el.querySelector("img.wide") !== null;
let seg = null; let seg = null;
for (const el of [...body.children]) { for (const el of [...body.children]) {
if (isSeparator(el)) { if (isSeparator(el)) {
+3 -12
View File
@@ -1,5 +1,4 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { readdirSync } from 'node:fs'
import fastapiVue from './vite-plugin-fastapi.js' import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@@ -8,15 +7,6 @@ import vueDevTools from 'vite-plugin-vue-devtools'
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200' const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
// Every theme directory ships its theme.css as a separate build entry, so
// the backend can link base and theme stylesheets independently.
const themesDir = fileURLToPath(new URL('./src/assets/themes', import.meta.url))
const themeInputs = Object.fromEntries(
readdirSync(themesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => [`theme_${d.name}`, `${themesDir}/${d.name}/theme.css`]),
)
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev. // Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the // Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
// backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin. // backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin.
@@ -25,7 +15,7 @@ const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
fastapiVue({ paths: ["/_api", "/_f"] }), fastapiVue({ paths: ["/_api", "/_f", "/_themes"] }),
vue(), vue(),
vueDevTools(), vueDevTools(),
], ],
@@ -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,
}, },
}, },
}, },
+129 -15
View File
@@ -18,6 +18,7 @@ import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import format_datetime
from pathlib import Path from pathlib import Path
import blake3 import blake3
@@ -129,13 +130,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
if missing: if missing:
with kanta.transaction("seed missing pages"): with kanta.transaction("seed missing pages"):
for path in missing: for path in missing:
title, markdown, files, banner, order = seed.PAGES[path] title, markdown, files, banner, order, design = seed.PAGES[path]
for orig, body in files.items(): for orig, body in files.items():
markdown, banner = _store_seed_file(markdown, banner, orig, body) markdown, banner = _store_seed_file(markdown, banner, orig, body)
node = _ensure(data.menu, path) node = _ensure(data.menu, path)
node.title = title node.title = title
node.content = markdown node.content = markdown
node.banner = banner node.banner = banner
node.banner_design = design
node.order = order node.order = order
await frontend.load() await frontend.load()
yield yield
@@ -154,6 +156,14 @@ app = FastAPI(
) )
@app.middleware("http")
async def _headers(request: Request, call_next) -> Response:
"""Replace uvicorn's default Server header with ours (no version)."""
response = await call_next(request)
response.headers["server"] = "pagerite"
return response
class PageIn(BaseModel): class PageIn(BaseModel):
"""Payload for creating or replacing a page.""" """Payload for creating or replacing a page."""
@@ -273,9 +283,17 @@ 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 and custom CSS).""" """Site-wide settings (brand, theme, custom CSS and favicon URL), plus
return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css} the themes and banner designs available on disk for the selectors."""
return {
"brand": data.brand,
"theme": data.theme,
"custom_css": data.custom_css,
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
"themes": views._theme_names(),
"banner_designs": views._banner_design_names(),
}
class SettingsIn(BaseModel): class SettingsIn(BaseModel):
@@ -296,6 +314,36 @@ async def put_settings(settings: SettingsIn) -> None:
data.version += 1 data.version += 1
@app.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it.
Raw image body (ico/png/svg...); the stored name is a blake3 hash
prefix + extension, and pages link it as <link rel="icon">. Returns
{"path": "/_f/..."}.
"""
body = await request.body()
if not body:
raise HTTPException(400, "empty file")
stored = _hash_name(body, request.headers.get("x-filename", "favicon.ico"))
with kanta.transaction("upload favicon"):
data.files[stored] = body
data.favicon = stored
data.version += 1
return {"path": f"/_f/{stored}"}
@app.delete("/_api/settings/favicon", status_code=204)
async def delete_favicon() -> None:
"""Clear the custom favicon (back to the build's /favicon.ico).
The blob stays in the content-addressed store; only the reference goes.
"""
with kanta.transaction("clear favicon"):
data.favicon = ""
data.version += 1
class ToggleTaskIn(BaseModel): class ToggleTaskIn(BaseModel):
"""Payload for toggling one task-list checkbox.""" """Payload for toggling one task-list checkbox."""
@@ -362,6 +410,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
@@ -404,6 +481,11 @@ async def delete_page(path: str) -> None:
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
def _http_date(dt: datetime) -> str:
"""RFC 7231 date for the Last-Modified header."""
return format_datetime(dt.astimezone(UTC), usegmt=True)
def _is_reserved(path: str) -> bool: def _is_reserved(path: str) -> bool:
"""Slug shape that content may never use: each segment must be lower-case """Slug shape that content may never use: each segment must be lower-case
ASCII letters, digits, hyphens and underscores (underscores may not be ASCII letters, digits, hyphens and underscores (underscores may not be
@@ -430,12 +512,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()
@@ -460,17 +542,32 @@ 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", "")
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
await ws.send_json({ await ws.send_json({
"type": "html", "type": "html",
"path": path, "path": path,
"html": render(markdown, path), "html": render(
markdown,
path,
node.created if node else None,
node.modified if node else None,
),
"has_h1": has_h1(markdown), "has_h1": has_h1(markdown),
}) })
case "save": case "save":
@@ -536,6 +633,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})
@@ -563,28 +662,43 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
""" """
path = path.strip("/") path = path.strip("/")
if path and _is_reserved(path): if path and _is_reserved(path):
# Reserved slug shape: never content — no tree lookup. # Invalid slug shape: not a content URL, let FastAPI return its
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404) # built-in 404 instead of rendering an editable article page.
raise HTTPException(404)
chain = resolve(data.menu, path) chain = resolve(data.menu, path)
node = chain[-1] if chain else None node = chain[-1] if chain else None
if node is not None and node.published and node.content is not None: if node is not None and node.published and node.content is not None:
# ETag on content + render version; clients revalidate cheaply, # ETag on content + render version; clients revalidate cheaply,
# which keeps prefetched pages warm and current. # which keeps prefetched pages warm and current. no-cache forces
# that revalidation: with Last-Modified but no Cache-Control,
# browsers would otherwise cache heuristically and serve stale
# pages (e.g. after a theme change) without asking us at all.
etag = f'"{path}@{node.modified.timestamp()}v{data.version}"' etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
if request.headers.get("if-none-match") == etag: if request.headers.get("if-none-match") == etag:
return Response(status_code=304) return Response(status_code=304)
return HTMLResponse( return HTMLResponse(
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme), views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
headers={"etag": etag}, headers={
"etag": etag,
"last-modified": _http_date(node.modified),
"cache-control": "no-cache",
},
) )
if node is not None and node.published and node.content is None: if node is not None and node.published and node.content is None:
# Category label without a landing page: placeholder with the pen # Category label without a landing page: placeholder with the pen
# to create it (404 — no page here, but the node is real). # to create it (404 — no page here, but the node is real).
return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme), 404) return HTMLResponse(
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
404,
headers={
"last-modified": _http_date(node.modified),
"cache-control": "no-cache",
},
)
if node is None and not path: if node is None and not path:
# No front page (no top-level node with slug ""): "/" opens the # No front page (no top-level node with slug ""): "/" opens the
# first item of the navigation instead. # first item of the navigation instead.
for slug, item in sorted_nodes(data.menu): for slug, item in sorted_nodes(data.menu):
if item.published: if item.published:
return RedirectResponse(f"/{slug}") return RedirectResponse(f"/{slug}")
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404) return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404)
+10 -1
View File
@@ -31,9 +31,15 @@ class Node(msgspec.Struct, omit_defaults=True):
#: Markdown source of the node's page; None = pure category label #: Markdown source of the node's page; None = pure category label
#: (its URL renders a placeholder page). #: (its URL renders a placeholder page).
content: str | None = None content: str | None = None
#: Raw HTML for the header banner (img, styled div, canvas+script...). #: Raw HTML for the header banner (img, styled div, canvas+script...),
#: rendered after the banner design's artwork so author code always
#: wins over the design's own styles.
#: Empty inherits the nearest ancestor's banner, front page last. #: Empty inherits the nearest ancestor's banner, front page last.
banner: str = "" banner: str = ""
#: Banner design: a theme folder name (its banner.css/banner.svg),
#: "" = explicitly no design, None = inherit (nearest ancestor, front
#: page last, then the active theme's own design).
banner_design: str | None = None
published: bool = True published: bool = True
children: dict[str, "Node"] = {} children: dict[str, "Node"] = {}
created: datetime = msgspec.field( created: datetime = msgspec.field(
@@ -86,6 +92,9 @@ class Data(msgspec.Struct):
#: Raw site-wide custom CSS, injected inline in every page <head>. #: Raw site-wide custom CSS, injected inline in every page <head>.
#: Trusted author content; not sanitized. #: Trusted author content; not sanitized.
custom_css: str = "" custom_css: str = ""
#: Favicon: name of a file in `files` (content-addressed), linked as
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
favicon: str = ""
#: Legacy flat page store (pre-tree databases); migrated into `menu` #: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise. #: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {} pages: dict[str, Page] = {}
+43 -4
View File
@@ -6,6 +6,12 @@ single author is trusted. Extensions: tables and strikethrough (from the
brace-attributes (`{.class width=300}` on any element, images in brace-attributes (`{.class width=300}` on any element, images in
particular). particular).
markdown-it's typographer is enabled, so body text gets SmartyPants-style
replacements: straight quotes become curly, ``--`` / ``---`` become en / em
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
line breaks inside paragraphs become ``<br>`` (``breaks: True``). Code
spans/blocks and raw HTML are left untouched.
Images get special treatment: a relative `src` is resolved against the Images get special treatment: a relative `src` is resolved against the
page's own path (so `![alt](photo.avif)` in `/docs/design` is served from page's own path (so `![alt](photo.avif)` in `/docs/design` is served from
`/docs/design/photo.avif`), and an image with a title becomes a `/docs/design/photo.avif`), and an image with a title becomes a
@@ -14,6 +20,7 @@ classes, e.g. `![alt](photo.avif "Caption"){.right}`.
""" """
import re import re
from datetime import datetime, timedelta
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from markdown_it.common.utils import escapeHtml from markdown_it.common.utils import escapeHtml
@@ -110,7 +117,15 @@ def _tag_task_checkboxes(state) -> None:
md = ( md = (
MarkdownIt("default", {"html": True, "highlight": _highlight}) MarkdownIt(
"default",
{
"html": True,
"highlight": _highlight,
"typographer": True,
"breaks": True,
},
)
.use(attrs_plugin) .use(attrs_plugin)
.use(footnote_plugin) .use(footnote_plugin)
.use(deflist_plugin) .use(deflist_plugin)
@@ -121,9 +136,33 @@ md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes) md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
def render(text: str, page_path: str = "") -> str: def render(
"""Render Markdown text to an HTML string.""" text: str,
return md.render(text, {"page_path": page_path}) page_path: str = "",
created: datetime | None = None,
modified: datetime | None = None,
) -> str:
"""Render Markdown text to an HTML string.
A ``{dates}`` line expands to the article's published/updated dateline
(needs ``created``/``modified``; left as-is in contexts without them,
e.g. the editor preview). Position is the author's choice — typically
right after the article's h1.
"""
html = md.render(text, {"page_path": page_path})
if created is not None and "<p>{dates}</p>" in html:
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
return html
def _dateline(created: datetime, modified: datetime | None) -> str:
"""Dateline for the ``{dates}`` tag: "1 Jan 2026", plus
" edited 3 Jan 2026" when the last edit came >= 24h after
publishing (quick fixes right after posting stay unmentioned)."""
out = f'<time datetime="{created.isoformat()}">{created.day} {created:%b %Y}</time>'
if modified is not None and modified - created >= timedelta(hours=24):
out += f' edited <time datetime="{modified.isoformat()}">{modified.day} {modified:%b %Y}</time>'
return f'<p class="dateline">{out}</p>'
def has_h1(text: str) -> bool: def has_h1(text: str) -> bool:
+41 -227
View File
@@ -6,23 +6,19 @@ positioning, footnotes, definition lists, task lists, tables and raw HTML.
""" """
WELCOME = """\ WELCOME = """\
Welcome to your new **Pagerite** site. Pages are written in Markdown — Welcome to your new **Pagerite** site. Pages are written in Markdown — including raw HTML — and served from pretty URLs.
including raw HTML — and served from pretty URLs.
Have a look around: Have a look around:
- The [docs](/docs) section explains [how to write content](/docs/editing), - The [docs](/docs) section explains [how to write content](/docs/editing), including images and positioning.
including images and positioning. - [The Long Read](/blog/the-long-read) demonstrates a longer article with scroll effects.
- [The Long Read](/blog/the-long-read) demonstrates a longer article with
scroll effects.
- The [about](/about) page shows off assorted formatting. - The [about](/about) page shows off assorted formatting.
![Abstract waves](waves.svg "Generated SVG artwork, attached to this page") ![Abstract waves](waves.svg "Generated SVG artwork, attached to this page")
""" """
ABOUT = """\ ABOUT = """\
This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content written in Markdown.
written in Markdown.
Some formatting samples: Some formatting samples:
@@ -47,15 +43,11 @@ Footnotes work too.[^1]
""" """
EDITING = """\ EDITING = """\
Pages are written in Markdown with extensions. Everything below is plain Pages are written in Markdown with extensions. Everything below is plain Markdown source — no special support from the article is needed for the site's layout or scroll effects.
Markdown source — no special support from the article is needed for the
site's layout or scroll effects.
## Images ## Images
Upload a file (`PUT /_api/files/{filename}`) and it lands in the Upload a file (`PUT /_api/files/{filename}`) and it lands in the content-addressed store, served immutable from `/_f/{hash}.ext` — an absolute URL that survives page moves:
content-addressed store, served immutable from `/_f/{hash}.ext` — an
absolute URL that survives page moves:
``` ```
![Abstract shapes](/_f/....svg "A captioned figure"){.right width=280} ![Abstract shapes](/_f/....svg "A captioned figure"){.right width=280}
@@ -63,15 +55,11 @@ absolute URL that survives page moves:
![Abstract shapes](shapes.svg "A captioned figure, floated right with an attribute class"){.right width=280} ![Abstract shapes](shapes.svg "A captioned figure, floated right with an attribute class"){.right width=280}
The title becomes a `<figcaption>`, and brace attributes (the attrs The title becomes a `<figcaption>`, and brace attributes (the attrs plugin) control positioning: `{.right}`, `{.left}`, `{.wide}`, plus plain attributes like `width=280`. Absolute and external URLs pass through unchanged.
plugin) control positioning: `{.right}`, `{.left}`, `{.wide}`, plus plain
attributes like `width=280`. Absolute and external URLs pass through
unchanged.
## Text ## Text
*Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and *Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and [links](/about) as usual. Blockquotes:
[links](/about) as usual. Blockquotes:
> The URL space is the author's. Pretty slugs at the root, nesting only > The URL space is the author's. Pretty slugs at the root, nesting only
> where the content is genuinely structured. > where the content is genuinely structured.
@@ -85,71 +73,35 @@ def render(text: str, page_path: str) -> str:
""" """
LONG_READ = """\ LONG_READ = """\
*An essay long enough to scroll, to demonstrate the gentle reveal of *An essay long enough to scroll, to demonstrate the gentle reveal of headings, figures and code blocks as they enter the viewport.*
headings, figures and code blocks as they enter the viewport.*
{dates}
![Layered dunes](dunes.svg "Full-width artwork between sections"){.wide} ![Layered dunes](dunes.svg "Full-width artwork between sections"){.wide}
## Chapter one ## Chapter one
The distinction between a blog and a website is largely an accident of The distinction between a blog and a website is largely an accident of history. Early content management systems filed everything under "posts", stamped them with a date, and arranged them in reverse chronological order under a `/blog/` prefix. Anything else was a "page", which lived somewhere else entirely, often in a separate editing interface with separate rules.
history. Early content management systems filed everything under "posts",
stamped them with a date, and arranged them in reverse chronological order
under a `/blog/` prefix. Anything else was a "page", which lived somewhere
else entirely, often in a separate editing interface with separate rules.
But readers do not think in these terms. A reader follows a link, reads But readers do not think in these terms. A reader follows a link, reads what is there, and follows another link. The URL is a promise about where something lives, not about which database table it came from. Pagerite therefore treats every piece of content as a page: named, addressable, and rendered on the fly.
what is there, and follows another link. The URL is a promise about where
something lives, not about which database table it came from. Pagerite
therefore treats every piece of content as a page: named, addressable, and
rendered on the fly.
## Chapter two ## Chapter two
Consider what happens to URLs when the tooling leads the design. You get Consider what happens to URLs when the tooling leads the design. You get addresses like `/cms/frontpage` or `/blog/post1` — the name of the machine leaking into the name of the thing. The slug should be chosen by the author, the way a book's title is chosen, and it should sit at the root of the site like the title sits on the cover.
addresses like `/cms/frontpage` or `/blog/post1` — the name of the machine
leaking into the name of the thing. The slug should be chosen by the
author, the way a book's title is chosen, and it should sit at the root of
the site like the title sits on the cover.
Nesting still has its place. Structured content — documentation, a series, Nesting still has its place. Structured content — documentation, a series, a portfolio — benefits from paths that mirror the structure. The navigation on this very site is derived from the paths: open a section, and you see what it contains. No menu editor, no duplication of structure in two places.
a portfolio — benefits from paths that mirror the structure. The
navigation on this very site is derived from the paths: open a section,
and you see what it contains. No menu editor, no duplication of structure
in two places.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo
inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut
fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem
sequi nesciunt.
## Chapter three ## Chapter three
On the reading experience itself: motion on the web is usually either On the reading experience itself: motion on the web is usually either absent or obnoxious. The interesting middle ground is motion that acknowledges the reader's own movement — the scroll. Elements that fade in as they enter the viewport give the page a sense of depth, as if the content were arriving just in time.
absent or obnoxious. The interesting middle ground is motion that
acknowledges the reader's own movement — the scroll. Elements that fade
in as they enter the viewport give the page a sense of depth, as if the
content were arriving just in time.
Crucially, none of this may depend on the article. The author writes Crucially, none of this may depend on the article. The author writes Markdown; the effects come from the layout. And when the reader prefers reduced motion, everything must hold still.
Markdown; the effects come from the layout. And when the reader prefers
reduced motion, everything must hold still.
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur?
consectetur, adipisci velit, sed quia non numquam eius modi tempora
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad
minima veniam, quis nostrum exercitationem ullam corporis suscipit
laboriosam, nisi ut aliquid ex ea commodi consequatur?
```text ```text
Quis autem vel eum iure reprehenderit Quis autem vel eum iure reprehenderit
@@ -158,24 +110,13 @@ molestiae consequatur, vel illum qui
dolorem eum fugiat quo voluptas nulla pariatur? dolorem eum fugiat quo voluptas nulla pariatur?
``` ```
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.
praesentium voluptatum deleniti atque corrupti quos dolores et quas
molestias excepturi sint occaecati cupiditate non provident, similique
sunt in culpa qui officia deserunt mollitia animi, id est laborum et
dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.
## Chapter four ## Chapter four
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.
impedit quo minus id quod maxime placeat facere possimus, omnis voluptas
assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut
officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates
repudiandae sint et molestiae non recusandae.
Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. And so we arrive back where we started: the blog and the website were one thing all along. [Return to the front page](/).
voluptatibus maiores alias consequatur aut perferendis doloribus
asperiores repellat. And so we arrive back where we started: the blog and
the website were one thing all along. [Return to the front page](/).
""" """
NOTES_ON_URLS = """\ NOTES_ON_URLS = """\
@@ -191,32 +132,26 @@ That's all. Short posts are posts too.
""" """
CANVAS_NIGHTS = """\ CANVAS_NIGHTS = """\
This post's banner is not an image at all — it's a `<canvas>` animated by This post's banner is not an image at all — it's a `<canvas>` animated by a few lines of JavaScript embedded in the page's banner HTML.
a few lines of JavaScript embedded in the page's banner HTML.
Banners on this site are arbitrary markup: an image, a gradient div, or a Banners on this site are arbitrary markup: an image, a gradient div, or a small animated scene like the one above. Subpages inherit the nearest banner up their path, so a whole section can share one look.
small animated scene like the one above. Subpages inherit the nearest
banner up their path, so a whole section can share one look.
```js ```js
// the essence of the banner above // the essence of the banner above
stars.forEach(s => { s.x = (s.x + s.speed * dt) % 1 }) stars.forEach(s => { s.x = (s.x + s.speed * dt) % 1 })
``` ```
No build step, no framework — the snippet is stored with the page and No build step, no framework — the snippet is stored with the page and dropped into the header as-is.
dropped into the header as-is.
""" """
SMALL_RELEASES = """\ SMALL_RELEASES = """\
Software wants to be shipped. The longer a change sits unmerged, the more Software wants to be shipped. The longer a change sits unmerged, the more it rots: context fades, conflicts accumulate, and the diff grows teeth.
it rots: context fades, conflicts accumulate, and the diff grows teeth.
1. Cut the scope until it fits in a day. 1. Cut the scope until it fits in a day.
2. Ship it behind whatever door you like. 2. Ship it behind whatever door you like.
3. Let real use argue with your assumptions. 3. Let real use argue with your assumptions.
A release is a conversation with reality. Small releases keep the A release is a conversation with reality. Small releases keep the conversation lively.
conversation lively.
""" """
CANVAS_BANNER = """\ CANVAS_BANNER = """\
@@ -251,135 +186,8 @@ CANVAS_BANNER = """\
</script> </script>
""" """
EYES_BANNER = """\
<canvas id="eyes"></canvas>
<script>
(() => {
const c = document.getElementById("eyes");
const ctx = c.getContext("2d");
const BG = "#f3e9d7";
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
fit();
addEventListener("resize", fit);
// Mouse in canvas coordinates; pupils wander idly when it goes stale.
let mx = 0, my = 0, lastMove = 0;
addEventListener("mousemove", (e) => {
const r = c.getBoundingClientRect();
mx = e.clientX - r.left;
my = e.clientY - r.top;
lastMove = performance.now();
});
// The pair of eyes is one critter: it wanders around the banner, and
// every so often ducks below the bottom edge, then pops back up.
let gx = 0.5, gy = 0.5; // group position (fractions of the canvas)
let tx = 0.5, ty = 0.5; // wander target
let yoff = 0, vy = 0; // vertical hide/pop spring (px)
let hidePhase = 0; // 0 = up, 1 = ducking, 2 = down, waiting
let nextMove = 0, nextHide = 4000 + Math.random() * 5000, resurfaceAt = 0;
// Per-eye pupil state: spring physics for goofy lag and overshoot.
const eyes = [{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }, { x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }];
let prev = performance.now();
(function frame(now) {
if (!c.isConnected) return;
const dt = Math.min(now - prev, 100) / 16.7; prev = now;
ctx.fillStyle = BG;
ctx.fillRect(0, 0, c.width, c.height);
const R = Math.min(c.height * 0.32, 70);
// Wander: ease toward a spot, pick a new one every few seconds.
if (now > nextMove && !hidePhase) {
tx = 0.15 + Math.random() * 0.7;
ty = 0.3 + Math.random() * 0.4;
nextMove = now + 2500 + Math.random() * 3500;
}
gx += (tx - gx) * 0.02 * dt;
gy += (ty - gy) * 0.02 * dt;
// Duck down, wait hidden, then spring back (underdamped = pops past
// the resting point and wobbles). Resurfaces at a new spot.
if (hidePhase === 0 && now > nextHide) hidePhase = 1;
if (hidePhase === 1 && yoff > c.height * 0.9) {
hidePhase = 2;
resurfaceAt = now + 500 + Math.random() * 900;
}
if (hidePhase === 2 && now > resurfaceAt) {
hidePhase = 0;
nextHide = now + 5000 + Math.random() * 7000;
tx = 0.15 + Math.random() * 0.7;
gx = tx;
nextMove = now + 3000 + Math.random() * 3000;
}
const yTarget = hidePhase ? c.height : 0;
vy += (yTarget - yoff) * 0.06 * dt;
vy *= 0.85;
yoff += vy * dt;
const cy0 = gy * c.height + yoff;
const cx0 = gx * c.width;
const watching = now - lastMove < 4000;
eyes.forEach((e, i) => {
const cx = cx0 + (i ? 1.3 : -1.3) * R;
// Pupil target: toward the cursor, or a slow idle drift.
let ptx, pty;
if (watching) {
const dx = mx - cx, dy = my - cy0;
const d = Math.hypot(dx, dy) || 1;
const reach = R * 0.45 * Math.min(1, d / 200);
ptx = (dx / d) * reach; pty = (dy / d) * reach;
} else {
ptx = Math.sin(now / 900 + i * 2) * R * 0.3;
pty = Math.cos(now / 1300 + i * 3) * R * 0.2;
}
// Spring toward the target (underdamped: overshoots, wobbles).
e.vx += (ptx - e.x) * 0.08 * dt; e.vy += (pty - e.y) * 0.08 * dt;
e.vx *= 0.82; e.vy *= 0.82;
e.x += e.vx * dt; e.y += e.vy * dt;
// Pupils dilate when the cursor comes close to the eye.
const near = Math.hypot(mx - cx, my - cy0) < R * 2.5;
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt;
// Sclera.
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
ctx.fill();
// Iris + pupil + glint, clipped to the sclera.
ctx.save();
ctx.beginPath();
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
ctx.clip();
ctx.fillStyle = "#7c5cff";
ctx.beginPath();
ctx.arc(cx + e.x, cy0 + e.y, R * 0.55, 0, 7);
ctx.fill();
ctx.fillStyle = "#1d1730";
ctx.beginPath();
ctx.arc(cx + e.x, cy0 + e.y, R * e.pr, 0, 7);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(cx + e.x - R * 0.15, cy0 + e.y - R * 0.18, R * 0.09, 0, 7);
ctx.fill();
ctx.restore();
ctx.strokeStyle = "#2b2440";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
ctx.stroke();
});
requestAnimationFrame(frame);
})(prev);
})();
</script>
"""
BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>' BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>'
FRONT_BANNER = '<img src="/waves.svg" alt="">'
WAVES_SVG = """\ WAVES_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
<defs> <defs>
@@ -422,19 +230,22 @@ DUNES_SVG = """\
</svg> </svg>
""" """
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order). #: path -> (title, markdown, {filename: bytes}, banner HTML, menu order,
#: banner design). Banners are deliberately set only on select sub pages
#: (not the front page), so the theme's default design shows elsewhere.
#: Note there are deliberately no "docs" or "blog" landing pages: those #: Note there are deliberately no "docs" or "blog" landing pages: those
#: labels are created without content, so they render a placeholder page #: labels are created without content, so they render a placeholder page
#: and their nav links point at the first child (see views.first_leaf). #: and their nav links point at the first child (see views.first_leaf).
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = { PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float, str | None]] = {
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1), "": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, "", 1, None),
"about": ("About", ABOUT, {}, "", 2), "about": ("About", ABOUT, {}, "", 2, None),
"docs/editing": ( "docs/editing": (
"Writing Content", "Writing Content",
EDITING, EDITING,
{"shapes.svg": SHAPES_SVG.encode()}, {"shapes.svg": SHAPES_SVG.encode()},
"", "",
1, 1,
None,
), ),
"blog/the-long-read": ( "blog/the-long-read": (
"The Long Read", "The Long Read",
@@ -442,8 +253,11 @@ PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
{"dunes.svg": DUNES_SVG.encode()}, {"dunes.svg": DUNES_SVG.encode()},
BLOG_BANNER, BLOG_BANNER,
1, 1,
None,
), ),
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, EYES_BANNER, 2), # The eyes critter is a named banner design (pagerite/themes/eyes/),
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3), # not code embedded in the page.
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4), "blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, "", 2, "eyes"),
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3, None),
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4, None),
} }
+79
View File
@@ -0,0 +1,79 @@
/* Corporate banner design: sizing and colors for the geometric artwork
(inlined by the backend into #page-banner). The cb-* classes recolor the
SVG from the active palette (var(--accent)), so one SVG serves light
and dark — and other themes too. */
#banner {
min-height: 15rem;
border-bottom: none;
}
/* Artwork colors, light mode */
.cb-bg0 {
stop-color: #ffffff;
}
.cb-bg1 {
stop-color: #e6eefe;
}
.cb-r0 {
stop-color: var(--accent);
}
.cb-r1 {
stop-color: #00b3ff;
}
.cb-g0,
.cb-g1 {
stop-color: var(--accent);
}
.cb-dot {
fill: var(--accent);
}
.cb-orbit {
stroke: var(--accent);
}
.cb-spark {
fill: var(--accent);
}
/* Artwork colors, dark mode */
@media (prefers-color-scheme: dark) {
.cb-bg0 {
stop-color: #0d1830;
}
.cb-bg1 {
stop-color: #0a1122;
}
.cb-r0 {
stop-color: #2f7bff;
}
.cb-r1 {
stop-color: #00d0ff;
}
.cb-g0,
.cb-g1 {
stop-color: #2f7bff;
}
.cb-dot {
fill: #4d8dff;
}
.cb-orbit {
stroke: #4d8dff;
}
.cb-spark {
fill: #6ea8ff;
}
}
@@ -1,9 +1,9 @@
/* Corporate theme: bright and bold professional. Saturated royal-blue /* Corporate theme: bright and bold professional. Saturated royal-blue
gradients on white, geometric Montserrat display type over Inter body, gradients on white, geometric Montserrat display type over Inter body,
and a genuinely large brand with a soft blue overlap shadow. Automatic and a genuinely large brand with a soft blue overlap shadow. Automatic
dark mode keeps the same saturated blue identity on deep navy; the dark mode keeps the same saturated blue identity on deep navy. The
banner artwork (inlined by the backend) is recolored from here via the companion banner design (banner.css, artwork inlined by the backend)
cb-* classes, so one SVG serves both modes. */ recolors its SVG from the active palette via the cb-* classes. */
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@@ -18,40 +18,6 @@
--font-heading: var(--font-montserrat); --font-heading: var(--font-montserrat);
} }
/* Banner artwork colors, light mode */
.cb-bg0 {
stop-color: #ffffff;
}
.cb-bg1 {
stop-color: #e6eefe;
}
.cb-r0 {
stop-color: var(--accent);
}
.cb-r1 {
stop-color: #00b3ff;
}
.cb-g0,
.cb-g1 {
stop-color: var(--accent);
}
.cb-dot {
fill: var(--accent);
}
.cb-orbit {
stroke: var(--accent);
}
.cb-spark {
fill: var(--accent);
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0b1428; --bg: #0b1428;
@@ -64,40 +30,6 @@
/* Code wells stay navy in dark mode (light mode uses --surface). */ /* Code wells stay navy in dark mode (light mode uses --surface). */
--code-bg: #0d1b3e; --code-bg: #0d1b3e;
} }
/* Banner artwork colors, dark mode */
.cb-bg0 {
stop-color: #0d1830;
}
.cb-bg1 {
stop-color: #0a1122;
}
.cb-r0 {
stop-color: #2f7bff;
}
.cb-r1 {
stop-color: #00d0ff;
}
.cb-g0,
.cb-g1 {
stop-color: #2f7bff;
}
.cb-dot {
fill: #4d8dff;
}
.cb-orbit {
stroke: #4d8dff;
}
.cb-spark {
fill: #6ea8ff;
}
} }
::selection { ::selection {
@@ -124,11 +56,6 @@
} }
} }
#banner {
min-height: 15rem;
border-bottom: none;
}
#nav { #nav {
font-size: 1.05em; font-size: 1.05em;
font-weight: 600; font-weight: 600;
+7
View File
@@ -0,0 +1,7 @@
/* Eyes banner design: a canvas critter watching the cursor from the
grass (banner.html — markup + styles + script inlined by the backend
into #page-banner). Fixed-height stage matching the canvas. */
#banner {
height: 240px;
}
+423
View File
@@ -0,0 +1,423 @@
<canvas id="eyes"></canvas>
<style>
#eyes {
width: 100%;
height: 240px;
display: block;
}
</style>
<script><!--
(() => {
const c = document.getElementById('eyes')
const ctx = c.getContext('2d')
const DPR = devicePixelRatio || 1
const fit = () => {
const w = Math.max(1, c.clientWidth)
const h = Math.max(1, c.clientHeight)
c.width = Math.round(w * DPR)
c.height = Math.round(h * DPR)
ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
}
fit()
addEventListener('resize', fit)
let mx = 0
let my = 0
let lastMove = 0
addEventListener('mousemove', e => {
const r = c.getBoundingClientRect()
// Convert viewport coordinates into the canvas' CSS-pixel coordinate
// system. This remains correct with browser zoom, CSS transforms, etc.
mx = (e.clientX - r.left) * c.clientWidth / r.width
my = (e.clientY - r.top) * c.clientHeight / r.height
lastMove = performance.now()
})
let gx = 0.5
let gy = 0.5
let tx = 0.5
let ty = 0.5
let yoff = 0
let vy = 0
let hidePhase = 0
let nextMove = 0
let nextHide = 4000 + Math.random() * 5000
let resurfaceAt = 0
const eyes = [
{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 },
{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }
]
const ridgeY = (x, w, h) =>
h * 0.72 +
Math.sin(x * 0.012) * 10 +
Math.sin(x * 0.003 + 1.4) * 16 +
Math.sin(x * 0.02 + 0.7) * 3
const drawCloud = (x, y, s) => {
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.beginPath()
ctx.arc(x - s * 0.55, y + s * 0.05, s * 0.38, 0, 7)
ctx.arc(x - s * 0.12, y - s * 0.08, s * 0.48, 0, 7)
ctx.arc(x + s * 0.32, y, s * 0.42, 0, 7)
ctx.arc(x + s * 0.64, y + s * 0.1, s * 0.28, 0, 7)
ctx.fill()
}
const drawHills = (w, h) => {
ctx.fillStyle = '#b7d7a8'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, h * 0.63)
ctx.quadraticCurveTo(w * 0.18, h * 0.48, w * 0.35, h * 0.62)
ctx.quadraticCurveTo(w * 0.52, h * 0.78, w * 0.68, h * 0.58)
ctx.quadraticCurveTo(w * 0.82, h * 0.43, w, h * 0.57)
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#99c685'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, h * 0.72)
ctx.quadraticCurveTo(w * 0.14, h * 0.6, w * 0.28, h * 0.7)
ctx.quadraticCurveTo(w * 0.46, h * 0.82, w * 0.62, h * 0.66)
ctx.quadraticCurveTo(w * 0.82, h * 0.5, w, h * 0.68)
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
}
const drawBackground = (w, h) => {
const sky = ctx.createLinearGradient(0, 0, 0, h)
sky.addColorStop(0, '#8ed0ff')
sky.addColorStop(0.62, '#d9f1ff')
sky.addColorStop(1, '#eef9ff')
ctx.fillStyle = sky
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = 'rgba(255,240,170,0.5)'
ctx.beginPath()
ctx.arc(w * 0.83, h * 0.2, h * 0.16, 0, 7)
ctx.fill()
drawCloud(w * 0.18, h * 0.2, h * 0.16)
drawCloud(w * 0.43, h * 0.14, h * 0.12)
drawCloud(w * 0.68, h * 0.24, h * 0.15)
drawHills(w, h)
for (let i = 0; i < 5; i++) {
const x = (i + 0.5) * w / 5
const y = h * 0.58 + Math.sin(i * 1.7) * 8
ctx.fillStyle = '#5f8d4e'
ctx.beginPath()
ctx.arc(x, y, 18, Math.PI, 0)
ctx.arc(x - 14, y + 2, 14, Math.PI, 0)
ctx.arc(x + 14, y + 3, 12, Math.PI, 0)
ctx.fill()
}
}
const drawCritter = (cx0, eyeY, R, now, dt) => {
const headR = R * 2
const headCx = cx0
const headCy = eyeY + R * 0.52
ctx.fillStyle = '#5fbe61'
ctx.strokeStyle = '#285838'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(headCx - headR * 0.45, headCy - headR * 0.84)
ctx.quadraticCurveTo(
headCx - headR * 0.62,
headCy - headR * 1.18,
headCx - headR * 0.2,
headCy - headR * 0.94
)
ctx.fill()
ctx.stroke()
ctx.beginPath()
ctx.moveTo(headCx + headR * 0.45, headCy - headR * 0.84)
ctx.quadraticCurveTo(
headCx + headR * 0.62,
headCy - headR * 1.18,
headCx + headR * 0.2,
headCy - headR * 0.94
)
ctx.fill()
ctx.stroke()
ctx.beginPath()
ctx.arc(headCx, headCy, headR, 0, 7)
ctx.fill()
ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,0.12)'
ctx.beginPath()
ctx.arc(
headCx - headR * 0.28,
headCy - headR * 0.22,
headR * 0.4,
0,
7
)
ctx.fill()
ctx.fillStyle = '#4caa50'
for (let i = -1; i <= 1; i++) {
ctx.beginPath()
ctx.arc(
headCx + i * headR * 0.42,
headCy - headR * 0.16,
headR * 0.13,
0,
7
)
ctx.fill()
}
ctx.strokeStyle = '#285838'
ctx.lineCap = 'round'
for (let i = -1; i <= 1; i++) {
ctx.lineWidth = 4
ctx.beginPath()
ctx.moveTo(headCx + i * 10, headCy - headR * 0.94)
ctx.lineTo(
headCx + i * 16,
headCy - headR * 1.1 - Math.sin(now / 180 + i) * 3
)
ctx.stroke()
}
eyes.forEach((e, i) => {
const cx = cx0 + (i ? 1.3 : -1.3) * R
const watching = now - lastMove < 4000
let ptx
let pty
if (watching) {
const dx = mx - cx
const dy = my - eyeY
const d = Math.hypot(dx, dy) || 1
const maxReach = R * 0.43
const responseDistance = R * 4
// Direction points exactly at the cursor, while reach increases
// smoothly with cursor distance.
const reach = maxReach * Math.min(1, d / responseDistance)
ptx = dx / d * reach
pty = dy / d * reach
} else {
ptx = Math.sin(now / 900 + i * 2) * R * 0.3
pty = Math.cos(now / 1300 + i * 3) * R * 0.2
}
e.vx += (ptx - e.x) * 0.08 * dt
e.vy += (pty - e.y) * 0.08 * dt
e.vx *= Math.pow(0.82, dt)
e.vy *= Math.pow(0.82, dt)
e.x += e.vx * dt
e.y += e.vy * dt
const near = Math.hypot(mx - cx, my - eyeY) < R * 2.5
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt
ctx.fillStyle = '#fff'
ctx.strokeStyle = '#1f2d22'
ctx.lineWidth = 2.5
ctx.beginPath()
ctx.ellipse(cx, eyeY, R, R * 1.12, 0, 0, 7)
ctx.fill()
ctx.stroke()
ctx.save()
ctx.beginPath()
ctx.ellipse(cx, eyeY, R, R * 1.12, 0, 0, 7)
ctx.clip()
ctx.fillStyle = '#f2b84b'
ctx.beginPath()
ctx.arc(cx + e.x, eyeY + e.y, R * 0.56, 0, 7)
ctx.fill()
ctx.strokeStyle = 'rgba(140,84,8,0.45)'
ctx.lineWidth = 1
for (let a = 0; a < 12; a++) {
const ang = a / 12 * Math.PI * 2
ctx.beginPath()
ctx.moveTo(cx + e.x, eyeY + e.y)
ctx.lineTo(
cx + e.x + Math.cos(ang) * R * 0.5,
eyeY + e.y + Math.sin(ang) * R * 0.5
)
ctx.stroke()
}
ctx.fillStyle = '#191919'
ctx.beginPath()
ctx.arc(cx + e.x, eyeY + e.y, R * e.pr, 0, 7)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.arc(
cx + e.x - R * 0.14,
eyeY + e.y - R * 0.17,
R * 0.09,
0,
7
)
ctx.fill()
ctx.restore()
ctx.strokeStyle = '#1f2d22'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(cx - R * 0.7, eyeY - R * 1.2)
ctx.quadraticCurveTo(
cx,
eyeY - R * 1.48 - (i ? -1 : 1) * 2,
cx + R * 0.72,
eyeY - R * 1.12
)
ctx.stroke()
})
}
const drawForeground = (w, h) => {
ctx.fillStyle = '#69ae4b'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, ridgeY(0, w, h))
for (let x = 0; x <= w; x += 8)
ctx.lineTo(x, ridgeY(x, w, h))
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
ctx.fillStyle = 'rgba(48,102,34,0.18)'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, ridgeY(0, w, h) + 10)
for (let x = 0; x <= w; x += 8)
ctx.lineTo(x, ridgeY(x, w, h) + 10)
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
ctx.strokeStyle = '#4d8d37'
ctx.lineWidth = 2
ctx.lineCap = 'round'
for (let x = 0; x <= w; x += 16) {
const y = ridgeY(x, w, h)
ctx.beginPath()
ctx.moveTo(x, y + 6)
ctx.quadraticCurveTo(x - 4, y - 10, x + 1, y - 2)
ctx.moveTo(x + 1, y + 6)
ctx.quadraticCurveTo(x + 4, y - 12, x + 3, y - 1)
ctx.stroke()
}
for (let i = 0; i < 8; i++) {
const x = (i + 0.4) * w / 8 + Math.sin(i * 2.4) * 10
const y = ridgeY(x, w, h) + 2
ctx.fillStyle = i % 2 ? '#ffdc6b' : '#ff8aa7'
ctx.beginPath()
ctx.arc(x, y, 3, 0, 7)
ctx.arc(x - 4, y + 2, 3, 0, 7)
ctx.arc(x + 4, y + 2, 3, 0, 7)
ctx.fill()
}
}
let prev = performance.now()
const frame = now => {
if (!c.isConnected) return
const dt = Math.min(now - prev, 100) / 16.7
prev = now
const w = c.clientWidth
const h = c.clientHeight
const R = Math.min(h * 0.11, 38)
if (now > nextMove && !hidePhase) {
tx = 0.15 + Math.random() * 0.7
ty = 0.3 + Math.random() * 0.4
nextMove = now + 2500 + Math.random() * 3500
}
gx += (tx - gx) * 0.02 * dt
gy += (ty - gy) * 0.02 * dt
if (hidePhase === 0 && now > nextHide)
hidePhase = 1
if (hidePhase === 1 && yoff > h * 0.9) {
hidePhase = 2
resurfaceAt = now + 500 + Math.random() * 900
}
if (hidePhase === 2 && now > resurfaceAt) {
hidePhase = 0
nextHide = now + 5000 + Math.random() * 7000
tx = 0.15 + Math.random() * 0.7
ty = 0.3 + Math.random() * 0.4
gx = tx
gy = ty
nextMove = now + 3000 + Math.random() * 3000
}
const yTarget = hidePhase ? h : 0
vy += (yTarget - yoff) * 0.06 * dt
vy *= Math.pow(0.85, dt)
yoff += vy * dt
drawBackground(w, h)
const cx0 = gx * w
const ridge = ridgeY(cx0, w, h)
// Normally the full pair of eyes sits above the grass. gy gives it
// a small amount of bobbing/wandering without burying it again.
const eyeY =
ridge -
R * 1.25 +
(gy - 0.5) * R * 0.8 +
yoff
drawCritter(cx0, eyeY, R, now, dt)
drawForeground(w, h)
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
})()
</script>
+79
View File
@@ -0,0 +1,79 @@
/* Nitro banner design: the bezier-swept artwork with wide orange stripes
(inlined by the backend into #page-banner), in neutral dark greys that
follow the page's color scheme. */
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
from the page by a straight orange blade. */
#banner {
height: 13rem;
border-bottom: 4px solid var(--accent);
}
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
page's violet family by the dark-scheme block below). */
.nb-base {
fill: #0b0b0d;
}
.nb-s1a {
stop-color: #242428;
}
.nb-s1b {
stop-color: #0b0b0d;
}
.nb-s2a {
stop-color: #19191d;
}
.nb-s2b {
stop-color: #060607;
}
.nb-c0 {
stop-color: #2a2a2f;
}
.nb-c1 {
stop-color: #131315;
}
.nb-c2 {
stop-color: #0b0b0d;
}
@media (prefers-color-scheme: dark) {
/* Banner dark tones tinted to the same violet family as the page. */
.nb-base {
fill: #100d18;
}
.nb-s1a {
stop-color: #292536;
}
.nb-s1b {
stop-color: #100d18;
}
.nb-s2a {
stop-color: #1e1a2b;
}
.nb-s2b {
stop-color: #090811;
}
.nb-c0 {
stop-color: #322d44;
}
.nb-c1 {
stop-color: #171422;
}
.nb-c2 {
stop-color: #100d18;
}
}
@@ -59,39 +59,6 @@
--code-bg: #12101b; --code-bg: #12101b;
/* code wells join the violet family */ /* code wells join the violet family */
} }
/* Banner dark tones tinted to the same violet family as the page. */
.nb-base {
fill: #100d18;
}
.nb-s1a {
stop-color: #292536;
}
.nb-s1b {
stop-color: #100d18;
}
.nb-s2a {
stop-color: #1e1a2b;
}
.nb-s2b {
stop-color: #090811;
}
.nb-c0 {
stop-color: #322d44;
}
.nb-c1 {
stop-color: #171422;
}
.nb-c2 {
stop-color: #100d18;
}
} }
::selection { ::selection {
@@ -112,47 +79,6 @@
text-shadow: 0 0 0.1em black; text-shadow: 0 0 0.1em black;
} }
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
from the page by a straight orange blade. */
#banner {
height: 13rem;
border-bottom: 4px solid var(--accent);
}
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
page's violet family by the dark-scheme block above). */
.nb-base {
fill: #0b0b0d;
}
.nb-s1a {
stop-color: #242428;
}
.nb-s1b {
stop-color: #0b0b0d;
}
.nb-s2a {
stop-color: #19191d;
}
.nb-s2b {
stop-color: #060607;
}
.nb-c0 {
stop-color: #2a2a2f;
}
.nb-c1 {
stop-color: #131315;
}
.nb-c2 {
stop-color: #0b0b0d;
}
#nav { #nav {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 0.95em; font-size: 0.95em;
+21
View File
@@ -0,0 +1,21 @@
/* Purple banner design: the sunrise artwork (inlined by the backend into
#page-banner) with parallax sun and a fade into the page background. */
/* Sunrise parallax: the sun and its glow rise faster than the artwork
drift (pagerite.js sets --pry on <html>), so scrolling the page makes
the sun come up. */
#page-banner .sun,
#page-banner .sun-glow {
transform-box: fill-box;
transform: translateY(calc(var(--pry, 0px) * -2));
}
/* The banner artwork fades into the page background at its bottom edge
(baked into the SVG, so a user banner replaces it cleanly). */
.banner-fade {
stop-color: var(--bg);
}
#banner {
min-height: 13rem;
}
@@ -28,7 +28,9 @@
#brand { #brand {
font-size: clamp(3.2rem, 9vw, 7.5rem); font-size: clamp(3.2rem, 9vw, 7.5rem);
line-height: 1; line-height: 1;
margin-bottom: -0.28em; /* Ensure below baseline stays visible */
padding-bottom: 0.3em;
margin-bottom: -0.3em;
transform: rotate(-2deg); transform: rotate(-2deg);
transform-origin: left bottom; transform-origin: left bottom;
background: linear-gradient(90deg, var(--accent), var(--accent2)); background: linear-gradient(90deg, var(--accent), var(--accent2));
@@ -39,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;
+190 -94
View File
@@ -25,11 +25,13 @@ from pagerite.markdown import has_h1, render
SITE_NAME = "Pagerite" SITE_NAME = "Pagerite"
BUILD = Path(__file__).with_name("frontend-build") BUILD = Path(__file__).with_name("frontend-build")
THEMES = Path(__file__).parent / "themes"
# Shared CSS built as separate entries so the backend can link base and theme # The base CSS is built by Vite as a separate entry so the backend can link
# independently. Order matters: base first, theme overrides it. # it independently of the theme. Themes and banner designs are plain .css
# files in THEMES/{name}/, served by the backend at /_themes/{name}/... and
# re-read from disk on every request (see app.py), so they are never built.
_BASE_CSS_KEY = "src/assets/pagerite.css" _BASE_CSS_KEY = "src/assets/pagerite.css"
_THEME_CSS_KEY = "src/assets/themes/{theme}/theme.css"
_manifest_cache: dict | None = None _manifest_cache: dict | None = None
_asset_cache: dict[str, tuple] = {} _asset_cache: dict[str, tuple] = {}
@@ -42,29 +44,54 @@ 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 artwork and/or styles."""
return sorted(
d.name
for d in THEMES.iterdir()
if d.is_dir()
and any((d / f).exists() for f in ("banner.css", "banner.svg", "banner.html"))
)
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,47 +101,55 @@ 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 = "",
) -> 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: # A custom favicon (from the site editor) is linked explicitly; without
doc.meta(name="pagerite:theme", content=theme) # one, browsers fall back to the build's /favicon.ico by convention.
if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# 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():
@@ -148,12 +183,13 @@ def _title(slug: str, node: Node) -> str:
def _nav_link(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None: def _nav_link(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None:
"""Render one <li> linking the node. Category labels (no content of """Render one <li> linking the node. Category labels (no content of
their own) link straight to their first child page, so normal their own — None, or empty markdown as left by the site editor's
navigation bypasses the placeholder page at their own URL.""" page creation) link straight to their first child page, so normal
navigation bypasses the placeholder/empty page at their own URL."""
# A top-level item is current also when viewing any of its subpages. # A top-level item is current also when viewing any of its subpages.
is_current = current == path or (path and current.startswith(f"{path}/")) is_current = current == path or (path and current.startswith(f"{path}/"))
href = f"/{path}" href = f"/{path}"
if node.content is None and (leaf := first_leaf(menu, path)) is not None: if not node.content and (leaf := first_leaf(menu, path)) is not None:
href = f"/{leaf}" href = f"/{leaf}"
doc.li.a( doc.li.a(
_title(path.rpartition("/")[2], node), _title(path.rpartition("/")[2], node),
@@ -182,9 +218,12 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
The sidebar is the current main level section's sub-navigation, so it The sidebar is the current main level section's sub-navigation, so it
exists only when there is something to navigate: the section must exists only when there is something to navigate: the section must
offer at least two published items. The front page, leaf pages and offer at least two published items, or exactly one while viewing
one-page sections get no aside element at all (rather than an empty anything else than that only page — the section index, a 404, a
or one-item box). grandchild (otherwise those pages offer no way to reach the child).
The front page, leaf pages, the sole page of a one-page section and
childless sections get no aside element at all (rather than an empty
or useless one-item box).
""" """
if not current: if not current:
return HTML("") return HTML("")
@@ -193,7 +232,7 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
if node is None: if node is None:
return HTML("") return HTML("")
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published] items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
if len(items) < 2: if not items or (len(items) == 1 and current == f"{section}/{items[0][0]}"):
return HTML("") return HTML("")
nav = E.ul nav = E.ul
with nav: with nav:
@@ -216,7 +255,7 @@ def first_leaf(menu: dict[str, Node], path: str) -> str | None:
def _first_leaf(node: Node, path: str) -> str | None: def _first_leaf(node: Node, path: str) -> str | None:
for slug, child in sorted_nodes(node.children): for slug, child in sorted_nodes(node.children):
cpath = f"{path}/{slug}" if path else slug cpath = f"{path}/{slug}" if path else slug
if child.published and child.content is not None: if child.published and child.content:
return cpath return cpath
if (leaf := _first_leaf(child, cpath)) is not None: if (leaf := _first_leaf(child, cpath)) is not None:
return leaf return leaf
@@ -224,48 +263,107 @@ 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 artwork (empty for none/unknown designs).
banner.html (arbitrary markup: canvas + style + script...) takes
def _theme_banner(theme: str) -> HTML: precedence over banner.svg. Read from disk on every request: design
"""The theme's inline banner SVG (empty for none/unknown themes).""" files are never built/cached, so editing them on disk shows on the
if not theme or "/" in theme: next page load, even in prod. The data-design wrapper marks the
artwork as the design's (not author code), so the site editor's live
banner preview keeps it in place.
"""
if not _valid_name(design):
return HTML("") return HTML("")
if theme not in _banner_cache: html = THEMES / design / "banner.html"
path = Path(__file__).parent / "themes" / theme / "banner.svg" svg = THEMES / design / "banner.svg"
_banner_cache[theme] = HTML(path.read_text()) if path.exists() else HTML("") if html.exists():
return _banner_cache[theme] body = html.read_text()
elif svg.exists():
body = svg.read_text()
else:
return HTML("")
return HTML(f'<div data-design="{design}">{body}</div>')
def banner_source(menu: dict[str, Node], path: str) -> str | None: def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
"""Which node's banner applies at ``path``: the nearest ancestor with """The effective banner design name at ``path`` ("" = no design).
one set (the front page, a top-level sibling of the chain, last).
None = the default artwork.""" 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 any(
(THEMES / theme / f).exists()
for f in ("banner.css", "banner.svg", "banner.html")
)
):
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 [] 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):
if chain[i].banner: if chain[i].banner_design is not None:
return "/".join(segs[: i + 1]) return "/".join(segs[: i + 1])
front = menu.get("") front = menu.get("")
if front and front.banner: if front and front.banner_design is not None:
return ""
return None
def banner_source(menu: dict[str, Node], path: str) -> str | None:
"""Which node's banner HTML applies at ``path``: the nearest ancestor with
one set (the front page, a top-level sibling of the chain, last). None =
no user banner anywhere (only the design artwork renders, if any).
Whitespace-only banners count as empty: clearing the editor can leave a
stray newline behind."""
chain = resolve(menu, path) or []
segs = path.split("/")
for i in range(len(chain) - 1, -1, -1):
if chain[i].banner.strip():
return "/".join(segs[: i + 1])
front = menu.get("")
if front and front.banner.strip():
return "" return ""
return None return None
@@ -279,7 +377,10 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
# only rendered as h1 when the markdown has none of its own. # only rendered as h1 when the markdown has none of its own.
if not has_h1(node.content or ""): if not has_h1(node.content or ""):
doc.h1(node.title) doc.h1(node.title)
doc.div(HTML(render(node.content or "", path)), class_="body") doc.div(
HTML(render(node.content or "", path, node.created, node.modified)),
class_="body",
)
return HTML(str(doc)) return HTML(str(doc))
@@ -289,13 +390,13 @@ def render_page(
brand: str = SITE_NAME, brand: str = SITE_NAME,
custom_css: str = "", custom_css: str = "",
theme: str = "", theme: str = "",
favicon: str = "",
) -> str: ) -> str:
"""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)( _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),
@@ -312,6 +413,7 @@ def render_category(
brand: str = SITE_NAME, brand: str = SITE_NAME,
custom_css: str = "", custom_css: str = "",
theme: str = "", theme: str = "",
favicon: str = "",
) -> str: ) -> str:
"""Render the placeholder for a content-less category label (404). """Render the placeholder for a content-less category label (404).
@@ -329,9 +431,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)( _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),
@@ -348,15 +449,15 @@ def render_not_found(
brand: str = SITE_NAME, brand: str = SITE_NAME,
custom_css: str = "", custom_css: str = "",
theme: str = "", theme: str = "",
favicon: str = "",
) -> str: ) -> str:
"""Render a 404 page within the normal layout.""" """Render a 404 page within the normal layout."""
doc = E.article doc = E.article
with doc: with doc:
doc.h1("Not Found") doc.h1("Not Found")
doc.p(f"No page at /{path}.") 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)( _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),
@@ -367,27 +468,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
@@ -396,9 +493,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"]
+3 -1
View File
@@ -44,7 +44,9 @@ source = "vcs"
packages = ["pagerite"] packages = ["pagerite"]
[tool.hatch.build] [tool.hatch.build]
artifacts = ["pagerite/frontend-build"] # `only-packages` drops directories without an __init__.py, so the theme
# files must be force-included as artifacts (like the frontend build).
artifacts = ["pagerite/frontend-build", "pagerite/themes"]
only-packages = true only-packages = true
[tool.hatch.build.targets.sdist.hooks.custom] [tool.hatch.build.targets.sdist.hooks.custom]