Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
180da893cb | ||
|
|
a9693e1dcb | ||
|
|
4dac16a9ac | ||
|
|
43b841ebb6 | ||
|
|
f2715088d2 | ||
|
|
f8cdfd53c0 | ||
|
|
e9297cac61 | ||
|
|
2140f06501 | ||
|
|
a75ad6f5da | ||
|
|
7f140001fa | ||
|
|
d87e5ae323 |
@@ -68,9 +68,14 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
and embedded in page ETags so nav-affecting changes invalidate caches.
|
||||
`Data.brand` is the site name (header link + `<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 =
|
||||
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),
|
||||
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
|
||||
@@ -90,24 +95,32 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
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; typographer + breaks on). Custom
|
||||
image rule: relative srcs resolve against the page path, titled images
|
||||
become figures. A `{dates}` line expands to the article's
|
||||
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 when the section offers at least two
|
||||
`#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); no aside element at all on the front page, leaf
|
||||
pages and the sole page of a one-page section. Also,
|
||||
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
|
||||
@@ -115,8 +128,12 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
- `seed.py` — demo content written on startup for paths missing from the
|
||||
database (never overwrites existing pages).
|
||||
- `frontend/src/` — the Vue editor and public-page entries.
|
||||
- `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
|
||||
- `main.js` — Vue editor app entry, mounts the tabbed EditorShell.
|
||||
- `pagerite.js` — public page entry; runs fetch-navigation, 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 first probes `GET /auth/api/settings`
|
||||
@@ -124,10 +141,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
|
||||
gates `/_api` returns 401 for anonymous users, 403 for users without
|
||||
the admin permission, and 200 for admins. When Paskia is detected, a
|
||||
🔑 login button (anonymous) or 👤 profile button (logged in) is shown in
|
||||
🔑 login button (anonymous) or 🔐 profile button (logged in) is shown in
|
||||
the banner corner; both open Paskia's iframe dialog via `showAuthIframe`
|
||||
instead of navigating away. Admins also get the 🖊️ edit pens (asset URLs
|
||||
from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is
|
||||
instead of navigating away. 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 —
|
||||
@@ -148,7 +166,13 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
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()`) and the
|
||||
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`) 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).
|
||||
@@ -156,19 +180,42 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
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 +
|
||||
favicon upload/remove + site-wide custom CSS + per-page banner design
|
||||
selector (inherit/none/named design, inherited by children) + banner
|
||||
HTML edited in small CodeMirror windows;
|
||||
banner previewed into `#page-banner`, CSS injected into
|
||||
`<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
|
||||
@@ -180,19 +227,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
|
||||
|
||||
@@ -69,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
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"paskia": "file:../../paskia/paskia-js",
|
||||
"transliteration": "^2.6.1",
|
||||
"vue": "^3.5.26",
|
||||
|
||||
@@ -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>
|
||||
+285
-50
@@ -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;
|
||||
|
||||
+245
-798
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
<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
|
||||
}
|
||||
}
|
||||
|
||||
// Two-step delete (no dialogs): the first click arms the row's button for
|
||||
// a few seconds, the second actually deletes.
|
||||
const arming = ref(null)
|
||||
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) {
|
||||
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,
|
||||
arming: () => arming.value,
|
||||
armRemove,
|
||||
reorder: onReorder,
|
||||
titleInput: onTitleInput,
|
||||
commitSlug,
|
||||
commitPending,
|
||||
discardPending,
|
||||
newPage,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
path.value = normPath(props.pagePath)
|
||||
refreshPages()
|
||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(armTimer)
|
||||
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)"
|
||||
|
||||
+308
-132
@@ -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 {
|
||||
@@ -266,9 +257,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;
|
||||
@@ -278,10 +288,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;
|
||||
}
|
||||
@@ -297,15 +304,20 @@ 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 {
|
||||
position: static;
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -342,6 +354,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);
|
||||
@@ -384,7 +412,8 @@ article ol,
|
||||
article dl,
|
||||
article blockquote,
|
||||
article pre,
|
||||
article figure {
|
||||
article figure,
|
||||
article table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -460,19 +489,10 @@ article h1 .edit-link {
|
||||
}
|
||||
|
||||
/* Login/profile buttons injected by pagerite.js when Paskia SSO is in use.
|
||||
Both sit in the banner's top-right corner, to the right of the site pen,
|
||||
and are styled like the edit pens. */
|
||||
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. */
|
||||
button.login-link,
|
||||
button.profile-link {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.5rem;
|
||||
z-index: 10;
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
@@ -549,6 +569,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;
|
||||
@@ -599,50 +655,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
|
||||
@@ -651,52 +778,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
|
||||
multi-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
|
||||
@@ -709,9 +865,10 @@ 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);
|
||||
@@ -720,40 +877,59 @@ article h2 {
|
||||
/* Long articles stay fluid here too: same 12rem reservation for the
|
||||
sidebar, then the article takes everything to the right viewport
|
||||
edge. The article's left edge stays at 12rem either way, so the
|
||||
constant .wide breakout margin below remains correct. */
|
||||
constant .wide breakout margin remains correct. */
|
||||
body:has(#sidebar):has(.multicol):not(.editing) #content {
|
||||
grid-template-columns: 12rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
body:has(#sidebar) figure:has(.wide),
|
||||
body:has(#sidebar) img.wide:not(figure img) {
|
||||
margin-inline: -13.25rem 0;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* .wide on multicol pages: the article is not viewport-centered (no right
|
||||
gutter), so the bleed anchors at the left gutter — the 1fr share of the
|
||||
1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
|
||||
right viewport edge. Loses to the sidebar rule above (id in :has) and to
|
||||
the editing rules below (same specificity, later in the file). */
|
||||
body:has(.multicol) figure:has(.wide),
|
||||
body:has(.multicol) img.wide:not(figure img) {
|
||||
margin-inline: calc(-20vw - 1.25rem) 0;
|
||||
}
|
||||
#sidebar {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
#sidebar ul {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
}
|
||||
|
||||
/* Editing + multicol: the left gutter is 1/5 of the space right of the
|
||||
editor. */
|
||||
body.editing:has(.multicol) figure:has(.wide),
|
||||
body.editing:has(.multicol) img.wide:not(figure img) {
|
||||
width: calc(100vw - var(--editor-w));
|
||||
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
|
||||
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) */
|
||||
@@ -794,8 +970,8 @@ body.editing:has(.multicol) img.wide:not(figure img) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.85rem;
|
||||
.footnotes {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
+64
-19
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
+54
-36
@@ -5,8 +5,20 @@
|
||||
// Also: scroll-reveal effects and code copy buttons. These need no
|
||||
// support from the article itself and are re-applied after each swap.
|
||||
import { showAuthIframe } from 'paskia'
|
||||
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) {
|
||||
// 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
|
||||
@@ -48,29 +60,33 @@ import { showAuthIframe } from 'paskia'
|
||||
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 addLoginButton(url) {
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (!banner || banner.parentElement.querySelector(".login-link")) return;
|
||||
function makeLoginButton(url) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "login-link";
|
||||
@@ -85,43 +101,45 @@ import { showAuthIframe } from 'paskia'
|
||||
// Cancelled or error: leave the button in place.
|
||||
}
|
||||
});
|
||||
banner.after(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function injectProfileButton() {
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (!banner || banner.parentElement.querySelector(".profile-link")) return;
|
||||
function makeProfileButton() {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "profile-link";
|
||||
btn.title = "profile";
|
||||
btn.textContent = "👤";
|
||||
btn.textContent = "🔐";
|
||||
btn.addEventListener("click", () => {
|
||||
showAuthIframe("/auth/").catch(() => {});
|
||||
});
|
||||
banner.after(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const el of banner.parentElement.querySelectorAll(
|
||||
".banner-edit-link, .login-link, .profile-link",
|
||||
)) {
|
||||
el.remove();
|
||||
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 (isAdmin && ssoAvailable) {
|
||||
pens.append(makeProfileButton());
|
||||
} else if (!isAdmin && ssoAvailable && loginIframeUrl) {
|
||||
pens.append(makeLoginButton(loginIframeUrl));
|
||||
} else if (!isAdmin && ssoAvailable) {
|
||||
pens.append(makeProfileButton());
|
||||
}
|
||||
banner.after(pens);
|
||||
}
|
||||
if (isAdmin) {
|
||||
injectPens();
|
||||
if (ssoAvailable) injectProfileButton();
|
||||
} else if (ssoAvailable && loginIframeUrl) {
|
||||
addLoginButton(loginIframeUrl);
|
||||
} else if (ssoAvailable) {
|
||||
injectProfileButton();
|
||||
} else {
|
||||
// No Paskia SSO: dev/no-proxy fallback, leave editing open.
|
||||
injectPens();
|
||||
}
|
||||
if (canEdit) injectPagePen();
|
||||
}
|
||||
|
||||
async function setupAuth() {
|
||||
@@ -421,10 +439,10 @@ import { showAuthIframe } from 'paskia'
|
||||
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,117 @@
|
||||
// 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}`
|
||||
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
|
||||
doc = new DOMParser().parseFromString(await res.text(), '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'))
|
||||
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
|
||||
return finalUrl
|
||||
}
|
||||
+29
-13
@@ -288,6 +288,7 @@ async def get_settings() -> dict:
|
||||
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 "",
|
||||
@@ -302,6 +303,7 @@ class SettingsIn(BaseModel):
|
||||
brand: str
|
||||
theme: str
|
||||
custom_css: str
|
||||
brand_html: str = ""
|
||||
|
||||
|
||||
@app.put("/_api/settings", status_code=204)
|
||||
@@ -309,6 +311,7 @@ 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
|
||||
@@ -412,14 +415,16 @@ async def delete_file(name: str) -> None:
|
||||
|
||||
@app.get("/_themes/{name}/{filename}")
|
||||
async def theme_file(name: str, filename: str, request: Request) -> Response:
|
||||
"""Serve a theme/banner-design stylesheet from pagerite/themes/{name}/.
|
||||
"""Serve a theme/banner-design file from pagerite/themes/{name}/.
|
||||
|
||||
Read from disk on every request (etag by mtime+size): theme files are
|
||||
never built or content-hashed, so edits on disk show on the next page
|
||||
load, in prod as well as dev.
|
||||
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 (
|
||||
filename not in {"theme.css", "banner.css"}
|
||||
"/" in filename
|
||||
or filename.startswith(".")
|
||||
or "/" in name
|
||||
or name.startswith(".")
|
||||
):
|
||||
@@ -429,12 +434,15 @@ async def theme_file(name: str, filename: str, request: Request) -> Response:
|
||||
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="text/css",
|
||||
media_type=mime,
|
||||
headers={"etag": etag, "cache-control": "no-cache"},
|
||||
)
|
||||
|
||||
@@ -549,10 +557,18 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
# 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 applies here
|
||||
# (null = the active theme's default design).
|
||||
"banner_design_from": views.banner_design_source(
|
||||
data.menu, path, data.theme
|
||||
# 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":
|
||||
@@ -677,7 +693,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
return HTMLResponse(
|
||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
|
||||
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),
|
||||
@@ -688,7 +704,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
# 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, data.favicon),
|
||||
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),
|
||||
@@ -701,4 +717,4 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
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, data.favicon), 404)
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404)
|
||||
|
||||
@@ -85,6 +85,11 @@ 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).
|
||||
|
||||
+47
-9
@@ -2,9 +2,13 @@
|
||||
|
||||
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
|
||||
@@ -14,8 +18,10 @@ 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}`.
|
||||
"""
|
||||
|
||||
@@ -25,9 +31,13 @@ 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
|
||||
@@ -70,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.
|
||||
"""
|
||||
@@ -88,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
|
||||
@@ -116,6 +132,23 @@ 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",
|
||||
@@ -127,13 +160,18 @@ md = (
|
||||
},
|
||||
)
|
||||
.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(
|
||||
|
||||
@@ -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="xMidYMid 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);
|
||||
}
|
||||
+77
-32
@@ -128,6 +128,9 @@ def _layout(
|
||||
order intact.
|
||||
"""
|
||||
doc = Document(E.Title, lang="en")
|
||||
# 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:
|
||||
@@ -171,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("")
|
||||
|
||||
|
||||
@@ -181,13 +188,19 @@ 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 — 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."""
|
||||
# A top-level item is current also when viewing any of its subpages.
|
||||
is_current = current == path or (path and current.startswith(f"{path}/"))
|
||||
# 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 not node.content and (leaf := first_leaf(menu, path)) is not None:
|
||||
href = f"/{leaf}"
|
||||
@@ -216,14 +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, 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).
|
||||
The front page, leaf pages, the sole page of a one-page section and
|
||||
childless sections get no aside element at all (rather than an empty
|
||||
or useless one-item box).
|
||||
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("")
|
||||
@@ -232,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 not items or (len(items) == 1 and current == f"{section}/{items[0][0]}"):
|
||||
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.
|
||||
|
||||
@@ -254,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:
|
||||
if child.content:
|
||||
return cpath
|
||||
if (leaf := _first_leaf(child, cpath)) is not None:
|
||||
return leaf
|
||||
@@ -307,14 +342,25 @@ def _design_banner(design: str) -> 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 (a theme folder doubles as a banner design when it ships
|
||||
banner.css or banner.svg).
|
||||
design.
|
||||
"""
|
||||
chain = resolve(menu, path) or []
|
||||
for node in reversed(chain):
|
||||
@@ -323,30 +369,26 @@ def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
|
||||
front = menu.get("")
|
||||
if front and front.banner_design is not None:
|
||||
return front.banner_design
|
||||
if (
|
||||
_valid_name(theme)
|
||||
and any(
|
||||
(THEMES / theme / f).exists()
|
||||
for f in ("banner.css", "banner.svg", "banner.html")
|
||||
)
|
||||
):
|
||||
return theme
|
||||
return ""
|
||||
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 applies at ``path`` (like
|
||||
banner_source), or None when the active theme's default applies.
|
||||
Used by the site editor for the design selector's inherit label."""
|
||||
"""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("/")
|
||||
for i in range(len(chain) - 1, -1, -1):
|
||||
# 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 front and front.banner_design is not None:
|
||||
if path and front and front.banner_design is not None:
|
||||
return ""
|
||||
return None
|
||||
|
||||
@@ -391,6 +433,7 @@ def render_page(
|
||||
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]
|
||||
@@ -398,7 +441,7 @@ def render_page(
|
||||
return str(
|
||||
_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),
|
||||
@@ -414,6 +457,7 @@ def render_category(
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
) -> str:
|
||||
"""Render the placeholder for a content-less category label (404).
|
||||
|
||||
@@ -434,7 +478,7 @@ def render_category(
|
||||
return str(
|
||||
_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),
|
||||
@@ -450,6 +494,7 @@ def render_not_found(
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
) -> str:
|
||||
"""Render a 404 page within the normal layout."""
|
||||
doc = E.article
|
||||
@@ -459,7 +504,7 @@ def render_not_found(
|
||||
return str(
|
||||
_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),
|
||||
|
||||
Reference in New Issue
Block a user