Serve built assets under /_/assets, admin at /_/admin

Reorganize the URL space so all machinery lives under /_/: the Vite
build (now with assetsDir: '', two ES-module entries and hashed shared
assets: style.css, pygments.css, banner.svg, fonts moved from
pagerite/static to frontend/src/assets) is served at /_/assets via
frontend.route(app, "/_/assets") with cached="/", and the admin
shell moves to /_/admin, leaving only "_" as a reserved top-level
slug. A /favicon.ico route serves the file Vite copies from
frontend/public. In dev, Vite proxies content pages and /_/admin to
the backend.

Editor changes: drop the noisy status line for a save-error indicator,
reconnect the WebSocket with exponential backoff, and re-establish the
connection from send() when it has dropped. The socket connects when
an editor is opened (they mount on pen click), not before.
This commit is contained in:
2026-08-16 16:13:12 +00:00
parent 36b250a15c
commit d5e40fba12
18 changed files with 205 additions and 137 deletions
+21 -15
View File
@@ -21,15 +21,15 @@ not for the public pages. See `docs/design-principles.md` for the design.
- `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled - `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled
(`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to (`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to
our content. Our own routes (content pages, `/_/api/...`, `/_/f/...`, our content. Our own routes (content pages, `/_/api/...`, `/_/f/...`,
`/static/...`) are registered BEFORE `frontend.route(app, "/")` is `/_/assets/...`, `/_/admin`) are registered BEFORE `frontend.route(app, "/_/assets")` is
called: fastapi-vue inserts its file routes at the position where called: fastapi-vue inserts its file routes at the position where
`route()` was called (during `load()` in the lifespan), so anything `route()` was called (during `load()` in the lifespan), so anything
defined earlier wins. The one exception is the content catch-all defined earlier wins. The one exception is the content catch-all
`/{path:path}`, registered AFTER `frontend.route()` so that built `/{path:path}`, registered AFTER `frontend.route()` so that built
frontend assets still take priority over content slugs. The `Frontend` frontend assets still take priority over content slugs. The `Frontend`
is constructed with `spa=False` explicitly: it only serves the built is constructed with `spa=False` explicitly: it only serves the built
asset files at root without a catch-all; an `index.html` in the build asset files under `/_/assets/` without a catch-all; an `index.html` in the build
would become a `/` route, so leave it out of the build to keep `/` ours. would become a `/_/assets/` route, so leave it out of the build to keep `/` ours.
- `data.py` — msgspec Structs for the kanta database. The site structure - `data.py` — msgspec Structs for the kanta database. The site structure
is a tree: `Data.menu` maps top-level slugs to `Node`s, each with is a tree: `Data.menu` maps top-level slugs to `Node`s, each with
`children` keyed by slug — the URL path is the slug chain. The front `children` keyed by slug — the URL path is the slug chain. The front
@@ -72,12 +72,17 @@ not for the public pages. See `docs/design-principles.md` for the design.
(`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps. (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps.
- `seed.py` — demo content written on startup for paths missing from the - `seed.py` — demo content written on startup for paths missing from the
database (never overwrites existing pages). database (never overwrites existing pages).
- `static/` — our own assets served at `/static/`: `style.css` (shared - `frontend/src/` — the Vue editor and public-page entries.
with Vue later), `pagerite.js` (fetch-navigation with rotating-cube - `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
`startViewTransition`, scroll-reveal), `banner.svg` - `pagerite.js` — public page entry; imports the shared style and runs
(full-width header art) and `fonts/` (self-hosted Fraunces/Literata/ fetch-navigation, scroll-reveal and code copy buttons.
Fira Code variable woff2). The `::view-transition*` block at the end of - `assets/` — shared styles and data files built by Vite and served hashed
`style.css` (from termotohtori.fi) is fragile — do not tweak. under `/_/assets/`: `style.css`, `pygments.css`, `banner.svg` and
`fonts/` (self-hosted Fraunces/Literata/Fira Code variable woff2). The
`::view-transition*` block at the end of `style.css` (from
termotohtori.fi) is fragile — do not tweak.
- Vite builds ES-module `.js` outputs; the backend renders `<script
type="module" defer>` for them.
- The database file is `pagerite.kanta` in the cwd (`PAGERITE_DB` - The database file is `pagerite.kanta` in the cwd (`PAGERITE_DB`
overrides); gitignored. Do not delete it without asking. overrides); gitignored. Do not delete it without asking.
- `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template - `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template
@@ -97,14 +102,15 @@ not for the public pages. See `docs/design-principles.md` for the design.
lists become drop zones while dragging). The two pens swap the docked lists become drop zones while dragging). 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 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 link is clicked (the link carries pagerite.js when a 🖊️ edit link is clicked (the link carries
`data-editor-src`/`data-editor-css`/`data-editor-mode`); the `/admin` `data-editor-src`/`data-editor-css`/`data-editor-mode`); the `/_/admin`
route (page selected by location hash) is the no-JS-import fallback shell route (page selected by location hash) is the no-JS-import fallback shell
rendered by `views.render_editor` and keeps its own preview pane. rendered by `views.render_editor` and keeps its own preview pane.
In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`),
in prod from the hashed build assets resolved via in prod from the hashed build assets resolved via
`frontend-build/.vite/manifest.json`. `vite.config.js` builds with `frontend-build/.vite/manifest.json`. `vite.config.js` builds with
`manifest: true` and a JS-only input (`src/main.js`) so no `index.html` `manifest: true`, `assetsDir: ''` and JS inputs (`src/main.js` and
ends up in the build (it would shadow `/`). vite-plugin-fastapi.js has an `src/pagerite.js`) so no `index.html` ends up in the build (it would shadow
`/`). All outputs are ES modules. vite-plugin-fastapi.js has an
auto-upgrade marker — edit `vite.config.js`, not the plugin. auto-upgrade marker — edit `vite.config.js`, not the plugin.
- `docs/` — design documentation. - `docs/` — design documentation.
@@ -145,14 +151,14 @@ not for the public pages. See `docs/design-principles.md` for the design.
- **markdown-it-py** — Markdown rendering with `html=True` raw - **markdown-it-py** — Markdown rendering with `html=True` raw
passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs; passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs;
**Pygments** for server-side code highlighting (`nowrap` spans, styles **Pygments** for server-side code highlighting (`nowrap` spans, styles
in `static/pygments.css` scoped to "pre code"). in `frontend/src/assets/pygments.css` scoped to "pre code").
## Conventions ## Conventions
- Keep dependencies minimal; add via `uv add` and mention it. - Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve - The public URL space belongs to content (pretty slugs at root). Reserve
only few prefixes (`/_/` for files + API, `/static`, `/admin`) for the only `/_/` for the machinery (files, API, built assets, admin); top-level
machinery; top-level `_` is a reserved slug. `_` is a reserved slug.
- No auth in core code; trusted single author. Never add output - No auth in core code; trusted single author. Never add output
sanitization "for safety" against the author — embedded HTML/scripts in sanitization "for safety" against the author — embedded HTML/scripts in
Markdown are passed through deliberately. Markdown are passed through deliberately.
+13 -11
View File
@@ -29,8 +29,8 @@ evolves.
directly at the site root; structured content may nest directly at the site root; structured content may nest
(`/docs/design-principles`-style). The URL space is the author's, so (`/docs/design-principles`-style). The URL space is the author's, so
reserved prefixes must be kept few and deliberate: everything internal reserved prefixes must be kept few and deliberate: everything internal
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`), lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
plus `/static` and `/admin`. assets at `/_/assets/`, and the admin shell at `/_/admin`).
- **Single user, trusted author.** No auth concerns in the core design. - **Single user, trusted author.** No auth concerns in the core design.
Everything published is public; only editing tools will later sit behind Everything published is public; only editing tools will later sit behind
access control (external SSO when that time comes). The author is trusted access control (external SSO when that time comes). The author is trusted
@@ -50,7 +50,7 @@ evolves.
lists, task lists, brace-attributes; tables and strikethrough from the lists, task lists, brace-attributes; tables and strikethrough from the
default preset), with `html=True` for raw passthrough. Fenced code blocks default preset), with `html=True` for raw passthrough. Fenced code blocks
are highlighted server-side with **Pygments** (github-dark palette in are highlighted server-side with **Pygments** (github-dark palette in
`/static/pygments.css`); a JS copy button appears on hover. Should this `/_/assets/pygments-*.css`); a JS copy button appears on hover. Should this
prove limiting, we implement our own renderer on top of html5tagger, prove limiting, we implement our own renderer on top of html5tagger,
which we already use for all HTML generation. which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_/api/files/{filename}`) - **Files are content-addressed.** Uploads (`PUT /_/api/files/{filename}`)
@@ -75,9 +75,9 @@ evolves.
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML **per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
snippet (an image, a styled div, canvas + script — anything), resolved by snippet (an image, a styled div, canvas + script — anything), resolved by
walking up the node's ancestors to the front page; when nothing in the walking up the node's ancestors to the front page; when nothing in the
chain sets one, the default `/static/banner.svg` artwork shows. chain sets one, the default `/_/assets/banner-*.svg` artwork shows.
- **Fetch-navigation.** Links are plain `<a href>`; a small script - **Fetch-navigation.** Links are plain `<a href>`; a small script
(`pagerite/static/pagerite.js`) intercepts same-origin clicks, fetches the (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the
page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions
and the document title, keeping `<head>` and the layout chrome. Without JS and the document title, keeping `<head>` and the layout chrome. Without JS
everything works as normal page loads. Scripts inside fetched banner and everything works as normal page loads. Scripts inside fetched banner and
@@ -124,11 +124,13 @@ evolves.
## Styling ## Styling
- A single shared `style.css` covers the server-rendered pages and the Vue - A single shared `frontend/src/assets/style.css` covers the server-rendered
components. Vue may add per-component styles on top where needed. pages and the Vue components. Vue may add per-component styles on top where
- Fonts are self-hosted under `/static/fonts/` (Fraunces for headings, needed.
Literata for body, Fira Code for code — variable woff2 files with local - Fonts, the shared stylesheet, pygments styles and the default banner SVG
`@font-face`). No third-party requests. live under `frontend/src/assets/` and are emitted as hashed assets under
`/_/assets/` (Fraunces for headings, Literata for body, Fira Code for code —
variable woff2 files with local `@font-face`). No third-party requests.
## Editing ## Editing
@@ -148,7 +150,7 @@ evolves.
reloads the page). The pens are `<button>`s wired up by `pagerite.js` reloads the page). The pens are `<button>`s wired up by `pagerite.js`
editing is an action, not a navigation. The editor's WebSocket editing is an action, not a navigation. The editor's WebSocket
**reconnects automatically** with local text and pending saves preserved. **reconnects automatically** with local text and pending saves preserved.
A standalone shell also exists at `/admin#/path` with its own preview A standalone shell also exists at `/_/admin#/path` with its own preview
pane. (All users are trusted authors for now; access control later with pane. (All users are trusted authors for now; access control later with
SSO.) SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published - **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published
+27 -25
View File
@@ -2,10 +2,11 @@
// Page editor: CodeMirror for Markdown, live server-rendered preview // Page editor: CodeMirror for Markdown, live server-rendered preview
// applied straight into the visible article, saving over one WebSocket // applied straight into the visible article, saving over one WebSocket
// (/_/api/ws/editor). Docked left of the article on the page itself // (/_/api/ws/editor). Docked left of the article on the page itself
// (main.js openEditor) or standalone at /admin with its own preview pane. // (main.js openEditor) or standalone at /_/admin with its own preview pane.
// The socket reconnects automatically; unsaved text and pending saves // The socket connects when the editor is opened and reconnects with
// survive a disconnect. Editor scroll drives the document scroll, keeping // exponential backoff after a failure; unsaved text and pending saves
// the rendered article at the cursor's position. // survive a disconnect. Editor scroll drives the document scroll, keeping the
// rendered article at the cursor's position.
import { nextTick, onMounted, onUnmounted, ref } from 'vue' import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
@@ -21,7 +22,7 @@ const emit = defineEmits(['close'])
const path = ref('') const path = ref('')
const title = ref('') const title = ref('')
const published = ref(true) const published = ref(true)
const status = ref('connecting…') const saveError = ref('')
const previewHtml = ref('') const previewHtml = ref('')
const previewHasH1 = ref(false) const previewHasH1 = ref(false)
const editorEl = ref(null) const editorEl = ref(null)
@@ -33,6 +34,8 @@ let view = null
let savedResolve = null let savedResolve = null
let pendingSave = null let pendingSave = null
let reconnectTimer = null let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false let everConnected = false
let dirty = false let dirty = false
let syncingScroll = false let syncingScroll = false
@@ -42,7 +45,17 @@ function currentPath() {
} }
function send(msg) { function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(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()
}
} }
function normPath(p) { function normPath(p) {
@@ -67,9 +80,6 @@ function save() {
published: published.value, published: published.value,
} }
pendingSave = msg pendingSave = msg
status.value = ws && ws.readyState === WebSocket.OPEN
? 'saving…'
: 'offline — will save on reconnect'
send(msg) send(msg)
return new Promise((resolve) => { savedResolve = resolve }) return new Promise((resolve) => { savedResolve = resolve })
} }
@@ -102,9 +112,6 @@ async function uploadImage(file) {
const { path: stored } = await res.json() const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '') const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`) insertAtCursor(`![${alt}](${stored})`)
status.value = `uploaded ${name}`
} else {
status.value = `upload failed (${res.status})`
} }
} }
@@ -170,16 +177,15 @@ function onMessage(ev) {
setDocument(msg.markdown) setDocument(msg.markdown)
requestRender() requestRender()
dirty = false // just loaded from the server, nothing unsaved dirty = false // just loaded from the server, nothing unsaved
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'html' && msg.path === path.value) { } else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1) previewIntoArticle(msg.html, msg.has_h1)
} else if (msg.type === 'saved') { } else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}` saveError.value = ''
pendingSave = null pendingSave = null
savedResolve?.() savedResolve?.()
savedResolve = null savedResolve = null
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
status.value = `error: ${msg.detail}` saveError.value = '⚠️ changes could not be saved'
} }
} }
@@ -217,7 +223,7 @@ function connect() {
) )
ws.onmessage = onMessage ws.onmessage = onMessage
ws.onopen = () => { ws.onopen = () => {
status.value = '' reconnectDelay = 2000
if (everConnected) { if (everConnected) {
// Reconnected: local text is authoritative — don't re-open (that // Reconnected: local text is authoritative — don't re-open (that
// would clobber the editor), just resync preview and pending saves. // would clobber the editor), just resync preview and pending saves.
@@ -229,9 +235,11 @@ function connect() {
everConnected = true everConnected = true
} }
ws.onclose = () => { ws.onclose = () => {
status.value = 'offline — reconnecting…'
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 1500) reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
} }
} }
@@ -294,9 +302,9 @@ onUnmounted(() => {
/> />
<button type="button" @click="fileInput.click()">image</button> <button type="button" @click="fileInput.click()">image</button>
<button type="button" @click="saveAndClose">save</button> <button type="button" @click="saveAndClose">save</button>
<span class="status">{{ status }}</span>
<button v-if="!standalone" type="button" class="close" title="close" @click="close"></button> <button v-if="!standalone" type="button" class="close" title="close" @click="close"></button>
</header> </header>
<div v-if="saveError">{{ saveError }}</div>
<div class="panes"> <div class="panes">
<div ref="editorEl" class="editor" /> <div ref="editorEl" class="editor" />
<div v-if="standalone" ref="previewEl" class="preview"> <div v-if="standalone" ref="previewEl" class="preview">
@@ -370,12 +378,6 @@ onUnmounted(() => {
color: var(--text); color: var(--text);
} }
.status {
color: var(--muted);
font-size: 0.85rem;
min-width: 5rem;
}
.panes { .panes {
flex: 1; flex: 1;
display: flex; display: flex;
+41 -27
View File
@@ -25,7 +25,7 @@ const emit = defineEmits(['close'])
const path = ref('') const path = ref('')
const banner = ref('') const banner = ref('')
const status = ref('connecting…') const saveError = ref('')
const tree = ref([]) const tree = ref([])
const fileInput = ref(null) const fileInput = ref(null)
const bannerEl = ref(null) const bannerEl = ref(null)
@@ -33,6 +33,8 @@ const bannerEl = ref(null)
let ws = null let ws = null
let pendingSave = null let pendingSave = null
let reconnectTimer = null let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false let everConnected = false
let view = null // CodeMirror for the banner HTML let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically let syncing = false // set while replacing the document programmatically
@@ -56,7 +58,17 @@ function normPath(p) {
} }
function send(msg) { function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(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()
}
} }
// Debounce per key: text edits save while typing, without a request per // Debounce per key: text edits save while typing, without a request per
@@ -72,9 +84,6 @@ function debounce(key, fn, ms = 600) {
function save() { function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value } const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg pendingSave = msg
if (ws && ws.readyState !== WebSocket.OPEN) {
status.value = 'offline — will save on reconnect'
}
send(msg) send(msg)
} }
@@ -189,7 +198,6 @@ async function commitPending() {
if (!node) return if (!node) return
const slug = node.slug.trim().replace(/\/+/g, '') const slug = node.slug.trim().replace(/\/+/g, '')
if (!slug) { if (!slug) {
status.value = 'a slug is needed'
return return
} }
const loc = locatePending(tree.value, '') const loc = locatePending(tree.value, '')
@@ -205,7 +213,7 @@ async function commitPending() {
}), }),
}) })
if (!res.ok) { if (!res.ok) {
status.value = `create failed (${res.status})` saveError.value = '⚠️ changes could not be saved'
return return
} }
// Place it exactly where the row was dropped: a fresh order key halfway // Place it exactly where the row was dropped: a fresh order key halfway
@@ -218,6 +226,8 @@ async function commitPending() {
: next ? next.order - 1 : next ? next.order - 1
: 1 : 1
await postStructure({ path: newPath, order }) await postStructure({ path: newPath, order })
} else {
saveError.value = ''
} }
pending.value = null pending.value = null
await refreshPages() await refreshPages()
@@ -232,10 +242,11 @@ async function addContent(node) {
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }), body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
}) })
if (res.ok) { if (res.ok) {
saveError.value = ''
await refreshPages() await refreshPages()
navigate(node.path) navigate(node.path)
} else { } else {
status.value = `failed (${res.status})` saveError.value = '⚠️ changes could not be saved'
} }
} }
@@ -283,7 +294,11 @@ async function saveBrand() {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ brand: brand.value }), body: JSON.stringify({ brand: brand.value }),
}) })
if (!res.ok) status.value = `brand save failed (${res.status})` if (res.ok) {
saveError.value = ''
} else {
saveError.value = '⚠️ changes could not be saved'
}
} }
// Two-step delete (no dialogs): the first click arms the row's button for // Two-step delete (no dialogs): the first click arms the row's button for
@@ -306,18 +321,19 @@ function armRemove(node) {
async function removePage(node) { async function removePage(node) {
const res = await fetch(`/_/api/pages/${node.path}`, { method: 'DELETE' }) const res = await fetch(`/_/api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) { if (res.ok) {
saveError.value = ''
refreshPages() refreshPages()
const p = node.path const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) { if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category that now // The current page was deleted — or reduced to a category that now
// redirects to its first child. Either way, re-render from the server. // redirects to its first child. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value) if (node.children.length) loadPlain(path.value)
else { status.value = 'deleted'; navigate('') } else navigate('')
} else { } else {
loadPlain(path.value) // refresh menus loadPlain(path.value) // refresh menus
} }
} else { } else {
status.value = `delete failed (${res.status})` saveError.value = '⚠️ changes could not be saved'
} }
} }
@@ -334,8 +350,12 @@ async function postStructure(op) {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(op), body: JSON.stringify(op),
}) })
if (!res.ok) status.value = `structure change failed (${res.status})` if (res.ok) {
else loadPlain(path.value) // refresh menus and content from the server saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
saveError.value = '⚠️ changes could not be saved'
}
await refreshPages() await refreshPages()
return res.ok return res.ok
} }
@@ -453,7 +473,6 @@ async function uploadBannerMedia(file) {
const name = file.name.replace(/[^\w.-]/g, '-') const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file }) const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) { if (!res.ok) {
status.value = `upload failed (${res.status})`
return return
} }
const { path: stored } = await res.json() const { path: stored } = await res.json()
@@ -464,7 +483,6 @@ async function uploadBannerMedia(file) {
setDocument(rest ? `${tag}\n${rest}` : tag) setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner() previewBanner()
save() save()
status.value = `uploaded ${name}`
} }
function onBannerPaste(ev) { function onBannerPaste(ev) {
@@ -491,13 +509,12 @@ function onMessage(ev) {
// Overlay this page's own banner on the swapped region. Empty means // Overlay this page's own banner on the swapped region. Empty means
// inherited: the server-rendered region already shows the right one. // inherited: the server-rendered region already shows the right one.
if (banner.value.trim()) previewBanner() if (banner.value.trim()) previewBanner()
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'saved') { } else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}` saveError.value = ''
pendingSave = null pendingSave = null
refreshPages() refreshPages()
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
status.value = `error: ${msg.detail}` saveError.value = '⚠️ changes could not be saved'
} }
} }
@@ -515,7 +532,7 @@ function connect() {
) )
ws.onmessage = onMessage ws.onmessage = onMessage
ws.onopen = () => { ws.onopen = () => {
status.value = '' reconnectDelay = 2000
if (everConnected) { if (everConnected) {
// Reconnected: resend any save attempted while offline. // Reconnected: resend any save attempted while offline.
if (pendingSave) send(pendingSave) if (pendingSave) send(pendingSave)
@@ -525,9 +542,11 @@ function connect() {
everConnected = true everConnected = true
} }
ws.onclose = () => { ws.onclose = () => {
status.value = 'offline — reconnecting…'
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 1500) reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
} }
} }
@@ -575,9 +594,9 @@ onUnmounted(() => {
<div class="editor-root overlay"> <div class="editor-root overlay">
<header class="toolbar"> <header class="toolbar">
<span class="mode-label">site editor</span> <span class="mode-label">site editor</span>
<span class="status">{{ status }}</span>
<button type="button" class="close" title="close" @click="close"></button> <button type="button" class="close" title="close" @click="close"></button>
</header> </header>
<div v-if="saveError">{{ saveError }}</div>
<section class="block"> <section class="block">
<label class="field"> <label class="field">
@@ -647,11 +666,6 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.status {
color: var(--muted);
font-size: 0.85rem;
}
/* Window-style close button, top right corner. */ /* Window-style close button, top right corner. */
.toolbar .close { .toolbar .close {
margin-left: auto; margin-left: auto;

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('fraunces.woff2') format('woff2'); }
@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('literata.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('firacode.woff2') format('woff2'); }
@@ -1,6 +1,6 @@
/* Shared styles for server-rendered pages and Vue components. */ /* Shared styles for server-rendered pages and Vue components. */
@import url("/static/fonts/fonts.css"); @import url("./fonts/fonts.css");
@import url("/static/pygments.css"); @import url("./pygments.css");
:root { :root {
color-scheme: dark; color-scheme: dark;
@@ -56,7 +56,7 @@ body {
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
min-height: 11rem; min-height: 11rem;
background: url("/static/banner.svg") center 40% / cover; background: url("./banner.svg") center 40% / cover;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
+4 -2
View File
@@ -1,10 +1,12 @@
import "./assets/style.css";
// Pagerite editor entries. Two separate apps, mounted in their own // Pagerite editor entries. Two separate apps, mounted in their own
// dynamically created host divs inside the static document: // dynamically created host divs inside the static document:
// - PageEditor ("page" mode): pen next to an article heading — Markdown // - PageEditor ("page" mode): pen next to an article heading — Markdown
// editing with the preview rendered into the visible article. // editing with the preview rendered into the visible article.
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing // - SiteEditor ("site" mode): pen on the banner — banner HTML editing
// (previewed into the real banner) and the site structure tree. // (previewed into the real banner) and the site structure tree.
// The standalone /admin shell (#app in the DOM) mounts PageEditor with the // The standalone /_/admin shell (#app in the DOM) mounts PageEditor with the
// page selected by location hash, as a no-dynamic-import fallback. // page selected by location hash, as a no-dynamic-import fallback.
import { createApp } from 'vue' import { createApp } from 'vue'
import PageEditor from './PageEditor.vue' import PageEditor from './PageEditor.vue'
@@ -45,7 +47,7 @@ export function closeEditor() {
const shell = document.getElementById('app') const shell = document.getElementById('app')
if (shell) { if (shell) {
// Standalone /admin shell: mount into it and follow the location hash. // Standalone /_/admin shell: mount into it and follow the location hash.
host = shell host = shell
createApp(PageEditor, { createApp(PageEditor, {
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''), pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
@@ -1,3 +1,5 @@
import "./assets/style.css";
// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full // Fetch-navigation: swap dynamic regions (#nav, #main) instead of full
// page loads. Real <a href> links are used throughout, so this is pure // page loads. Real <a href> links are used throughout, so this is pure
// progressive enhancement - without JS every link does a normal load. // progressive enhancement - without JS every link does a normal load.
@@ -127,7 +129,7 @@
function preload() { function preload() {
const urls = new Set(); const urls = new Set();
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) { for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) {
if (!a.pathname.startsWith("/admin")) urls.add(a.pathname); if (!a.pathname.startsWith("/_/admin")) urls.add(a.pathname);
} }
for (const url of urls) { for (const url of urls) {
if (url === location.pathname) continue; if (url === location.pathname) continue;
@@ -246,8 +248,7 @@
if (url.origin !== location.origin) return; if (url.origin !== location.origin) return;
// Same-page anchor links (footnotes etc.): let the browser handle them // Same-page anchor links (footnotes etc.): let the browser handle them
if (url.pathname === location.pathname && url.hash) return; if (url.pathname === location.pathname && url.hash) return;
if (url.pathname.startsWith("/_/") || url.pathname.startsWith("/static/") if (url.pathname.startsWith("/_/")) return;
|| url.pathname === "/admin") return;
ev.preventDefault(); ev.preventDefault();
load(url); load(url);
}); });
+22 -4
View File
@@ -5,19 +5,37 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
// backend's /_ prefix. /_/api and /_/f are handled by the fastapi-vue plugin;
// /_/admin is proxied explicitly below.
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
fastapiVue(), fastapiVue({ paths: ["/_/api", "/_/f"] }),
vue(), vue(),
vueDevTools(), vueDevTools(),
], ],
server: {
proxy: {
"/_/admin": { target: backendUrl, changeOrigin: false },
[CONTENT_PROXY]: { target: backendUrl, changeOrigin: false },
},
},
build: { build: {
// JS entry only: no index.html in the build (it would shadow our /), // Emit hashed assets at the root of frontend-build so the backend can
// and a manifest so the backend can resolve hashed asset names. // serve them under /_/assets/{file} without a nested /assets directory.
manifest: true, manifest: true,
assetsDir: '',
rollupOptions: { rollupOptions: {
input: fileURLToPath(new URL('./src/main.js', import.meta.url)), input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
},
}, },
}, },
}) })
+18 -16
View File
@@ -1,8 +1,8 @@
"""FastAPI application: server-rendered content pages plus Vue assets. """FastAPI application: server-rendered content pages plus Vue assets.
Route ordering matters: our routes are defined before Route ordering matters: our routes are defined before
``frontend.route(app, "/")`` is called, so they take priority over the ``frontend.route(app, "/_/assets")`` is called, so they take priority over
asset routes that fastapi-vue inserts at that position during ``load()``. the asset routes that fastapi-vue inserts at that position during ``load()``.
The content catch-all (``/{path:path}``) is defined last, so built The content catch-all (``/{path:path}``) is defined last, so built
frontend assets still win over content slugs; anything unmatched falls frontend assets still win over content slugs; anything unmatched falls
through to content (and 404 if no page exists there). through to content (and 404 if no page exists there).
@@ -40,14 +40,16 @@ from pagerite.data import (
from pagerite.markdown import has_h1, render from pagerite.markdown import has_h1, render
DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta") DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta")
STATIC = Path(__file__).with_name("static")
# Our own data root; kanta edits it in place, reads are plain attribute access. # Our own data root; kanta edits it in place, reads are plain attribute access.
data = Data() data = Data()
kanta = Kanta(DB_PATH, data) kanta = Kanta(DB_PATH, data)
# Vue build assets served at root, no SPA catch-all (assets only). # Vue build assets served under /_/assets/, no SPA catch-all (assets only).
frontend = Frontend(Path(__file__).with_name("frontend-build"), spa=False) # With assetsDir: '', all files are emitted at the build root, so cached="/"
# marks every built file immutable.
BUILD_DIR = Path(__file__).with_name("frontend-build")
frontend = Frontend(BUILD_DIR, spa=False, cached="/")
def _hash_name(body: bytes, orig: str) -> str: def _hash_name(body: bytes, orig: str) -> str:
@@ -341,12 +343,12 @@ async def delete_page(path: str) -> None:
def _check_reserved(path: str) -> None: def _check_reserved(path: str) -> None:
"""Reject slugs that collide with machinery prefixes. """Reject slugs under the machinery prefix.
The public URL space belongs to content; only "/_/" (files + API), The public URL space belongs to content; only "/_/" is reserved for
"/static" and "/admin" are reserved. the machinery (API, files, built assets, admin).
""" """
if path.split("/", 1)[0] in {"_", "static", "admin"}: if path.split("/", 1)[0] == "_":
raise HTTPException(400, "reserved path prefix") raise HTTPException(400, "reserved path prefix")
@@ -467,17 +469,17 @@ async def editor_ws(ws: WebSocket) -> None:
pass pass
@app.get("/admin", response_class=HTMLResponse) @app.get("/_/admin", response_class=HTMLResponse)
async def admin() -> HTMLResponse: async def admin() -> HTMLResponse:
"""Serve the editor app shell (Vue mounts into #app).""" """Serve the editor app shell (Vue mounts into #app)."""
return HTMLResponse(views.render_editor()) return HTMLResponse(views.render_editor())
@app.get("/static/{path:path}") @app.get("/favicon.ico")
async def static_file(path: str) -> FileResponse: async def favicon() -> FileResponse:
"""Serve our own static assets (style.css, pagerite.js).""" """Serve the favicon copied from frontend/public by the Vite build."""
file = STATIC / path file = BUILD_DIR / "favicon.ico"
if not file.is_file() or not file.resolve().is_relative_to(STATIC): if not file.is_file():
raise HTTPException(404) raise HTTPException(404)
return FileResponse(file) return FileResponse(file)
@@ -489,7 +491,7 @@ async def front_page(request: Request) -> Response:
# Vue build asset routes are inserted at this position during load(). # Vue build asset routes are inserted at this position during load().
frontend.route(app, "/") frontend.route(app, "/_/assets")
@app.get("/{path:path}", response_model=None) @app.get("/{path:path}", response_model=None)
+1 -1
View File
@@ -25,7 +25,7 @@ from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound from pygments.util import ClassNotFound
# Styles in /static/pygments.css match this formatter (regenerate: # Styles in /_/assets/pygments-*.css match this formatter (regenerate:
# HtmlFormatter(style="github-dark").get_style_defs("pre code")) # HtmlFormatter(style="github-dark").get_style_defs("pre code"))
_formatter = HtmlFormatter(style="github-dark", nowrap=True) _formatter = HtmlFormatter(style="github-dark", nowrap=True)
-3
View File
@@ -1,3 +0,0 @@
@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('/static/fonts/fraunces.woff2') format('woff2'); }
@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('/static/fonts/literata.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('/static/fonts/firacode.woff2') format('woff2'); }
+48 -27
View File
@@ -24,26 +24,28 @@ from pagerite.markdown import has_h1, render
SITE_NAME = "Pagerite" SITE_NAME = "Pagerite"
BUILD = Path(__file__).with_name("frontend-build") BUILD = Path(__file__).with_name("frontend-build")
Layout = Template(
Document( def _layout(urls: list[str], modules: list[str] = ()) -> Template:
E.Title, """Page layout template with standard asset URLs and ES-module scripts."""
lang="en", doc = Document(E.Title, lang="en", _urls=urls)
_urls=["/static/style.css", "/static/pagerite.js"], for src in modules:
doc.script(src=src, type="module", defer=True)
return Template(
doc
.header(
E.div(E.Banner, id="page-banner"),
E.BannerEdit,
E.Brand,
E.nav(E.Nav, id="nav"),
id="banner",
)
.div(
E.aside(E.Sidebar, id="sidebar"),
E.main(E.Main, id="main"),
id="content",
)
.footer(None), # kept empty for now; zero-height (see style.css)
) )
.header(
E.div(E.Banner, id="page-banner"),
E.BannerEdit,
E.Brand,
E.nav(E.Nav, id="nav"),
id="banner",
)
.div(
E.aside(E.Sidebar, id="sidebar"),
E.main(E.Main, id="main"),
id="content",
)
.footer(None), # kept empty for now; zero-height (see style.css)
)
def _brand_link(brand: str) -> HTML: def _brand_link(brand: str) -> HTML:
@@ -168,7 +170,7 @@ def _edit_attrs(path: str, mode: str = "page") -> dict:
"class": "edit-link" if mode == "page" else "edit-link banner-edit-link", "class": "edit-link" if mode == "page" else "edit-link banner-edit-link",
"title": "edit", "title": "edit",
"data-editor-src": scripts[-1], "data-editor-src": scripts[-1],
"data-editor-css": ",".join(s for s in styles if s != "/static/style.css"), "data-editor-css": ",".join(styles),
"data-editor-mode": mode, "data-editor-mode": mode,
} }
@@ -192,8 +194,9 @@ def render_page(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str
"""Render a full HTML page for the slug path.""" """Render a full HTML page for the slug path."""
node = resolve(menu, path)[-1] node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node) title = _title(path.rpartition("/")[2], node)
scripts, styles = _page_assets()
return str( return str(
Layout( _layout(styles, scripts)(
Title=f"{title} {brand}" if brand else title, Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand), Brand=_brand_link(brand),
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
@@ -213,8 +216,9 @@ def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -
# Editing works here too: this is how brand new pages get created. # Editing works here too: this is how brand new pages get created.
doc.button("🖊️", **_edit_attrs(path)) doc.button("🖊️", **_edit_attrs(path))
doc.p(f"No page at /{path}.") doc.p(f"No page at /{path}.")
scripts, styles = _page_assets()
return str( return str(
Layout( _layout(styles, scripts)(
Title=f"Not Found {brand}" if brand else "Not Found", Title=f"Not Found {brand}" if brand else "Not Found",
Brand=_brand_link(brand), Brand=_brand_link(brand),
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
@@ -226,8 +230,25 @@ def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -
) )
def _page_assets() -> tuple[list[str], list[str]]:
"""Script and CSS URLs for public pages (pagerite entry).
Dev mode loads the entry from the Vite dev server; production uses
the Vite build manifest to resolve the hashed asset names.
"""
if vite_url := os.environ.get("PAGERITE_VITE_URL"):
return (
[f"{vite_url}/src/pagerite.js"],
[], # Vite injects the imported CSS in dev
)
manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
entry = manifest["src/pagerite.js"]
styles = [f"/_/assets/{css}" for css in entry.get("css", [])]
return [f"/_/assets/{entry['file']}"], styles
def _editor_assets() -> tuple[list[str], list[str]]: def _editor_assets() -> tuple[list[str], list[str]]:
"""Script and CSS URLs for the admin editor (Vue app). """Script and CSS URLs for the admin editor (main entry).
Dev mode loads the modules from the Vite dev server; production uses Dev mode loads the modules from the Vite dev server; production uses
the Vite build manifest to resolve the hashed asset names. the Vite build manifest to resolve the hashed asset names.
@@ -235,12 +256,12 @@ def _editor_assets() -> tuple[list[str], list[str]]:
if vite_url := os.environ.get("PAGERITE_VITE_URL"): if vite_url := os.environ.get("PAGERITE_VITE_URL"):
return ( return (
[f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"],
["/static/style.css"], [], # Vite injects the imported CSS in dev
) )
manifest = json.loads((BUILD / ".vite/manifest.json").read_text()) manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
entry = manifest["src/main.js"] entry = manifest["src/main.js"]
styles = [f"/{css}" for css in entry.get("css", [])] styles = [f"/_/assets/{css}" for css in entry.get("css", [])]
return [f"/{entry['file']}"], ["/static/style.css", *styles] return [f"/_/assets/{entry['file']}"], styles
def render_editor() -> str: def render_editor() -> str:
@@ -248,6 +269,6 @@ def render_editor() -> str:
scripts, styles = _editor_assets() scripts, styles = _editor_assets()
doc = Document(f"Admin {SITE_NAME}", lang="en", _urls=styles) doc = Document(f"Admin {SITE_NAME}", lang="en", _urls=styles)
for src in scripts: for src in scripts:
doc.script("", src=src, type="module") doc.script(src=src, type="module", defer=True)
doc.div(None, id="app") doc.div(None, id="app")
return str(doc) return str(doc)