Compare commits

..
6 Commits
Author SHA1 Message Date
LeoVasanko 178d22c05c Seed: eyes design on showcase, whale/wave art, richer long read
- The showcase category picks the eyes banner design (inherited by its
  pages; night-sky overrides with stars); the seeder now leaves
  content=None for entries with empty markdown so it stays a category
  label.
- Public-domain demo images in pagerite/seed-assets/ (Hokusai's Great
  Wave, Shute's 1892 Moby-Dick engravings), shipped via build artifacts.
- Welcome figure uses {width=420}; Gallery gets the Great Wave as its
  {.wide} piece; Loomings gains h2/h3 sections and the engravings.
2026-08-19 02:00:54 +00:00
LeoVasanko dfd3ef1dad Shorten seed doc titles; full names as h1 in page source
Menu entries read Basics / Extensions / Images and Layout; the pages
open with their own '# Markdown Basics' / '# Markdown Extensions'
headings (a markdown h1 suppresses the title h1, so nothing doubles).
2026-08-19 01:40:50 +00:00
LeoVasanko 512ed91b31 Bottom-anchor banner artwork, instant navigation via in-memory page cache, seed/stars/structure tweaks
- Fix banner artwork sizing: explicit 100% grid track so children
  stretch instead of resolving height:100% against a content-sized row
  (SVG intrinsic ratio bloated the row, cropping the artwork's bottom).
  Bottom-anchor via object-position, transform-origin and YMax slice.
- pagerite.js: in-memory page cache — preload every visible internal
  link once, serve navigation from memory without fetching; editors'
  loadPlain keeps the cache in sync (pagerite:page-fetched).
- Structure editor: delete pages directly, no two-step confirmation.
- New 'stars' banner design (drifting starfield) alongside 'eyes'.
- Rewrite seed content: welcome page, three-level docs section covering
  all Markdown features (source + rendered), showcase hierarchy with
  image positioning and a simple leaf-page banner example.
2026-08-19 01:40:26 +00:00
LeoVasanko 3b074c02ed Seed demo content only on database creation via @kanta.bootstrap
Previously startup appended any missing seed pages into existing
databases, resurrecting deleted content on running sites.
2026-08-19 00:57:35 +00:00
LeoVasanko 58e5d3ef2b Replace Paskia iframe auth flow with plain /auth/ links
Paskia does not support being iframed; the login/profile buttons are now
plain anchors, and a pageshow handler re-probes auth when history
navigation restores a cached page. Drops the paskia JS dependency.
2026-08-19 00:57:35 +00:00
LeoVasanko 5769747a95 Disable ligatures in CodeMirror editors
CodeMirror measures text per character; Fira Code's ligature glyphs
render wider than the measured sum of their parts, corrupting cursor
and selection rendering. The font is set on .cm-content with ligatures
off, and inner spans inherit it for consistent metrics.
2026-08-19 00:44:40 +00:00
21 changed files with 564 additions and 306 deletions
+16 -7
View File
@@ -125,11 +125,16 @@ not for the public pages. See `docs/design-principles.md` for the design.
and their nav links point at their first child page. Dynamic regions have stable ids and their nav links point at their first child page. Dynamic regions have stable ids
(`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps
(`#sidebar` may be absent on either side of a swap). (`#sidebar` may be absent on either side of a swap).
- `seed.py` — demo content written on startup for paths missing from the - `seed.py` — demo content written only when the database is first
database (never overwrites existing pages). created, via a `@kanta.bootstrap` handler in `app.py`.
- `frontend/src/` — the Vue editor and public-page entries. - `frontend/src/` — the Vue editor and public-page entries.
- `main.js` — Vue editor app entry, mounts the tabbed EditorShell. - `main.js` — Vue editor app entry, mounts the tabbed EditorShell.
- `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal, - `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 OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars
that never reserve layout space or shift the page when appearing; that never reserve layout space or shift the page when appearing;
native scroll APIs like `window.scrollTo` keep working; themed via the native scroll APIs like `window.scrollTo` keep working; themed via the
@@ -141,9 +146,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
learn the current session's admin status. The same reverse proxy that learn the current session's admin status. The same reverse proxy that
gates `/_api` returns 401 for anonymous users, 403 for users without gates `/_api` returns 401 for anonymous users, 403 for users without
the admin permission, and 200 for admins. When Paskia is detected, a the admin permission, and 200 for admins. When Paskia is detected, a
🔑 login button (anonymous) or 🔐 profile button (logged in) is shown in 🔑 login link (anonymous) or 🔐 profile link (logged in) is shown in
the banner corner; both open Paskia's iframe dialog via `showAuthIframe` the banner corner; both are plain `<a href="/auth/">` links (Paskia
instead of navigating away. Admins also get the 🖊️ page/banner edit pens 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 and a ⚙️ site-settings pen (asset URLs from the
`pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is
detected (dev/no proxy), editing is left open. Pages themselves render detected (dev/no proxy), editing is left open. Pages themselves render
@@ -172,7 +179,9 @@ not for the public pages. See `docs/design-principles.md` for the design.
gradient brand, flower bullets, and a layered-parallax banner (sun gradient brand, flower bullets, and a layered-parallax banner (sun
rises, clouds drift, nearer hills move less) with idle animations rises, clouds drift, nearer hills move less) with idle animations
(swaying flowers, floating clouds, breathing sun glow) wrapped in (swaying flowers, floating clouds, breathing sun glow) wrapped in
`prefers-reduced-motion: no-preference`) and the `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. companion `banner.css` banner designs are served by the backend.
- Vite builds ES-module `.js` outputs; the backend renders `<script - Vite builds ES-module `.js` outputs; the backend renders `<script
type="module">` for them (module scripts defer by default). type="module">` for them (module scripts defer by default).
-1
View File
@@ -19,7 +19,6 @@
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"overlayscrollbars": "^2.16.0", "overlayscrollbars": "^2.16.0",
"paskia": "file:../../paskia/paskia-js",
"transliteration": "^2.6.1", "transliteration": "^2.6.1",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
+2 -20
View File
@@ -228,23 +228,7 @@ async function commitSlug(node, ev) {
} }
} }
// Two-step delete (no dialogs): the first click arms the row's button for // Deletion is immediate, no confirmation.
// a few seconds, the second actually deletes.
const arming = ref(null)
let armTimer = null
function armRemove(node) {
if (arming.value === node.path) {
clearTimeout(armTimer)
arming.value = null
removePage(node)
} else {
arming.value = node.path
clearTimeout(armTimer)
armTimer = setTimeout(() => { arming.value = null }, 3000)
}
}
async function removePage(node) { async function removePage(node) {
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' }) const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) { if (res.ok) {
@@ -267,8 +251,7 @@ async function removePage(node) {
provide('structureHandlers', { provide('structureHandlers', {
current: () => path.value, current: () => path.value,
open: navigate, open: navigate,
arming: () => arming.value, removePage,
armRemove,
reorder: onReorder, reorder: onReorder,
titleInput: onTitleInput, titleInput: onTitleInput,
commitSlug, commitSlug,
@@ -284,7 +267,6 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
clearTimeout(armTimer)
for (const t of Object.values(timers)) clearTimeout(t) for (const t of Object.values(timers)) clearTimeout(t)
removeEventListener('pagerite:editor-shown', onEditorShown) removeEventListener('pagerite:editor-shown', onEditorShown)
}) })
+2 -9
View File
@@ -144,12 +144,11 @@ function onEnd() {
v-if="element.has_content || !element.children.length" v-if="element.has_content || !element.children.length"
type="button" type="button"
class="act del" class="act del"
:class="{ armed: handlers.arming() === element.path }"
:title="element.children.length :title="element.children.length
? 'delete the landing page (the category keeps its subpages)' ? 'delete the landing page (the category keeps its subpages)'
: 'delete page'" : 'delete page'"
@click="handlers.armRemove(element)" @click="handlers.removePage(element)"
>{{ handlers.arming() === element.path ? 'delete?' : '' }}</button> ></button>
</span> </span>
</template> </template>
</div> </div>
@@ -300,12 +299,6 @@ body.tree-dragging .treelist {
white-space: nowrap; white-space: nowrap;
} }
/* Two-step delete: the first click arms the button, the second deletes. */
.act.armed {
color: #e06c75;
font-weight: 600;
}
.del:hover { .del:hover {
color: #e06c75; color: #e06c75;
} }
+26 -11
View File
@@ -132,22 +132,35 @@ body {
overflow: hidden; overflow: hidden;
/* Stack the design artwork and the page's own banner code on top of /* 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 each other (artwork first): the banner is a background layer, author
code overlays it. A single child behaves exactly as before. */ 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; display: grid;
grid-template: 100% / 100%;
} }
/* :not(style, script): author-level display:block would override the UA's /* :not(style, script): author-level display:block would override the UA's
display:none on those and render their source as banner text. */ 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) { #page-banner>*:not(style, script) {
grid-area: 1 / 1; grid-area: 1 / 1;
display: block; display: block;
width: 100%;
height: 100%;
object-fit: cover; 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 /* Scroll parallax: pagerite.js sets --pry on <html>; the banner stays
windowed in place while the artwork drifts inside it. The scale windowed in place while the artwork drifts inside it. The scale
provides overscan so the drift never reveals an edge. */ 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: scale(1.25) translateY(var(--pry, 0px));
transform-origin: bottom;
will-change: transform; will-change: transform;
} }
@@ -316,13 +329,15 @@ body.editing #main {
gap: 0.6rem; gap: 0.6rem;
} }
.editor-pens button { .editor-pens button,
.editor-pens a {
position: static; position: static;
font: inherit; font: inherit;
border: none; border: none;
cursor: pointer; cursor: pointer;
background: none; background: none;
padding: 0; padding: 0;
text-decoration: none;
} }
#sidebar { #sidebar {
@@ -488,17 +503,17 @@ article h1 .edit-link {
opacity: 1; opacity: 1;
} }
/* Login/profile buttons injected by pagerite.js when Paskia SSO is in use. /* 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 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. */ corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
button.login-link, .editor-pens a.login-link,
button.profile-link { .editor-pens a.profile-link {
opacity: 0.7; opacity: 0.7;
text-shadow: 0 0 0.1em black; text-shadow: 0 0 0.1em black;
} }
button.login-link:hover, .editor-pens a.login-link:hover,
button.profile-link:hover { .editor-pens a.profile-link:hover {
opacity: 1; opacity: 1;
} }
+16 -1
View File
@@ -14,7 +14,22 @@ export const cmTheme = EditorView.theme({
color: "var(--text)", color: "var(--text)",
}, },
".cm-scroller": { fontFamily: '"Fira Code", monospace' }, ".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)" }, ".cm-cursor": { borderLeftColor: "var(--text)" },
// basicSetup's active-line highlight assumes a dark theme. // basicSetup's active-line highlight assumes a dark theme.
".cm-activeLine": { backgroundColor: "transparent" }, ".cm-activeLine": { backgroundColor: "transparent" },
+55 -79
View File
@@ -4,7 +4,6 @@
// //
// Also: scroll-reveal effects and code copy buttons. These need no // Also: scroll-reveal effects and code copy buttons. These need no
// support from the article itself and are re-applied after each swap. // support from the article itself and are re-applied after each swap.
import { showAuthIframe } from 'paskia'
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
@@ -47,14 +46,13 @@ import "overlayscrollbars/overlayscrollbars.css";
// /_api/settings endpoint: the same reverse proxy that gates /_api returns // /_api/settings endpoint: the same reverse proxy that gates /_api returns
// 401/403 here, and a 200 means the permission is present. // 401/403 here, and a 200 means the permission is present.
// //
// When Paskia SSO is in use, 401/403 responses carry `auth.iframe`, which // When Paskia SSO is in use (probed via /auth/api/settings), the banner
// we use to open the login/profile dialogs inline instead of navigating // corner gets a plain link to /auth/ — 🔑 log in for anonymous visitors,
// away. A separate probe to /auth/api/settings tells us whether Paskia is // 🔐 profile when logged in. Normal navigation: Paskia does not support
// available at all; if it isn't, we treat the site as dev/no-proxy and // being iframed, and history.back() returns to the page as-is (the
// leave editing open. // pageshow handler below re-probes auth to refresh the pens).
let ssoAvailable = false; let ssoAvailable = false;
let isAdmin = false; let isAdmin = false;
let loginIframeUrl = null;
let editorMeta = null; let editorMeta = null;
function makePen(mode) { function makePen(mode) {
@@ -86,34 +84,13 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
} }
function makeLoginButton(url) { function makeAuthLink(admin) {
const btn = document.createElement("button"); const a = document.createElement("a");
btn.type = "button"; a.className = admin ? "profile-link" : "login-link";
btn.className = "login-link"; a.href = "/auth/";
btn.title = "log in"; a.title = admin ? "profile" : "log in";
btn.textContent = "🔑"; a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
btn.addEventListener("click", async () => { return a;
try {
await showAuthIframe(url);
// Successful login: refresh the auth UI (may now show edit pens).
setupAuth();
} catch {
// Cancelled or error: leave the button in place.
}
});
return btn;
}
function makeProfileButton() {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "profile-link";
btn.title = "profile";
btn.textContent = "🔐";
btn.addEventListener("click", () => {
showAuthIframe("/auth/").catch(() => {});
});
return btn;
} }
function renderAuthUi() { function renderAuthUi() {
@@ -130,13 +107,7 @@ import "overlayscrollbars/overlayscrollbars.css";
pens.append(makePen("banner")); pens.append(makePen("banner"));
pens.append(makePen("site")); pens.append(makePen("site"));
} }
if (isAdmin && ssoAvailable) { if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
pens.append(makeProfileButton());
} else if (!isAdmin && ssoAvailable && loginIframeUrl) {
pens.append(makeLoginButton(loginIframeUrl));
} else if (!isAdmin && ssoAvailable) {
pens.append(makeProfileButton());
}
banner.after(pens); banner.after(pens);
} }
if (canEdit) injectPagePen(); if (canEdit) injectPagePen();
@@ -160,23 +131,21 @@ import "overlayscrollbars/overlayscrollbars.css";
// Check whether the current session has pagerite:admin. // Check whether the current session has pagerite:admin.
isAdmin = false; isAdmin = false;
loginIframeUrl = null;
let status = 0;
try { try {
const res = await fetch("/_api/settings"); isAdmin = (await fetch("/_api/settings")).status === 200;
status = res.status;
if (status === 401) {
const data = await res.json().catch(() => ({}));
loginIframeUrl = data.auth?.iframe || null;
}
} catch { } catch {
// No auth proxy / dev. // No auth proxy / dev.
} }
if (status === 200) isAdmin = true;
renderAuthUi(); 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) { function runScripts(root) {
// Scripts inserted via DOM swapping do not execute; re-create them. // Scripts inserted via DOM swapping do not execute; re-create them.
for (const old of root.querySelectorAll("script")) { for (const old of root.querySelectorAll("script")) {
@@ -299,29 +268,31 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
} }
// --- Preloading ------------------------------------------------------ // --- Page cache / preloading ------------------------------------------
// Warm the HTTP cache with all linked pages and their resources, so // Articles are deliberately NOT HTTP-cacheable, so speed comes from an
// navigation (and the cube transition) is instant. Pages carry ETags, // in-memory cache instead: at load (and after each swap) every visible
// so re-running this after each navigation revalidates cheaply (304) // internal link is fetched exactly once, and navigation is served from
// and picks up changed content and images. // 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() { function preload() {
const urls = new Set(); const urls = new Set([location.pathname]);
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) { for (const a of document.querySelectorAll(
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
)) {
urls.add(a.pathname); urls.add(a.pathname);
} }
for (const url of urls) { for (const url of urls) {
if (url === location.pathname) continue; if (pageCache.has(url)) continue;
fetch(url) fetch(url)
.then((r) => (r.ok ? r.text() : "")) .then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
.then((html) => { ? r.text() : ""))
if (!html) return; .then((html) => { if (html) pageCache.set(url, html); })
// 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;
}
})
.catch(() => {}); .catch(() => {});
} }
} }
@@ -364,16 +335,21 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
let doc; let doc;
let finalUrl = url; let finalUrl = url;
try { const cached = pageCache.get(new URL(url, location.href).pathname);
const res = await fetch(url); if (cached) {
const type = res.headers.get("content-type") || ""; doc = new DOMParser().parseFromString(cached, "text/html");
if (!res.ok || !type.includes("text/html")) throw new Error("not a page"); } else {
// Reflect any redirect the server issued. try {
if (res.redirected) finalUrl = res.url; const res = await fetch(url);
doc = new DOMParser().parseFromString(await res.text(), "text/html"); const type = res.headers.get("content-type") || "";
} catch { if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
location.href = url; // fall back to a normal navigation // Reflect any redirect the server issued.
return; if (res.redirected) finalUrl = res.url;
doc = new DOMParser().parseFromString(await res.text(), "text/html");
} catch {
location.href = url; // fall back to a normal navigation
return;
}
} }
if (REGIONS.some((id) => !doc.getElementById(id))) { if (REGIONS.some((id) => !doc.getElementById(id))) {
location.href = url; location.href = url;
+5 -1
View File
@@ -100,18 +100,22 @@ function swapRegions(doc) {
export async function loadPlain(p) { export async function loadPlain(p) {
let doc let doc
let finalUrl = `/${p}` let finalUrl = `/${p}`
let html
try { try {
const res = await fetch(finalUrl) const res = await fetch(finalUrl)
const type = res.headers.get('content-type') || '' const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html') html = await res.text()
doc = new DOMParser().parseFromString(html, 'text/html')
} catch { return null } } catch { return null }
if (!doc.getElementById('main')) return null if (!doc.getElementById('main')) return null
swapRegions(doc) swapRegions(doc)
history.replaceState(null, '', finalUrl) history.replaceState(null, '', finalUrl)
runScripts(document.getElementById('page-banner')) runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main')) runScripts(document.getElementById('main'))
// 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 dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
return finalUrl return finalUrl
} }
+26 -19
View File
@@ -121,24 +121,30 @@ def _migrate_legacy() -> None:
data.version += 1 data.version += 1
@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 @asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]: async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Open the database, migrate/seed content, load assets.""" """Open the database, migrate legacy content, load assets."""
await kanta.open() await kanta.open()
_migrate_legacy() _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, 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
node.content = markdown
node.banner = banner
node.banner_design = design
node.order = order
await frontend.load() await frontend.load()
yield yield
await kanta.close() await kanta.close()
@@ -684,11 +690,12 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
chain = resolve(data.menu, path) chain = resolve(data.menu, path)
node = chain[-1] if chain else None node = chain[-1] if chain else None
if node is not None and node.published and node.content is not None: if node is not None and node.published and node.content is not None:
# ETag on content + render version; clients revalidate cheaply, # no-cache forbids serving a stored page without revalidation
# which keeps prefetched pages warm and current. no-cache forces # (browsers would otherwise cache heuristically and serve stale
# that revalidation: with Last-Modified but no Cache-Control, # pages, e.g. after a theme change). In-session speed instead comes
# browsers would otherwise cache heuristically and serve stale # from pagerite.js's in-memory page cache (preload everything, never
# pages (e.g. after a theme change) without asking us at all. # 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}"' etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
if request.headers.get("if-none-match") == etag: if request.headers.get("if-none-match") == etag:
return Response(status_code=304) return Response(status_code=304)
+2 -2
View File
@@ -91,8 +91,8 @@ class Data(msgspec.Struct):
#: banner artwork, next to the nav. Empty = the plain brand link. #: banner artwork, next to the nav. Empty = the plain brand link.
brand_html: str = "" brand_html: str = ""
#: Active theme name (empty = none/base only). Themes live in #: Active theme name (empty = none/base only). Themes live in
#: frontend/src/assets/themes/{theme}/theme.css, with their banner #: pagerite/themes/{theme}/ (theme.css and/or banner.css/banner.svg/
#: artwork at pagerite/themes/{theme}/banner.svg (inlined server-side). #: banner.html), served by the backend from disk.
theme: str = "purple" theme: str = "purple"
#: Raw site-wide custom CSS, injected inline in every page <head>. #: Raw site-wide custom CSS, injected inline in every page <head>.
#: Trusted author content; not sanitized. #: Trusted author content; not sanitized.
Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+348 -150
View File
@@ -1,147 +1,341 @@
"""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 A "welcome to your new site" starter: a structured docs section (three
from the page path, figures with captions, attribute classes for menu levels deep) covering editing and the full Markdown feature set —
positioning, footnotes, definition lists, task lists, tables and raw HTML. 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 = """\
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 [docs](/docs/editing) section explains how to edit this site and shows every supported Markdown feature, source and result side by side.
- [The Long Read](/blog/the-long-read) demonstrates a longer article with scroll effects. - The [showcase](/showcase/gallery) section shows what finished pages can look like: image positioning, banners, a long read.
- The [about](/about) page shows off assorted formatting. - Click the 🖊️ pen on any page to open the editor, and the ⚙️ pen for site settings and the structure tree.
![Abstract waves](waves.svg "Generated SVG artwork, attached to this page") ![Abstract waves](waves.svg "Generated SVG artwork, attached to this page"){width=420}
*Delete or rewrite any of these pages — they are only here to get you started.*
""" """
ABOUT = """\ EDITING = """\
This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content written in Markdown. 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 The 🖊️ pens open a tabbed editor over the page you are viewing:
- [x] Attach images to pages
- [ ] Add editing UI
Term - **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.
: A definition list entry, rendered by the deflist plugin. - **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 | | Feature | Status |
|---------|--------| |---------|--------|
| Pages | done | | Pages | done |
| Images | 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. [^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 = """\ MD_LAYOUT = """\
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. ## 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: ```markdown
![Abstract shapes](shapes.svg "A captioned figure")
```
![Abstract shapes](/_f/....svg "A captioned figure"){.right width=280}
``` ```
![Abstract shapes](shapes.svg "A captioned figure, floated right with an attribute class"){.right width=280} ![Abstract shapes](shapes.svg "A captioned figure")
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: ```markdown
![Abstract shapes](shapes.svg "Floated right"){.right width=280}
> 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})
``` ```
"""
LONG_READ = """\ ![Abstract shapes](shapes.svg "Floated right — text wraps around it"){.right width=280}
*An essay long enough to scroll, to demonstrate the gentle reveal of headings, figures and code blocks as they enter the viewport.*
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
![Dunes](dunes.svg "Full-width artwork"){.wide}
```
![Dunes](dunes.svg "Full-width artwork between sections"){.wide}
## Datelines
A `{dates}` line on its own expands to the article's published/updated dateline:
```markdown
{dates}
```
{dates} {dates}
![Layered dunes](dunes.svg "Full-width artwork between sections"){.wide} ## The page title
## Chapter one 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.
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.
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.
## Chapter two
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](/).
""" """
NOTES_ON_URLS = """\ GALLERY = """\
A URL is part of the content. A few rules of thumb I keep coming back to: 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).
- Pick slugs like book titles, not like database keys. ![The Great Wave off Kanagawa](great-wave.jpg "Hokusai, c. 1831 — full-bleed with {.wide}"){.wide}
- Nest only when the structure is real.
- Once published, a URL is a promise. Redirect if you must break it.
> Cool URIs don't change; uncool ones at least apologise. A wide image escapes the text column for emphasis between sections. No HTML needed — just Markdown and an attribute.
That's all. Short posts are posts too. ![Shapes](shapes.svg "Floated left with {.left}"){.left width=240}
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.
![Abstract waves](waves.svg "Floated right with {.right}"){.right width=240}
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.
""" """
CANVAS_NIGHTS = """\ NIGHT_SKY = """\
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. 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.
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. 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.
```js 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.
// the essence of the banner above
stars.forEach(s => { s.x = (s.x + s.speed * dt) % 1 })
```
No build step, no framework — the snippet is stored with the page and dropped into the header as-is. 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.
"""
# 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.*
{dates}
![Dunes at dusk](dunes.svg "Full-width artwork between sections"){.wide}
## 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.
![Moby Dick breeches a whaleboat](md-whale.jpg "Augustus Burnham Shute, 1892 — public domain"){.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.
![The final chase](md-chase.jpg "A. Burnham Shute's illustration of the final chase, 1892 — public domain")
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 = """\ SMALL_RELEASES = """\
@@ -154,39 +348,15 @@ Software wants to be shipped. The longer a change sits unmerged, the more it rot
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 = """\ ABOUT = """\
<canvas id="stars"></canvas> This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content written in Markdown and rendered on the fly.
<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>
"""
BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>' - [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.*
"""
WAVES_SVG = """\ WAVES_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
@@ -231,33 +401,61 @@ DUNES_SVG = """\
""" """
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order, #: path -> (title, markdown, {filename: bytes}, banner HTML, menu order,
#: banner design). Banners are deliberately set only on select sub pages #: banner design). Designs demonstrate inheritance: the "showcase" label
#: (not the front page), so the theme's default design shows elsewhere. #: picks "eyes" (all its pages show the critter), and the leaf
#: Note there are deliberately no "docs" or "blog" landing pages: those #: "showcase/night-sky" overrides that with "stars". Elsewhere the active
#: labels are created without content, so they render a placeholder page #: theme's own design shows.
#: and their nav links point at the first child (see views.first_leaf). #: 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]] = { PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float, str | None]] = {
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, "", 1, None), "": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, "", 1, None),
"about": ("About", ABOUT, {}, "", 2, None), "about": ("About", ABOUT, {}, "", 3, None),
"docs/editing": ( "docs/editing": ("Editing This Site", EDITING, {}, "", 1, None),
"Writing Content", "docs/markdown/basics": (
EDITING, "Basics",
{"shapes.svg": SHAPES_SVG.encode()}, MD_BASICS,
{},
"", "",
1, 1,
None, None,
), ),
"blog/the-long-read": ( "docs/markdown/extensions": ("Extensions", MD_EXTENSIONS, {}, "", 2, None),
"The Long Read", "docs/markdown/images-and-layout": (
LONG_READ, "Images and Layout",
{"dunes.svg": DUNES_SVG.encode()}, MD_LAYOUT,
BLOG_BANNER, {"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, 1,
None, None,
), ),
# The eyes critter is a named banner design (pagerite/themes/eyes/), "showcase/loomings": (
# not code embedded in the page. "Loomings — a Long Read",
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, "", 2, "eyes"), LOOMINGS,
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3, None), {
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4, None), "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),
} }
+1 -1
View File
@@ -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> <defs>
<linearGradient id="cbg" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="cbg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="cb-bg0"/> <stop offset="0" class="cb-bg0"/>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+1 -1
View File
@@ -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> <defs>
<linearGradient id="flare" x1="0" y1="0" x2="1" y2="0"> <linearGradient id="flare" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ff6a00"/> <stop offset="0" stop-color="#ff6a00"/>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+1 -1
View File
@@ -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> <defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2b1b4d"/> <stop offset="0" stop-color="#2b1b4d"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+7
View File
@@ -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;
}
+52
View File
@@ -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>
+1 -1
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1600 320" viewBox="0 0 1600 320"
preserveAspectRatio="xMidYMid slice"> preserveAspectRatio="xMidYMax slice">
<defs> <defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+3 -2
View File
@@ -45,8 +45,9 @@ packages = ["pagerite"]
[tool.hatch.build] [tool.hatch.build]
# `only-packages` drops directories without an __init__.py, so the theme # `only-packages` drops directories without an __init__.py, so the theme
# files must be force-included as artifacts (like the frontend build). # files and seed image assets must be force-included as artifacts (like
artifacts = ["pagerite/frontend-build", "pagerite/themes"] # the frontend build).
artifacts = ["pagerite/frontend-build", "pagerite/themes", "pagerite/seed-assets"]
only-packages = true only-packages = true
[tool.hatch.build.targets.sdist.hooks.custom] [tool.hatch.build.targets.sdist.hooks.custom]