Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14655ee4d7 | ||
|
|
0ec9be330e | ||
|
|
c5a91edac7 | ||
|
|
1c0ef315ff | ||
|
|
87c0aac3c0 | ||
|
|
3cefdec56e | ||
|
|
e044ab86ee | ||
|
|
6303d5ae6d | ||
|
|
7ec0d10155 | ||
|
|
30947df16b | ||
|
|
c0817330e7 | ||
|
|
d03ee7fa8f | ||
|
|
f9e314e5ea | ||
|
|
0fcd7b16f5 | ||
|
|
f125966bb7 | ||
|
|
32f30dbdaa | ||
|
|
124ef62b5f | ||
|
|
a0990fc3f6 | ||
|
|
87deaa380b | ||
|
|
f11b772493 | ||
|
|
a45a575f72 |
@@ -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,31 +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 probes `GET /_api/settings`
|
code copy buttons, and the auth check. It first probes `GET /auth/api/settings`
|
||||||
and only then injects the 🖊️ edit pens (asset URLs from the
|
to detect whether Paskia SSO is available, then `GET /_api/settings` to
|
||||||
`pagerite:editor-src`/`-css` meta tags). The same reverse proxy that
|
learn the current session's admin status. The same reverse proxy that
|
||||||
gates `/_api` returns 401 for anonymous users, 403 for users without
|
gates `/_api` returns 401 for anonymous users, 403 for users without
|
||||||
the admin permission, and 200 for admins; a 401 adds a "log in" link
|
the admin permission, and 200 for admins. When Paskia is detected, a
|
||||||
to `/auth/` in the banner corner, a 403 nothing, and any other
|
🔑 login button (anonymous) or 👤 profile button (logged in) is shown in
|
||||||
result (no auth proxy, e.g. dev) leaves editing open. Pages themselves
|
the banner corner; both open Paskia's iframe dialog via `showAuthIframe`
|
||||||
render identically for everyone; the real gate is the auth proxy in
|
instead of navigating away. Admins also get the 🖊️ edit pens (asset URLs
|
||||||
front of all of `/_api`. The backend links the shared CSS as two separate
|
from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is
|
||||||
stylesheets (base and theme) so they can be swapped or augmented.
|
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`
|
||||||
@@ -134,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 —
|
||||||
@@ -170,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
@@ -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.
|
||||||
|
|||||||
@@ -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 |
+204
-25
@@ -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 (theme.value) {
|
||||||
if (el.dataset.viteDevId.includes('/themes/')) el.remove()
|
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)
|
||||||
}
|
}
|
||||||
if (theme.value) {
|
} else if (link) {
|
||||||
await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`)
|
link.remove()
|
||||||
}
|
|
||||||
// The freshly injected theme style now sits after the custom CSS;
|
|
||||||
// move the custom CSS back to the end so it keeps winning.
|
|
||||||
applyCustomCss(customCss.value)
|
|
||||||
}
|
}
|
||||||
loadPlain(path.value)
|
loadPlain(path.value)
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }"
|
||||||
|
|||||||
@@ -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 {
|
||||||
column-gap: 3.5rem;
|
columns: 30rem;
|
||||||
column-rule: 1px solid var(--line);
|
column-gap: 3.5rem;
|
||||||
}
|
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);
|
||||||
|
|||||||
+113
-36
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +34,15 @@
|
|||||||
// only after we know the user has pagerite:admin access. We probe our own
|
// only after we know the user has pagerite:admin access. We probe our own
|
||||||
// /_api/settings endpoint: the same reverse proxy that gates /_api returns
|
// /_api/settings endpoint: the same reverse proxy that gates /_api returns
|
||||||
// 401/403 here, and a 200 means the permission is present.
|
// 401/403 here, and a 200 means the permission is present.
|
||||||
// 401 = anonymous: show a small login link in the banner corner instead.
|
//
|
||||||
// 403 = logged in without the permission: no pens. Any other outcome
|
// When Paskia SSO is in use, 401/403 responses carry `auth.iframe`, which
|
||||||
// (network error — i.e. no auth proxy deployed, as in dev) leaves editing
|
// we use to open the login/profile dialogs inline instead of navigating
|
||||||
// open as before: the real gate is the proxy in front of /_api.
|
// away. A separate probe to /auth/api/settings tells us whether Paskia is
|
||||||
let authorized = false;
|
// 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) {
|
||||||
@@ -61,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() {
|
||||||
@@ -78,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("/_api/settings")).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 proxy 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) {
|
||||||
@@ -148,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) {
|
||||||
@@ -169,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
@@ -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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+126
-13
@@ -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})
|
||||||
@@ -570,22 +669,36 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
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
@@ -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
@@ -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 `` in `/docs/design` is served from
|
page's own path (so `` 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. `{.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
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
"""
|
"""
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
{.right width=280}
|
{.right width=280}
|
||||||
@@ -63,15 +55,11 @@ absolute URL that survives page moves:
|
|||||||
|
|
||||||
{.right width=280}
|
{.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}
|
||||||
|
|
||||||
{.wide}
|
{.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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* Corporate banner design: sizing and colors for the geometric artwork
|
||||||
|
(inlined by the backend into #page-banner). The cb-* classes recolor the
|
||||||
|
SVG from the active palette (var(--accent)), so one SVG serves light
|
||||||
|
and dark — and other themes too. */
|
||||||
|
|
||||||
|
#banner {
|
||||||
|
min-height: 15rem;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork colors, light mode */
|
||||||
|
.cb-bg0 {
|
||||||
|
stop-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-bg1 {
|
||||||
|
stop-color: #e6eefe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-r0 {
|
||||||
|
stop-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-r1 {
|
||||||
|
stop-color: #00b3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-g0,
|
||||||
|
.cb-g1 {
|
||||||
|
stop-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-dot {
|
||||||
|
fill: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-orbit {
|
||||||
|
stroke: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-spark {
|
||||||
|
fill: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork colors, dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.cb-bg0 {
|
||||||
|
stop-color: #0d1830;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-bg1 {
|
||||||
|
stop-color: #0a1122;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-r0 {
|
||||||
|
stop-color: #2f7bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-r1 {
|
||||||
|
stop-color: #00d0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-g0,
|
||||||
|
.cb-g1 {
|
||||||
|
stop-color: #2f7bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-dot {
|
||||||
|
fill: #4d8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-orbit {
|
||||||
|
stroke: #4d8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-spark {
|
||||||
|
fill: #6ea8ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-76
@@ -1,9 +1,9 @@
|
|||||||
/* Corporate theme: bright and bold professional. Saturated royal-blue
|
/* Corporate theme: bright and bold professional. Saturated royal-blue
|
||||||
gradients on white, geometric Montserrat display type over Inter body,
|
gradients on white, geometric Montserrat display type over Inter body,
|
||||||
and a genuinely large brand with a soft blue overlap shadow. Automatic
|
and a genuinely large brand with a soft blue overlap shadow. Automatic
|
||||||
dark mode keeps the same saturated blue identity on deep navy; the
|
dark mode keeps the same saturated blue identity on deep navy. The
|
||||||
banner artwork (inlined by the backend) is recolored from here via the
|
companion banner design (banner.css, artwork inlined by the backend)
|
||||||
cb-* classes, so one SVG serves both modes. */
|
recolors its SVG from the active palette via the cb-* classes. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
@@ -18,40 +18,6 @@
|
|||||||
--font-heading: var(--font-montserrat);
|
--font-heading: var(--font-montserrat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner artwork colors, light mode */
|
|
||||||
.cb-bg0 {
|
|
||||||
stop-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-bg1 {
|
|
||||||
stop-color: #e6eefe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-r0 {
|
|
||||||
stop-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-r1 {
|
|
||||||
stop-color: #00b3ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-g0,
|
|
||||||
.cb-g1 {
|
|
||||||
stop-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-dot {
|
|
||||||
fill: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-orbit {
|
|
||||||
stroke: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-spark {
|
|
||||||
fill: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b1428;
|
--bg: #0b1428;
|
||||||
@@ -64,40 +30,6 @@
|
|||||||
/* Code wells stay navy in dark mode (light mode uses --surface). */
|
/* Code wells stay navy in dark mode (light mode uses --surface). */
|
||||||
--code-bg: #0d1b3e;
|
--code-bg: #0d1b3e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner artwork colors, dark mode */
|
|
||||||
.cb-bg0 {
|
|
||||||
stop-color: #0d1830;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-bg1 {
|
|
||||||
stop-color: #0a1122;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-r0 {
|
|
||||||
stop-color: #2f7bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-r1 {
|
|
||||||
stop-color: #00d0ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-g0,
|
|
||||||
.cb-g1 {
|
|
||||||
stop-color: #2f7bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-dot {
|
|
||||||
fill: #4d8dff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-orbit {
|
|
||||||
stroke: #4d8dff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cb-spark {
|
|
||||||
fill: #6ea8ff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -124,11 +56,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#banner {
|
|
||||||
min-height: 15rem;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav {
|
#nav {
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* Nitro banner design: the bezier-swept artwork with wide orange stripes
|
||||||
|
(inlined by the backend into #page-banner), in neutral dark greys that
|
||||||
|
follow the page's color scheme. */
|
||||||
|
|
||||||
|
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
|
||||||
|
from the page by a straight orange blade. */
|
||||||
|
#banner {
|
||||||
|
height: 13rem;
|
||||||
|
border-bottom: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
|
||||||
|
page's violet family by the dark-scheme block below). */
|
||||||
|
.nb-base {
|
||||||
|
fill: #0b0b0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s1a {
|
||||||
|
stop-color: #242428;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s1b {
|
||||||
|
stop-color: #0b0b0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s2a {
|
||||||
|
stop-color: #19191d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s2b {
|
||||||
|
stop-color: #060607;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-c0 {
|
||||||
|
stop-color: #2a2a2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-c1 {
|
||||||
|
stop-color: #131315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-c2 {
|
||||||
|
stop-color: #0b0b0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
/* Banner dark tones tinted to the same violet family as the page. */
|
||||||
|
.nb-base {
|
||||||
|
fill: #100d18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s1a {
|
||||||
|
stop-color: #292536;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s1b {
|
||||||
|
stop-color: #100d18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s2a {
|
||||||
|
stop-color: #1e1a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-s2b {
|
||||||
|
stop-color: #090811;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-c0 {
|
||||||
|
stop-color: #322d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-c1 {
|
||||||
|
stop-color: #171422;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-c2 {
|
||||||
|
stop-color: #100d18;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,39 +59,6 @@
|
|||||||
--code-bg: #12101b;
|
--code-bg: #12101b;
|
||||||
/* code wells join the violet family */
|
/* code wells join the violet family */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner dark tones tinted to the same violet family as the page. */
|
|
||||||
.nb-base {
|
|
||||||
fill: #100d18;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s1a {
|
|
||||||
stop-color: #292536;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s1b {
|
|
||||||
stop-color: #100d18;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s2a {
|
|
||||||
stop-color: #1e1a2b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s2b {
|
|
||||||
stop-color: #090811;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-c0 {
|
|
||||||
stop-color: #322d44;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-c1 {
|
|
||||||
stop-color: #171422;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-c2 {
|
|
||||||
stop-color: #100d18;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -112,47 +79,6 @@
|
|||||||
text-shadow: 0 0 0.1em black;
|
text-shadow: 0 0 0.1em black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
|
|
||||||
from the page by a straight orange blade. */
|
|
||||||
#banner {
|
|
||||||
height: 13rem;
|
|
||||||
border-bottom: 4px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
|
|
||||||
page's violet family by the dark-scheme block above). */
|
|
||||||
.nb-base {
|
|
||||||
fill: #0b0b0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s1a {
|
|
||||||
stop-color: #242428;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s1b {
|
|
||||||
stop-color: #0b0b0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s2a {
|
|
||||||
stop-color: #19191d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-s2b {
|
|
||||||
stop-color: #060607;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-c0 {
|
|
||||||
stop-color: #2a2a2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-c1 {
|
|
||||||
stop-color: #131315;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-c2 {
|
|
||||||
stop-color: #0b0b0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav {
|
#nav {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* Purple banner design: the sunrise artwork (inlined by the backend into
|
||||||
|
#page-banner) with parallax sun and a fade into the page background. */
|
||||||
|
|
||||||
|
/* Sunrise parallax: the sun and its glow rise faster than the artwork
|
||||||
|
drift (pagerite.js sets --pry on <html>), so scrolling the page makes
|
||||||
|
the sun come up. */
|
||||||
|
#page-banner .sun,
|
||||||
|
#page-banner .sun-glow {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform: translateY(calc(var(--pry, 0px) * -2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The banner artwork fades into the page background at its bottom edge
|
||||||
|
(baked into the SVG, so a user banner replaces it cleanly). */
|
||||||
|
.banner-fade {
|
||||||
|
stop-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#banner {
|
||||||
|
min-height: 13rem;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+189
-93
@@ -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 article at /{path}. If there was before, it may have been deleted.")
|
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
|
||||||
scripts, styles = _page_assets(theme)
|
|
||||||
return str(
|
return str(
|
||||||
_layout(styles, scripts, custom_css, theme)(
|
_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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user