Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178d22c05c | ||
|
|
dfd3ef1dad | ||
|
|
512ed91b31 | ||
|
|
3b074c02ed | ||
|
|
58e5d3ef2b | ||
|
|
5769747a95 | ||
|
|
180da893cb | ||
|
|
a9693e1dcb | ||
|
|
4dac16a9ac | ||
|
|
43b841ebb6 | ||
|
|
f2715088d2 | ||
|
|
f8cdfd53c0 | ||
|
|
e9297cac61 | ||
|
|
2140f06501 | ||
|
|
a75ad6f5da | ||
|
|
7f140001fa | ||
|
|
d87e5ae323 | ||
|
|
14655ee4d7 | ||
|
|
0ec9be330e | ||
|
|
c5a91edac7 | ||
|
|
1c0ef315ff | ||
|
|
87c0aac3c0 | ||
|
|
3cefdec56e | ||
|
|
e044ab86ee | ||
|
|
6303d5ae6d | ||
|
|
7ec0d10155 | ||
|
|
30947df16b | ||
|
|
c0817330e7 | ||
|
|
d03ee7fa8f | ||
|
|
f9e314e5ea | ||
|
|
0fcd7b16f5 | ||
|
|
f125966bb7 | ||
|
|
32f30dbdaa | ||
|
|
124ef62b5f | ||
|
|
a0990fc3f6 | ||
|
|
87deaa380b | ||
|
|
f11b772493 | ||
|
|
a45a575f72 |
@@ -53,18 +53,33 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
caching; pages reference files by absolute `/_f/` URLs so hierarchy
|
||||
moves never break them. `Node.banner` is a raw trusted HTML snippet
|
||||
for the header banner (img, styled div, canvas+script...); empty
|
||||
inherits from the node's ancestors (front page last), then the active
|
||||
theme's banner artwork: an inline SVG from
|
||||
`pagerite/themes/{theme}/banner.svg`, inlined into `#page-banner` by
|
||||
the backend only when no user banner applies (so it is recolorable
|
||||
from the theme CSS via `var(...)` and never fights user designs; the
|
||||
base stylesheet falls back to a plain gradient).
|
||||
inherits from the node's ancestors (front page last). It is rendered
|
||||
AFTER the banner design's artwork, so author code (e.g. a `<style>`
|
||||
override) always wins over the design's own styles.
|
||||
`Node.banner_design` picks a banner design: a theme folder name whose
|
||||
`banner.css` styles it and whose `banner.html` (arbitrary markup:
|
||||
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
|
||||
and embedded in page ETags so nav-affecting changes invalidate caches.
|
||||
`Data.brand` is the site name (header link + `<title>` suffix), editable
|
||||
in the site editor via `/_api/settings`; empty = no header link and
|
||||
no `<title>` suffix. `Data.theme` is the active theme name (empty =
|
||||
none/base only); themes live in `frontend/src/assets/themes/{theme}`.
|
||||
no `<title>` suffix. `Data.brand_html` is raw trusted HTML replacing the
|
||||
brand link entirely (rendered in a `#brand` div on top of the banner,
|
||||
next to the nav) — site-wide, not per-page like banners; edited in the
|
||||
site editor with image/video upload into `Data.files`. `Data.theme` is
|
||||
the active theme name (empty =
|
||||
none/base only); themes are folders in `pagerite/themes/{name}`
|
||||
containing `theme.css` and/or `banner.css` (+ `banner.svg` artwork and
|
||||
any extra assets the CSS references, like summer's `grass.svg`),
|
||||
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
|
||||
`<head>` (id `pagerite-user`) and swapped during fetch-navigation;
|
||||
editable in the site editor. Font picks (heading/body/brand) in the
|
||||
@@ -74,71 +89,142 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
referencing the per-family variables (`--font-source-sans` etc.) from
|
||||
pagerite.css;
|
||||
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,
|
||||
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
|
||||
resolve against the page path, titled images become figures.
|
||||
footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript
|
||||
plugins; typographer + breaks on). Custom
|
||||
image rule: relative srcs resolve against the page path; an image
|
||||
standing alone in its paragraph becomes a figure (captioned when
|
||||
titled), while inline-with-text images and raw <img> HTML stay plain.
|
||||
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
|
||||
placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav
|
||||
rendering straight from the `Data.menu` tree (siblings sorted by
|
||||
`Node.order`; nav links to content-less labels point at their first
|
||||
child via `first_leaf`), and page/404 rendering. If the markdown contains its own h1, the page title
|
||||
child via `first_leaf`, the first published descendant with content),
|
||||
and page/404 rendering. If the markdown contains its own h1, the page title
|
||||
is NOT rendered as an additional h1 (it still supplies <title> and nav
|
||||
labels). The navbar holds
|
||||
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
|
||||
published items (no aside element at all on the front page, leaf pages
|
||||
and one-page sections). Dynamic regions have stable ids
|
||||
`#sidebar` as a nested list (the section's direct children plain,
|
||||
deeper levels indented with article-list-style markers), which is
|
||||
rendered when the section offers at least two
|
||||
published items, or exactly one while viewing anything other than that
|
||||
only page — the section index, a 404, a grandchild (so those pages can
|
||||
reach the child), and also on that only page itself when it has
|
||||
published children of its own; no aside element at all on the front
|
||||
page, leaf
|
||||
pages and the sole childless 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
|
||||
(`#sidebar` may be absent on either side of a swap).
|
||||
- `seed.py` — demo content written on startup for paths missing from the
|
||||
database (never overwrites existing pages).
|
||||
- `seed.py` — demo content written only when the database is first
|
||||
created, via a `@kanta.bootstrap` handler in `app.py`.
|
||||
- `frontend/src/` — the Vue editor and public-page entries.
|
||||
- `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
|
||||
- `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal,
|
||||
- `main.js` — Vue editor app entry, mounts the tabbed EditorShell.
|
||||
- `pagerite.js` — public page entry; runs fetch-navigation (backed by an
|
||||
in-memory page cache: every visible internal link — and the current
|
||||
page — is fetched once at load, clicks are then served from JS with no
|
||||
fetch, and the editors' `loadPlain` keeps the cache current via a
|
||||
`pagerite:page-fetched` event; articles are `cache-control: no-cache`
|
||||
on the wire), scroll-reveal,
|
||||
OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars
|
||||
that never reserve layout space or shift the page when appearing;
|
||||
native scroll APIs like `window.scrollTo` keep working; themed via the
|
||||
`--os-*` variables in pagerite.css),
|
||||
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),
|
||||
code copy buttons, and the auth check: it probes `GET /_api/settings`
|
||||
and only then injects the 🖊️ edit pens (asset URLs from the
|
||||
`pagerite:editor-src`/`-css` meta tags). The same reverse proxy that
|
||||
code copy buttons, and the auth check. It first probes `GET /auth/api/settings`
|
||||
to detect whether Paskia SSO is available, then `GET /_api/settings` to
|
||||
learn the current session's admin status. The same reverse proxy that
|
||||
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
|
||||
to `/auth/` in the banner corner, a 403 nothing, and any other
|
||||
result (no auth proxy, e.g. dev) leaves editing open. Pages themselves
|
||||
render identically for everyone; the real gate is the auth proxy in
|
||||
front of all of `/_api`. The backend links the shared CSS as two separate
|
||||
stylesheets (base and theme) so they can be swapped or augmented.
|
||||
the admin permission, and 200 for admins. When Paskia is detected, a
|
||||
🔑 login link (anonymous) or 🔐 profile link (logged in) is shown in
|
||||
the banner corner; both are plain `<a href="/auth/">` links (Paskia
|
||||
does not support being iframed, so we navigate normally), and a
|
||||
`pageshow` handler re-probes auth when history navigation restores a
|
||||
cached page. Admins also get the 🖊️ page/banner edit pens
|
||||
and a ⚙️ site-settings pen (asset URLs from the
|
||||
`pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is
|
||||
detected (dev/no proxy), editing is left open. Pages themselves render
|
||||
identically for everyone; the real gate is the auth proxy in front of
|
||||
all of `/_api`. The backend links the stylesheets in a fixed order —
|
||||
base (Vite build), theme, banner design, custom CSS last — each with
|
||||
a stable id so the site editor can swap them in place.
|
||||
- `assets/` — shared styles and data files built by Vite and served hashed
|
||||
under `/_assets/`: `pagerite.css` (base layout + conservative variables),
|
||||
`themes/{purple,corporate,nitro}/theme.css` (theme overrides and font
|
||||
picks: `purple` = dark dusk palette with Fraunces/Literata and a tilted
|
||||
under `/_assets/`: `pagerite.css` (base layout + conservative
|
||||
variables), `pygments.css`,
|
||||
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
|
||||
`prefers-color-scheme` dark mode, Montserrat/Inter and a huge solid
|
||||
brand; `nitro` = racing/HUD style following `prefers-color-scheme`
|
||||
(warm light-grey page, deep violet in dark), Montserrat/Literata,
|
||||
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`,
|
||||
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.
|
||||
an orange racing-tab nav clipped with a bezier `shape()`; `summer` =
|
||||
light playful meadow, one palette sampled from its illustrated
|
||||
`banner.svg` (sky/grass/sun/flower pink), Fraunces/Literata, a tilted
|
||||
gradient brand, flower bullets, and a layered-parallax banner (sun
|
||||
rises, clouds drift, nearer hills move less) with idle animations
|
||||
(swaying flowers, floating clouds, breathing sun glow) wrapped in
|
||||
`prefers-reduced-motion: no-preference`); standalone banner designs
|
||||
(no theme.css) ship as `eyes` (a canvas critter in the grass) and
|
||||
`stars` (a drifting starfield)) and the
|
||||
companion `banner.css` banner designs are served by the backend.
|
||||
- Vite builds ES-module `.js` outputs; the backend renders `<script
|
||||
type="module">` for them (module scripts defer by default).
|
||||
- The database file is `pagerite.kantadb` in the cwd (`PAGERITE_DB`
|
||||
overrides); gitignored. Do not delete it without asking.
|
||||
- `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template
|
||||
(build hook etc.), do not edit.
|
||||
- `frontend/` — the Vue editor as **two separate apps** mounted in their
|
||||
own host divs created inside the static document: `PageEditor.vue`
|
||||
- `frontend/` — the Vue editor as a single tabbed `EditorShell.vue` mounted
|
||||
in a host div created inside the static document. The shell hosts four
|
||||
kept-alive tabs (ordered site-wide first — site, structure — then, after a
|
||||
visual break, the per-page tabs — article, banner): `PageEditor.vue`
|
||||
(CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`,
|
||||
previewing into the visible article; editor scroll drives document
|
||||
scroll) opened by the article pen — it edits content and title only,
|
||||
never the path — and `SiteEditor.vue` (site brand + theme selector +
|
||||
site-wide custom CSS + banner HTML edited in small CodeMirror windows;
|
||||
banner previewed into `#page-banner`, CSS injected into
|
||||
`<head id="pagerite-user">`) + vue-draggable structure tree with
|
||||
always-editable title/slug inputs per row, opened by the banner pen —
|
||||
everything saves immediately as you edit (brand/title/CSS debounced,
|
||||
previewing into the visible article; editor scroll drives the article
|
||||
scroll — while any editor is open the window scroll is locked
|
||||
(`body.editing`), the panel exactly fills the available window height, and
|
||||
only `#main` scrolls; a format bar offers Markdown helpers — bold/italic/code/link/
|
||||
table/image upload, with Ctrl/Cmd-B/I/S bindings — for the
|
||||
hard-to-remember syntax) — edits content and
|
||||
title only, never the path — `BannerEditor.vue`
|
||||
(per-page banner HTML + banner design selector, previewed into
|
||||
`#page-banner`), `SiteEditor.vue` (site brand + optional custom brand
|
||||
HTML with image/video upload + theme selector + font picker + favicon
|
||||
upload — clicking the preview tile picks a new one — +
|
||||
site-wide custom CSS, CSS injected into
|
||||
`<head id="pagerite-user">`), and `StructureEditor.vue` (the
|
||||
vue-draggable structure tree with
|
||||
always-editable title/slug inputs per row). Media uploads everywhere use
|
||||
🖼️ icon buttons (pasting into the editor works too). The article, banner and
|
||||
site-settings pens are shorthands that open the shell on the matching tab;
|
||||
once open, clicking a pen switches tabs (and retargets the editors to the
|
||||
current page) instead of closing/remounting. The ✕ in the tab bar closes
|
||||
the shell (Escape too); tabs have no close buttons of their own. Closing
|
||||
only HIDES the shell — the Vue app stays mounted, so page-editor state
|
||||
(unsaved text included) survives until a real page reload; saving there is
|
||||
explicit (💾/Ctrl+S) and refreshes the page regions in place. Admin panels
|
||||
never reload the page. In-place
|
||||
page re-rendering shared by the banner/site/structure tabs lives in
|
||||
`swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic
|
||||
regions, replaceState). Placeholder texts are reserved for showing the
|
||||
actual default in effect when a field is left empty (e.g. the pending
|
||||
row's slug derived from its title); labels and help are real elements or
|
||||
tooltips, never placeholders.
|
||||
Everything saves immediately as you edit (brand/title/CSS debounced,
|
||||
slug on commit since it renames the path), theme change swaps the
|
||||
stylesheet in place, tree rows navigate in place without transitions when
|
||||
focused, and the front page is a root-only row whose empty slug is
|
||||
@@ -150,19 +236,16 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
target. Committing a pending page PUTs it with empty markdown (creates
|
||||
an empty page that renders with its title — saving never deletes;
|
||||
deletion is the page editor's explicit choice: saving trimmed-empty
|
||||
text issues a REST DELETE), then hands over to the page editor
|
||||
(CodeMirror focuses on mount). Dropping ON the lower part of a row
|
||||
moves the page under that
|
||||
row (the child list's container invisibly overlaps its own row's bottom
|
||||
via negative margin — Sortable inserts it as the first child natively),
|
||||
while a row's exposed top edge inserts a sibling before it. Row
|
||||
text issues a REST DELETE), then switches to the page editor tab for
|
||||
the actual writing. Dropping ON the lower part of a row moves the page
|
||||
under that row (the child list's container invisibly overlaps its own
|
||||
row's bottom via negative margin — Sortable inserts it as the first child
|
||||
natively), while a row's exposed top edge inserts a sibling before it. Row
|
||||
indentation is structural (each nested list margin-indents itself), so a
|
||||
dragged row previews its whole subtree at the target list's depth. The
|
||||
two pens swap the docked
|
||||
panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by
|
||||
pagerite.js when a 🖊️ edit pen is clicked (the pens are injected by
|
||||
pagerite.js after the session validates; they carry
|
||||
`data-editor-src`/`data-editor-css`/`data-editor-mode`).
|
||||
shell is dynamic-imported onto the content page by pagerite.js when an edit
|
||||
pen is clicked (the pens are injected by pagerite.js after the session
|
||||
validates; they carry `data-editor-src`/`data-editor-css`/`data-editor-mode`).
|
||||
In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`),
|
||||
in prod from the hashed build assets resolved via
|
||||
`frontend-build/.vite/manifest.json`. `vite.config.js` sets
|
||||
@@ -170,19 +253,20 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
`assetsDir: '_/assets'` (so the build mirrors the URL space;
|
||||
`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
|
||||
`src/assets/pagerite.css` and every `src/assets/themes/*/theme.css` as
|
||||
separate stylesheet entries (enumerated from the themes directory, so new
|
||||
themes need no config change); there
|
||||
`src/assets/pagerite.css` as a separate stylesheet entry; theme and
|
||||
banner-design CSS are NOT built — they live in `pagerite/themes/{name}/`
|
||||
and are served by the backend. There
|
||||
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
|
||||
`preserveEntrySignatures: 'exports-only'` because main.js is consumed
|
||||
via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite
|
||||
app builds otherwise strip unused entry exports, leaving dead edit pens.
|
||||
In dev the backend links no stylesheets (Vite injects them from JS); the
|
||||
active theme reaches the page as `<meta name="pagerite:theme">` and
|
||||
pagerite.js imports that theme's CSS, while a theme switch in the site
|
||||
editor swaps the Vite-injected `<style data-vite-dev-id>` tags (the
|
||||
`<link>` sync used in prod is a no-op in dev).
|
||||
In dev the backend links theme/banner-design stylesheets like in prod
|
||||
(`/_themes/...`); only the base CSS is Vite-injected from JS, and
|
||||
pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/
|
||||
`#pagerite-user` elements to restore the canonical order (base < theme <
|
||||
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
|
||||
auto-upgrade marker — edit `vite.config.js`, not the plugin.
|
||||
- `docs/` — design documentation.
|
||||
|
||||
@@ -51,8 +51,13 @@ evolves.
|
||||
single-trusted-author assumption above.
|
||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition
|
||||
lists, task lists, brace-attributes; tables and strikethrough from the
|
||||
default preset), with `html=True` for raw passthrough. Fenced code blocks
|
||||
are highlighted server-side with **Pygments** (`nowrap` spans styled by
|
||||
default preset), with `html=True` for raw passthrough,
|
||||
`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-*`
|
||||
variables; the base stylesheet defines light and dark palette sets resolved
|
||||
via `light-dark()`, so each theme gets the set matching its `color-scheme`
|
||||
@@ -64,11 +69,13 @@ evolves.
|
||||
are stored by content hash — blake3, first 6 bytes hex + original
|
||||
extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs
|
||||
that survive page renames and dedupe identical content; pages no longer
|
||||
own files. An image with a title becomes a `<figure>` with
|
||||
`<figcaption>`. Positioning is by attribute classes:
|
||||
own files. An image standing alone in its paragraph becomes a block
|
||||
`<figure>` — with `<figcaption>` when it has a title; images inline
|
||||
with text and raw `<img>` HTML stay plain inline images. Positioning
|
||||
is by attribute classes:
|
||||
`{.right}` — `{.right}`, `{.left}` float at
|
||||
30% of the text column (the caption wraps within it; an explicit
|
||||
`width=300` overrides on uncaptioned images),
|
||||
`width=300` makes the figure shrink-wrap the image instead),
|
||||
`{.wide}` goes full bleed (viewport edge to edge, or up to the docked
|
||||
editor; the sidebar stacks on top of it); plain attributes like `width=300`
|
||||
work too. Headings (h1/h2) clear floats, so images never overflow into the
|
||||
@@ -81,15 +88,21 @@ evolves.
|
||||
`Sidebar`, `Main`) filled per request. The dynamic regions carry stable
|
||||
ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
|
||||
- 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
|
||||
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
|
||||
navigation bar overlaid on it — no separate chrome header. The banner
|
||||
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
|
||||
walking up the node's ancestors to the front page; when nothing in the
|
||||
chain sets one, the active theme's banner artwork shows. That artwork is
|
||||
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.
|
||||
walking up the node's ancestors to the front page and rendered **after**
|
||||
the design artwork, so author styles always win over the design's own.
|
||||
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
|
||||
ruin user-supplied designs; themes that want one bake it into their SVG
|
||||
@@ -149,20 +162,24 @@ evolves.
|
||||
|
||||
- The base stylesheet `frontend/src/assets/pagerite.css` provides the layout,
|
||||
typography and interaction rules with conservative CSS variables. A theme layer
|
||||
(`frontend/src/assets/themes/{name}/theme.css` — currently `purple`, `corporate`
|
||||
and `nitro`) overrides those variables and
|
||||
(`pagerite/themes/{name}/theme.css` — currently `purple`, `corporate`
|
||||
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
|
||||
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
|
||||
`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
|
||||
banner and orange accents carry over unchanged); purple (dusk) uses one
|
||||
fixed palette for everyone. Themes may restyle structural details the base
|
||||
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
|
||||
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.
|
||||
- Fonts, the shared stylesheet and pygments styles
|
||||
live under `frontend/src/assets/` and are emitted as hashed assets under
|
||||
@@ -188,7 +205,9 @@ evolves.
|
||||
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
|
||||
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
|
||||
the **structure tree**. Everything saves immediately as you edit — no
|
||||
save button, no edit mode.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"transliteration": "^2.6.1",
|
||||
"vue": "^3.5.26",
|
||||
"vuedraggable": "^4.1.0"
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,415 @@
|
||||
<script setup>
|
||||
// Banner editor tab: per-page banner HTML and banner design, previewed into
|
||||
// the real #page-banner region. Close and tab switching live in EditorShell.
|
||||
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { loadPlain, runScripts } from './swapdoc'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
})
|
||||
// close/path-change are wired by EditorShell; this tab never emits them.
|
||||
defineEmits(['close', 'pathChange'])
|
||||
|
||||
const path = ref('')
|
||||
const banner = ref('')
|
||||
const saveError = ref('')
|
||||
const fileInput = ref(null)
|
||||
const bannerEl = ref(null)
|
||||
|
||||
let ws = null
|
||||
let pendingSave = null
|
||||
let reconnectTimer = null
|
||||
let reconnectDelay = 2000
|
||||
const MAX_RECONNECT_DELAY = 16000
|
||||
let everConnected = false
|
||||
let view = null // CodeMirror for the banner HTML
|
||||
let syncing = false // set while replacing the document programmatically
|
||||
|
||||
// Banner design selector options come from the backend via settings.
|
||||
const theme = ref('')
|
||||
const bannerDesign = ref(null)
|
||||
const bannerDesignFrom = ref(null)
|
||||
const bannerDesigns = ref([])
|
||||
// Where an empty banner code field falls back to (null = nowhere: the
|
||||
// banner is just the design artwork), shown as a caption under the field.
|
||||
const bannerFrom = ref(null)
|
||||
// The design that "inherit" resolves to and where it comes from
|
||||
// (bannerDesignFrom: an ancestor path, "" = the front page, null = the
|
||||
// active theme's default).
|
||||
const bannerDesignInherited = ref('')
|
||||
// One-shot callback run on the next save ack (set by onBannerDesignChange,
|
||||
// whose re-render must not race the save it triggers).
|
||||
let refreshOnSave = null
|
||||
|
||||
// The inherit option names the design actually in effect and its source.
|
||||
const inheritLabel = computed(() => {
|
||||
if (bannerDesignFrom.value === null) {
|
||||
return `— (from ${theme.value || 'none'})`
|
||||
}
|
||||
const where = bannerDesignFrom.value === ''
|
||||
? 'the front page'
|
||||
: `/${bannerDesignFrom.value}`
|
||||
return `— (${bannerDesignInherited.value || 'none'} from ${where})`
|
||||
})
|
||||
|
||||
function normPath(p) {
|
||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg))
|
||||
} else {
|
||||
ensureConnected()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
const timers = {}
|
||||
function debounce(key, fn, ms = 600) {
|
||||
clearTimeout(timers[key])
|
||||
timers[key] = setTimeout(fn, ms)
|
||||
}
|
||||
|
||||
function save() {
|
||||
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
|
||||
pendingSave = msg
|
||||
send(msg)
|
||||
}
|
||||
|
||||
function openPath(p) {
|
||||
path.value = p
|
||||
send({ type: 'open', path: p })
|
||||
}
|
||||
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
|
||||
|
||||
function updateWindowTitle() {
|
||||
document.title = `banner: /${path.value} 🖊️`
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
updateWindowTitle()
|
||||
if (banner.value.trim()) previewBanner()
|
||||
})
|
||||
|
||||
// The shell stays mounted while hidden: when it is re-shown with this tab
|
||||
// active, restore the window title and banner preview.
|
||||
function onEditorShown() {
|
||||
if (document.body.dataset.editorMode !== 'banner') return
|
||||
updateWindowTitle()
|
||||
if (banner.value.trim()) previewBanner()
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const s = await (await fetch('/_api/settings')).json()
|
||||
theme.value = s.theme || ''
|
||||
bannerDesigns.value = s.banner_designs || []
|
||||
} catch { /* keep default */ }
|
||||
}
|
||||
|
||||
// Re-render the page from the server (after banner design changes), then
|
||||
// re-overlay the page's own banner code if one is being edited.
|
||||
async function rerender() {
|
||||
if (await loadPlain(path.value)) {
|
||||
if (banner.value.trim()) previewBanner()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Banner design ---------------------------------------------------------
|
||||
function onBannerDesignChange() {
|
||||
const msg = {
|
||||
type: 'save',
|
||||
path: normPath(path.value),
|
||||
banner_design: bannerDesign.value,
|
||||
}
|
||||
pendingSave = msg
|
||||
send(msg)
|
||||
refreshOnSave = rerender
|
||||
}
|
||||
|
||||
// --- Banner editing ------------------------------------------------------
|
||||
function setDocument(text) {
|
||||
syncing = true
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
|
||||
syncing = false
|
||||
banner.value = text
|
||||
}
|
||||
|
||||
function previewBanner() {
|
||||
const el = document.getElementById('page-banner')
|
||||
if (!el) return
|
||||
if (banner.value.trim()) {
|
||||
// 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
|
||||
runScripts(el)
|
||||
el.prepend(...artwork)
|
||||
} else {
|
||||
// No banner code of its own: the region must show the inherited/design
|
||||
// banner — re-render from the server (an empty write here would wipe it).
|
||||
loadPlain(path.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onBannerInput() {
|
||||
previewBanner()
|
||||
debounce('banner-html', save, 400)
|
||||
}
|
||||
|
||||
function stripBannerMedia(html) {
|
||||
// A banner has one piece of media: uploading replaces earlier img/video
|
||||
// tags instead of stacking them. (Other HTML, e.g. canvas+script, stays.)
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
for (const el of doc.querySelectorAll('img, video')) el.remove()
|
||||
return doc.body.innerHTML.trim()
|
||||
}
|
||||
|
||||
async function uploadBannerMedia(file) {
|
||||
// Banner media goes to the shared content store, like article images.
|
||||
if (!file || !/^(image|video)\//.test(file.type)) return
|
||||
const name = file.name.replace(/[^\w.-]/g, '-')
|
||||
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
||||
if (!res.ok) return
|
||||
const { path: stored } = await res.json()
|
||||
const tag = file.type.startsWith('video/')
|
||||
? `<video src="${stored}" autoplay muted loop playsinline></video>`
|
||||
: `<img src="${stored}" alt="">`
|
||||
const rest = stripBannerMedia(banner.value)
|
||||
setDocument(rest ? `${tag}\n${rest}` : tag)
|
||||
previewBanner()
|
||||
save()
|
||||
}
|
||||
|
||||
function onBannerPaste(ev) {
|
||||
const file = [...(ev.clipboardData?.files || [])]
|
||||
.find((f) => /^(image|video)\//.test(f.type))
|
||||
if (file) {
|
||||
ev.preventDefault()
|
||||
uploadBannerMedia(file)
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(ev) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg.type === 'doc' && msg.path === path.value) {
|
||||
setDocument(msg.banner ?? '')
|
||||
bannerDesign.value = msg.banner_design ?? null
|
||||
bannerDesignFrom.value = msg.banner_design_from ?? null
|
||||
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
|
||||
bannerFrom.value = msg.banner_from ?? null
|
||||
if (banner.value.trim()) previewBanner()
|
||||
} else if (msg.type === 'saved') {
|
||||
saveError.value = ''
|
||||
pendingSave = null
|
||||
refreshOnSave?.()
|
||||
refreshOnSave = null
|
||||
} else if (msg.type === 'error') {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
ev.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(
|
||||
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
|
||||
)
|
||||
ws.onmessage = onMessage
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = 2000
|
||||
if (everConnected) {
|
||||
if (pendingSave) send(pendingSave)
|
||||
} else {
|
||||
openPath(normPath(props.pagePath))
|
||||
}
|
||||
everConnected = true
|
||||
}
|
||||
ws.onclose = () => {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect()
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}, reconnectDelay)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
connect()
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
html(),
|
||||
cmTheme,
|
||||
cmHighlight,
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged && !syncing) {
|
||||
banner.value = view.state.doc.toString()
|
||||
onBannerInput()
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: bannerEl.value,
|
||||
})
|
||||
addEventListener('keydown', onKeydown)
|
||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||
await loadSettings()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(reconnectTimer)
|
||||
for (const t of Object.values(timers)) clearTimeout(t)
|
||||
if (ws) {
|
||||
ws.onclose = null // intentional close, no reconnect
|
||||
ws.close()
|
||||
}
|
||||
view?.destroy()
|
||||
removeEventListener('keydown', onKeydown)
|
||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="banner-editor">
|
||||
<div v-if="saveError">{{ saveError }}</div>
|
||||
|
||||
<section class="block" @paste="onBannerPaste">
|
||||
<div class="block-head">
|
||||
<select
|
||||
v-model="bannerDesign"
|
||||
class="text-input design-select"
|
||||
title="Banner design (artwork + its own styles)"
|
||||
@change="onBannerDesignChange"
|
||||
>
|
||||
<option :value="null">{{ inheritLabel }}</option>
|
||||
<option value="">none</option>
|
||||
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="upload banner image/video (replaces existing media) — pasting works too"
|
||||
@click="fileInput.click()"
|
||||
>🖼️</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
hidden
|
||||
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="bannerFrom !== null" class="note">
|
||||
left empty, the banner code is inherited from /{{ bannerFrom }}
|
||||
</div>
|
||||
<div ref="bannerEl" class="banner-cm" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.block-head .icon-btn {
|
||||
margin-left: auto;
|
||||
padding: 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.block-head .icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The banner design selector stays compact; the upload button is pushed
|
||||
right by its auto margin. */
|
||||
.design-select {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* CodeMirror window for the banner HTML; fills the tab and scrolls
|
||||
internally. */
|
||||
.banner-cm {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-cm :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.banner-cm :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.banner-cm :deep(.cm-gutters) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
// Tabbed shell for the four admin editors. The individual pens are shorthands
|
||||
// that open the shell on a given tab; once open, tabs switch instantly without
|
||||
// closing the panel. Tabs are kept alive so switching preserves state.
|
||||
import { onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import PageEditor from './PageEditor.vue'
|
||||
import BannerEditor from './BannerEditor.vue'
|
||||
import SiteEditor from './SiteEditor.vue'
|
||||
import StructureEditor from './StructureEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
initialMode: { type: String, default: 'page' },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const currentPath = ref(props.pagePath)
|
||||
const activeMode = ref(props.initialMode)
|
||||
|
||||
// Tab order: site-wide settings first (site, structure), then — after a
|
||||
// visual break — the per-page editors (article, banner).
|
||||
const MODES = [
|
||||
{ key: 'site', label: 'site', component: SiteEditor },
|
||||
{ key: 'structure', label: 'structure', component: StructureEditor },
|
||||
{ key: 'page', label: 'article', component: PageEditor, breakBefore: true },
|
||||
{ key: 'banner', label: 'banner', component: BannerEditor },
|
||||
]
|
||||
|
||||
function switchMode(mode) {
|
||||
if (MODES.some((m) => m.key === mode)) activeMode.value = mode
|
||||
}
|
||||
|
||||
function onPathChange(path) {
|
||||
currentPath.value = path
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
provide('editorShell', { switchMode })
|
||||
|
||||
watch(activeMode, (mode) => {
|
||||
document.body.dataset.editorMode = mode
|
||||
}, { immediate: true })
|
||||
|
||||
// A pen click while the shell is open (main.js) switches tabs, and also
|
||||
// retargets the editors when the user fetch-navigated with the shell open.
|
||||
function onSwitchEvent(ev) {
|
||||
if (ev.detail?.path != null) currentPath.value = ev.detail.path
|
||||
if (ev.detail?.mode) switchMode(ev.detail.mode)
|
||||
}
|
||||
|
||||
// Closing the shell hides it but keeps it mounted (main.js); the tabs stay
|
||||
// cached in KeepAlive the whole time, so no state is ever lost until a real
|
||||
// page reload. On re-show each active tab re-applies its window title and
|
||||
// preview via its own pagerite:editor-shown listener.
|
||||
function onKeydown(ev) {
|
||||
if (ev.key === 'Escape' && document.body.classList.contains('editing')) close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.body.dataset.editorMode = activeMode.value
|
||||
addEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||
addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||
removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-root overlay">
|
||||
<header class="editor-tabs">
|
||||
<template v-for="m in MODES" :key="m.key">
|
||||
<span v-if="m.breakBefore" class="tab-break" />
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: activeMode === m.key }"
|
||||
@click="switchMode(m.key)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" class="close" title="close" @click="close">✕</button>
|
||||
</header>
|
||||
<div class="editor-tab-body">
|
||||
<KeepAlive>
|
||||
<component
|
||||
:is="MODES.find((m) => m.key === activeMode).component"
|
||||
:key="activeMode"
|
||||
:page-path="currentPath"
|
||||
@close="close"
|
||||
@path-change="onPathChange"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Docked-overlay positioning (sticky, height, slide-in) comes from the
|
||||
global pagerite.css (.editor-root.overlay); the shell is a flex column so
|
||||
the tab body fills what the tab bar leaves. */
|
||||
.editor-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-tabs .tab {
|
||||
padding: 0.25rem 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-tabs .tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Visual break between the site-wide tabs and the per-page tabs. */
|
||||
.editor-tabs .tab-break {
|
||||
align-self: stretch;
|
||||
margin: 0.2rem 0.5rem;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.editor-tabs .tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.editor-tabs .close {
|
||||
margin-left: auto;
|
||||
padding: 0 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-tabs .close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.editor-tab-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The active tab component's root fills the body. */
|
||||
.editor-tab-body > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,18 +4,23 @@
|
||||
// (/_api/ws/editor). Docked left of the article on the page itself.
|
||||
// The socket connects when the editor is opened and reconnects with
|
||||
// exponential backoff after a failure; unsaved text and pending saves
|
||||
// survive a disconnect. Editor scroll drives the document scroll, keeping the
|
||||
// rendered article at the cursor's position.
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
// survive a disconnect. Editor scroll drives the article scroll (while
|
||||
// editing the window scroll is locked and only #main scrolls), keeping the
|
||||
// rendered article at the cursor's position. Saving (💾 / Ctrl+S) is explicit
|
||||
// and refreshes the page regions in place — never a reload — so the editor
|
||||
// state (unsaved text included) also survives closing the shell; it is lost
|
||||
// only on a real page reload.
|
||||
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { loadPlain } from './swapdoc'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
const emit = defineEmits(['close', 'pathChange'])
|
||||
|
||||
const path = ref('')
|
||||
const title = ref('')
|
||||
@@ -32,7 +37,7 @@ let reconnectTimer = null
|
||||
let reconnectDelay = 2000
|
||||
const MAX_RECONNECT_DELAY = 16000
|
||||
let everConnected = false
|
||||
let dirty = false
|
||||
const dirty = ref(false) // unsaved text exists (drives the 💾 button)
|
||||
let syncingScroll = false
|
||||
|
||||
function send(msg) {
|
||||
@@ -53,15 +58,36 @@ function normPath(p) {
|
||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function pageLabel() {
|
||||
return title.value.trim() || ('/' + (path.value || ''))
|
||||
}
|
||||
|
||||
// Show the article title (or path for a new page) plus the edit pen in the
|
||||
// window title while editing; the server-rendered public title is restored on
|
||||
// close.
|
||||
function updateWindowTitle() {
|
||||
document.title = `${pageLabel()} 🖊️`
|
||||
}
|
||||
|
||||
watch([title, path], updateWindowTitle)
|
||||
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
|
||||
|
||||
onActivated(() => {
|
||||
updateWindowTitle()
|
||||
// Re-shown with unsaved text: restore the working preview into the
|
||||
// article (closing discarded it in favour of the server-rendered page).
|
||||
if (dirty.value) requestRender()
|
||||
})
|
||||
|
||||
function requestRender() {
|
||||
// No debounce: server-side rendering is fast enough per keystroke.
|
||||
if (!view) return
|
||||
dirty = true
|
||||
dirty.value = true
|
||||
send({ type: 'render', path: path.value, markdown: view.state.doc.toString() })
|
||||
}
|
||||
|
||||
function save() {
|
||||
// Path is not editable here (that's the site editor's job); saving
|
||||
// Path is not editable here (that's the structure tab's job); saving
|
||||
// never moves the page.
|
||||
const markdown = view.state.doc.toString()
|
||||
if (markdown.trim() === '') {
|
||||
@@ -83,20 +109,20 @@ function save() {
|
||||
return new Promise((resolve) => { savedResolve = resolve })
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
async function saveAndRefresh() {
|
||||
await save()
|
||||
// Reload so nav/sidebar changes apply, then the editor is gone.
|
||||
dirty = false
|
||||
emit('close')
|
||||
location.reload()
|
||||
dirty.value = false
|
||||
// Refresh the page regions from the server so nav/sidebar changes apply
|
||||
// (never a reload: the editor keeps its state).
|
||||
loadPlain(path.value)
|
||||
}
|
||||
|
||||
function close() {
|
||||
// Reload if the visible page is showing unsaved preview edits.
|
||||
const stale = dirty
|
||||
dirty = false
|
||||
emit('close')
|
||||
if (stale) location.reload()
|
||||
// Discard the unsaved preview by re-rendering the page from the server.
|
||||
// The editor text itself is kept (the shell stays mounted while hidden)
|
||||
// and can still be saved later.
|
||||
if (dirty.value) loadPlain(path.value)
|
||||
}
|
||||
|
||||
function insertAtCursor(text) {
|
||||
@@ -115,6 +141,82 @@ async function uploadImage(file) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Format toolbar --------------------------------------------------------
|
||||
// Small Markdown helpers for the hard-to-remember syntax; each leaves the
|
||||
// relevant part selected so typing replaces it.
|
||||
function wrapInline(mark) {
|
||||
// Toggle: wrapped selection (or wrapping marks around it) is unwrapped.
|
||||
const { from, to } = view.state.selection.main
|
||||
const doc = view.state.doc
|
||||
if (doc.sliceString(Math.max(0, from - mark.length), from) === mark
|
||||
&& doc.sliceString(to, to + mark.length) === mark) {
|
||||
view.dispatch({ changes: [
|
||||
{ from: to, to: to + mark.length },
|
||||
{ from: from - mark.length, to: from },
|
||||
] })
|
||||
} else {
|
||||
const text = doc.sliceString(from, to)
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: mark + text + mark },
|
||||
selection: { anchor: from + mark.length, head: to + mark.length },
|
||||
})
|
||||
}
|
||||
view.focus()
|
||||
}
|
||||
|
||||
function insertCode() {
|
||||
// On an empty line with no selection: a fenced code block, cursor inside.
|
||||
// Otherwise an inline code wrap (toggling).
|
||||
const { from, to } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(from)
|
||||
if (from === to && !line.text.trim()) {
|
||||
view.dispatch({
|
||||
changes: { from: line.from, to: line.to, insert: '```\n\n```' },
|
||||
selection: { anchor: line.from + 4 },
|
||||
})
|
||||
view.focus()
|
||||
return
|
||||
}
|
||||
wrapInline('`')
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
// Selected text becomes the link label — or the URL if it looks like one.
|
||||
const { from, to } = view.state.selection.main
|
||||
const text = view.state.sliceDoc(from, to)
|
||||
const isUrl = /^https?:\/\/\S+$/.test(text)
|
||||
const insert = isUrl ? `[](${text})` : `[${text}]()`
|
||||
const urlStart = from + insert.length - 1 // inside the parens
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: isUrl ? { anchor: from + 1 } : { anchor: urlStart },
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// Table size picker: a hover grid popup (cols × rows) under the toolbar.
|
||||
const tablePicker = ref(false)
|
||||
const tableSize = ref({ cols: 0, rows: 0 })
|
||||
const TABLE_MAX_COLS = 8
|
||||
const TABLE_MAX_ROWS = 6
|
||||
|
||||
function insertTable(cols, rows) {
|
||||
// A GFM table on its own blank-separated block, first header cell
|
||||
// selected.
|
||||
const { from, to } = view.state.selection.main
|
||||
const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : ''
|
||||
const row = (cells) => `| ${cells.join(' | ')} |`
|
||||
const table = `${before}${row(Array(cols).fill('column'))}\n`
|
||||
+ `${row(Array(cols).fill('---'))}\n`
|
||||
+ `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n`
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: table },
|
||||
selection: { anchor: from + before.length + 2, head: from + before.length + 8 },
|
||||
})
|
||||
tablePicker.value = false
|
||||
view.focus()
|
||||
}
|
||||
|
||||
function openPath(p) {
|
||||
path.value = p
|
||||
send({ type: 'open', path: p })
|
||||
@@ -164,12 +266,13 @@ function onMessage(ev) {
|
||||
published.value = msg.published
|
||||
setDocument(msg.markdown)
|
||||
requestRender()
|
||||
dirty = false // just loaded from the server, nothing unsaved
|
||||
dirty.value = false // just loaded from the server, nothing unsaved
|
||||
} else if (msg.type === 'html' && msg.path === path.value) {
|
||||
previewIntoArticle(msg.html, msg.has_h1)
|
||||
} else if (msg.type === 'saved') {
|
||||
saveError.value = ''
|
||||
pendingSave = null
|
||||
dirty.value = false
|
||||
savedResolve?.()
|
||||
savedResolve = null
|
||||
} else if (msg.type === 'error') {
|
||||
@@ -178,24 +281,45 @@ function onMessage(ev) {
|
||||
}
|
||||
|
||||
function onKeydown(ev) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
if (!(ev.ctrlKey || ev.metaKey)) return
|
||||
if (ev.key === 's') {
|
||||
ev.preventDefault()
|
||||
save()
|
||||
return
|
||||
}
|
||||
if (ev.key === 'Escape') close()
|
||||
// Bold/italic only when typing in the CodeMirror editor itself.
|
||||
if (!view?.hasFocus) return
|
||||
if (ev.key === 'b') {
|
||||
ev.preventDefault()
|
||||
wrapInline('**')
|
||||
} else if (ev.key === 'i') {
|
||||
ev.preventDefault()
|
||||
wrapInline('*')
|
||||
}
|
||||
}
|
||||
|
||||
// The shell stays mounted while hidden: when it is re-shown with this tab
|
||||
// active, restore the window title (and the working preview if unsaved
|
||||
// text exists — closing discarded it in favour of the server render).
|
||||
function onEditorShown() {
|
||||
if (document.body.dataset.editorMode !== 'page') return
|
||||
updateWindowTitle()
|
||||
if (dirty.value) requestRender()
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
// Editor scroll drives the document: keep the rendered article at the
|
||||
// same proportional position as the cursor area in the editor.
|
||||
// Editor scroll drives the article: keep the rendered page at the same
|
||||
// proportional position as the cursor area in the editor. While editing
|
||||
// the window scroll is locked and #main is the scrolling element.
|
||||
if (syncingScroll || !view) return
|
||||
const main = document.getElementById('main')
|
||||
if (!main) return
|
||||
syncingScroll = true
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = view.scrollDOM
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
const pct = max > 0 ? scroller.scrollTop / max : 0
|
||||
const doc = document.documentElement
|
||||
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
|
||||
main.scrollTop = pct * (main.scrollHeight - main.clientHeight)
|
||||
syncingScroll = false
|
||||
})
|
||||
}
|
||||
@@ -228,6 +352,7 @@ function connect() {
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
updateWindowTitle()
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
@@ -263,6 +388,7 @@ onMounted(() => {
|
||||
path: () => path.value,
|
||||
}
|
||||
addEventListener('keydown', onKeydown)
|
||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -274,13 +400,17 @@ onUnmounted(() => {
|
||||
view?.destroy()
|
||||
delete window.__pageritePageEditor
|
||||
removeEventListener('keydown', onKeydown)
|
||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-root overlay">
|
||||
<div class="page-editor">
|
||||
<header class="toolbar">
|
||||
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
|
||||
<label class="title-field">
|
||||
<span class="field-label">title</span>
|
||||
<input v-model="title" class="title" @input="requestRender" />
|
||||
</label>
|
||||
<label><input v-model="published" type="checkbox" /> published</label>
|
||||
<input
|
||||
ref="fileInput"
|
||||
@@ -289,10 +419,41 @@ onUnmounted(() => {
|
||||
hidden
|
||||
@change="(ev) => { uploadImage(ev.target.files[0]); ev.target.value = '' }"
|
||||
/>
|
||||
<button type="button" @click="fileInput.click()">image</button>
|
||||
<button type="button" @click="saveAndClose">save</button>
|
||||
<button type="button" class="close" title="close" @click="close">✕</button>
|
||||
<button
|
||||
type="button"
|
||||
class="save"
|
||||
title="save (Ctrl+S)"
|
||||
:disabled="!dirty"
|
||||
@click="saveAndRefresh"
|
||||
>💾</button>
|
||||
</header>
|
||||
<div class="format-bar">
|
||||
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
||||
<button type="button" title="italic" @click="wrapInline('*')"><i>I</i></button>
|
||||
<button type="button" title="code (empty line: code block)" @click="insertCode"><code></></code></button>
|
||||
<button type="button" title="link" @click="insertLink">🔗</button>
|
||||
<button
|
||||
type="button"
|
||||
title="table"
|
||||
:class="{ active: tablePicker }"
|
||||
@click="tablePicker = !tablePicker"
|
||||
>▦</button>
|
||||
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼️</button>
|
||||
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
|
||||
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
|
||||
<button
|
||||
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||||
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||||
@click="insertTable(tableSize.cols, tableSize.rows)"
|
||||
/>
|
||||
</div>
|
||||
<div class="tp-size">{{ tableSize.cols || '–' }} × {{ tableSize.rows || '–' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="saveError">{{ saveError }}</div>
|
||||
<div class="panes">
|
||||
<div ref="editorEl" class="editor" />
|
||||
@@ -301,15 +462,11 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-root {
|
||||
.page-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Docked-overlay positioning lives in the global pagerite.css (.editor-host /
|
||||
.editor-root.overlay) since the host element is created by main.js. */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -319,6 +476,19 @@ onUnmounted(() => {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.toolbar .title-field {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar .field-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toolbar .title {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
@@ -336,28 +506,93 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
font: inherit;
|
||||
padding: 0.25rem 0.8rem;
|
||||
background: var(--accent2);
|
||||
color: white;
|
||||
/* Borderless icon button; greyed out (desaturated) while there is nothing
|
||||
to save. */
|
||||
.toolbar button.save {
|
||||
padding: 0 0.3rem;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Disabled = black & white only; an explicit color overrides the browser's
|
||||
built-in dimmed disabled-button color. */
|
||||
.toolbar button.save:disabled {
|
||||
color: var(--text);
|
||||
filter: saturate(0);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Markdown helpers: plain icon buttons under the toolbar. */
|
||||
.format-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.format-bar button {
|
||||
min-width: 1.7rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Window-style close button, top right corner. */
|
||||
.toolbar .close {
|
||||
margin-left: auto;
|
||||
padding: 0 0.3rem;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.toolbar .close:hover {
|
||||
.format-bar button:hover,
|
||||
.format-bar button.active {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* Table size picker: hover grid popup below the format bar; the hovered
|
||||
cell and everything up-left of it is the table to insert. */
|
||||
.table-picker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 6.5rem;
|
||||
z-index: 20;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.tp-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tp-cell {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tp-cell.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tp-size {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panes {
|
||||
@@ -371,7 +606,7 @@ onUnmounted(() => {
|
||||
|
||||
/* CodeMirror sits inside a bordered box, like a dialog's input area, with
|
||||
a slight margin to the panel edges. Wheel scroll stays in the editor and
|
||||
drives the document (syncScroll) instead of double-scrolling. */
|
||||
drives the article (syncScroll) instead of double-scrolling. */
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup>
|
||||
// Structure tab: the draggable site structure tree. Focusing a page's row
|
||||
// navigates to it in place (no transitions). Close and tab switching live
|
||||
// in EditorShell.
|
||||
//
|
||||
// The tree comes from the server nested (GET /_api/pages); every node is
|
||||
// real — a label with a title and slug, with content (landing page) or
|
||||
// without (category whose URL renders a placeholder page). The front page
|
||||
// is a top-level row with an empty slug, not the parent of the others.
|
||||
import { inject, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import StructureTree from './StructureTree.vue'
|
||||
import { slugify } from './slugify'
|
||||
import { loadPlain } from './swapdoc'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['close', 'pathChange'])
|
||||
|
||||
const shell = inject('editorShell', null)
|
||||
|
||||
const path = ref('')
|
||||
const saveError = ref('')
|
||||
const tree = ref([])
|
||||
|
||||
function normPath(p) {
|
||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
// Debounce per key: text edits save while typing, without a request per
|
||||
// keystroke.
|
||||
const timers = {}
|
||||
function debounce(key, fn, ms = 600) {
|
||||
clearTimeout(timers[key])
|
||||
timers[key] = setTimeout(fn, ms)
|
||||
}
|
||||
|
||||
function updateWindowTitle() {
|
||||
document.title = 'site structure 🖊️'
|
||||
}
|
||||
|
||||
watch(() => props.pagePath, (p) => { path.value = normPath(p) })
|
||||
onActivated(updateWindowTitle)
|
||||
|
||||
// The shell stays mounted while hidden: when it is re-shown with this tab
|
||||
// active, restore the window title.
|
||||
function onEditorShown() {
|
||||
if (document.body.dataset.editorMode === 'structure') updateWindowTitle()
|
||||
}
|
||||
|
||||
// Tree row focus: switch the edited page and show it, skipping transitions.
|
||||
async function navigate(p) {
|
||||
path.value = p
|
||||
emit('pathChange', p)
|
||||
await loadPlain(p)
|
||||
}
|
||||
|
||||
// If the currently edited page moved (rename/move of itself or an
|
||||
// ancestor), follow it to the new path.
|
||||
function followMove(oldPath, newPath) {
|
||||
if (path.value === oldPath) navigate(newPath)
|
||||
else if (oldPath && path.value.startsWith(`${oldPath}/`)) {
|
||||
navigate(newPath + path.value.slice(oldPath.length))
|
||||
}
|
||||
}
|
||||
|
||||
// --- New page flow -------------------------------------------------------
|
||||
// The ➕ row at the end of any list adds a *pending* row there: a
|
||||
// local-only item that can be dragged into place before anything is
|
||||
// filled in. It is persisted only on commit (✓/Enter), at wherever it
|
||||
// currently sits.
|
||||
const pending = ref(null)
|
||||
|
||||
function newPage(list) {
|
||||
if (pending.value) return // one at a time
|
||||
pending.value = {
|
||||
slug: '',
|
||||
path: '',
|
||||
title: '',
|
||||
order: 0,
|
||||
published: true,
|
||||
has_content: true,
|
||||
children: [],
|
||||
pending: true,
|
||||
}
|
||||
list.push(pending.value)
|
||||
}
|
||||
|
||||
// Where does the pending row currently sit? -> {parentPath, list, index}.
|
||||
function locatePending(nodes, parentPath) {
|
||||
const i = nodes.indexOf(pending.value)
|
||||
if (i >= 0) return { parentPath, list: nodes, i }
|
||||
for (const n of nodes) {
|
||||
const found = locatePending(n.children, n.path)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function discardPending() {
|
||||
const loc = locatePending(tree.value, '')
|
||||
if (loc) loc.list.splice(loc.i, 1)
|
||||
pending.value = null
|
||||
}
|
||||
|
||||
async function commitPending() {
|
||||
const node = pending.value
|
||||
if (!node) return
|
||||
// Empty slug: derive one from the title (transliterated to ASCII).
|
||||
const slug = slugify(node.slug.trim()) || slugify(node.title)
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
const loc = locatePending(tree.value, '')
|
||||
const parentPath = loc?.parentPath ?? ''
|
||||
const newPath = parentPath ? `${parentPath}/${slug}` : slug
|
||||
const res = await fetch(`/_api/pages/${newPath}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: node.title.trim() || slug,
|
||||
markdown: '', // empty markdown creates an empty page (never deletes)
|
||||
published: true,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Show the server's reason (e.g. a reserved file name); the pending
|
||||
// row stays so it can be edited and committed again.
|
||||
saveError.value = `⚠️ ${await errorDetail(res)}`
|
||||
return
|
||||
}
|
||||
// Place it exactly where the row was dropped: a fresh order key halfway
|
||||
// between its new siblings (the PUT appended it at the end).
|
||||
if (loc) {
|
||||
const prev = loc.list[loc.i - 1]
|
||||
const next = loc.list[loc.i + 1]
|
||||
const order = prev && next ? (prev.order + next.order) / 2
|
||||
: prev ? prev.order + 1
|
||||
: next ? next.order - 1
|
||||
: 1
|
||||
await postStructure({ path: newPath, order })
|
||||
} else {
|
||||
saveError.value = ''
|
||||
}
|
||||
pending.value = null
|
||||
await refreshPages()
|
||||
await navigate(newPath)
|
||||
// Hand over to the page editor tab for the actual writing.
|
||||
shell?.switchMode('page')
|
||||
}
|
||||
|
||||
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
|
||||
async function refreshPages() {
|
||||
try {
|
||||
tree.value = await (await fetch('/_api/pages')).json()
|
||||
} catch { /* list stays stale; not fatal */ }
|
||||
}
|
||||
|
||||
// Human-readable reason from a failed API call (FastAPI errors carry a
|
||||
// JSON {detail}), falling back to a generic message.
|
||||
async function errorDetail(res) {
|
||||
const body = await res.json().catch(() => null)
|
||||
return body?.detail || 'changes could not be saved'
|
||||
}
|
||||
|
||||
async function postStructure(op) {
|
||||
const res = await fetch('/_api/structure', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(op),
|
||||
})
|
||||
if (res.ok) {
|
||||
saveError.value = ''
|
||||
loadPlain(path.value) // refresh menus and content from the server
|
||||
} else {
|
||||
saveError.value = `⚠️ ${await errorDetail(res)}`
|
||||
}
|
||||
await refreshPages()
|
||||
return res.ok
|
||||
}
|
||||
|
||||
async function onReorder(parentPath, list, evt) {
|
||||
// vuedraggable already mutated `list`; persist the moved item only: a
|
||||
// fresh order key halfway between its new siblings (all other items
|
||||
// keep theirs), plus the new path when the parent changed. The pending
|
||||
// new-page row is local-only — its position is read at commit time.
|
||||
const change = evt.moved || evt.added
|
||||
if (!change) return
|
||||
const el = change.element
|
||||
if (el.pending) return
|
||||
const i = change.newIndex
|
||||
let prev, next
|
||||
for (let j = i - 1; j >= 0 && !prev; j--) if (!list[j].pending) prev = list[j]
|
||||
for (let j = i + 1; j < list.length && !next; j++) if (!list[j].pending) next = list[j]
|
||||
const order = prev && next ? (prev.order + next.order) / 2
|
||||
: prev ? prev.order + 1
|
||||
: next ? next.order - 1
|
||||
: 1
|
||||
const newPath = parentPath ? `${parentPath}/${el.slug}` : el.slug
|
||||
const op = { path: el.path, order }
|
||||
if (newPath !== el.path) op.move_to = newPath
|
||||
if (await postStructure(op) && op.move_to) followMove(el.path, op.move_to)
|
||||
}
|
||||
|
||||
// Inline title/slug editing: rows are always editable. Title saves while
|
||||
// typing (debounced); the slug commits on blur/Enter, since it renames
|
||||
// the path (moving the whole subtree with it).
|
||||
function onTitleInput(node, ev) {
|
||||
const title = ev.target.value.trim()
|
||||
if (!title || title === node.title) return
|
||||
debounce(`title:${node.path}`, async () => {
|
||||
await postStructure({ path: node.path, title })
|
||||
})
|
||||
}
|
||||
|
||||
// The slug inputs are filtered as you type (StructureTree onSlugInput,
|
||||
// see slugify.js); the server re-validates and its reason is shown.
|
||||
async function commitSlug(node, ev) {
|
||||
const slug = ev.target.value.trim()
|
||||
if (slug === node.slug) return
|
||||
const parent = node.path.split('/').slice(0, -1).join('/')
|
||||
// Empty slug at top level = the front page (path "").
|
||||
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
|
||||
if (await postStructure({ path: node.path, move_to: moveTo })) {
|
||||
followMove(node.path, moveTo)
|
||||
} else {
|
||||
ev.target.value = node.slug // rename failed: put the old slug back
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion is immediate, no confirmation.
|
||||
async function removePage(node) {
|
||||
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
saveError.value = ''
|
||||
refreshPages()
|
||||
const p = node.path
|
||||
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
|
||||
// The current page was deleted — or reduced to a category, which now
|
||||
// renders a placeholder page. Either way, re-render from the server.
|
||||
if (node.children.length) loadPlain(path.value)
|
||||
else navigate('')
|
||||
} else {
|
||||
loadPlain(path.value) // refresh menus
|
||||
}
|
||||
} else {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
provide('structureHandlers', {
|
||||
current: () => path.value,
|
||||
open: navigate,
|
||||
removePage,
|
||||
reorder: onReorder,
|
||||
titleInput: onTitleInput,
|
||||
commitSlug,
|
||||
commitPending,
|
||||
discardPending,
|
||||
newPage,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
path.value = normPath(props.pagePath)
|
||||
refreshPages()
|
||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const t of Object.values(timers)) clearTimeout(t)
|
||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="structure-editor">
|
||||
<div v-if="saveError">{{ saveError }}</div>
|
||||
<section class="block structure">
|
||||
<StructureTree :nodes="tree" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.structure-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.structure {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
||||
// Nodes come from the server (GET /_api/pages via SiteEditor.vue) as
|
||||
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
||||
// {slug, path, title, order, published, has_content, children}.
|
||||
// Every node is real: a label whose title and slug are always editable
|
||||
// inline — the title saves while typing (and focusing it opens the page),
|
||||
@@ -19,8 +19,8 @@
|
||||
// The front page is the root row with an empty slug: renaming it away
|
||||
// leaves no front page, and giving another top-level row the empty slug
|
||||
// makes it the front page. Delete is a two-step inline button (no dialog).
|
||||
// Actions are injected from SiteEditor.vue to avoid per-level event
|
||||
// forwarding.
|
||||
// Actions are injected from the parent editor (StructureEditor.vue) to
|
||||
// avoid per-level event forwarding.
|
||||
import { inject } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { slugify } from './slugify'
|
||||
@@ -104,7 +104,7 @@ function onEnd() {
|
||||
v-model="element.title"
|
||||
v-focus
|
||||
class="edit title-edit"
|
||||
placeholder="Title"
|
||||
title="Page title"
|
||||
@keyup.enter="handlers.commitPending()"
|
||||
@keyup.esc="handlers.discardPending()"
|
||||
/>
|
||||
@@ -126,7 +126,6 @@ function onEnd() {
|
||||
<input
|
||||
class="edit title-edit"
|
||||
:value="element.title"
|
||||
placeholder="Title"
|
||||
title="Label in the navigation — saves while typing; click opens the page"
|
||||
@input="handlers.titleInput(element, $event)"
|
||||
@focus="handlers.open(element.path)"
|
||||
@@ -142,14 +141,14 @@ function onEnd() {
|
||||
<span class="acts">
|
||||
<span v-if="!element.published" class="draft">draft</span>
|
||||
<button
|
||||
v-if="element.has_content || !element.children.length"
|
||||
type="button"
|
||||
class="act del"
|
||||
:class="{ armed: handlers.arming() === element.path }"
|
||||
:title="element.children.length
|
||||
? 'delete the landing page (the category keeps its subpages)'
|
||||
: 'delete page'"
|
||||
@click="handlers.armRemove(element)"
|
||||
>{{ handlers.arming() === element.path ? 'delete?' : '✕' }}</button>
|
||||
@click="handlers.removePage(element)"
|
||||
>✕</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -300,12 +299,6 @@ body.tree-dragging .treelist {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Two-step delete: the first click arms the button, the second deletes. */
|
||||
.act.armed {
|
||||
color: #e06c75;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.del:hover {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
@@ -67,31 +67,22 @@
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Overlay scrollbars where supported (Chromium/WebKit, deprecated but
|
||||
working): a scrollbar that appears covers a strip of the layout instead
|
||||
of shifting it, and no space is reserved while absent — so 100vw always
|
||||
equals the body width and .wide breakout margins stay exact. Ignored by
|
||||
Firefox (unknown value), which keeps its thin classic scrollbar. */
|
||||
overflow-y: overlay;
|
||||
/* Lightweight scrollbar styling: thin, theme-muted thumb on a transparent
|
||||
track where supported (Firefox: the two properties; WebKit: the rules
|
||||
below). Full restyling deliberately not attempted. */
|
||||
/* Native-scrollbar fallback styling (JS off or before pagerite.js runs):
|
||||
thin, theme-muted thumb on a transparent track. With JS the scrollbars
|
||||
are replaced by OverlayScrollbars (see pagerite.js) — floating,
|
||||
auto-hidden scrollbars styled by the --os-* variables below, so they
|
||||
never reserve layout space or shift the page when appearing. */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--muted) 45%, transparent);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.os-scrollbar {
|
||||
--os-size: 0.5rem;
|
||||
--os-thumb-bg: color-mix(in srgb, var(--muted) 45%, transparent);
|
||||
--os-thumb-hover-bg: color-mix(in srgb, var(--muted) 65%, transparent);
|
||||
--os-thumb-active-bg: var(--muted);
|
||||
--os-track-bg: transparent;
|
||||
--os-thumb-border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -131,25 +122,55 @@ body {
|
||||
}
|
||||
|
||||
/* Per-page banner content (img, styled div, inline SVG...) fills the
|
||||
banner; swapped along with #nav/#main on fetch-navigation. When the page
|
||||
sets no banner of its own, the backend inlines the active theme's SVG
|
||||
artwork here instead (pagerite/themes/{theme}/banner.svg). */
|
||||
banner; swapped along with #nav/#main on fetch-navigation. The backend
|
||||
inlines the effective banner design's SVG artwork here first
|
||||
(pagerite/themes/{design}/banner.svg, marked svg[data-design]), then the
|
||||
page's own banner code after it. */
|
||||
#page-banner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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. The track
|
||||
is explicitly banner-sized: an auto row would size to the content
|
||||
(an SVG's intrinsic aspect ratio makes it far taller than the banner),
|
||||
and children's height:100% would resolve against that bloated row. */
|
||||
display: grid;
|
||||
grid-template: 100% / 100%;
|
||||
}
|
||||
|
||||
#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. No
|
||||
width/height: the default stretch alignment fills the track without a
|
||||
percentage-resolution cycle. */
|
||||
#page-banner>*:not(style, script) {
|
||||
grid-area: 1 / 1;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
/* Banner artwork is bottom-anchored: the meaningful content (horizon,
|
||||
ground, characters) sits at the bottom, so on wide viewports — where
|
||||
the fixed-height banner crops the artwork vertically — the sky/top is
|
||||
what scrolls out of view. object-position covers replaced elements
|
||||
(img, canvas); inline SVG is anchored by the transform-origin below
|
||||
together with preserveAspectRatio="...YMax slice" in the artwork. */
|
||||
object-position: bottom;
|
||||
/* Scroll parallax: pagerite.js sets --pry on <html>; the banner stays
|
||||
windowed in place while the artwork drifts inside it. The scale
|
||||
provides overscan so the drift never reveals an edge; scaling from the
|
||||
bottom keeps the artwork's bottom edge pinned to the banner's. */
|
||||
transform: scale(1.25) translateY(var(--pry, 0px));
|
||||
transform-origin: bottom;
|
||||
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%;
|
||||
object-fit: cover;
|
||||
/* Scroll parallax: pagerite.js sets --pry on <html>; the banner stays
|
||||
windowed in place while the artwork drifts inside it. The scale
|
||||
provides overscan so the drift never reveals an edge. */
|
||||
transform: scale(1.25) translateY(var(--pry, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
#brand,
|
||||
@@ -226,6 +247,16 @@ body {
|
||||
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 {
|
||||
margin-left: var(--editor-w);
|
||||
padding-left: 1rem;
|
||||
@@ -239,9 +270,28 @@ body.editing #sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* While editing, the window itself does not scroll: the editor panel is
|
||||
exactly the remaining window height, and only the article area (#main)
|
||||
scrolls. Without the editor, normal full-page scroll applies. */
|
||||
body.editing {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.editing #content {
|
||||
min-height: 0;
|
||||
grid-template-rows: 100%;
|
||||
}
|
||||
|
||||
body.editing #main {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The editor host lives inside #content: it starts below the banner and
|
||||
ends above the footer. The panel itself sticks to the viewport while
|
||||
scrolling (but never taller than the content area). */
|
||||
ends above the footer. With the page scroll locked while editing, the
|
||||
panel exactly fills that space — the full available window height. */
|
||||
.editor-host {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -251,10 +301,7 @@ body.editing #sidebar {
|
||||
}
|
||||
|
||||
.editor-root.overlay {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
animation: editor-slide-in 0.25s ease;
|
||||
}
|
||||
@@ -270,20 +317,27 @@ body.editing #sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
/* The banner's own pen: opens the site editor (banner + structure).
|
||||
Qualified with `button` to beat the later .edit-link rule's left offset
|
||||
(both classes apply to the same element). */
|
||||
button.banner-edit-link {
|
||||
/* Banner-area pens (banner editor, site editor) and auth buttons live in a
|
||||
single flex container pinned to the banner's top-right corner. */
|
||||
.editor-pens {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 1.25rem;
|
||||
left: auto;
|
||||
right: 0.5rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.editor-pens button,
|
||||
.editor-pens a {
|
||||
position: static;
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
@@ -315,6 +369,22 @@ button.banner-edit-link {
|
||||
line-height: 1.0;
|
||||
}
|
||||
|
||||
/* Third level (and deeper): nested like the first level of article lists —
|
||||
slight indent, emoji marker — while the sidebar's own top level (the
|
||||
section's direct children) stays plain, without indicators or indent. */
|
||||
#sidebar ul ul {
|
||||
gap: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
padding-inline-start: 1em;
|
||||
}
|
||||
|
||||
#sidebar ul ul li::before {
|
||||
content: "🔹";
|
||||
display: inline-block;
|
||||
margin-left: -1.3em;
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
#sidebar a {
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
@@ -357,7 +427,8 @@ article ol,
|
||||
article dl,
|
||||
article blockquote,
|
||||
article pre,
|
||||
article figure {
|
||||
article figure,
|
||||
article table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -432,18 +503,17 @@ article h1 .edit-link {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Login link injected by pagerite.js for anonymous visitors when an auth
|
||||
server gates /_api (validate answered 401). Same corner as the site pen. */
|
||||
a.login-link {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 1.25rem;
|
||||
z-index: 10;
|
||||
font-size: 0.85rem;
|
||||
/* Login/profile links injected by pagerite.js when Paskia SSO is in use.
|
||||
They live inside the .editor-pens flex container in the banner's top-right
|
||||
corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
|
||||
.editor-pens a.login-link,
|
||||
.editor-pens a.profile-link {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
a.login-link:hover {
|
||||
.editor-pens a.login-link:hover,
|
||||
.editor-pens a.profile-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -454,17 +524,17 @@ article dd {
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Multi-column reading on wide displays, but only for long articles
|
||||
(pagerite.js adds .multicol based on content length and splits the body
|
||||
into .colseg segments separated by full-width h2s and wide figures;
|
||||
only segments with enough text get .cols and thus columns). Columns only
|
||||
reflow text inside the article; the article's width never changes. */
|
||||
@media (min-width: 100rem) {
|
||||
.multicol .colseg.cols {
|
||||
columns: 2;
|
||||
/* Multi-column reading, but only for long articles (pagerite.js adds
|
||||
.multicol based on content length and splits the body into .colseg segments
|
||||
separated by full-width h2s and wide figures; only segments with enough
|
||||
text get .cols). No fixed breakpoint: `columns: 30rem` lets CSS fit as
|
||||
many columns of at least 30rem as the article's current width allows —
|
||||
since .multicol also uncaps the article width (see #content above), a
|
||||
wider window simply yields more columns. */
|
||||
.multicol .colseg.cols {
|
||||
columns: 30rem;
|
||||
column-gap: 3.5rem;
|
||||
column-rule: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
.multicol .colseg {
|
||||
@@ -514,6 +584,42 @@ blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Admonitions (markdown !!! note/warning/...): a lightweight callout in
|
||||
the blockquote idiom — accent bar and a faint wash, recolored per type.
|
||||
Bottom-only margins like everything else in articles; inner paragraphs
|
||||
carry no margins of their own. */
|
||||
.admonition {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-left: 0.25rem solid var(--admonition-color, var(--accent));
|
||||
border-radius: 0 0.3rem 0.3rem 0;
|
||||
background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent);
|
||||
}
|
||||
|
||||
.admonition > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admonition-title {
|
||||
margin: 0 0 0.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--admonition-color, var(--accent));
|
||||
}
|
||||
|
||||
.admonition.tip,
|
||||
.admonition.important,
|
||||
.admonition.hint,
|
||||
.admonition.success {
|
||||
--admonition-color: var(--accent2);
|
||||
}
|
||||
|
||||
.admonition.warning,
|
||||
.admonition.caution,
|
||||
.admonition.danger,
|
||||
.admonition.failure {
|
||||
--admonition-color: var(--accent3);
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 0.8rem;
|
||||
@@ -564,50 +670,121 @@ code {
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
/* Tables separate by color, not lines: the header is a soft vertical
|
||||
gradient tinted with the theme's accent (themes can override the
|
||||
--table-head-* stops outright), body cells carry a very faint diagonal
|
||||
wash of --table-tint that lifts them apart, and only a whisper of a row
|
||||
separator remains. */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Images and figures */
|
||||
th {
|
||||
text-align: left;
|
||||
background: linear-gradient(180deg,
|
||||
var(--table-head-a, color-mix(in srgb, var(--accent) 10%, var(--surface))),
|
||||
var(--table-head-b, color-mix(in srgb, var(--accent) 18%, var(--surface))));
|
||||
}
|
||||
|
||||
td {
|
||||
background: linear-gradient(160deg,
|
||||
color-mix(in srgb, var(--table-tint, var(--accent)) 5%, transparent),
|
||||
transparent 75%);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) td {
|
||||
background: linear-gradient(160deg,
|
||||
color-mix(in srgb, var(--table-tint, var(--accent)) 9%, transparent),
|
||||
color-mix(in srgb, var(--table-tint, var(--accent)) 3%, transparent) 75%);
|
||||
}
|
||||
|
||||
tbody tr+tr td {
|
||||
border-top: 1px solid color-mix(in srgb, var(--table-tint, var(--accent)) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Definition lists are laid out as a lightweight two-column grid — terms
|
||||
in an accent column with a sensible minimum width, descriptions filling
|
||||
the rest — with no borders or fills (tables carry the heavier shading).
|
||||
Explicit grid columns keep runs of multiple <dt>s or <dd>s stacking
|
||||
correctly. */
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, max-content) 1fr;
|
||||
}
|
||||
|
||||
dt {
|
||||
grid-column: 1;
|
||||
font-weight: 550;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
dd {
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
/* Images and figures
|
||||
|
||||
Markdown images standing alone in a paragraph render as a block
|
||||
<figure> (with <figcaption> when the image has a title); the
|
||||
brace-attribute positioning class ({.left}, {.right}, {.wide}) lives
|
||||
on the img inside, but only the figure is ever positioned, so the
|
||||
caption stays below the image. Raw <img> HTML written by the author
|
||||
stays inline and unstyled beyond these defaults. */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Figures are block containers filling the column; the image fills the
|
||||
figure. The auto inline margins only take effect once a rule below
|
||||
shrinks the width (centering the figure); at 100% they compute to 0. */
|
||||
figure {
|
||||
margin: 0 0 1.5rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
/* The image fills the figure, except when it carries an explicit width
|
||||
attribute — that width is a browser presentational hint which CSS
|
||||
cannot restore once overridden, so such images are simply left alone. */
|
||||
figure img:not([width]) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Captions of full-bleed images: centered and kept to a readable width. */
|
||||
figure:has(.wide) figcaption {
|
||||
max-width: 65ch;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */
|
||||
figure:has(.right),
|
||||
img.right {
|
||||
/* Floated figures: {.right} / {.left}, defaulting to 30% of the column
|
||||
and capped at half of it. */
|
||||
figure:has(.right) {
|
||||
float: right;
|
||||
margin: 0.3rem 0 1rem 1.5rem;
|
||||
width: 30%;
|
||||
max-width: 50%;
|
||||
margin: 0.3rem 0 1rem 1em;
|
||||
}
|
||||
|
||||
figure:has(.left),
|
||||
img.left {
|
||||
figure:has(.left) {
|
||||
float: left;
|
||||
margin: 0.3rem 1.5rem 1rem 0;
|
||||
width: 30%;
|
||||
max-width: 50%;
|
||||
margin: 0.3rem 1em 1rem 0;
|
||||
}
|
||||
|
||||
/* An image with an explicit width attribute shrink-wraps instead: the
|
||||
figure fits the image and, per the auto inline margins above, centers
|
||||
in the column. Placed after the percentage widths above so it
|
||||
overrides them at equal specificity. */
|
||||
figure:has(img[width]) {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* .wide is full bleed: edge to edge of the viewport (or of the space right
|
||||
@@ -616,52 +793,81 @@ img.left {
|
||||
symmetric margins of (50% of the column − 50% of the target width) break
|
||||
out of the column and center the element on the same center — making the
|
||||
left edge land exactly at the viewport's edge (or the editor's right
|
||||
border). The sidebar stacks above it (z-index + translucent blur). */
|
||||
figure:has(.wide),
|
||||
img.wide {
|
||||
display: block;
|
||||
/* no inline strut/descender gaps around the image */
|
||||
border). The sidebar stacks above it (z-index + translucent blur).
|
||||
|
||||
The rules below re-anchor the bleed for the layouts where the article
|
||||
is not viewport-centered; each just overrides width/margin-inline, and
|
||||
later rules win at equal specificity. */
|
||||
figure:has(.wide) {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin-inline: calc(50% - 50vw);
|
||||
/* negative margins also kill the UA figure margin */
|
||||
/* Follow the docked editor's margin-left transition smoothly. */
|
||||
transition: margin-left 0.25s ease;
|
||||
}
|
||||
|
||||
/* A paragraph wrapping only a wide image must not add its line height. */
|
||||
p:has(> img.wide:only-child) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* A captioned image is a <figure>, and the positioning class lives on the
|
||||
img inside it: only the figure may float/shift, never the img itself,
|
||||
or the caption would wrap beside the image instead of sitting below.
|
||||
The image fills the figure's width. */
|
||||
figure:has(.right) img,
|
||||
figure:has(.left) img {
|
||||
float: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Full bleed means edge to edge — no rounded corners. */
|
||||
figure:has(.wide) img {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* 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
|
||||
min-content collapses them). A percentage also scales correctly inside
|
||||
two-column segments, where the column is the containing block. The
|
||||
caption wraps within that width. A width attribute ({.right width=300})
|
||||
overrides the default on uncaptioned images. */
|
||||
figure:has(.right),
|
||||
figure:has(.left),
|
||||
img.right:not([width]),
|
||||
img.left:not([width]) {
|
||||
width: 30%;
|
||||
/* .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. */
|
||||
body:has(.multicol) figure:has(.wide) {
|
||||
margin-inline: calc(-20vw - 1.25rem) 0;
|
||||
}
|
||||
|
||||
/* Editing: shrink the bleed to the space right of the docked editor. */
|
||||
body.editing figure:has(.wide) {
|
||||
width: calc(100vw - var(--editor-w));
|
||||
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
||||
}
|
||||
|
||||
/* Editing + multicol: the left gutter is 1/5 of the space right of the
|
||||
editor, and the bleed also crosses #content's 1rem editing gap plus
|
||||
main's 1.25rem left padding (the gutter shrink from the padding roughly
|
||||
cancels the rounding): −2rem in total. */
|
||||
body.editing:has(.multicol) figure:has(.wide) {
|
||||
margin-inline: calc((100vw - var(--editor-w)) / -5 - 2rem) 0;
|
||||
}
|
||||
|
||||
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
||||
longer both hold the 12rem sidebar, so #content reserves it with a fixed
|
||||
left track (see the matching media query below) and the article always
|
||||
starts at 12rem (+ main's 1.25rem padding) — the bleed margin is a plain
|
||||
constant. Scoped by :has(#sidebar) since the sidebar element is omitted
|
||||
entirely on pages without sub-navigation, and excluded while editing,
|
||||
where the editing rules above apply instead. */
|
||||
@media (max-width: 102rem) {
|
||||
body:has(#sidebar):not(.editing) figure:has(.wide) {
|
||||
margin-inline: -13.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--muted);
|
||||
line-height: 1;
|
||||
font-size: 0.85rem;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
text-wrap: pretty;
|
||||
text-align: left;
|
||||
/* Never let a long caption stretch a shrink-to-fit figure wider than the
|
||||
image; the caption wraps at the figure's width instead. */
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Full-bleed figures: center the caption, shrink-wrapped to its text and
|
||||
capped at a readable width. The min-width shrink-wrap trick above would
|
||||
win over max-width (min beats max per spec), so it is reset here. */
|
||||
figure:has(.wide) figcaption {
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
max-width: 65ch;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Section headings start below any floated figures of the previous
|
||||
@@ -674,25 +880,71 @@ article h2 {
|
||||
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
||||
longer both hold the 12rem sidebar, so reserve its space with a fixed
|
||||
left track instead of letting it overlap the article. The article then
|
||||
always starts at 12rem (+ main's 1.25rem padding), so the .wide breakout
|
||||
margin is a plain constant. Scoped by :has(#sidebar) since the sidebar
|
||||
element is omitted entirely on pages without sub-navigation. */
|
||||
always starts at 12rem (+ main's 1.25rem padding), which the matching
|
||||
.wide breakout rule in the images section relies on. Scoped by
|
||||
:has(#sidebar) since the sidebar element is omitted entirely on pages
|
||||
without sub-navigation. */
|
||||
@media (max-width: 102rem) {
|
||||
body:has(#sidebar):not(.editing) #content {
|
||||
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body:has(#sidebar) figure:has(.wide),
|
||||
body:has(#sidebar) img.wide:not(figure img) {
|
||||
margin-inline: -13.25rem 0;
|
||||
/* 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 remains correct. */
|
||||
body:has(#sidebar):has(.multicol):not(.editing) #content {
|
||||
grid-template-columns: 12rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Editing: shrink the bleed to the space right of the docked editor. */
|
||||
body.editing figure:has(.wide),
|
||||
body.editing img.wide:not(figure img) {
|
||||
width: calc(100vw - var(--editor-w));
|
||||
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
||||
/* Phones and other narrow viewports: single-column layout with the
|
||||
sidebar lifted above the article as a wrapping link strip, and no
|
||||
floated figures — .left/.right fall back to plain centered figures
|
||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
||||
viewport bleed. */
|
||||
@media (max-width: 48rem) {
|
||||
#content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
#sidebar ul {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
}
|
||||
|
||||
figure:has(.right),
|
||||
figure:has(.left) {
|
||||
float: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
/* An explicit img width still shrink-wraps the figure (redeclared: this
|
||||
block comes after the desktop rule at equal specificity). */
|
||||
figure:has(img[width]) {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* The 102rem sidebar/multicol .wide margins assume a left sidebar
|
||||
column; with the sidebar on top the article is viewport-wide and the
|
||||
plain centered bleed applies again. */
|
||||
body:has(#sidebar):not(.editing) figure:has(.wide),
|
||||
body:has(.multicol) figure:has(.wide) {
|
||||
margin-inline: calc(50% - 50vw);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
|
||||
@@ -725,8 +977,16 @@ body.editing img.wide:not(figure img) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,22 @@ export const cmTheme = EditorView.theme({
|
||||
color: "var(--text)",
|
||||
},
|
||||
".cm-scroller": { fontFamily: '"Fira Code", monospace' },
|
||||
".cm-content": { caretColor: "var(--text)" },
|
||||
// Fira Code in CodeMirror: set the font on .cm-content (not only the
|
||||
// scroller) and force every span inside to inherit it, so highlighting
|
||||
// spans can't drift to a different font/metrics. Ligatures are disabled
|
||||
// entirely — CodeMirror measures per character, and ligature glyphs
|
||||
// render wider than the measured sum of their parts.
|
||||
".cm-content": {
|
||||
caretColor: "var(--text)",
|
||||
fontFamily: '"Fira Code", monospace',
|
||||
fontVariantLigatures: "none",
|
||||
fontFeatureSettings: '"calt" 0',
|
||||
letterSpacing: "normal",
|
||||
},
|
||||
".cm-content *": {
|
||||
fontFamily: "inherit",
|
||||
letterSpacing: "inherit",
|
||||
},
|
||||
".cm-cursor": { borderLeftColor: "var(--text)" },
|
||||
// basicSetup's active-line highlight assumes a dark theme.
|
||||
".cm-activeLine": { backgroundColor: "transparent" },
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Pagerite editor entries. Two separate apps, mounted in their own
|
||||
// dynamically created host divs inside the static document:
|
||||
// - PageEditor ("page" mode): pen next to an article heading — Markdown
|
||||
// editing with the preview rendered into the visible article.
|
||||
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing
|
||||
// (previewed into the real banner) and the site structure tree.
|
||||
// Pagerite editor entry. A single tabbed EditorShell is mounted in a
|
||||
// dynamically created host div inside the static document. The shell hosts
|
||||
// four tabs: PageEditor (Markdown + preview), BannerEditor (per-page banner
|
||||
// HTML/design), SiteEditor (site-wide config), and StructureEditor (the
|
||||
// site structure tree).
|
||||
//
|
||||
// Individual pens are shorthands that open the shell on a particular tab;
|
||||
// once the shell is open, pens switch tabs instead of closing/remounting.
|
||||
// Closing hides the shell but keeps the Vue app mounted, so editor state —
|
||||
// including unsaved page text — survives and editing can continue on the
|
||||
// next pen click; it is lost only on a real page reload.
|
||||
if (import.meta.env.DEV) {
|
||||
// Base styles only; the theme CSS is imported by pagerite.js (which
|
||||
// always runs first — the editor opens from public pages).
|
||||
@@ -11,25 +16,39 @@ if (import.meta.env.DEV) {
|
||||
}
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import PageEditor from './PageEditor.vue'
|
||||
import SiteEditor from './SiteEditor.vue'
|
||||
import EditorShell from './EditorShell.vue'
|
||||
|
||||
let host = null
|
||||
let app = null
|
||||
let savedTitle = null
|
||||
let visible = false
|
||||
|
||||
export function openEditor(path, { mode = 'page' } = {}) {
|
||||
closeEditor()
|
||||
if (app) {
|
||||
// Shell already mounted: re-show it if hidden, switch to the requested
|
||||
// tab and retarget the editors to the current page (the shell survives
|
||||
// fetch-navigation).
|
||||
if (!visible) showEditor()
|
||||
document.body.dataset.editorMode = mode
|
||||
dispatchEvent(new CustomEvent('pagerite:switch-editor', { detail: { mode, path } }))
|
||||
return
|
||||
}
|
||||
savedTitle = document.title
|
||||
host = document.createElement('div')
|
||||
host.className = 'editor-host'
|
||||
// Docked inside #content: below the banner, next to the article only.
|
||||
document.getElementById('content').prepend(host)
|
||||
document.body.classList.add('editing')
|
||||
// Which kind of editor is open; pagerite.js uses this to decide
|
||||
// whether a pen click closes the panel or swaps in the other editor.
|
||||
// Which tab is active; pagerite.js uses this to decide whether a pen click
|
||||
// closes the panel or switches tabs.
|
||||
document.body.dataset.editorMode = mode
|
||||
createApp(mode === 'site' ? SiteEditor : PageEditor, {
|
||||
visible = true
|
||||
app = createApp(EditorShell, {
|
||||
pagePath: path,
|
||||
initialMode: mode,
|
||||
onClose: closeEditor,
|
||||
}).mount(host)
|
||||
})
|
||||
app.mount(host)
|
||||
// The slide-in (editor-slide-in in pagerite.css) is a one-shot open
|
||||
// effect; once finished, drop it so that later stylesheet swaps (theme
|
||||
// change re-creating @keyframes) cannot restart it.
|
||||
@@ -41,13 +60,39 @@ export function openEditor(path, { mode = 'page' } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function showEditor() {
|
||||
savedTitle = document.title
|
||||
host.style.display = ''
|
||||
host.firstElementChild?.classList.remove('closing')
|
||||
document.body.classList.add('editing')
|
||||
visible = true
|
||||
dispatchEvent(new CustomEvent('pagerite:editor-shown'))
|
||||
}
|
||||
|
||||
export function closeEditor() {
|
||||
if (!host) return
|
||||
if (!visible) return
|
||||
visible = false
|
||||
document.body.classList.remove('editing')
|
||||
delete document.body.dataset.editorMode
|
||||
// Slide the panel out in sync with the page shifting back.
|
||||
// dataset.editorMode is kept while hidden: the tabs use it to tell whether
|
||||
// a pagerite:editor-shown event targets them.
|
||||
// Slide the panel out in sync with the page shifting back, then hide it.
|
||||
host.firstElementChild?.classList.add('closing')
|
||||
const old = host
|
||||
host = null
|
||||
setTimeout(() => old.remove(), 250)
|
||||
const h = host
|
||||
setTimeout(() => { h.style.display = 'none' }, 250)
|
||||
dispatchEvent(new CustomEvent('pagerite:editor-hidden'))
|
||||
// Restore the server-rendered title for the current URL. Re-fetching makes
|
||||
// sure a brand change in the site editor or an in-place navigation leaves
|
||||
// the correct public title behind.
|
||||
const restoreTitle = savedTitle
|
||||
savedTitle = null
|
||||
fetch(location.pathname)
|
||||
.then((r) => r.text())
|
||||
.then((html) => {
|
||||
if (visible) return // reopened meanwhile; the editor owns the title
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
if (doc.title) document.title = doc.title
|
||||
})
|
||||
.catch(() => {
|
||||
if (!visible && restoreTitle != null) document.title = restoreTitle
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,19 +4,33 @@
|
||||
//
|
||||
// Also: scroll-reveal effects and code copy buttons. These need no
|
||||
// support from the article itself and are re-applied after each swap.
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
(() => {
|
||||
// Overlay scrollbars: the native kind reserves a strip of layout (or
|
||||
// shifts the layout when it appears; overflow: overlay is dead), so we
|
||||
// replace it with floating ones that cover content instead. The body
|
||||
// remains the native viewport scroller — window scroll events, scrollY
|
||||
// and scroll restoration are unaffected; only the scrollbar UI is
|
||||
// custom. Theme variables are in pagerite.css.
|
||||
OverlayScrollbars(document.body, {
|
||||
scrollbars: { autoHide: "scroll" },
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// The theme is selectable; the backend names the active one in a meta
|
||||
// tag (dev links no stylesheets — Vite injects them from JS).
|
||||
const theme = document.querySelector('meta[name="pagerite:theme"]')?.content;
|
||||
const sheets = [import("./assets/pagerite.css")];
|
||||
if (theme) sheets.push(import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`));
|
||||
// The injected styles land after the server-rendered custom CSS in
|
||||
// <head>; move the custom CSS back to the end so its equal-specificity
|
||||
// :root rules (font variables) win.
|
||||
Promise.all(sheets).then(() => {
|
||||
const el = document.getElementById("pagerite-user");
|
||||
// In dev the base stylesheet is injected by Vite from JS (linking the
|
||||
// raw module would pull in its HMR wrapper). Theme and banner-design
|
||||
// stylesheets are plain files served by the backend (/_themes/...), so
|
||||
// the backend renders their <link>s in both dev and prod. The injected
|
||||
// base styles land at the end of <head> — after them, restore the
|
||||
// canonical order: base < theme < banner design < custom CSS (whose
|
||||
// equal-specificity :root rules — font variables — must win by order).
|
||||
import("./assets/pagerite.css").then(() => {
|
||||
for (const id of ["pagerite-theme", "pagerite-banner", "pagerite-user"]) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) document.head.append(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,44 +45,72 @@
|
||||
// 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
|
||||
// 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
|
||||
// (network error — i.e. no auth proxy deployed, as in dev) leaves editing
|
||||
// open as before: the real gate is the proxy in front of /_api.
|
||||
let authorized = false;
|
||||
//
|
||||
// When Paskia SSO is in use (probed via /auth/api/settings), the banner
|
||||
// corner gets a plain link to /auth/ — 🔑 log in for anonymous visitors,
|
||||
// 🔐 profile when logged in. Normal navigation: Paskia does not support
|
||||
// being iframed, and history.back() returns to the page as-is (the
|
||||
// pageshow handler below re-probes auth to refresh the pens).
|
||||
let ssoAvailable = false;
|
||||
let isAdmin = false;
|
||||
let editorMeta = null;
|
||||
|
||||
function makePen(mode) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = mode === "page" ? "edit-link" : "edit-link banner-edit-link";
|
||||
btn.title = "edit";
|
||||
btn.textContent = "🖊️";
|
||||
btn.dataset.editorSrc = editorMeta.src;
|
||||
btn.dataset.editorCss = editorMeta.css || "";
|
||||
btn.dataset.editorMode = mode;
|
||||
if (mode === "page") {
|
||||
btn.className = "edit-link";
|
||||
btn.title = "edit page";
|
||||
btn.textContent = "🖊️";
|
||||
} else if (mode === "banner") {
|
||||
btn.className = "edit-link";
|
||||
btn.title = "edit banner";
|
||||
btn.textContent = "🖊️";
|
||||
} else {
|
||||
btn.className = "edit-link site-edit-link";
|
||||
btn.title = "site settings";
|
||||
btn.textContent = "⚙️";
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
function injectPens() {
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (banner && !banner.parentElement.querySelector(".banner-edit-link")) {
|
||||
banner.after(makePen("site"));
|
||||
}
|
||||
function injectPagePen() {
|
||||
const article = document.querySelector("#main article");
|
||||
if (article && !article.querySelector("button.edit-link")) {
|
||||
article.prepend(makePen("page"));
|
||||
}
|
||||
}
|
||||
|
||||
function addLoginLink() {
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (!banner || banner.parentElement.querySelector(".login-link")) return;
|
||||
function makeAuthLink(admin) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "login-link";
|
||||
a.className = admin ? "profile-link" : "login-link";
|
||||
a.href = "/auth/";
|
||||
a.textContent = "log in";
|
||||
banner.after(a);
|
||||
a.title = admin ? "profile" : "log in";
|
||||
a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
|
||||
return a;
|
||||
}
|
||||
|
||||
function renderAuthUi() {
|
||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||
// Paskia SSO is detected at all.
|
||||
const canEdit = isAdmin || !ssoAvailable;
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (banner) {
|
||||
const old = banner.parentElement.querySelector(".editor-pens");
|
||||
if (old) old.remove();
|
||||
const pens = document.createElement("div");
|
||||
pens.className = "editor-pens";
|
||||
if (canEdit) {
|
||||
pens.append(makePen("banner"));
|
||||
pens.append(makePen("site"));
|
||||
}
|
||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||
banner.after(pens);
|
||||
}
|
||||
if (canEdit) injectPagePen();
|
||||
}
|
||||
|
||||
async function setupAuth() {
|
||||
@@ -78,20 +120,32 @@
|
||||
src,
|
||||
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
|
||||
};
|
||||
let status = 0;
|
||||
|
||||
// Detect whether Paskia SSO is available on this site.
|
||||
try {
|
||||
status = (await fetch("/_api/settings")).status;
|
||||
const ssoRes = await fetch("/auth/api/settings");
|
||||
ssoAvailable = ssoRes.ok;
|
||||
} catch {
|
||||
// Auth proxy unreachable: treat as not deployed.
|
||||
ssoAvailable = false;
|
||||
}
|
||||
if (status === 401) addLoginLink();
|
||||
else if (status !== 403) {
|
||||
authorized = true;
|
||||
injectPens();
|
||||
placeEditPen();
|
||||
|
||||
// Check whether the current session has pagerite:admin.
|
||||
isAdmin = false;
|
||||
try {
|
||||
isAdmin = (await fetch("/_api/settings")).status === 200;
|
||||
} catch {
|
||||
// No auth proxy / dev.
|
||||
}
|
||||
|
||||
renderAuthUi();
|
||||
}
|
||||
|
||||
// Returning to the page via history back/forward may restore a cached
|
||||
// copy whose auth UI predates a login/logout — re-probe and re-render.
|
||||
addEventListener("pageshow", (ev) => {
|
||||
if (ev.persisted) setupAuth();
|
||||
});
|
||||
|
||||
function runScripts(root) {
|
||||
// Scripts inserted via DOM swapping do not execute; re-create them.
|
||||
for (const old of root.querySelectorAll("script")) {
|
||||
@@ -148,17 +202,25 @@
|
||||
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() {
|
||||
(window.requestIdleCallback || setTimeout)(preload);
|
||||
const main = document.getElementById("main");
|
||||
addCopyButtons(main);
|
||||
// Fetch-navigation swaps #main, discarding the article pen; re-add it.
|
||||
if (authorized) injectPens();
|
||||
// Fetch-navigation swaps #main, discarding the article pen and corner
|
||||
// buttons; re-add whichever auth UI is appropriate for this session.
|
||||
renderAuthUi();
|
||||
placeEditPen();
|
||||
// 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.
|
||||
const article = main.querySelector("article");
|
||||
if (article) {
|
||||
@@ -169,9 +231,11 @@
|
||||
);
|
||||
if (body && article.classList.contains("multicol")
|
||||
&& !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) =>
|
||||
el.tagName === "H2" || el.querySelector("img.wide") !== null;
|
||||
el.tagName === "H1" || el.tagName === "H2"
|
||||
|| el.querySelector("img.wide") !== null;
|
||||
let seg = null;
|
||||
for (const el of [...body.children]) {
|
||||
if (isSeparator(el)) {
|
||||
@@ -204,29 +268,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preloading ------------------------------------------------------
|
||||
// Warm the HTTP cache with all linked pages and their resources, so
|
||||
// navigation (and the cube transition) is instant. Pages carry ETags,
|
||||
// so re-running this after each navigation revalidates cheaply (304)
|
||||
// and picks up changed content and images.
|
||||
// --- Page cache / preloading ------------------------------------------
|
||||
// Articles are deliberately NOT HTTP-cacheable, so speed comes from an
|
||||
// in-memory cache instead: at load (and after each swap) every visible
|
||||
// internal link is fetched exactly once, and navigation is served from
|
||||
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
|
||||
// announce their fresh copies via pagerite:page-fetched, keeping the
|
||||
// cache in sync after edits.
|
||||
const pageCache = new Map(); // pathname -> HTML text
|
||||
addEventListener("pagerite:page-fetched", (ev) => {
|
||||
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
|
||||
});
|
||||
|
||||
function preload() {
|
||||
const urls = new Set();
|
||||
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) {
|
||||
const urls = new Set([location.pathname]);
|
||||
for (const a of document.querySelectorAll(
|
||||
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
|
||||
)) {
|
||||
urls.add(a.pathname);
|
||||
}
|
||||
for (const url of urls) {
|
||||
if (url === location.pathname) continue;
|
||||
if (pageCache.has(url)) continue;
|
||||
fetch(url)
|
||||
.then((r) => (r.ok ? r.text() : ""))
|
||||
.then((html) => {
|
||||
if (!html) return;
|
||||
// Off-screen parse: load the page's images and other resources
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of doc.querySelectorAll("img")) {
|
||||
const i = new Image();
|
||||
i.src = img.src;
|
||||
}
|
||||
})
|
||||
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
|
||||
? r.text() : ""))
|
||||
.then((html) => { if (html) pageCache.set(url, html); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -269,6 +335,10 @@
|
||||
}
|
||||
let doc;
|
||||
let finalUrl = url;
|
||||
const cached = pageCache.get(new URL(url, location.href).pathname);
|
||||
if (cached) {
|
||||
doc = new DOMParser().parseFromString(cached, "text/html");
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const type = res.headers.get("content-type") || "";
|
||||
@@ -280,6 +350,7 @@
|
||||
location.href = url; // fall back to a normal navigation
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (REGIONS.some((id) => !doc.getElementById(id))) {
|
||||
location.href = url;
|
||||
return;
|
||||
@@ -344,10 +415,10 @@
|
||||
addEventListener("click", (ev) => {
|
||||
if (ev.defaultPrevented || ev.button !== 0
|
||||
|| ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
// Edit buttons toggle the editor panel docked on this page: load the
|
||||
// Vue app on demand (with any extra styles) and mount it in place.
|
||||
// Clicking the pen of the already-open editor closes it; clicking the
|
||||
// other pen swaps the panel for the other editor type.
|
||||
// Edit buttons toggle the tabbed editor panel docked on this page: load
|
||||
// the Vue app on demand (with any extra styles) and mount it in place.
|
||||
// Clicking the pen of the already-open tab closes the shell; clicking
|
||||
// another pen switches the shell to that tab.
|
||||
const editBtn = ev.target.closest("button.edit-link");
|
||||
if (editBtn && editBtn.dataset.editorSrc) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Shared in-place page re-rendering for the editor tabs: fetch a page
|
||||
// without transitions and swap its dynamic regions into the live document.
|
||||
// Used by BannerEditor (banner design changes), SiteEditor (theme changes)
|
||||
// and StructureEditor (tree navigation).
|
||||
|
||||
export function runScripts(root) {
|
||||
// Scripts injected via innerHTML do not execute; re-create them.
|
||||
if (!root) return
|
||||
for (const old of root.querySelectorAll('script')) {
|
||||
const s = document.createElement('script')
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value)
|
||||
s.textContent = old.textContent
|
||||
old.replaceWith(s)
|
||||
}
|
||||
}
|
||||
|
||||
function swapRegions(doc) {
|
||||
for (const id of ['page-banner', 'nav', 'main']) {
|
||||
const fresh = doc.getElementById(id)
|
||||
const el = document.getElementById(id)
|
||||
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
|
||||
}
|
||||
// #sidebar is omitted entirely when the section has no sub-navigation,
|
||||
// so it may be absent on either side: replace, insert, or remove.
|
||||
const freshSidebar = doc.getElementById('sidebar')
|
||||
const curSidebar = document.getElementById('sidebar')
|
||||
if (freshSidebar && curSidebar) {
|
||||
curSidebar.replaceWith(document.importNode(freshSidebar, true))
|
||||
} else if (freshSidebar) {
|
||||
document.getElementById('main')?.before(document.importNode(freshSidebar, true))
|
||||
} else if (curSidebar) {
|
||||
curSidebar.remove()
|
||||
}
|
||||
// The brand lives in the header, outside the swappable regions, and is
|
||||
// absent entirely when neither a brand nor custom brand HTML is set. A
|
||||
// plain link keeps its element (text swap only, preserving the shrink-
|
||||
// to-fit observers); anything else (custom HTML wrapper) is replaced.
|
||||
const freshBrand = doc.getElementById('brand')
|
||||
const curBrand = document.getElementById('brand')
|
||||
if (freshBrand && curBrand && freshBrand.tagName === 'A' && curBrand.tagName === 'A') {
|
||||
curBrand.textContent = freshBrand.textContent
|
||||
} else if (freshBrand && curBrand) {
|
||||
curBrand.replaceWith(document.importNode(freshBrand, true))
|
||||
runScripts(document.getElementById('brand'))
|
||||
} else if (curBrand) {
|
||||
curBrand.remove()
|
||||
} else if (freshBrand) {
|
||||
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
|
||||
runScripts(document.getElementById('brand'))
|
||||
}
|
||||
// Site-wide custom CSS is in <head> and must be swapped too.
|
||||
const freshUserStyle = doc.getElementById('pagerite-user')
|
||||
const curUserStyle = document.getElementById('pagerite-user')
|
||||
if (freshUserStyle && curUserStyle) {
|
||||
curUserStyle.textContent = freshUserStyle.textContent
|
||||
} else if (freshUserStyle) {
|
||||
document.head.appendChild(document.importNode(freshUserStyle, true))
|
||||
} else if (curUserStyle) {
|
||||
curUserStyle.remove()
|
||||
}
|
||||
// Theme and other public stylesheets live in <head>, rendered with stable
|
||||
// ids by the backend; sync them positionally so the custom CSS (rendered
|
||||
// last) always keeps winning by order. Diff-based: unchanged sheets keep
|
||||
// their elements, so their @keyframes are never torn down (re-creating
|
||||
// keyframes would replay the editor's slide-in animation).
|
||||
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
|
||||
const freshIds = new Set(freshLinks.map((l) => l.id))
|
||||
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
|
||||
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
|
||||
}
|
||||
// Insert missing sheets in the fresh document's order, each right after
|
||||
// its predecessor's element. The first sheet rendered is always the base
|
||||
// CSS, so its link doubles as the fallback anchor when nothing matched yet
|
||||
// (e.g. no theme was selected before and the position is otherwise lost).
|
||||
let anchor = null
|
||||
for (const link of freshLinks) {
|
||||
const cur = link.id && document.getElementById(link.id)
|
||||
if (cur && cur.href === link.href) {
|
||||
anchor = cur
|
||||
continue
|
||||
}
|
||||
const el = document.importNode(link, true)
|
||||
// Same id, new URL (theme switch): replace in place, keeping position.
|
||||
if (cur) cur.replaceWith(el)
|
||||
else if (anchor) anchor.after(el)
|
||||
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
|
||||
anchor = el
|
||||
}
|
||||
// The editor keeps its own title while open; only inherit the server title
|
||||
// when navigating outside the editor (e.g. fetch-navigation swaps).
|
||||
if (!document.body.classList.contains('editing')) {
|
||||
document.title = doc.title
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch /p, swap its regions into the live page and replaceState to it.
|
||||
// Returns the final URL (after redirects), or null when the fetch did not
|
||||
// yield a page. Category and missing URLs render a placeholder 404 page —
|
||||
// fine to swap in (new pages are created by editing them).
|
||||
export async function loadPlain(p) {
|
||||
let doc
|
||||
let finalUrl = `/${p}`
|
||||
let html
|
||||
try {
|
||||
const res = await fetch(finalUrl)
|
||||
const type = res.headers.get('content-type') || ''
|
||||
if (!type.includes('text/html')) return null
|
||||
if (res.redirected) finalUrl = res.url
|
||||
html = await res.text()
|
||||
doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
} catch { return null }
|
||||
if (!doc.getElementById('main')) return null
|
||||
swapRegions(doc)
|
||||
history.replaceState(null, '', finalUrl)
|
||||
runScripts(document.getElementById('page-banner'))
|
||||
runScripts(document.getElementById('main'))
|
||||
// Keep pagerite.js's in-memory page cache in sync with the fresh copy.
|
||||
dispatchEvent(new CustomEvent('pagerite:page-fetched', { detail: { url: finalUrl, html } }))
|
||||
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
|
||||
return finalUrl
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
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'
|
||||
|
||||
// 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.
|
||||
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
|
||||
// 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/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/_api", "/_f"] }),
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes"] }),
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
@@ -49,8 +39,9 @@ export default defineConfig({
|
||||
input: {
|
||||
main: fileURLToPath(new URL('./src/main.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)),
|
||||
...themeInputs,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import re
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import format_datetime
|
||||
from pathlib import Path
|
||||
|
||||
import blake3
|
||||
@@ -120,23 +121,30 @@ def _migrate_legacy() -> None:
|
||||
data.version += 1
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Open the database, migrate/seed content, load assets."""
|
||||
await kanta.open()
|
||||
_migrate_legacy()
|
||||
missing = [p for p in seed.PAGES if resolve(data.menu, p) is None]
|
||||
if missing:
|
||||
with kanta.transaction("seed missing pages"):
|
||||
for path in missing:
|
||||
title, markdown, files, banner, order = seed.PAGES[path]
|
||||
@kanta.bootstrap
|
||||
def _seed(data: Data) -> None:
|
||||
"""Write the demo pages on database creation (never on existing dbs)."""
|
||||
for path in seed.PAGES:
|
||||
title, markdown, files, banner, order, design = seed.PAGES[path]
|
||||
for orig, body in files.items():
|
||||
markdown, banner = _store_seed_file(markdown, banner, orig, body)
|
||||
node = _ensure(data.menu, path)
|
||||
node.title = title
|
||||
# Empty markdown means a pure category label (e.g. "showcase",
|
||||
# seeded only to carry a banner design): leave content as None so
|
||||
# the node renders the placeholder and nav points at its children.
|
||||
if markdown:
|
||||
node.content = markdown
|
||||
node.banner = banner
|
||||
node.banner_design = design
|
||||
node.order = order
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Open the database, migrate legacy content, load assets."""
|
||||
await kanta.open()
|
||||
_migrate_legacy()
|
||||
await frontend.load()
|
||||
yield
|
||||
await kanta.close()
|
||||
@@ -154,6 +162,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):
|
||||
"""Payload for creating or replacing a page."""
|
||||
|
||||
@@ -273,9 +289,18 @@ async def update_structure(op: StructureOp) -> None:
|
||||
|
||||
|
||||
@app.get("/_api/settings")
|
||||
async def get_settings() -> dict[str, str]:
|
||||
"""Site-wide settings (brand, theme and custom CSS)."""
|
||||
return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css}
|
||||
async def get_settings() -> dict:
|
||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||
the themes and banner designs available on disk for the selectors."""
|
||||
return {
|
||||
"brand": data.brand,
|
||||
"brand_html": data.brand_html,
|
||||
"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):
|
||||
@@ -284,6 +309,7 @@ class SettingsIn(BaseModel):
|
||||
brand: str
|
||||
theme: str
|
||||
custom_css: str
|
||||
brand_html: str = ""
|
||||
|
||||
|
||||
@app.put("/_api/settings", status_code=204)
|
||||
@@ -291,11 +317,42 @@ async def put_settings(settings: SettingsIn) -> None:
|
||||
"""Update site-wide settings; bumps the version so ETags invalidate."""
|
||||
with kanta.transaction("update settings"):
|
||||
data.brand = settings.brand
|
||||
data.brand_html = settings.brand_html
|
||||
data.theme = settings.theme
|
||||
data.custom_css = settings.custom_css
|
||||
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):
|
||||
"""Payload for toggling one task-list checkbox."""
|
||||
|
||||
@@ -362,6 +419,40 @@ async def delete_file(name: str) -> None:
|
||||
data.version += 1
|
||||
|
||||
|
||||
@app.get("/_themes/{name}/{filename}")
|
||||
async def theme_file(name: str, filename: str, request: Request) -> Response:
|
||||
"""Serve a theme/banner-design file from pagerite/themes/{name}/.
|
||||
|
||||
Stylesheets plus any extra assets the CSS references (like summer's
|
||||
grass.svg). 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 (
|
||||
"/" in filename
|
||||
or filename.startswith(".")
|
||||
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
|
||||
if not path.is_file():
|
||||
raise HTTPException(404)
|
||||
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return Response(
|
||||
path.read_bytes(),
|
||||
media_type=mime,
|
||||
headers={"etag": etag, "cache-control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/_f/{name}")
|
||||
async def stored_file(name: str, request: Request) -> Response:
|
||||
"""Serve a file from the content-addressed store (immutable: the name
|
||||
@@ -404,6 +495,11 @@ async def delete_page(path: str) -> None:
|
||||
_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:
|
||||
"""Slug shape that content may never use: each segment must be lower-case
|
||||
ASCII letters, digits, hyphens and underscores (underscores may not be
|
||||
@@ -430,12 +526,12 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
Stateless protocol (each message carries the path):
|
||||
<- {"type": "open", "path"}
|
||||
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
||||
"banner"}
|
||||
"banner", "banner_design"}
|
||||
<- {"type": "render", "path", "markdown"}
|
||||
-> {"type": "html", "path", "html"}
|
||||
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
||||
"banner"?, "move_from"?} (absent fields keep their old values;
|
||||
move_from: rename/move a page, subtree included)
|
||||
"banner"?, "banner_design"?, "move_from"?} (absent fields keep
|
||||
their old values; move_from: rename/move a page, subtree included)
|
||||
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
||||
"""
|
||||
await ws.accept()
|
||||
@@ -460,17 +556,40 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
"markdown": node.content if node and node.content is not None else "",
|
||||
"published": node.published if node else True,
|
||||
"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,
|
||||
# null = default artwork); the site editor shows it
|
||||
# as the banner field's placeholder.
|
||||
"banner_from": views.banner_source(data.menu, path),
|
||||
# Which node's banner-design setting would apply on
|
||||
# inherit ("" = front page, null = the active
|
||||
# theme's default) and what design that resolves to.
|
||||
"banner_design_from": (
|
||||
src := views.banner_design_source(
|
||||
data.menu, path, data.theme
|
||||
)
|
||||
),
|
||||
"banner_design_inherited": (
|
||||
views.banner_design(data.menu, src, data.theme)
|
||||
if src is not None
|
||||
else views.theme_banner_design(data.theme)
|
||||
),
|
||||
})
|
||||
case "render":
|
||||
markdown = msg.get("markdown", "")
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
await ws.send_json({
|
||||
"type": "html",
|
||||
"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),
|
||||
})
|
||||
case "save":
|
||||
@@ -536,6 +655,8 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
node.published = bool(msg["published"])
|
||||
if "banner" in msg:
|
||||
node.banner = msg["banner"]
|
||||
if "banner_design" in msg:
|
||||
node.banner_design = msg["banner_design"]
|
||||
node.modified = datetime.now(UTC)
|
||||
data.version += 1
|
||||
await ws.send_json({"type": "saved", "path": path})
|
||||
@@ -569,23 +690,38 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
if node is not None and node.published and node.content is not None:
|
||||
# ETag on content + render version; clients revalidate cheaply,
|
||||
# which keeps prefetched pages warm and current.
|
||||
# no-cache forbids serving a stored page without revalidation
|
||||
# (browsers would otherwise cache heuristically and serve stale
|
||||
# pages, e.g. after a theme change). In-session speed instead comes
|
||||
# from pagerite.js's in-memory page cache (preload everything, never
|
||||
# fetch on navigation); the ETag just makes those one-time preload
|
||||
# fetches and any revalidation cheap.
|
||||
etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
return HTMLResponse(
|
||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme),
|
||||
headers={"etag": etag},
|
||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
|
||||
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:
|
||||
# Category label without a landing page: placeholder with the pen
|
||||
# to create it (404 — no page here, but the node is real).
|
||||
return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme), 404)
|
||||
return HTMLResponse(
|
||||
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
|
||||
404,
|
||||
headers={
|
||||
"last-modified": _http_date(node.modified),
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
)
|
||||
if node is None and not path:
|
||||
# No front page (no top-level node with slug ""): "/" opens the
|
||||
# first item of the navigation instead.
|
||||
for slug, item in sorted_nodes(data.menu):
|
||||
if item.published:
|
||||
return RedirectResponse(f"/{slug}")
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404)
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404)
|
||||
|
||||
@@ -31,9 +31,15 @@ class Node(msgspec.Struct, omit_defaults=True):
|
||||
#: Markdown source of the node's page; None = pure category label
|
||||
#: (its URL renders a placeholder page).
|
||||
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.
|
||||
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
|
||||
children: dict[str, "Node"] = {}
|
||||
created: datetime = msgspec.field(
|
||||
@@ -79,13 +85,21 @@ class Data(msgspec.Struct):
|
||||
#: Site name shown in the header and <title> suffix; editable in the
|
||||
#: site editor. Empty = no brand link in the header, no title suffix.
|
||||
brand: str = "Pagerite"
|
||||
#: Raw trusted HTML replacing the brand link entirely (a logo image,
|
||||
#: styled markup, canvas+script...), site-wide — not per-page
|
||||
#: overridable like banners. Rendered in the header on top of the
|
||||
#: banner artwork, next to the nav. Empty = the plain brand link.
|
||||
brand_html: str = ""
|
||||
#: Active theme name (empty = none/base only). Themes live in
|
||||
#: frontend/src/assets/themes/{theme}/theme.css, with their banner
|
||||
#: artwork at pagerite/themes/{theme}/banner.svg (inlined server-side).
|
||||
#: pagerite/themes/{theme}/ (theme.css and/or banner.css/banner.svg/
|
||||
#: banner.html), served by the backend from disk.
|
||||
theme: str = "purple"
|
||||
#: Raw site-wide custom CSS, injected inline in every page <head>.
|
||||
#: Trusted author content; not sanitized.
|
||||
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`
|
||||
#: on startup, then cleared. Never written otherwise.
|
||||
pages: dict[str, Page] = {}
|
||||
|
||||
@@ -2,25 +2,42 @@
|
||||
|
||||
Raw HTML (including inline scripts) is passed through unfiltered: the
|
||||
single author is trusted. Extensions: tables and strikethrough (from the
|
||||
"default" preset), footnotes, definition lists, task lists and
|
||||
"default" preset), footnotes, definition lists, task lists,
|
||||
brace-attributes (`{.class width=300}` on any element, images in
|
||||
particular).
|
||||
particular) and admonitions (``!!! note Title`` with an indented body —
|
||||
note/tip/warning/etc., the title optional). Bare URLs autolink (GFM), with
|
||||
the ``https://`` scheme hidden in the link text (``http://`` and other
|
||||
schemes stay visible; manually labelled links are untouched), and
|
||||
``H~2~O`` / ``x^2^`` give sub/superscripts.
|
||||
|
||||
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
|
||||
page's own path (so `` in `/docs/design` is served from
|
||||
`/docs/design/photo.avif`), and an image with a title becomes a
|
||||
`<figure>` with `<figcaption>`. Positioning is done with attribute
|
||||
`/docs/design/photo.avif`), and an image standing alone in its paragraph
|
||||
becomes a block `<figure>` — with `<figcaption>` when it has a title.
|
||||
Images inline with other content stay plain inline `<img>`, as does raw
|
||||
`<img>` HTML written by the author. Positioning is done with attribute
|
||||
classes, e.g. `{.right}`.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.common.utils import escapeHtml
|
||||
from markdown_it.renderer import RendererHTML
|
||||
from mdit_py_plugins.admon import admon_plugin
|
||||
from mdit_py_plugins.attrs import attrs_plugin
|
||||
from mdit_py_plugins.deflist import deflist_plugin
|
||||
from mdit_py_plugins.footnote import footnote_plugin
|
||||
from mdit_py_plugins.gfm_autolink import gfm_autolink_plugin
|
||||
from mdit_py_plugins.subscript import sub_plugin
|
||||
from mdit_py_plugins.superscript import superscript_plugin
|
||||
from mdit_py_plugins.tasklists import tasklists_plugin
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
@@ -63,16 +80,22 @@ def _image_rule(
|
||||
token.attrs["src"] = f"/{page}/{src}" if page else f"/{src}"
|
||||
token.attrs["alt"] = self.renderInlineAsText(token.children, options, env)
|
||||
img = self.renderToken(tokens, idx, options, env)
|
||||
if title := token.attrs.get("title"):
|
||||
return f"<figure>{img}<figcaption>{escapeHtml(title)}</figcaption></figure>"
|
||||
if len(tokens) == 1:
|
||||
# The only inline content of its paragraph: render as a block
|
||||
# figure, captioned when titled. (The <p> wrapper is dropped by
|
||||
# _unwrap_lone_figures below.)
|
||||
title = token.attrs.get("title")
|
||||
caption = f"<figcaption>{escapeHtml(title)}</figcaption>" if title else ""
|
||||
return f"<figure>{img}{caption}</figure>"
|
||||
# Inline with other content: a plain inline image.
|
||||
return img
|
||||
|
||||
|
||||
def _unwrap_lone_figures(state) -> None:
|
||||
"""Drop the <p> wrapper around a lone titled image.
|
||||
"""Drop the <p> wrapper around a lone image.
|
||||
|
||||
markdown-it wraps inline content in a paragraph, but our image rule
|
||||
turns titled images into <figure> — a block element that is invalid
|
||||
turns lone images into <figure> — a block element that is invalid
|
||||
inside <p>. Browsers hoist it out, leaving an empty paragraph whose
|
||||
margins disturb the layout.
|
||||
"""
|
||||
@@ -81,7 +104,7 @@ def _unwrap_lone_figures(state) -> None:
|
||||
if token.type != "inline" or not token.children:
|
||||
continue
|
||||
[child] = token.children if len(token.children) == 1 else [None]
|
||||
if child and child.type == "image" and child.attrs.get("title"):
|
||||
if child and child.type == "image":
|
||||
if (tokens[i - 1].type == "paragraph_open"
|
||||
and tokens[i + 1].type == "paragraph_close"):
|
||||
tokens[i - 1].hidden = True
|
||||
@@ -109,21 +132,75 @@ def _tag_task_checkboxes(state) -> None:
|
||||
index += 1
|
||||
|
||||
|
||||
def _shorten_autolinks(state) -> None:
|
||||
"""Hide the https:// scheme in the text of bare autolinked URLs.
|
||||
|
||||
GFM linkify sets the link text to the URL itself; only those links
|
||||
(markup "autolink") are shortened. http:// and other schemes stay
|
||||
visible, and manually labelled links keep whatever label was written.
|
||||
"""
|
||||
for token in state.tokens:
|
||||
if token.type != "inline" or not token.children:
|
||||
continue
|
||||
for i, child in enumerate(token.children):
|
||||
if child.type == "link_open" and child.markup == "autolink":
|
||||
text = token.children[i + 1]
|
||||
if text.type == "text" and text.content.startswith("https://"):
|
||||
text.content = text.content.removeprefix("https://")
|
||||
|
||||
|
||||
md = (
|
||||
MarkdownIt("default", {"html": True, "highlight": _highlight})
|
||||
MarkdownIt(
|
||||
"default",
|
||||
{
|
||||
"html": True,
|
||||
"highlight": _highlight,
|
||||
"typographer": True,
|
||||
"breaks": True,
|
||||
},
|
||||
)
|
||||
.use(attrs_plugin)
|
||||
.use(admon_plugin)
|
||||
.use(footnote_plugin)
|
||||
.use(deflist_plugin)
|
||||
.use(tasklists_plugin, enabled=True)
|
||||
.use(gfm_autolink_plugin)
|
||||
.use(sub_plugin)
|
||||
.use(superscript_plugin)
|
||||
)
|
||||
md.add_render_rule("image", _image_rule)
|
||||
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
||||
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||
|
||||
|
||||
def render(text: str, page_path: str = "") -> str:
|
||||
"""Render Markdown text to an HTML string."""
|
||||
return md.render(text, {"page_path": page_path})
|
||||
def render(
|
||||
text: str,
|
||||
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:
|
||||
|
||||
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -1,385 +1,363 @@
|
||||
"""Seed content written to the database on first run (when it is empty).
|
||||
"""Seed content written to the database on first creation only
|
||||
(``@kanta.bootstrap`` in ``app.py``).
|
||||
|
||||
Demonstrates the formatting options: images attached to pages and served
|
||||
from the page path, figures with captions, attribute classes for
|
||||
positioning, footnotes, definition lists, task lists, tables and raw HTML.
|
||||
A "welcome to your new site" starter: a structured docs section (three
|
||||
menu levels deep) covering editing and the full Markdown feature set —
|
||||
each feature shown as its Markdown source in a code block followed by
|
||||
the rendered result — and a showcase section with image positioning,
|
||||
long-form layout and a simple custom banner.
|
||||
|
||||
Binary seed images live in ``seed-assets/`` (public domain, from
|
||||
Wikimedia Commons: the two whale engravings are Augustus Burnham
|
||||
Shute's illustrations for an 1892 edition of Moby-Dick; the wave is
|
||||
Hokusai's "The Great Wave off Kanagawa").
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS = Path(__file__).with_name("seed-assets")
|
||||
|
||||
|
||||
def _asset(name: str) -> bytes:
|
||||
return (ASSETS / name).read_bytes()
|
||||
|
||||
WELCOME = """\
|
||||
Welcome to your new **Pagerite** site. Pages are written in Markdown —
|
||||
including raw HTML — and served from pretty URLs.
|
||||
Welcome to your new **Pagerite** site. Everything you see is a page written in Markdown, served from a pretty URL, and editable right here in the browser.
|
||||
|
||||
Have a look around:
|
||||
Where to go next:
|
||||
|
||||
- The [docs](/docs) section explains [how to write content](/docs/editing),
|
||||
including images and positioning.
|
||||
- [The Long Read](/blog/the-long-read) demonstrates a longer article with
|
||||
scroll effects.
|
||||
- The [about](/about) page shows off assorted formatting.
|
||||
- The [docs](/docs/editing) section explains how to edit this site and shows every supported Markdown feature, source and result side by side.
|
||||
- The [showcase](/showcase/gallery) section shows what finished pages can look like: image positioning, banners, a long read.
|
||||
- Click the 🖊️ pen on any page to open the editor, and the ⚙️ pen for site settings and the structure tree.
|
||||
|
||||

|
||||
{width=420}
|
||||
|
||||
*Delete or rewrite any of these pages — they are only here to get you started.*
|
||||
"""
|
||||
|
||||
ABOUT = """\
|
||||
This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content
|
||||
written in Markdown.
|
||||
EDITING = """\
|
||||
Everything on the site is editable in place. Log in, and pens appear: 🖊️ on the page and banner, ⚙️ in the banner corner for site settings.
|
||||
|
||||
Some formatting samples:
|
||||
## The editor
|
||||
|
||||
- [x] Write content in Markdown
|
||||
- [x] Attach images to pages
|
||||
- [ ] Add editing UI
|
||||
The 🖊️ pens open a tabbed editor over the page you are viewing:
|
||||
|
||||
Term
|
||||
: A definition list entry, rendered by the deflist plugin.
|
||||
- **Article** — the page's title and Markdown, with a live preview. The format bar inserts the harder-to-remember syntax (links, tables, images); Ctrl/Cmd-B, I and S do what you expect. Saving is explicit: 💾 or Ctrl+S.
|
||||
- **Banner** — per-page banner HTML and a banner design picker. Banners are raw HTML (an image, a styled div, a canvas with a script) and subpages inherit the nearest banner up their path.
|
||||
- **Site** — brand, theme, fonts, favicon and custom CSS, all applied immediately.
|
||||
- **Structure** — the page tree. Drag rows to reorder or nest, rename titles and slugs inline, ➕ adds a page, ✕ deletes one.
|
||||
|
||||
And a table:
|
||||
## URLs and structure
|
||||
|
||||
The URL is the structure: a page at `docs/markdown/basics` lives under `docs` and `markdown`, and the menus are derived from that. Slugs are lowercase ASCII (`a-z 0-9 - _`). A node without content is a category label — it renders a placeholder and its menu link points at its first child page.
|
||||
|
||||
Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}.ext`, so links survive page moves. The article editor's format bar and copy-paste both upload images for you.
|
||||
|
||||
{dates}
|
||||
"""
|
||||
|
||||
MD_BASICS = """\
|
||||
# Markdown Basics
|
||||
|
||||
Every feature below is shown twice: first the Markdown source, then how it renders.
|
||||
|
||||
## Headings and text
|
||||
|
||||
```markdown
|
||||
## A section heading
|
||||
### A subsection
|
||||
|
||||
*Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and a
|
||||
[link to the front page](/). Plain URLs become links automatically:
|
||||
https://example.com — and a hard line break
|
||||
is just a newline.
|
||||
```
|
||||
|
||||
## A section heading
|
||||
### A subsection
|
||||
|
||||
*Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and a [link to the front page](/). Plain URLs become links automatically: https://example.com — and a hard line break
|
||||
is just a newline.
|
||||
|
||||
## Lists and quotes
|
||||
|
||||
```markdown
|
||||
- One
|
||||
- Two
|
||||
- Nested
|
||||
|
||||
1. First
|
||||
2. Second
|
||||
|
||||
> A blockquote. The URL space is the author's:
|
||||
> pretty slugs at the root, nesting only where
|
||||
> the content is genuinely structured.
|
||||
```
|
||||
|
||||
- One
|
||||
- Two
|
||||
- Nested
|
||||
|
||||
1. First
|
||||
2. Second
|
||||
|
||||
> A blockquote. The URL space is the author's:
|
||||
> pretty slugs at the root, nesting only where
|
||||
> the content is genuinely structured.
|
||||
|
||||
## Code
|
||||
|
||||
Fenced blocks get server-side syntax highlighting:
|
||||
|
||||
````markdown
|
||||
```python
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
````
|
||||
|
||||
```python
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
```markdown
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Pages | done |
|
||||
| Images | done |
|
||||
```
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Pages | done |
|
||||
| Images | done |
|
||||
| Comments| later |
|
||||
"""
|
||||
|
||||
Footnotes work too.[^1]
|
||||
MD_EXTENSIONS = """\
|
||||
# Markdown Extensions
|
||||
|
||||
Markdown extensions enabled on this site, source first, then rendered.
|
||||
|
||||
## Footnotes
|
||||
|
||||
```markdown
|
||||
Footnotes work inline.[^1]
|
||||
|
||||
[^1]: Rendered at the bottom of the page, with a back-reference.
|
||||
```
|
||||
|
||||
Footnotes work inline.[^1]
|
||||
|
||||
[^1]: Rendered at the bottom of the page, with a back-reference.
|
||||
|
||||
## Definition lists
|
||||
|
||||
```markdown
|
||||
Term
|
||||
: A definition list entry.
|
||||
|
||||
Another term
|
||||
: With its definition.
|
||||
```
|
||||
|
||||
Term
|
||||
: A definition list entry.
|
||||
|
||||
Another term
|
||||
: With its definition.
|
||||
|
||||
## Task lists
|
||||
|
||||
```markdown
|
||||
- [x] Write content in Markdown
|
||||
- [x] Attach images to pages
|
||||
- [x] Make tasks clickable on the rendered page
|
||||
```
|
||||
|
||||
- [x] Write content in Markdown
|
||||
- [x] Attach images to pages
|
||||
- [x] Make tasks clickable on the rendered page
|
||||
|
||||
## Admonitions
|
||||
|
||||
```markdown
|
||||
!!! note
|
||||
An admonition block for notes, warnings, tips...
|
||||
```
|
||||
|
||||
!!! note
|
||||
An admonition block for notes, warnings, tips...
|
||||
|
||||
## Sub- and superscript
|
||||
|
||||
```markdown
|
||||
H~2~O and x^2^ + y^2^ = z^2^.
|
||||
```
|
||||
|
||||
H~2~O and x^2^ + y^2^ = z^2^.
|
||||
|
||||
## Raw HTML
|
||||
|
||||
HTML passes through untouched — useful for `<kbd>` keys, `<details>` sections, embedded media:
|
||||
|
||||
```html
|
||||
<details><summary>Click to expand</summary>Hidden content.</details>
|
||||
```
|
||||
|
||||
<details><summary>Click to expand</summary>Hidden content.</details>
|
||||
|
||||
## Smart typography
|
||||
|
||||
The typographer is on, so straight quotes become curly, `--` becomes -- and `...` becomes ...
|
||||
"""
|
||||
|
||||
EDITING = """\
|
||||
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.
|
||||
MD_LAYOUT = """\
|
||||
## Images and figures
|
||||
|
||||
## Images
|
||||
An image standing alone in its paragraph becomes a `<figure>`; its title becomes the caption. Inline images within text stay plain.
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
{.right width=280}
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
{.right width=280}
|
||||

|
||||
|
||||
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.
|
||||
## Positioning with attributes
|
||||
|
||||
## Text
|
||||
Brace attributes (the attrs plugin) control placement: `{.right}` and `{.left}` float, `{.wide}` breaks out of the text column, and plain attributes like `width=280` pass through.
|
||||
|
||||
*Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and
|
||||
[links](/about) as usual. Blockquotes:
|
||||
|
||||
> The URL space is the author's. Pretty slugs at the root, nesting only
|
||||
> where the content is genuinely structured.
|
||||
|
||||
## Code
|
||||
|
||||
```python
|
||||
def render(text: str, page_path: str) -> str:
|
||||
return md.render(text, {"page_path": page_path})
|
||||
```markdown
|
||||
{.right width=280}
|
||||
```
|
||||
|
||||
{.right width=280}
|
||||
|
||||
Floated images let the text wrap around them, like this paragraph does. Relative image paths resolve against the page's own path, so attached files travel with the page. Uploaded files get content-addressed `/_f/` URLs that never break, no matter where the page moves.
|
||||
|
||||
{.wide} artwork spans the full content width:
|
||||
|
||||
```markdown
|
||||
{.wide}
|
||||
```
|
||||
|
||||
{.wide}
|
||||
|
||||
## Datelines
|
||||
|
||||
A `{dates}` line on its own expands to the article's published/updated dateline:
|
||||
|
||||
```markdown
|
||||
{dates}
|
||||
```
|
||||
|
||||
{dates}
|
||||
|
||||
## The page title
|
||||
|
||||
If your Markdown contains its own `# heading`, the page title is not repeated as a second h1 — it still supplies the `<title>` and the menu labels.
|
||||
"""
|
||||
|
||||
LONG_READ = """\
|
||||
*An essay long enough to scroll, to demonstrate the gentle reveal of
|
||||
headings, figures and code blocks as they enter the viewport.*
|
||||
GALLERY = """\
|
||||
Pages can attach images and position them freely. The vector artwork here is generated SVG; the woodblock print is Hokusai's *The Great Wave off Kanagawa* (public domain, via Wikimedia Commons).
|
||||
|
||||
{.wide}
|
||||
{.wide}
|
||||
|
||||
## Chapter one
|
||||
A wide image escapes the text column for emphasis between sections. No HTML needed — just Markdown and an attribute.
|
||||
|
||||
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.
|
||||
{.left width=240}
|
||||
|
||||
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.
|
||||
This text wraps around a left-floated figure. The caption comes from the image title, the float from `{.left width=240}` — brace attributes on the image itself.
|
||||
|
||||
## Chapter two
|
||||
{.right width=240}
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Chapter three
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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?
|
||||
|
||||
```text
|
||||
Quis autem vel eum iure reprehenderit
|
||||
qui in ea voluptate velit esse quam nihil
|
||||
molestiae consequatur, vel illum qui
|
||||
dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Chapter four
|
||||
|
||||
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.
|
||||
|
||||
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](/).
|
||||
Mixing floats in one article is fine. Both images were uploaded to this page and referenced by relative path, so the whole page (images included) can be moved in the structure tree without breaking anything.
|
||||
"""
|
||||
|
||||
NOTES_ON_URLS = """\
|
||||
A URL is part of the content. A few rules of thumb I keep coming back to:
|
||||
NIGHT_SKY = """\
|
||||
This page's banner is not an image or a code snippet — it's the **stars** banner design, picked from a dropdown in the banner editor (🖊️ in the banner corner). Nothing is stored in the page beyond that choice.
|
||||
|
||||
- Pick slugs like book titles, not like database keys.
|
||||
- Nest only when the structure is real.
|
||||
- Once published, a URL is a promise. Redirect if you must break it.
|
||||
Banner designs are folders in `pagerite/themes/{name}/` — a `banner.css` plus a `banner.html` or `banner.svg` — so a design can be anything from a static gradient to an animated canvas like the starfield above. This site ships `stars` and `eyes` (a critter in the grass), and themes can bring their own.
|
||||
|
||||
> Cool URIs don't change; uncool ones at least apologise.
|
||||
Subpages inherit the nearest banner and design up their path, so a whole section can share one look. This page is a leaf: set a design here and nothing else is affected.
|
||||
|
||||
That's all. Short posts are posts too.
|
||||
A page can also carry its own banner HTML — an `<img>`, a styled div, a canvas with a script — which renders *on top of* the design's artwork, so author code always wins. But most of the time, picking a design is all you need.
|
||||
"""
|
||||
|
||||
CANVAS_NIGHTS = """\
|
||||
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.
|
||||
# Moby-Dick; or, The Whale (1851), Herman Melville — public domain.
|
||||
# Chapter 1, abridged and headed. A real long-read: flowing sections,
|
||||
# figures, a list — not a feature showcase. (Engravings: Augustus
|
||||
# Burnham Shute's illustrations for the 1892 edition, public domain.)
|
||||
LOOMINGS = """\
|
||||
*The opening of Herman Melville's Moby-Dick (1851), abridged — here to show what a longer article feels like: the multi-column layout on wide screens, images breaking up the text, and the gentle reveal as sections scroll into view.*
|
||||
|
||||
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.
|
||||
{dates}
|
||||
|
||||
```js
|
||||
// the essence of the banner above
|
||||
stars.forEach(s => { s.x = (s.x + s.speed * dt) % 1 })
|
||||
```
|
||||
{.wide}
|
||||
|
||||
No build step, no framework — the snippet is stored with the page and
|
||||
dropped into the header as-is.
|
||||
## The watery part of the world
|
||||
|
||||
Call me Ishmael. Some years ago — never mind how long precisely — having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people's hats off — then, I account it high time to get to sea as soon as I can. This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me.
|
||||
|
||||
{.right width=320}
|
||||
|
||||
There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs — commerce surrounds it with her surf. Right and left, the streets take you waterward. Its extreme downtown is the battery, where that noble mole is washed by waves, and cooled by breezes, which a few hours previous were out of sight of land. Look at the crowds of water-gazers there.
|
||||
|
||||
Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears Hook to Coenties Slip, and from thence, by Whitehall, northward. What do you see? — Posted like silent sentinels all around the town, stand thousands upon thousands of mortal men fixed in ocean reveries. Some leaning against the spiles; some seated upon the pier-heads; some looking over the bulwarks of ships from China; some high aloft in the rigging, as if striving to get a still better seaward peep. But these are all landsmen; of week days pent up in lath and plaster — tied to counters, nailed to benches, clinched to desks. How then is this? Are the green fields gone? What do they here?
|
||||
|
||||
But look! here come more crowds, pacing straight for the water, and seemingly bound for a dive. Strange! Nothing will content them but the extremest limit of the land; loitering under the shady lee of yonder warehouses will not suffice. No. They must get just as nigh the water as they possibly can without falling in. And there they stand — miles of them — leagues. Inlanders all, they come from lanes and alleys, streets and avenues — north, east, south, and west. Yet here they all unite. Tell me, does the magnetic virtue of the needles of the compasses of all those ships attract them thither?
|
||||
|
||||
## Meditation and water
|
||||
|
||||
Once more. Say you are in the country; in some high land of lakes. Take almost any path you please, and ten to one it carries you down in a dale, and leaves you there by a pool in the stream. There is magic in it. Let the most absent-minded of men be plunged in his deepest reveries — stand that man on his legs, set his feet a-going, and he will infallibly lead you to water, if water there be in all that region. Should you ever be athirst in the great American desert, try this experiment, if your caravan happen to be supplied with a metaphysical professor. Yes, as every one knows, meditation and water are wedded for ever.
|
||||
|
||||
### The artist's problem
|
||||
|
||||
But here is an artist. He desires to paint you the dreamiest, shadiest, quietest, most enchanting bit of romantic landscape in all the valley of the Saco. What is the chief element he employs? There stand his trees, each with a hollow trunk, as if a hermit and a crucifix were within; and here sleeps his meadow, and there sleep his cattle; and up from yonder cottage goes a sleepy smoke. Deep into distant woodlands winds a mazy way, reaching to overlapping spurs of mountains bathed in their hill-side blue. But though the picture lies thus tranced, and though this pine-tree shakes down its sighs like leaves upon this shepherd's head, yet all were vain, unless the shepherd's eye were fixed upon the magic stream before him.
|
||||
|
||||
Why did the poor poet of Tennessee, upon suddenly receiving two handfuls of silver, deliberate whether to buy him a coat, which he sadly needed, or invest his money in a pedestrian trip to Rockaway Beach? Why is almost every robust healthy boy with a robust healthy soul in him, at some time or other crazy to go to sea? Why upon your first voyage as a passenger, did you yourself feel such a mystical vibration, when you were first told that you and your ship were now out of sight of land? Why did the old Persians hold the sea holy? Why did the Greeks give it a separate deity, and own brother of Jove? Surely all this is not without meaning. And still deeper the meaning of that story of Narcissus, who because he could not grasp the tormenting, mild image he saw in the fountain, plunged into it and was drowned. But that same image, we ourselves see in all rivers and oceans. It is the image of the ungraspable phantom of life; and this is the key to it all.
|
||||
|
||||
## A simple sailor, right before the mast
|
||||
|
||||
Now, when I say that I am in the habit of going to sea whenever I begin to grow hazy about the eyes, and begin to be over conscious of my lungs, I do not mean to have it inferred that I ever go to sea as a passenger. For to go as a passenger you must needs have a purse, and a purse is but a rag unless you have something in it. Besides, passengers get sea-sick — grow quarrelsome — don't sleep of nights — do not enjoy themselves much, as a general thing; — no, I never go as a passenger; nor, though I am something of a salt, do I ever go to sea as a Commodore, or a Captain, or a Cook. I abandon the glory and distinction of such offices to those who like them. For my part, I abominate all honorable respectable toils, trials, and tribulations of every kind whatsoever. It is quite as much as I can do to take care of myself, without taking care of ships, barques, brigs, schooners, and what not.
|
||||
|
||||
No, when I go to sea, I go as a simple sailor, right before the mast, plumb down into the forecastle, aloft there to the royal mast-head. True, they rather order me about some, and make me jump from spar to spar, like a grasshopper in a May meadow. And at first, this sort of thing is unpleasant enough. It touches one's sense of honor, particularly if you come of an old established family in the land, the Van Rensselaers, or Randolphs, or Hardicanutes. And more than all, if just previous to putting your hand into the tar-pot, you have been lording it as a country schoolmaster, making the tallest boys stand in awe of you. The transition is a keen one, I assure you, from a schoolmaster to a sailor, and requires a strong decoction of Seneca and the Stoics to enable you to grin and bear it. But even this wears off in time.
|
||||
|
||||

|
||||
|
||||
What of it, if some old hunks of a sea-captain orders me to get a broom and sweep down the decks? What does that indignity amount to, weighed, I mean, in the scales of the New Testament? Do you think the archangel Gabriel thinks anything the less of me, because I promptly and respectfully obey that old hunks in that particular instance? Who ain't a slave? Tell me that. Well, then, however the old sea-captains may order me about — however they may thump and punch me about, I have the satisfaction of knowing that it is all right; that everybody else is one way or other served in much the same way — either in a physical or metaphysical point of view, that is; and so the universal thump is passed round, and all hands should rub each other's shoulder-blades, and be content.
|
||||
|
||||
And finally, what shall I say of the reasons for going a-whaling? Chief among them:
|
||||
|
||||
- The overwhelming idea of the great whale himself — such a portentous and mysterious monster roused all my curiosity.
|
||||
- The undeliverable, nameless perils of the whale, and the attendants of the wondrous world of waters.
|
||||
- The tormenting, mild image of the ungraspable phantom of life, seen in all rivers and oceans.
|
||||
|
||||
These were the things that finally drew me to the sea — and if they but knew it, almost all men cherish very nearly the same feelings towards the ocean with me.
|
||||
"""
|
||||
|
||||
SMALL_RELEASES = """\
|
||||
Software wants to be shipped. The longer a change sits unmerged, the more
|
||||
it rots: context fades, conflicts accumulate, and the diff grows teeth.
|
||||
Software wants to be shipped. The longer a change sits unmerged, the more it rots: context fades, conflicts accumulate, and the diff grows teeth.
|
||||
|
||||
1. Cut the scope until it fits in a day.
|
||||
2. Ship it behind whatever door you like.
|
||||
3. Let real use argue with your assumptions.
|
||||
|
||||
A release is a conversation with reality. Small releases keep the
|
||||
conversation lively.
|
||||
A release is a conversation with reality. Small releases keep the conversation lively.
|
||||
"""
|
||||
|
||||
CANVAS_BANNER = """\
|
||||
<canvas id="stars"></canvas>
|
||||
<script>
|
||||
(() => {
|
||||
const c = document.getElementById("stars");
|
||||
const ctx = c.getContext("2d");
|
||||
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
|
||||
fit();
|
||||
addEventListener("resize", fit);
|
||||
const stars = Array.from({ length: 110 }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
r: Math.random() * 1.4 + 0.3, v: Math.random() * 0.05 + 0.01,
|
||||
}));
|
||||
let prev = performance.now();
|
||||
(function frame(now) {
|
||||
if (!c.isConnected) return;
|
||||
const dt = Math.min(now - prev, 100); prev = now;
|
||||
ctx.fillStyle = "#0b0e1d";
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
ctx.fillStyle = "#cdd6ff";
|
||||
for (const s of stars) {
|
||||
s.x = (s.x + s.v * dt / 1000) % 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * c.width, s.y * c.height, s.r, 0, 7);
|
||||
ctx.fill();
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
})(prev);
|
||||
})();
|
||||
</script>
|
||||
ABOUT = """\
|
||||
This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content written in Markdown and rendered on the fly.
|
||||
|
||||
- [How to edit this site](/docs/editing)
|
||||
- [Markdown features](/docs/markdown/basics)
|
||||
- [The showcase](/showcase/gallery)
|
||||
|
||||
*Replace this page with whatever your site is about.*
|
||||
"""
|
||||
|
||||
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>'
|
||||
|
||||
FRONT_BANNER = '<img src="/waves.svg" alt="">'
|
||||
|
||||
WAVES_SVG = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
|
||||
<defs>
|
||||
@@ -422,28 +400,62 @@ DUNES_SVG = """\
|
||||
</svg>
|
||||
"""
|
||||
|
||||
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order).
|
||||
#: Note there are deliberately no "docs" or "blog" landing pages: those
|
||||
#: labels are created without content, so they render a placeholder page
|
||||
#: and their nav links point at the first child (see views.first_leaf).
|
||||
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
||||
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
|
||||
"about": ("About", ABOUT, {}, "", 2),
|
||||
"docs/editing": (
|
||||
"Writing Content",
|
||||
EDITING,
|
||||
{"shapes.svg": SHAPES_SVG.encode()},
|
||||
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order,
|
||||
#: banner design). Designs demonstrate inheritance: the "showcase" label
|
||||
#: picks "eyes" (all its pages show the critter), and the leaf
|
||||
#: "showcase/night-sky" overrides that with "stars". Elsewhere the active
|
||||
#: theme's own design shows.
|
||||
#: Note there are deliberately no "docs" or "showcase" landing pages:
|
||||
#: those labels are created without content, so they render a placeholder
|
||||
#: page and their nav links point at the first child (see
|
||||
#: views.first_leaf). "showcase" is seeded explicitly (empty markdown,
|
||||
#: which the seeder leaves as content=None) just to carry the design.
|
||||
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float, str | None]] = {
|
||||
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, "", 1, None),
|
||||
"about": ("About", ABOUT, {}, "", 3, None),
|
||||
"docs/editing": ("Editing This Site", EDITING, {}, "", 1, None),
|
||||
"docs/markdown/basics": (
|
||||
"Basics",
|
||||
MD_BASICS,
|
||||
{},
|
||||
"",
|
||||
1,
|
||||
None,
|
||||
),
|
||||
"blog/the-long-read": (
|
||||
"The Long Read",
|
||||
LONG_READ,
|
||||
{"dunes.svg": DUNES_SVG.encode()},
|
||||
BLOG_BANNER,
|
||||
"docs/markdown/extensions": ("Extensions", MD_EXTENSIONS, {}, "", 2, None),
|
||||
"docs/markdown/images-and-layout": (
|
||||
"Images and Layout",
|
||||
MD_LAYOUT,
|
||||
{"shapes.svg": SHAPES_SVG.encode(), "dunes.svg": DUNES_SVG.encode()},
|
||||
"",
|
||||
3,
|
||||
None,
|
||||
),
|
||||
"showcase": ("Showcase", "", {}, "", 4, "eyes"),
|
||||
"showcase/gallery": (
|
||||
"Gallery",
|
||||
GALLERY,
|
||||
{
|
||||
"great-wave.jpg": _asset("great-wave.jpg"),
|
||||
"shapes.svg": SHAPES_SVG.encode(),
|
||||
"waves.svg": WAVES_SVG.encode(),
|
||||
},
|
||||
"",
|
||||
1,
|
||||
None,
|
||||
),
|
||||
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, EYES_BANNER, 2),
|
||||
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3),
|
||||
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4),
|
||||
"showcase/loomings": (
|
||||
"Loomings — a Long Read",
|
||||
LOOMINGS,
|
||||
{
|
||||
"dunes.svg": DUNES_SVG.encode(),
|
||||
"md-whale.jpg": _asset("md-whale.jpg"),
|
||||
"md-chase.jpg": _asset("md-chase.jpg"),
|
||||
},
|
||||
"",
|
||||
2,
|
||||
None,
|
||||
),
|
||||
"showcase/night-sky": ("Night Sky", NIGHT_SKY, {}, "", 3, "stars"),
|
||||
"showcase/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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360" preserveAspectRatio="xMidYMid slice">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360" preserveAspectRatio="xMidYMax slice">
|
||||
<defs>
|
||||
<linearGradient id="cbg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" class="cb-bg0"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,9 +1,9 @@
|
||||
/* Corporate theme: bright and bold professional. Saturated royal-blue
|
||||
gradients on white, geometric Montserrat display type over Inter body,
|
||||
and a genuinely large brand with a soft blue overlap shadow. Automatic
|
||||
dark mode keeps the same saturated blue identity on deep navy; the
|
||||
banner artwork (inlined by the backend) is recolored from here via the
|
||||
cb-* classes, so one SVG serves both modes. */
|
||||
dark mode keeps the same saturated blue identity on deep navy. The
|
||||
companion banner design (banner.css, artwork inlined by the backend)
|
||||
recolors its SVG from the active palette via the cb-* classes. */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -18,40 +18,6 @@
|
||||
--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) {
|
||||
:root {
|
||||
--bg: #0b1428;
|
||||
@@ -64,40 +30,6 @@
|
||||
/* Code wells stay navy in dark mode (light mode uses --surface). */
|
||||
--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 {
|
||||
@@ -124,11 +56,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
#banner {
|
||||
min-height: 15rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#nav {
|
||||
font-size: 1.05em;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360" preserveAspectRatio="xMidYMid slice">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360" preserveAspectRatio="xMidYMax slice">
|
||||
<defs>
|
||||
<linearGradient id="flare" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#ff6a00"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -59,39 +59,6 @@
|
||||
--code-bg: #12101b;
|
||||
/* 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 {
|
||||
@@ -112,47 +79,6 @@
|
||||
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 {
|
||||
font-family: var(--font-heading);
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" preserveAspectRatio="xMidYMid slice">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" preserveAspectRatio="xMidYMax slice">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2b1b4d"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -28,7 +28,9 @@
|
||||
#brand {
|
||||
font-size: clamp(3.2rem, 9vw, 7.5rem);
|
||||
line-height: 1;
|
||||
margin-bottom: -0.28em;
|
||||
/* Ensure below baseline stays visible */
|
||||
padding-bottom: 0.3em;
|
||||
margin-bottom: -0.3em;
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: left bottom;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
@@ -39,25 +41,6 @@
|
||||
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. */
|
||||
#nav {
|
||||
text-shadow: 0 0 0.15em black;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Stars banner design: a drifting starfield (banner.html — canvas + script
|
||||
inlined by the backend into #page-banner). Fixed-height stage matching
|
||||
the canvas. */
|
||||
|
||||
#banner {
|
||||
height: 240px;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<canvas id="stars"></canvas>
|
||||
<style>
|
||||
#stars {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<script><!--
|
||||
(() => {
|
||||
const c = document.getElementById('stars')
|
||||
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)
|
||||
|
||||
const stars = Array.from({ length: 110 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: Math.random() * 1.4 + 0.3,
|
||||
v: Math.random() * 0.05 + 0.01
|
||||
}))
|
||||
|
||||
let prev = performance.now()
|
||||
;(function frame(now) {
|
||||
if (!c.isConnected) return
|
||||
const w = c.clientWidth
|
||||
const h = c.clientHeight
|
||||
const dt = Math.min(now - prev, 100)
|
||||
prev = now
|
||||
ctx.fillStyle = '#0b0e1d'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
ctx.fillStyle = '#cdd6ff'
|
||||
for (const s of stars) {
|
||||
s.x = (s.x + (s.v * dt) / 1000) % 1
|
||||
ctx.beginPath()
|
||||
ctx.arc(s.x * w, s.y * h, s.r, 0, 7)
|
||||
ctx.fill()
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
})(prev)
|
||||
})()
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
/* Summer banner design: a bright illustrated meadow (inlined by the
|
||||
backend into #page-banner) — rolling hills, leafy bushes, swaying
|
||||
flowers, drifting clouds and a sun that rises as you scroll. */
|
||||
|
||||
#banner {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
/* The artwork fades into the page background at its bottom edge. */
|
||||
.summer-fade {
|
||||
stop-color: var(--bg);
|
||||
}
|
||||
|
||||
/* Layered parallax on top of the whole-artwork drift (pagerite.js sets
|
||||
--pry on <html>): the sun rises fastest, the clouds lag behind, and the
|
||||
nearer a hill the less it moves — a cheap depth cue while scrolling. */
|
||||
#page-banner .sun,
|
||||
#page-banner .sun-glow {
|
||||
transform-box: fill-box;
|
||||
transform: translateY(calc(var(--pry, 0px) * -1.5));
|
||||
}
|
||||
|
||||
#page-banner .clouds {
|
||||
transform: translateY(calc(var(--pry, 0px) * -0.7));
|
||||
}
|
||||
|
||||
#page-banner .hills-far {
|
||||
transform: translateY(calc(var(--pry, 0px) * -0.3));
|
||||
}
|
||||
|
||||
#page-banner .hills-near {
|
||||
transform: translateY(calc(var(--pry, 0px) * -0.15));
|
||||
}
|
||||
|
||||
#page-banner .meadow {
|
||||
transform: translateY(calc(var(--pry, 0px) * -0.05));
|
||||
}
|
||||
|
||||
/* Idle animations: the sun breathes, clouds float sideways, flowers sway.
|
||||
Each animated group is a transform-less wrapper so the CSS transform
|
||||
never clobbers the placement transforms inside. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#page-banner .sun-glow {
|
||||
animation: summer-sun 9s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
#page-banner .cloud-a { animation: summer-drift 56s ease-in-out infinite alternate; }
|
||||
#page-banner .cloud-b { animation: summer-drift 73s ease-in-out infinite alternate-reverse; }
|
||||
#page-banner .cloud-c { animation: summer-drift 64s ease-in-out infinite alternate; }
|
||||
|
||||
#page-banner .flower {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 100%;
|
||||
animation: summer-sway 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* Stagger the sway so the meadow doesn't move in lockstep. */
|
||||
#page-banner .flower:nth-child(3n) { animation-delay: -1.7s; }
|
||||
#page-banner .flower:nth-child(3n + 1) { animation-delay: -3.1s; animation-duration: 6s; }
|
||||
}
|
||||
|
||||
@keyframes summer-sun {
|
||||
from { opacity: 0.22; }
|
||||
to { opacity: 0.38; }
|
||||
}
|
||||
|
||||
@keyframes summer-drift {
|
||||
from { transform: translateX(-1.6%); }
|
||||
to { transform: translateX(1.6%); }
|
||||
}
|
||||
|
||||
@keyframes summer-sway {
|
||||
from { transform: rotate(-2.6deg); }
|
||||
to { transform: rotate(2.6deg); }
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1600 320"
|
||||
preserveAspectRatio="xMidYMax slice">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#8ed0ff"/>
|
||||
<stop offset=".64" stop-color="#d9f1ff"/>
|
||||
<stop offset="1" stop-color="#eef9ff"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="farHill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#b8dca7"/>
|
||||
<stop offset="1" stop-color="#a4cf90"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="nearHill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#91c97c"/>
|
||||
<stop offset="1" stop-color="#74b65e"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#69ae4b"/>
|
||||
<stop offset="1" stop-color="#4f913b"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Soft fade into the page background (stop-color from banner.css) -->
|
||||
<linearGradient id="meadowFade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" class="summer-fade" stop-color="#e6f4cf" stop-opacity="0"/>
|
||||
<stop offset="1" class="summer-fade" stop-color="#e6f4cf"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="soft">
|
||||
<feGaussianBlur stdDeviation="16"/>
|
||||
</filter>
|
||||
|
||||
<filter id="cloudShadow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="4"
|
||||
flood-color="#4f8fb0" flood-opacity=".12"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Sky -->
|
||||
<rect width="1600" height="320" fill="url(#sky)"/>
|
||||
|
||||
<!-- Sun (rises on scroll, glow pulses) -->
|
||||
<circle class="sun-glow" cx="1330" cy="72" r="82"
|
||||
fill="#fff0a2"
|
||||
opacity=".28"
|
||||
filter="url(#soft)"/>
|
||||
<circle class="sun" cx="1330" cy="72" r="42"
|
||||
fill="#fff0a2"
|
||||
opacity=".82"/>
|
||||
|
||||
<!-- Clouds (drift on scroll, lazily float sideways) -->
|
||||
<g class="clouds" fill="#fff" opacity=".82" filter="url(#cloudShadow)">
|
||||
<g class="cloud cloud-a">
|
||||
<g transform="translate(160 72)">
|
||||
<ellipse cx="0" cy="12" rx="54" ry="20"/>
|
||||
<circle cx="-27" cy="4" r="26"/>
|
||||
<circle cx="7" cy="-6" r="34"/>
|
||||
<circle cx="40" cy="7" r="25"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="cloud cloud-b" opacity=".72">
|
||||
<g transform="translate(650 52)">
|
||||
<ellipse cx="0" cy="10" rx="45" ry="16"/>
|
||||
<circle cx="-21" cy="4" r="21"/>
|
||||
<circle cx="7" cy="-5" r="27"/>
|
||||
<circle cx="32" cy="6" r="20"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="cloud cloud-c" opacity=".58">
|
||||
<g transform="translate(1070 110)">
|
||||
<ellipse cx="0" cy="8" rx="38" ry="14"/>
|
||||
<circle cx="-18" cy="3" r="18"/>
|
||||
<circle cx="6" cy="-4" r="23"/>
|
||||
<circle cx="28" cy="5" r="17"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Far rolling hills (slow parallax); drawn past the bottom edge so the
|
||||
drift never exposes sky underneath -->
|
||||
<path class="hills-far" d="
|
||||
M0 211
|
||||
C145 170 263 173 390 211
|
||||
C516 249 616 237 730 194
|
||||
C846 151 945 168 1065 208
|
||||
C1195 251 1334 214 1600 171
|
||||
L1600 340
|
||||
L0 340
|
||||
Z"
|
||||
fill="url(#farHill)"/>
|
||||
|
||||
<!-- Near rolling hills -->
|
||||
<path class="hills-near" d="
|
||||
M0 248
|
||||
C120 217 228 217 342 247
|
||||
C454 278 566 269 674 228
|
||||
C789 184 901 202 1024 243
|
||||
C1143 282 1278 271 1391 229
|
||||
C1473 199 1538 196 1600 207
|
||||
L1600 340
|
||||
L0 340
|
||||
Z"
|
||||
fill="url(#nearHill)"/>
|
||||
|
||||
<!-- Proper leafy bushes (ride with the near hills) -->
|
||||
<g class="hills-near">
|
||||
<g transform="translate(130 236)">
|
||||
<path d="
|
||||
M-58 28
|
||||
C-61 7 -46 -5 -28 0
|
||||
C-28 -20 -4 -31 11 -16
|
||||
C22 -36 54 -27 53 -5
|
||||
C73 -10 90 6 85 27
|
||||
Z"
|
||||
fill="#5e9c4b"/>
|
||||
<path d="
|
||||
M-47 27
|
||||
C-48 10 -37 1 -23 5
|
||||
C-20 -10 -4 -17 8 -7
|
||||
C19 -21 40 -17 43 -1
|
||||
C60 -4 70 8 68 26
|
||||
Z"
|
||||
fill="#70ad58"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(1240 225) scale(.9)">
|
||||
<path d="
|
||||
M-58 28
|
||||
C-61 7 -46 -5 -28 0
|
||||
C-28 -20 -4 -31 11 -16
|
||||
C22 -36 54 -27 53 -5
|
||||
C73 -10 90 6 85 27
|
||||
Z"
|
||||
fill="#5e9c4b"/>
|
||||
<path d="
|
||||
M-47 27
|
||||
C-48 10 -37 1 -23 5
|
||||
C-20 -10 -4 -17 8 -7
|
||||
C19 -21 40 -17 43 -1
|
||||
C60 -4 70 8 68 26
|
||||
Z"
|
||||
fill="#70ad58"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(460 250) scale(.58)">
|
||||
<path d="
|
||||
M-58 28
|
||||
C-61 7 -46 -5 -28 0
|
||||
C-28 -20 -4 -31 11 -16
|
||||
C22 -36 54 -27 53 -5
|
||||
C73 -10 90 6 85 27
|
||||
Z"
|
||||
fill="#5a9748"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(940 245) scale(.55)">
|
||||
<path d="
|
||||
M-58 28
|
||||
C-61 7 -46 -5 -28 0
|
||||
C-28 -20 -4 -31 11 -16
|
||||
C22 -36 54 -27 53 -5
|
||||
C73 -10 90 6 85 27
|
||||
Z"
|
||||
fill="#5a9748"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Foreground meadow (near-static parallax) -->
|
||||
<path class="meadow" d="
|
||||
M0 270
|
||||
C95 260 175 267 260 273
|
||||
C347 279 431 265 521 262
|
||||
C620 258 705 275 808 272
|
||||
C914 269 1004 251 1115 258
|
||||
C1229 265 1324 279 1424 270
|
||||
C1491 264 1547 258 1600 262
|
||||
L1600 340
|
||||
L0 340
|
||||
Z"
|
||||
fill="url(#grass)"/>
|
||||
|
||||
<!-- Dense grass blades -->
|
||||
<g class="meadow" fill="none"
|
||||
stroke="#4b8937"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round">
|
||||
|
||||
<path d="M20 276 q-2 -19 4 -31 M28 276 q8 -18 11 -29"/>
|
||||
<path d="M65 272 q-5 -18 1 -28 M74 272 q8 -16 11 -25"/>
|
||||
<path d="M110 271 q-2 -23 5 -36 M121 272 q9 -18 12 -30"/>
|
||||
<path d="M165 273 q-6 -16 -1 -28 M175 273 q7 -18 11 -27"/>
|
||||
<path d="M225 274 q-3 -24 4 -37 M236 274 q8 -17 12 -29"/>
|
||||
<path d="M286 273 q-6 -19 -1 -31 M296 274 q9 -18 12 -28"/>
|
||||
<path d="M345 270 q-3 -18 3 -31 M355 270 q8 -17 12 -28"/>
|
||||
<path d="M405 266 q-4 -22 2 -35 M416 266 q9 -17 13 -28"/>
|
||||
<path d="M472 263 q-3 -18 3 -30 M482 263 q8 -19 13 -29"/>
|
||||
<path d="M535 263 q-6 -19 -1 -31 M546 264 q9 -19 13 -30"/>
|
||||
<path d="M598 266 q-3 -24 4 -37 M609 267 q8 -19 12 -31"/>
|
||||
<path d="M665 271 q-6 -17 -1 -29 M676 271 q9 -17 13 -28"/>
|
||||
<path d="M735 273 q-4 -22 3 -34 M746 273 q8 -18 12 -29"/>
|
||||
<path d="M805 272 q-6 -18 -1 -30 M816 271 q9 -17 13 -27"/>
|
||||
<path d="M875 265 q-3 -23 4 -35 M886 265 q8 -18 12 -28"/>
|
||||
<path d="M945 258 q-5 -18 1 -30 M955 258 q9 -17 12 -28"/>
|
||||
<path d="M1018 256 q-3 -22 4 -34 M1029 256 q8 -18 12 -28"/>
|
||||
<path d="M1090 259 q-5 -20 1 -31 M1101 259 q8 -18 12 -29"/>
|
||||
<path d="M1162 264 q-3 -24 4 -36 M1173 264 q8 -18 12 -30"/>
|
||||
<path d="M1234 269 q-5 -19 1 -31 M1245 269 q8 -17 12 -28"/>
|
||||
<path d="M1304 273 q-3 -23 4 -35 M1315 273 q8 -18 12 -29"/>
|
||||
<path d="M1374 272 q-5 -18 1 -29 M1385 272 q8 -18 12 -28"/>
|
||||
<path d="M1445 268 q-3 -22 4 -34 M1456 268 q8 -17 12 -28"/>
|
||||
<path d="M1515 264 q-5 -20 1 -31 M1526 264 q8 -18 12 -29"/>
|
||||
<path d="M1570 263 q-3 -21 4 -33 M1581 263 q8 -17 12 -28"/>
|
||||
</g>
|
||||
|
||||
<!-- Extra foreground tufts -->
|
||||
<g class="meadow" fill="#579a40">
|
||||
<path d="M80 320 l15 -45 3 45 14 -57 0 57 20 -38 -7 38z"/>
|
||||
<path d="M365 320 l10 -37 5 37 15 -49 -2 49 20 -34 -7 34z"/>
|
||||
<path d="M710 320 l13 -42 3 42 15 -54 -1 54 20 -38 -8 38z"/>
|
||||
<path d="M1040 320 l10 -35 5 35 14 -49 0 49 18 -32 -6 32z"/>
|
||||
<path d="M1360 320 l13 -43 3 43 16 -55 -2 55 20 -37 -7 37z"/>
|
||||
</g>
|
||||
|
||||
<!-- Flowers (sway gently; each wrapped in a classed group so the CSS
|
||||
transform doesn't clobber the placement transform) -->
|
||||
<g class="meadow">
|
||||
<g class="flower">
|
||||
<g transform="translate(190 270)">
|
||||
<path d="M0 8 Q-2 25 -4 42" stroke="#42833a" stroke-width="2" fill="none"/>
|
||||
<g fill="#ff8aa7">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ffe071"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(310 280) scale(.8)">
|
||||
<path d="M0 8 Q2 23 1 37" stroke="#42833a" stroke-width="2" fill="none"/>
|
||||
<g fill="#fff0a0">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ef7191"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(540 265)">
|
||||
<path d="M0 8 Q-2 25 -2 42" stroke="#42833a" stroke-width="2" fill="none"/>
|
||||
<g fill="#ff8aa7">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ffe071"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(670 279) scale(.7)">
|
||||
<g fill="#fff0a0">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ef7191"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(845 272)">
|
||||
<path d="M0 8 Q2 25 0 42" stroke="#42833a" stroke-width="2" fill="none"/>
|
||||
<g fill="#ff8aa7">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ffe071"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(1010 265) scale(.85)">
|
||||
<path d="M0 8 Q-2 24 -1 38" stroke="#42833a" stroke-width="2" fill="none"/>
|
||||
<g fill="#fff0a0">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ef7191"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(1190 275)">
|
||||
<g fill="#ff8aa7">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ffe071"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(1410 269)">
|
||||
<path d="M0 8 Q2 26 0 42" stroke="#42833a" stroke-width="2" fill="none"/>
|
||||
<g fill="#fff0a0">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ef7191"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="flower">
|
||||
<g transform="translate(1510 282) scale(.72)">
|
||||
<g fill="#ff8aa7">
|
||||
<circle cx="-5" cy="0" r="5"/>
|
||||
<circle cx="5" cy="0" r="5"/>
|
||||
<circle cx="0" cy="-5" r="5"/>
|
||||
<circle cx="0" cy="5" r="5"/>
|
||||
</g>
|
||||
<circle r="3" fill="#ffe071"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Small scattered meadow dots -->
|
||||
<g class="meadow" fill="#ffe071" opacity=".9">
|
||||
<circle cx="250" cy="287" r="2.5"/>
|
||||
<circle cx="445" cy="281" r="2.5"/>
|
||||
<circle cx="615" cy="289" r="2.5"/>
|
||||
<circle cx="760" cy="283" r="2.5"/>
|
||||
<circle cx="930" cy="279" r="2.5"/>
|
||||
<circle cx="1125" cy="287" r="2.5"/>
|
||||
<circle cx="1290" cy="281" r="2.5"/>
|
||||
</g>
|
||||
|
||||
<g class="meadow" fill="#ff8aa7" opacity=".8">
|
||||
<circle cx="120" cy="291" r="2.5"/>
|
||||
<circle cx="395" cy="291" r="2.5"/>
|
||||
<circle cx="730" cy="294" r="2.5"/>
|
||||
<circle cx="1080" cy="294" r="2.5"/>
|
||||
<circle cx="1460" cy="291" r="2.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Soft fade into the page background -->
|
||||
<rect y="252" width="1600" height="68" fill="url(#meadowFade)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,143 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="9" viewBox="0 0 300 9">
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<path d="M1.0 9 q2.0 -3.0 1.5 -5.5" stroke="#579a40" stroke-width="1.0"/>
|
||||
<path d="M3.5 9 q1.7 -4.1 3.0 -7.5" stroke="#579a40" stroke-width="1.6"/>
|
||||
<path d="M6.3 9 q-2.0 -3.8 -1.8 -6.9" stroke="#63ad4d" stroke-width="0.8"/>
|
||||
<path d="M8.4 9 q-1.7 -2.3 -3.1 -4.2" stroke="#74b65e" stroke-width="1.2"/>
|
||||
<path d="M11.0 9 q0.6 -3.5 1.8 -6.3" stroke="#4f913b" stroke-width="1.3"/>
|
||||
<path d="M12.7 9 q-0.4 -2.6 -1.9 -4.8" stroke="#63ad4d" stroke-width="0.8"/>
|
||||
<path d="M14.3 9 q-2.3 -2.9 2.5 -5.3" stroke="#4f913b" stroke-width="1.4"/>
|
||||
<path d="M16.9 9 q1.8 -4.5 -2.9 -8.1" stroke="#74b65e" stroke-width="1.8"/>
|
||||
<path d="M19.5 9 q0.5 -2.6 2.9 -4.7" stroke="#63ad4d" stroke-width="1.4"/>
|
||||
<path d="M22.5 9 q2.3 -2.1 -1.7 -3.9" stroke="#579a40" stroke-width="1.7"/>
|
||||
<path d="M25.2 9 q-2.1 -4.4 -3.8 -7.9" stroke="#42833a" stroke-width="1.0"/>
|
||||
<path d="M27.7 9 q-1.9 -4.5 -3.8 -8.2" stroke="#74b65e" stroke-width="1.4"/>
|
||||
<path d="M29.4 9 q-0.3 -3.7 -3.1 -6.7" stroke="#579a40" stroke-width="1.4"/>
|
||||
<path d="M31.0 9 q-0.9 -3.5 0.1 -6.3" stroke="#579a40" stroke-width="0.9"/>
|
||||
<path d="M32.6 9 q-0.9 -4.3 1.1 -7.8" stroke="#4f913b" stroke-width="1.4"/>
|
||||
<path d="M35.7 9 q-1.4 -2.4 2.3 -4.3" stroke="#63ad4d" stroke-width="1.5"/>
|
||||
<path d="M36.9 9 q-0.4 -3.2 0.0 -5.8" stroke="#63ad4d" stroke-width="1.6"/>
|
||||
<path d="M38.5 9 q1.1 -4.6 0.6 -8.4" stroke="#63ad4d" stroke-width="1.2"/>
|
||||
<path d="M41.3 9 q2.3 -2.9 4.0 -5.4" stroke="#63ad4d" stroke-width="1.8"/>
|
||||
<path d="M43.3 9 q1.6 -2.6 4.5 -4.7" stroke="#4f913b" stroke-width="1.0"/>
|
||||
<path d="M45.4 9 q-0.3 -2.6 -2.1 -4.8" stroke="#63ad4d" stroke-width="0.8"/>
|
||||
<path d="M47.0 9 q0.0 -4.3 -3.8 -7.8" stroke="#42833a" stroke-width="1.6"/>
|
||||
<path d="M49.6 9 q1.6 -3.1 0.3 -5.7" stroke="#4f913b" stroke-width="1.4"/>
|
||||
<path d="M51.6 9 q-1.2 -2.7 -0.2 -4.9" stroke="#74b65e" stroke-width="1.2"/>
|
||||
<path d="M53.7 9 q-1.0 -2.4 3.6 -4.3" stroke="#579a40" stroke-width="1.6"/>
|
||||
<path d="M55.7 9 q1.4 -2.1 3.5 -3.9" stroke="#579a40" stroke-width="1.2"/>
|
||||
<path d="M58.4 9 q-0.6 -2.6 0.7 -4.8" stroke="#579a40" stroke-width="1.2"/>
|
||||
<path d="M60.9 9 q-2.3 -2.4 -3.7 -4.4" stroke="#579a40" stroke-width="1.0"/>
|
||||
<path d="M62.7 9 q1.0 -2.3 -1.3 -4.1" stroke="#579a40" stroke-width="0.8"/>
|
||||
<path d="M64.9 9 q-0.3 -4.0 -3.5 -7.2" stroke="#42833a" stroke-width="1.3"/>
|
||||
<path d="M67.6 9 q1.7 -4.2 4.2 -7.6" stroke="#579a40" stroke-width="0.9"/>
|
||||
<path d="M70.5 9 q-0.9 -2.3 1.3 -4.1" stroke="#42833a" stroke-width="1.7"/>
|
||||
<path d="M72.8 9 q-0.2 -2.9 -2.9 -5.2" stroke="#42833a" stroke-width="1.0"/>
|
||||
<path d="M74.3 9 q-0.5 -2.9 4.5 -5.2" stroke="#42833a" stroke-width="1.4"/>
|
||||
<path d="M76.9 9 q-1.6 -2.3 0.5 -4.1" stroke="#42833a" stroke-width="1.7"/>
|
||||
<path d="M78.4 9 q1.1 -5.1 -0.4 -9.2" stroke="#579a40" stroke-width="1.0"/>
|
||||
<path d="M80.6 9 q-1.2 -3.8 2.1 -6.9" stroke="#42833a" stroke-width="1.8"/>
|
||||
<path d="M81.9 9 q-1.7 -2.7 -2.8 -5.0" stroke="#42833a" stroke-width="1.0"/>
|
||||
<path d="M83.8 9 q1.8 -3.1 4.5 -5.7" stroke="#74b65e" stroke-width="0.8"/>
|
||||
<path d="M85.8 9 q-0.7 -4.0 -0.4 -7.3" stroke="#42833a" stroke-width="0.8"/>
|
||||
<path d="M88.0 9 q-1.6 -5.0 -0.1 -9.2" stroke="#63ad4d" stroke-width="0.8"/>
|
||||
<path d="M90.9 9 q1.5 -4.4 2.2 -7.9" stroke="#74b65e" stroke-width="1.5"/>
|
||||
<path d="M92.3 9 q1.5 -3.3 1.5 -6.0" stroke="#579a40" stroke-width="1.2"/>
|
||||
<path d="M94.7 9 q-0.4 -3.4 0.2 -6.2" stroke="#63ad4d" stroke-width="1.0"/>
|
||||
<path d="M96.4 9 q-2.1 -4.8 -1.6 -8.7" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M99.3 9 q-0.1 -4.9 0.3 -8.9" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M101.1 9 q1.0 -4.6 1.4 -8.4" stroke="#4f913b" stroke-width="0.8"/>
|
||||
<path d="M103.6 9 q-1.6 -4.8 -0.8 -8.8" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M105.1 9 q-2.5 -2.1 1.7 -3.9" stroke="#74b65e" stroke-width="1.5"/>
|
||||
<path d="M108.0 9 q1.2 -2.2 -3.6 -3.9" stroke="#74b65e" stroke-width="1.8"/>
|
||||
<path d="M110.5 9 q1.0 -4.2 2.3 -7.6" stroke="#579a40" stroke-width="1.6"/>
|
||||
<path d="M113.6 9 q0.2 -2.4 2.7 -4.4" stroke="#42833a" stroke-width="1.5"/>
|
||||
<path d="M115.8 9 q-0.7 -4.6 -1.1 -8.3" stroke="#579a40" stroke-width="1.0"/>
|
||||
<path d="M117.8 9 q-2.0 -3.2 3.7 -5.8" stroke="#579a40" stroke-width="1.1"/>
|
||||
<path d="M119.2 9 q-1.0 -4.8 1.2 -8.7" stroke="#4f913b" stroke-width="1.6"/>
|
||||
<path d="M121.9 9 q-0.2 -4.7 -3.9 -8.5" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M125.0 9 q0.4 -2.2 -0.7 -3.9" stroke="#42833a" stroke-width="1.6"/>
|
||||
<path d="M127.3 9 q-1.6 -5.0 -0.4 -9.0" stroke="#74b65e" stroke-width="1.4"/>
|
||||
<path d="M129.3 9 q-0.2 -4.1 4.2 -7.4" stroke="#42833a" stroke-width="1.1"/>
|
||||
<path d="M131.7 9 q-1.7 -2.6 -0.4 -4.7" stroke="#42833a" stroke-width="1.2"/>
|
||||
<path d="M133.0 9 q2.2 -4.9 -0.0 -8.9" stroke="#579a40" stroke-width="1.2"/>
|
||||
<path d="M134.8 9 q-1.2 -4.4 -0.5 -8.0" stroke="#42833a" stroke-width="1.6"/>
|
||||
<path d="M137.2 9 q2.3 -4.9 1.1 -8.8" stroke="#42833a" stroke-width="1.3"/>
|
||||
<path d="M139.4 9 q-2.1 -4.4 2.3 -7.9" stroke="#4f913b" stroke-width="0.9"/>
|
||||
<path d="M141.6 9 q1.3 -2.4 2.5 -4.4" stroke="#74b65e" stroke-width="1.4"/>
|
||||
<path d="M143.3 9 q1.8 -3.5 2.7 -6.4" stroke="#74b65e" stroke-width="1.4"/>
|
||||
<path d="M146.0 9 q0.1 -4.9 0.9 -8.9" stroke="#579a40" stroke-width="0.8"/>
|
||||
<path d="M147.2 9 q2.3 -3.6 -2.1 -6.5" stroke="#42833a" stroke-width="1.5"/>
|
||||
<path d="M149.9 9 q0.8 -3.0 4.6 -5.4" stroke="#63ad4d" stroke-width="0.9"/>
|
||||
<path d="M151.7 9 q2.5 -3.7 2.8 -6.8" stroke="#74b65e" stroke-width="1.7"/>
|
||||
<path d="M154.4 9 q-1.8 -2.5 2.6 -4.5" stroke="#63ad4d" stroke-width="1.8"/>
|
||||
<path d="M155.9 9 q2.4 -4.9 3.1 -8.9" stroke="#63ad4d" stroke-width="1.2"/>
|
||||
<path d="M158.8 9 q2.4 -3.5 2.3 -6.3" stroke="#63ad4d" stroke-width="1.2"/>
|
||||
<path d="M161.5 9 q-1.5 -4.1 2.1 -7.5" stroke="#63ad4d" stroke-width="1.7"/>
|
||||
<path d="M164.2 9 q2.4 -3.4 -1.4 -6.1" stroke="#63ad4d" stroke-width="1.1"/>
|
||||
<path d="M166.1 9 q1.0 -2.4 2.1 -4.3" stroke="#74b65e" stroke-width="1.1"/>
|
||||
<path d="M167.7 9 q-1.2 -5.2 1.7 -9.4" stroke="#579a40" stroke-width="1.0"/>
|
||||
<path d="M169.8 9 q0.9 -5.0 1.2 -9.1" stroke="#74b65e" stroke-width="1.7"/>
|
||||
<path d="M171.9 9 q-2.3 -5.1 0.1 -9.2" stroke="#63ad4d" stroke-width="1.4"/>
|
||||
<path d="M174.8 9 q-2.3 -4.0 -2.4 -7.4" stroke="#42833a" stroke-width="1.8"/>
|
||||
<path d="M176.1 9 q2.3 -3.9 3.4 -7.1" stroke="#74b65e" stroke-width="1.6"/>
|
||||
<path d="M178.2 9 q2.3 -2.2 2.4 -4.1" stroke="#579a40" stroke-width="1.7"/>
|
||||
<path d="M179.6 9 q-0.5 -3.2 -3.3 -5.8" stroke="#4f913b" stroke-width="1.1"/>
|
||||
<path d="M181.1 9 q-2.1 -3.9 1.2 -7.0" stroke="#4f913b" stroke-width="1.4"/>
|
||||
<path d="M183.7 9 q-2.5 -3.2 0.2 -5.8" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M186.2 9 q0.9 -4.4 0.1 -8.1" stroke="#42833a" stroke-width="1.3"/>
|
||||
<path d="M188.7 9 q-1.1 -2.8 1.0 -5.1" stroke="#579a40" stroke-width="1.1"/>
|
||||
<path d="M190.3 9 q-0.9 -3.0 -3.0 -5.5" stroke="#579a40" stroke-width="1.8"/>
|
||||
<path d="M193.2 9 q0.2 -5.1 0.9 -9.3" stroke="#4f913b" stroke-width="0.9"/>
|
||||
<path d="M195.3 9 q-1.0 -2.3 -3.2 -4.2" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M196.9 9 q-1.2 -2.7 1.3 -5.0" stroke="#4f913b" stroke-width="0.9"/>
|
||||
<path d="M199.2 9 q-1.5 -4.5 4.5 -8.1" stroke="#63ad4d" stroke-width="1.1"/>
|
||||
<path d="M201.6 9 q1.9 -3.7 -0.1 -6.7" stroke="#63ad4d" stroke-width="1.8"/>
|
||||
<path d="M202.8 9 q-0.3 -2.2 0.4 -3.9" stroke="#42833a" stroke-width="1.6"/>
|
||||
<path d="M205.5 9 q0.7 -4.2 3.1 -7.6" stroke="#579a40" stroke-width="0.9"/>
|
||||
<path d="M207.3 9 q0.4 -4.3 -2.1 -7.8" stroke="#63ad4d" stroke-width="0.9"/>
|
||||
<path d="M209.9 9 q2.0 -3.4 -0.4 -6.3" stroke="#4f913b" stroke-width="1.6"/>
|
||||
<path d="M211.1 9 q-1.7 -2.1 0.8 -3.8" stroke="#4f913b" stroke-width="1.5"/>
|
||||
<path d="M214.0 9 q-2.0 -2.5 4.3 -4.6" stroke="#579a40" stroke-width="0.9"/>
|
||||
<path d="M215.6 9 q-2.2 -2.2 -2.1 -4.0" stroke="#74b65e" stroke-width="1.0"/>
|
||||
<path d="M217.1 9 q-1.8 -3.1 -0.9 -5.6" stroke="#63ad4d" stroke-width="1.3"/>
|
||||
<path d="M220.0 9 q1.4 -4.7 -1.8 -8.5" stroke="#42833a" stroke-width="1.8"/>
|
||||
<path d="M223.1 9 q0.9 -3.4 -1.5 -6.2" stroke="#42833a" stroke-width="1.7"/>
|
||||
<path d="M225.3 9 q2.4 -3.5 -3.8 -6.3" stroke="#4f913b" stroke-width="0.9"/>
|
||||
<path d="M227.1 9 q2.2 -3.8 -3.0 -7.0" stroke="#579a40" stroke-width="1.0"/>
|
||||
<path d="M229.7 9 q2.1 -4.4 4.4 -8.0" stroke="#74b65e" stroke-width="1.3"/>
|
||||
<path d="M231.0 9 q-1.2 -4.8 2.8 -8.7" stroke="#63ad4d" stroke-width="1.6"/>
|
||||
<path d="M232.7 9 q-0.9 -3.1 -3.3 -5.6" stroke="#63ad4d" stroke-width="1.5"/>
|
||||
<path d="M234.8 9 q2.1 -4.6 0.4 -8.4" stroke="#4f913b" stroke-width="1.0"/>
|
||||
<path d="M237.9 9 q-0.9 -4.6 -0.1 -8.4" stroke="#74b65e" stroke-width="1.4"/>
|
||||
<path d="M239.4 9 q1.6 -4.7 -1.0 -8.5" stroke="#579a40" stroke-width="1.6"/>
|
||||
<path d="M241.3 9 q1.9 -5.1 0.9 -9.4" stroke="#579a40" stroke-width="1.4"/>
|
||||
<path d="M244.3 9 q-2.2 -3.6 -1.9 -6.6" stroke="#42833a" stroke-width="1.1"/>
|
||||
<path d="M245.6 9 q1.7 -4.2 -1.7 -7.6" stroke="#579a40" stroke-width="1.4"/>
|
||||
<path d="M247.1 9 q0.1 -3.1 2.0 -5.7" stroke="#42833a" stroke-width="1.5"/>
|
||||
<path d="M249.5 9 q-2.2 -3.8 -2.2 -6.9" stroke="#42833a" stroke-width="0.9"/>
|
||||
<path d="M251.5 9 q-2.1 -4.4 3.5 -8.0" stroke="#579a40" stroke-width="0.9"/>
|
||||
<path d="M253.2 9 q0.8 -4.3 0.7 -7.8" stroke="#4f913b" stroke-width="1.7"/>
|
||||
<path d="M255.5 9 q1.4 -2.2 -2.9 -4.0" stroke="#4f913b" stroke-width="1.4"/>
|
||||
<path d="M258.1 9 q1.9 -4.6 1.0 -8.3" stroke="#63ad4d" stroke-width="1.2"/>
|
||||
<path d="M260.5 9 q-1.9 -4.8 -3.7 -8.6" stroke="#579a40" stroke-width="1.4"/>
|
||||
<path d="M263.5 9 q1.4 -3.9 -1.3 -7.2" stroke="#4f913b" stroke-width="1.1"/>
|
||||
<path d="M265.2 9 q-0.9 -4.8 2.7 -8.7" stroke="#42833a" stroke-width="0.9"/>
|
||||
<path d="M267.4 9 q2.5 -2.2 3.0 -4.1" stroke="#4f913b" stroke-width="1.5"/>
|
||||
<path d="M269.4 9 q-1.8 -1.9 -3.9 -3.5" stroke="#579a40" stroke-width="1.5"/>
|
||||
<path d="M272.3 9 q-1.8 -4.5 2.9 -8.2" stroke="#74b65e" stroke-width="1.1"/>
|
||||
<path d="M274.0 9 q-0.6 -2.2 1.8 -3.9" stroke="#4f913b" stroke-width="1.6"/>
|
||||
<path d="M276.4 9 q1.2 -2.8 -3.9 -5.1" stroke="#63ad4d" stroke-width="1.5"/>
|
||||
<path d="M277.7 9 q2.4 -4.2 3.0 -7.7" stroke="#4f913b" stroke-width="1.1"/>
|
||||
<path d="M279.6 9 q0.6 -5.0 -3.3 -9.2" stroke="#4f913b" stroke-width="1.4"/>
|
||||
<path d="M282.5 9 q0.6 -3.5 1.2 -6.3" stroke="#4f913b" stroke-width="1.2"/>
|
||||
<path d="M284.0 9 q-2.4 -3.8 -2.5 -6.8" stroke="#74b65e" stroke-width="1.5"/>
|
||||
<path d="M285.6 9 q-1.6 -5.2 -2.4 -9.4" stroke="#63ad4d" stroke-width="1.5"/>
|
||||
<path d="M288.4 9 q1.6 -2.4 3.1 -4.4" stroke="#63ad4d" stroke-width="1.2"/>
|
||||
<path d="M290.2 9 q-1.9 -3.0 2.9 -5.4" stroke="#63ad4d" stroke-width="0.8"/>
|
||||
<path d="M291.6 9 q-0.5 -2.7 -2.1 -4.9" stroke="#579a40" stroke-width="1.8"/>
|
||||
<path d="M294.2 9 q0.8 -3.7 4.4 -6.7" stroke="#63ad4d" stroke-width="0.9"/>
|
||||
<path d="M296.6 9 q1.2 -3.7 4.2 -6.7" stroke="#74b65e" stroke-width="0.9"/>
|
||||
<path d="M298.1 9 q1.0 -3.8 3.9 -7.0" stroke="#42833a" stroke-width="0.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,267 @@
|
||||
/* Summer theme: the banner's illustrated meadow carried through the whole
|
||||
page — one palette (sky blue, grass green, sun yellow, flower pink),
|
||||
soft daylight surfaces, and playful touches like a tilted brand and
|
||||
flower bullets. */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Every color is sampled (or text-darkened) from banner.svg. */
|
||||
--bg: #e6f4cf;
|
||||
/* pale meadow — the banner fades into this */
|
||||
--surface: #f8fbf0;
|
||||
--text: #2c4a2f;
|
||||
/* deep leaf */
|
||||
--muted: #5c7a5e;
|
||||
|
||||
--accent: #2598d6;
|
||||
/* sky, darkened for text contrast */
|
||||
--accent2: #4f913b;
|
||||
/* grass */
|
||||
--accent3: #e95f83;
|
||||
/* flower pink, darkened for text contrast */
|
||||
--sun: #ffe071;
|
||||
--line: #4f913b29;
|
||||
|
||||
--link: color-mix(in oklab, var(--text) 38%, var(--accent));
|
||||
|
||||
--font-body: var(--font-cause);
|
||||
--font-heading: var(--font-new-rocker);
|
||||
--font-brand: var(--font-cause);
|
||||
}
|
||||
|
||||
/* The page is the same landscape the banner paints: hazy sky light up top
|
||||
(with an echo of the banner's sun in the top-right corner) settling into
|
||||
a pale meadow below. Fixed, so the banner parallax reads against a
|
||||
still backdrop. */
|
||||
body {
|
||||
background:
|
||||
radial-gradient(ellipse 36rem 24rem at 85% -6rem,
|
||||
#fff0a299 0,
|
||||
#fff0a63d 45%,
|
||||
transparent 70%),
|
||||
radial-gradient(ellipse 42rem 26rem at 6% 2rem,
|
||||
#8ed0ff4d 0,
|
||||
transparent 70%),
|
||||
radial-gradient(ellipse 30rem 22rem at 96% 62%,
|
||||
#ff8aa726 0,
|
||||
transparent 70%),
|
||||
linear-gradient(180deg,
|
||||
var(--bg) 0,
|
||||
#d9eebd 24rem,
|
||||
#c6e5a8 55rem,
|
||||
#b8dd9c 100%);
|
||||
background-attachment: fixed;
|
||||
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--sun);
|
||||
color: #3d5223;
|
||||
}
|
||||
|
||||
/* The banner frame ties into the meadow beneath it. */
|
||||
#banner {
|
||||
border-bottom-color: #4f913b30;
|
||||
box-shadow: 0 0.3rem 1rem #4f913b22;
|
||||
}
|
||||
|
||||
/* Cheerful oversized tilted brand in a sky→grass→flower gradient. */
|
||||
#brand {
|
||||
font-size: clamp(3.2rem, 9vw, 7.5rem);
|
||||
line-height: 1;
|
||||
padding-bottom: 0.3em;
|
||||
margin-bottom: -0.3em;
|
||||
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: left bottom;
|
||||
|
||||
background: linear-gradient(100deg,
|
||||
var(--accent) 0 30%,
|
||||
var(--accent2) 55%,
|
||||
var(--accent3) 95%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
|
||||
filter:
|
||||
drop-shadow(0 1px 0 #fff) drop-shadow(0 0.15rem 0.15rem #ffffff99) drop-shadow(0 0.3rem 0.4rem #4f913b33);
|
||||
}
|
||||
|
||||
/* Navigation floats over the artwork: dark leaf text with a daylight halo. */
|
||||
#nav {
|
||||
color: #24452d;
|
||||
text-shadow:
|
||||
0 1px #ffffff,
|
||||
0 0 0.2em #ffffff,
|
||||
0 0 0.5em #d9f1ff;
|
||||
}
|
||||
|
||||
#nav a {
|
||||
color: #2c4a2f;
|
||||
}
|
||||
|
||||
#nav ul ul a,
|
||||
#nav span {
|
||||
color: #527048;
|
||||
}
|
||||
|
||||
#nav a:hover,
|
||||
#nav .current {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Content sits in a soft wash of sunlit meadow rather than on a white
|
||||
card. */
|
||||
main {
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
#f0f8dc80 15%,
|
||||
#f0f8dc8c 50%,
|
||||
#f0f8dc80 85%,
|
||||
transparent);
|
||||
}
|
||||
|
||||
/* The sidebar is a piece of the same meadow: glassy green with a light
|
||||
top edge and a grassy shadow. */
|
||||
#sidebar {
|
||||
background: linear-gradient(160deg, #f4faddd9, #d9eec5cf);
|
||||
border-right: 1px solid #ffffff80;
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 0.3rem 1rem #4f913b1f;
|
||||
}
|
||||
|
||||
#sidebar a {
|
||||
color: #527048;
|
||||
}
|
||||
|
||||
#sidebar a:hover,
|
||||
#sidebar .current {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* One accent per heading level: sky h1 growing a dense low strip of grass
|
||||
right against its baseline (grass.svg, ~140 varied blades per tile,
|
||||
served by the backend next to this stylesheet), grass h2, flower h3 in
|
||||
a quieter weight. */
|
||||
article h1 {
|
||||
color: var(--accent);
|
||||
width: fit-content;
|
||||
text-shadow: 0 1px #ffffffaa;
|
||||
background: url("grass.svg") bottom left / auto 0.35em repeat-x;
|
||||
}
|
||||
|
||||
article h2 {
|
||||
color: var(--accent2);
|
||||
text-shadow: 0 1px #ffffff88;
|
||||
}
|
||||
|
||||
article h3 {
|
||||
color: var(--accent3);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
article a {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
article a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Flower bullets, one accent per nesting level. */
|
||||
article ul li::before {
|
||||
content: "❀";
|
||||
color: var(--accent3);
|
||||
font-size: 0.8em;
|
||||
vertical-align: 0.1em;
|
||||
}
|
||||
|
||||
article ul ul li::before {
|
||||
content: "✿";
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
article ul ul ul li::before {
|
||||
content: "❁";
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
/* Quotes get a grassy edge and a wash of sunlight. */
|
||||
blockquote {
|
||||
color: #4d6849;
|
||||
border-left-color: var(--accent2);
|
||||
background: linear-gradient(90deg, #fff0a238, transparent 70%);
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Admonitions join the rounded, softly shaded landscape elements. */
|
||||
.admonition {
|
||||
border-radius: 0.6rem;
|
||||
box-shadow: 0 0.2rem 0.7rem #4f913b1a;
|
||||
}
|
||||
|
||||
/* Code rests on a shaded leaf panel, keeping the base light syntax
|
||||
palette. */
|
||||
pre {
|
||||
--code-bg: #e7f1d6;
|
||||
|
||||
background: linear-gradient(160deg, #f0f7e2, #ddebc9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow:
|
||||
inset 0 1px #ffffff99,
|
||||
0 0.2rem 0.6rem #4f913b1a;
|
||||
}
|
||||
|
||||
.copy {
|
||||
background: linear-gradient(180deg, #f6faec, #e2efcf);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* Tables: a rounded piece of the landscape. The base's header-gradient
|
||||
hooks get sky→grass colors and the cell wash tints green; soft corners
|
||||
and a grassy shadow replace the old hard grid. */
|
||||
table {
|
||||
--table-head-a: #fdfccd88;
|
||||
--table-head-b: #ece9ba88;
|
||||
--table-tint: var(--accent2);
|
||||
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 0.7rem;
|
||||
overflow: clip;
|
||||
box-shadow: 0 0.1rem 0.5rem #4f913b88;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #2c4a2f;
|
||||
}
|
||||
|
||||
/* A sun-kissed sheen on hover, fading in over the cell wash. */
|
||||
tbody td {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background-color: #fff0a238;
|
||||
}
|
||||
|
||||
figcaption,
|
||||
.dateline,
|
||||
.footnote {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Editor chrome inherits the meadow rather than the base white. */
|
||||
.editor-root.overlay {
|
||||
background: linear-gradient(160deg, #eef7dd, #d9eec5);
|
||||
}
|
||||
|
||||
/* The page transition exposes summer color around the rotating
|
||||
snapshots. */
|
||||
::view-transition {
|
||||
background: var(--bg);
|
||||
}
|
||||
@@ -25,11 +25,13 @@ from pagerite.markdown import has_h1, render
|
||||
|
||||
SITE_NAME = "Pagerite"
|
||||
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
|
||||
# independently. Order matters: base first, theme overrides it.
|
||||
# The base CSS is built by Vite as a separate entry so the backend can link
|
||||
# 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"
|
||||
_THEME_CSS_KEY = "src/assets/themes/{theme}/theme.css"
|
||||
|
||||
_manifest_cache: dict | None = None
|
||||
_asset_cache: dict[str, tuple] = {}
|
||||
@@ -42,29 +44,54 @@ def _manifest() -> dict:
|
||||
return _manifest_cache
|
||||
|
||||
|
||||
def _css_keys(theme: str) -> list[str]:
|
||||
"""Manifest keys for the stylesheets to load for ``theme`` (empty = none)."""
|
||||
keys = [_BASE_CSS_KEY]
|
||||
if theme:
|
||||
keys.append(_THEME_CSS_KEY.format(theme=theme))
|
||||
return keys
|
||||
def _theme_names() -> list[str]:
|
||||
"""Theme folders on disk (a folder is a theme when it has theme.css)."""
|
||||
return sorted(
|
||||
d.name for d in THEMES.iterdir() if d.is_dir() and (d / "theme.css").exists()
|
||||
)
|
||||
|
||||
|
||||
def _shared_css_urls(vite_url: str | None, theme: str) -> list[str]:
|
||||
"""URLs for the base and theme stylesheets.
|
||||
def _banner_design_names() -> list[str]:
|
||||
"""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.
|
||||
Themes added after the last frontend build are missing from the
|
||||
manifest — fall back to the base stylesheet rather than failing.
|
||||
"""
|
||||
|
||||
def _valid_name(name: str) -> bool:
|
||||
"""Guard against path traversal in theme/design names."""
|
||||
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:
|
||||
return []
|
||||
return None
|
||||
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).
|
||||
|
||||
This is linked by the public-page edit pen so the editor styles are
|
||||
@@ -74,47 +101,58 @@ def _editor_css_url(vite_url: str | None, theme: str) -> str | None:
|
||||
return None
|
||||
manifest = _manifest()
|
||||
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", []):
|
||||
if css not in shared_files:
|
||||
if css != base:
|
||||
return f"/{css}"
|
||||
return None
|
||||
|
||||
|
||||
def _layout(
|
||||
urls: list[str],
|
||||
modules: list[str] = (),
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
banner_design: str = "",
|
||||
favicon: str = "",
|
||||
) -> Template:
|
||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||
|
||||
Stylesheets use ``blocking="render"`` so the browser waits for them before
|
||||
showing the page, avoiding a flash of unstyled content.
|
||||
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
|
||||
backend links no stylesheets and Vite injects them from JS) the
|
||||
frontend entries know which theme CSS module to import.
|
||||
In dev, pagerite.js re-appends the backend-rendered theme/design links
|
||||
(and the custom CSS) after the Vite-injected base styles, keeping this
|
||||
order intact.
|
||||
"""
|
||||
doc = Document(E.Title, lang="en")
|
||||
if theme:
|
||||
doc.meta(name="pagerite:theme", content=theme)
|
||||
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
|
||||
# the real device width, not the default 980px layout viewport.
|
||||
doc.meta(name="viewport", content="width=device-width, initial-scale=1")
|
||||
# A custom favicon (from the site editor) is linked explicitly; without
|
||||
# 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
|
||||
# itself once it has validated the session (pages render identically
|
||||
# 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])
|
||||
if editor_css:
|
||||
doc.meta(name="pagerite:editor-css", content=editor_css)
|
||||
# Stylesheet links carry stable ids so the site editor's hot swap can
|
||||
# keep each sheet at its rendered position (see swapRegions).
|
||||
for i, url in enumerate(urls):
|
||||
doc.link(
|
||||
rel="stylesheet",
|
||||
href=url,
|
||||
blocking="render",
|
||||
id="pagerite-base" if i == 0 else "pagerite-theme",
|
||||
)
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
sheets = [
|
||||
("pagerite-base", _base_css_url(vite_url)),
|
||||
("pagerite-theme", _theme_css_url(theme)),
|
||||
("pagerite-banner", _banner_css_url(banner_design)),
|
||||
]
|
||||
for id_, url in sheets:
|
||||
if url:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
|
||||
for src in modules:
|
||||
doc.script(src=src, type="module")
|
||||
if custom_css.strip():
|
||||
@@ -136,8 +174,12 @@ def _layout(
|
||||
)
|
||||
|
||||
|
||||
def _brand_link(brand: str) -> HTML:
|
||||
"""Header brand link; omitted entirely when no brand is configured."""
|
||||
def _brand_link(brand: str, brand_html: str = "") -> HTML:
|
||||
"""Header brand: custom HTML (in a #brand wrapper, rendered instead of
|
||||
the link) when configured, else the plain brand link; omitted entirely
|
||||
when neither is set."""
|
||||
if brand_html.strip():
|
||||
return HTML(str(E.div(HTML(brand_html), id="brand")))
|
||||
return HTML(str(E.a(brand, href="/", id="brand"))) if brand else HTML("")
|
||||
|
||||
|
||||
@@ -146,14 +188,21 @@ def _title(slug: str, node: Node) -> str:
|
||||
return node.title or prettify(slug) or "Home"
|
||||
|
||||
|
||||
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,
|
||||
ancestors_current: bool = True,
|
||||
) -> None:
|
||||
"""Render one <li> linking the node. Category labels (no content of
|
||||
their own) link straight to their first child page, so normal
|
||||
navigation bypasses the placeholder page at their own URL."""
|
||||
# A top-level item is current also when viewing any of its subpages.
|
||||
is_current = current == path or (path and current.startswith(f"{path}/"))
|
||||
their own — None, or empty markdown as left by the site editor's
|
||||
page creation) link straight to their first child page, so normal
|
||||
navigation bypasses the placeholder/empty page at their own URL."""
|
||||
# The navbar highlights a top-level item also when viewing any of its
|
||||
# subpages; the sidebar highlights only the actually viewed page.
|
||||
is_current = current == path or (
|
||||
ancestors_current and path and current.startswith(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}"
|
||||
doc.li.a(
|
||||
_title(path.rpartition("/")[2], node),
|
||||
@@ -180,11 +229,18 @@ def nav_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
"""Render the #sidebar element for the current path (empty when none).
|
||||
|
||||
The sidebar is the current main level section's sub-navigation, so it
|
||||
The sidebar is the current main level section's sub-navigation: the
|
||||
section's direct children as the top list level, with each item's own
|
||||
published children nested under it (third level and deeper), so it
|
||||
exists only when there is something to navigate: the section must
|
||||
offer at least two published items. The front page, leaf pages and
|
||||
one-page sections get no aside element at all (rather than an empty
|
||||
or one-item box).
|
||||
offer at least two published items, or exactly one while viewing
|
||||
anything else than that only page — the section index, a 404, a
|
||||
grandchild (otherwise those pages offer no way to reach the child) —
|
||||
and viewing that only page itself still shows the sidebar when the
|
||||
page has published children of its own to reach.
|
||||
The front page, leaf pages, the sole childless 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:
|
||||
return HTML("")
|
||||
@@ -193,15 +249,31 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
if node is None:
|
||||
return HTML("")
|
||||
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
|
||||
if len(items) < 2:
|
||||
if not items:
|
||||
return HTML("")
|
||||
if len(items) == 1 and current == f"{section}/{items[0][0]}":
|
||||
# Viewing the only item: useless unless it has children to reach.
|
||||
if not any(c.published for c in items[0][1].children.values()):
|
||||
return HTML("")
|
||||
nav = E.ul
|
||||
with nav:
|
||||
for slug, child in items:
|
||||
_nav_link(nav, menu, child, f"{section}/{slug}", current)
|
||||
_sidebar_item(nav, menu, child, f"{section}/{slug}", current)
|
||||
return HTML(str(E.aside(nav, id="sidebar")))
|
||||
|
||||
|
||||
def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None:
|
||||
"""One sidebar <li>: the node link, with its published children as a
|
||||
nested list (third level and deeper, recursively)."""
|
||||
_nav_link(doc, menu, node, path, current, ancestors_current=False)
|
||||
sub = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
|
||||
if sub:
|
||||
# doc.li.a(...) above left the <li> open for nesting.
|
||||
with doc.ul:
|
||||
for slug, child in sub:
|
||||
_sidebar_item(doc, menu, child, f"{path}/{slug}", current)
|
||||
|
||||
|
||||
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
|
||||
"""First published descendant page (content set) in menu order.
|
||||
|
||||
@@ -215,8 +287,10 @@ def first_leaf(menu: dict[str, Node], path: str) -> str | None:
|
||||
|
||||
def _first_leaf(node: Node, path: str) -> str | None:
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
if not child.published:
|
||||
continue
|
||||
cpath = f"{path}/{slug}" if path else slug
|
||||
if child.published and child.content is not None:
|
||||
if child.content:
|
||||
return cpath
|
||||
if (leaf := _first_leaf(child, cpath)) is not None:
|
||||
return leaf
|
||||
@@ -224,48 +298,114 @@ def _first_leaf(node: Node, path: str) -> str | None:
|
||||
|
||||
|
||||
def banner_html(menu: dict[str, Node], path: str, theme: str = "") -> HTML:
|
||||
"""Resolve the banner for a path: the nearest node on the ancestor
|
||||
chain (the node itself first), then the front page, then the theme
|
||||
artwork. The front page is a top-level *sibling* of the other
|
||||
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.
|
||||
"""Resolve the banner for a path: the effective banner design's inline
|
||||
SVG artwork first, then the user's own banner HTML — always last, so
|
||||
author code (e.g. <style> overrides) wins over the design's own styles.
|
||||
|
||||
With no user banner anywhere in the chain, the active theme's inline
|
||||
SVG artwork is inlined instead: as markup it can be recolored from the
|
||||
theme stylesheet (``var(--accent)`` etc.) and animated, and it is not
|
||||
rendered at all when the user supplies their own banner.
|
||||
The design comes from banner_design(); the user banner from the nearest
|
||||
node on the ancestor chain (the node itself first), then the front page.
|
||||
The front page is a top-level *sibling* of the other 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.
|
||||
"""
|
||||
parts = []
|
||||
design = banner_design(menu, path, theme)
|
||||
if design:
|
||||
parts.append(_design_banner(design))
|
||||
source = banner_source(menu, path)
|
||||
if source is not None:
|
||||
return HTML(resolve(menu, source)[-1].banner)
|
||||
return _theme_banner(theme)
|
||||
parts.append(HTML(resolve(menu, source)[-1].banner))
|
||||
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).
|
||||
|
||||
|
||||
def _theme_banner(theme: str) -> HTML:
|
||||
"""The theme's inline banner SVG (empty for none/unknown themes)."""
|
||||
if not theme or "/" in theme:
|
||||
banner.html (arbitrary markup: canvas + style + script...) takes
|
||||
precedence over banner.svg. Read from disk on every request: design
|
||||
files are never built/cached, so editing them on disk shows on the
|
||||
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("")
|
||||
if theme not in _banner_cache:
|
||||
path = Path(__file__).parent / "themes" / theme / "banner.svg"
|
||||
_banner_cache[theme] = HTML(path.read_text()) if path.exists() else HTML("")
|
||||
return _banner_cache[theme]
|
||||
html = THEMES / design / "banner.html"
|
||||
svg = THEMES / design / "banner.svg"
|
||||
if html.exists():
|
||||
body = html.read_text()
|
||||
elif svg.exists():
|
||||
body = svg.read_text()
|
||||
else:
|
||||
return HTML("")
|
||||
return HTML(f'<div data-design="{design}">{body}</div>')
|
||||
|
||||
|
||||
def theme_banner_design(theme: str) -> str:
|
||||
"""The theme's own banner design (a theme folder doubles as a banner
|
||||
design when it ships banner.css, banner.svg or banner.html), "" if it
|
||||
has none."""
|
||||
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(menu: dict[str, Node], path: str, theme: str = "") -> str:
|
||||
"""The effective banner design name at ``path`` ("" = no design).
|
||||
|
||||
Nodes set ``banner_design`` to a design name, "" (explicitly none) or
|
||||
None (inherit). Resolution walks the ancestor chain from the node
|
||||
upwards, then the front page, then falls back to the active theme's own
|
||||
design.
|
||||
"""
|
||||
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
|
||||
return theme_banner_design(theme)
|
||||
|
||||
|
||||
def banner_design_source(
|
||||
menu: dict[str, Node], path: str, theme: str = ""
|
||||
) -> str | None:
|
||||
"""Which node's banner-design setting *would* apply at ``path`` if the
|
||||
node itself set nothing: the nearest ancestor's path ("" = the front
|
||||
page), or None when the active theme's default applies. Used by the
|
||||
banner editor for the design selector's inherit label."""
|
||||
chain = resolve(menu, path) or []
|
||||
segs = path.split("/")
|
||||
# Skip the node itself (chain[-1]): its own setting is not inheritance.
|
||||
for i in range(len(chain) - 2, -1, -1):
|
||||
if chain[i].banner_design is not None:
|
||||
return "/".join(segs[: i + 1])
|
||||
# The front page is a top-level sibling of the chain, not an ancestor;
|
||||
# it does not inherit from itself.
|
||||
front = menu.get("")
|
||||
if path and 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 applies at ``path``: the nearest ancestor with
|
||||
one set (the front page, a top-level sibling of the chain, last).
|
||||
None = the default artwork."""
|
||||
"""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:
|
||||
if chain[i].banner.strip():
|
||||
return "/".join(segs[: i + 1])
|
||||
front = menu.get("")
|
||||
if front and front.banner:
|
||||
if front and front.banner.strip():
|
||||
return ""
|
||||
return None
|
||||
|
||||
@@ -279,7 +419,10 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||
# only rendered as h1 when the markdown has none of its own.
|
||||
if not has_h1(node.content or ""):
|
||||
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))
|
||||
|
||||
|
||||
@@ -289,15 +432,16 @@ def render_page(
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
) -> str:
|
||||
"""Render a full HTML page for the slug path."""
|
||||
node = resolve(menu, path)[-1]
|
||||
title = _title(path.rpartition("/")[2], node)
|
||||
scripts, styles = _page_assets(theme)
|
||||
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,
|
||||
Brand=_brand_link(brand),
|
||||
Brand=_brand_link(brand, brand_html),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar_html(menu, path),
|
||||
Banner=banner_html(menu, path, theme),
|
||||
@@ -312,6 +456,8 @@ def render_category(
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
) -> str:
|
||||
"""Render the placeholder for a content-less category label (404).
|
||||
|
||||
@@ -329,11 +475,10 @@ def render_category(
|
||||
doc.p("Pages in this section are listed in the menu on the left.")
|
||||
else:
|
||||
doc.p("This section has no page of its own yet.")
|
||||
scripts, styles = _page_assets(theme)
|
||||
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,
|
||||
Brand=_brand_link(brand),
|
||||
Brand=_brand_link(brand, brand_html),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar,
|
||||
Banner=banner_html(menu, path, theme),
|
||||
@@ -348,17 +493,18 @@ def render_not_found(
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
) -> str:
|
||||
"""Render a 404 page within the normal layout."""
|
||||
doc = E.article
|
||||
with doc:
|
||||
doc.h1("Not Found")
|
||||
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
|
||||
scripts, styles = _page_assets(theme)
|
||||
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",
|
||||
Brand=_brand_link(brand),
|
||||
Brand=_brand_link(brand, brand_html),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar_html(menu, path),
|
||||
Banner=banner_html(menu, path, theme),
|
||||
@@ -367,27 +513,23 @@ def render_not_found(
|
||||
)
|
||||
|
||||
|
||||
def _page_assets(theme: str) -> tuple[list[str], list[str]]:
|
||||
"""Script and CSS URLs for public pages (pagerite entry).
|
||||
def _page_assets() -> list[str]:
|
||||
"""Script URLs for public pages (pagerite entry).
|
||||
|
||||
Dev mode loads the entry from the Vite dev server; production uses
|
||||
the Vite build manifest to resolve the hashed asset names.
|
||||
"""
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url, theme)
|
||||
key = f"page:{theme}"
|
||||
if key not in _asset_cache:
|
||||
return [f"{vite_url}/src/pagerite.js"]
|
||||
if "page" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/pagerite.js"]
|
||||
_asset_cache[key] = (
|
||||
[f"/{entry['file']}"],
|
||||
_shared_css_urls(None, theme),
|
||||
)
|
||||
return _asset_cache[key]
|
||||
_asset_cache["page"] = [f"/{entry['file']}"]
|
||||
return _asset_cache["page"]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
The shared CSS is already linked on the page, so the pen only needs the
|
||||
@@ -396,9 +538,8 @@ def _editor_assets(theme: str) -> tuple[list[str], str | None]:
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
if vite_url:
|
||||
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
|
||||
key = f"editor:{theme}"
|
||||
if key not in _asset_cache:
|
||||
if "editor" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/main.js"]
|
||||
_asset_cache[key] = [f"/{entry['file']}"], _editor_css_url(None, theme)
|
||||
return _asset_cache[key]
|
||||
_asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
|
||||
return _asset_cache["editor"]
|
||||
|
||||
@@ -44,7 +44,10 @@ source = "vcs"
|
||||
packages = ["pagerite"]
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["pagerite/frontend-build"]
|
||||
# `only-packages` drops directories without an __init__.py, so the theme
|
||||
# files and seed image assets must be force-included as artifacts (like
|
||||
# the frontend build).
|
||||
artifacts = ["pagerite/frontend-build", "pagerite/themes", "pagerite/seed-assets"]
|
||||
only-packages = true
|
||||
|
||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||
|
||||