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
(`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to
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
`route()` was called (during `load()` in the lifespan), so anything
defined earlier wins. The one exception is the content catch-all
`/{path:path}`, registered AFTER `frontend.route()` so that built
frontend assets still take priority over content slugs. The `Frontend`
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
would become a `/` route, so leave it out of the build to keep `/` ours.
asset files under `/_/assets/` without a catch-all; an `index.html` in the build
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
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
@@ -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.
- `seed.py` — demo content written on startup for paths missing from the
database (never overwrites existing pages).
- `static/` — our own assets served at `/static/`: `style.css` (shared
with Vue later), `pagerite.js` (fetch-navigation with rotating-cube
`startViewTransition`, scroll-reveal), `banner.svg`
(full-width header art) 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.
- `frontend/src/` — the Vue editor and public-page entries.
- `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
- `pagerite.js` — public page entry; imports the shared style and runs
fetch-navigation, scroll-reveal and code copy buttons.
- `assets/` — shared styles and data files built by Vite and served hashed
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`
overrides); gitignored. Do not delete it without asking.
- `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
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
`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
rendered by `views.render_editor` and keeps its own preview pane.
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` builds with
`manifest: true` and a JS-only input (`src/main.js`) so no `index.html`
ends up in the build (it would shadow `/`). vite-plugin-fastapi.js has an
`manifest: true`, `assetsDir: ''` and JS inputs (`src/main.js` and
`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.
- `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
passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs;
**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
- Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve
only few prefixes (`/_/` for files + API, `/static`, `/admin`) for the
machinery; top-level `_` is a reserved slug.
only `/_/` for the machinery (files, API, built assets, admin); top-level
`_` is a reserved slug.
- No auth in core code; trusted single author. Never add output
sanitization "for safety" against the author — embedded HTML/scripts in
Markdown are passed through deliberately.
+13 -11
View File
@@ -29,8 +29,8 @@ evolves.
directly at the site root; structured content may nest
(`/docs/design-principles`-style). The URL space is the author's, so
reserved prefixes must be kept few and deliberate: everything internal
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`),
plus `/static` and `/admin`.
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
assets at `/_/assets/`, and the admin shell at `/_/admin`).
- **Single user, trusted author.** No auth concerns in the core design.
Everything published is public; only editing tools will later sit behind
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
default preset), with `html=True` for raw passthrough. Fenced code blocks
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,
which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_/api/files/{filename}`)
@@ -75,9 +75,9 @@ evolves.
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
snippet (an image, a styled div, canvas + script — anything), resolved by
walking up the node's ancestors to the front page; when nothing in the
chain sets one, the 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
(`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
and the document title, keeping `<head>` and the layout chrome. Without JS
everything works as normal page loads. Scripts inside fetched banner and
@@ -124,11 +124,13 @@ evolves.
## Styling
- A single shared `style.css` covers the server-rendered pages and the Vue
components. Vue may add per-component styles on top where needed.
- Fonts are self-hosted under `/static/fonts/` (Fraunces for headings,
Literata for body, Fira Code for code — variable woff2 files with local
`@font-face`). No third-party requests.
- A single shared `frontend/src/assets/style.css` covers the server-rendered
pages and the Vue components. Vue may add per-component styles on top where
needed.
- Fonts, the shared stylesheet, pygments styles and the default banner SVG
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
@@ -148,7 +150,7 @@ evolves.
reloads the page). The pens are `<button>`s wired up by `pagerite.js`
editing is an action, not a navigation. The editor's WebSocket
**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
SSO.)
- **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
// applied straight into the visible article, saving over one WebSocket
// (/_/api/ws/editor). Docked left of the article on the page itself
// (main.js openEditor) or standalone at /admin with its own preview pane.
// The socket reconnects automatically; unsaved text and pending saves
// survive a disconnect. Editor scroll drives the document scroll, keeping
// the rendered article at the cursor's position.
// (main.js openEditor) or standalone at /_/admin with its own preview pane.
// 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 { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
@@ -21,7 +22,7 @@ const emit = defineEmits(['close'])
const path = ref('')
const title = ref('')
const published = ref(true)
const status = ref('connecting…')
const saveError = ref('')
const previewHtml = ref('')
const previewHasH1 = ref(false)
const editorEl = ref(null)
@@ -33,6 +34,8 @@ let view = null
let savedResolve = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let dirty = false
let syncingScroll = false
@@ -42,7 +45,17 @@ function currentPath() {
}
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) {
@@ -67,9 +80,6 @@ function save() {
published: published.value,
}
pendingSave = msg
status.value = ws && ws.readyState === WebSocket.OPEN
? 'saving…'
: 'offline — will save on reconnect'
send(msg)
return new Promise((resolve) => { savedResolve = resolve })
}
@@ -102,9 +112,6 @@ async function uploadImage(file) {
const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`)
status.value = `uploaded ${name}`
} else {
status.value = `upload failed (${res.status})`
}
}
@@ -170,16 +177,15 @@ function onMessage(ev) {
setDocument(msg.markdown)
requestRender()
dirty = false // just loaded from the server, nothing unsaved
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1)
} else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}`
saveError.value = ''
pendingSave = null
savedResolve?.()
savedResolve = null
} 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.onopen = () => {
status.value = ''
reconnectDelay = 2000
if (everConnected) {
// Reconnected: local text is authoritative — don't re-open (that
// would clobber the editor), just resync preview and pending saves.
@@ -229,9 +235,11 @@ function connect() {
everConnected = true
}
ws.onclose = () => {
status.value = 'offline — reconnecting…'
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="saveAndClose">save</button>
<span class="status">{{ status }}</span>
<button v-if="!standalone" type="button" class="close" title="close" @click="close"></button>
</header>
<div v-if="saveError">{{ saveError }}</div>
<div class="panes">
<div ref="editorEl" class="editor" />
<div v-if="standalone" ref="previewEl" class="preview">
@@ -370,12 +378,6 @@ onUnmounted(() => {
color: var(--text);
}
.status {
color: var(--muted);
font-size: 0.85rem;
min-width: 5rem;
}
.panes {
flex: 1;
display: flex;
+41 -27
View File
@@ -25,7 +25,7 @@ const emit = defineEmits(['close'])
const path = ref('')
const banner = ref('')
const status = ref('connecting…')
const saveError = ref('')
const tree = ref([])
const fileInput = ref(null)
const bannerEl = ref(null)
@@ -33,6 +33,8 @@ 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
@@ -56,7 +58,17 @@ function normPath(p) {
}
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
@@ -72,9 +84,6 @@ function debounce(key, fn, ms = 600) {
function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg
if (ws && ws.readyState !== WebSocket.OPEN) {
status.value = 'offline — will save on reconnect'
}
send(msg)
}
@@ -189,7 +198,6 @@ async function commitPending() {
if (!node) return
const slug = node.slug.trim().replace(/\/+/g, '')
if (!slug) {
status.value = 'a slug is needed'
return
}
const loc = locatePending(tree.value, '')
@@ -205,7 +213,7 @@ async function commitPending() {
}),
})
if (!res.ok) {
status.value = `create failed (${res.status})`
saveError.value = '⚠️ changes could not be saved'
return
}
// Place it exactly where the row was dropped: a fresh order key halfway
@@ -218,6 +226,8 @@ async function commitPending() {
: next ? next.order - 1
: 1
await postStructure({ path: newPath, order })
} else {
saveError.value = ''
}
pending.value = null
await refreshPages()
@@ -232,10 +242,11 @@ async function addContent(node) {
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
})
if (res.ok) {
saveError.value = ''
await refreshPages()
navigate(node.path)
} 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' },
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
@@ -306,18 +321,19 @@ function armRemove(node) {
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 that now
// redirects to its first child. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else { status.value = 'deleted'; navigate('') }
else navigate('')
} else {
loadPlain(path.value) // refresh menus
}
} 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' },
body: JSON.stringify(op),
})
if (!res.ok) status.value = `structure change failed (${res.status})`
else loadPlain(path.value) // refresh menus and content from the server
if (res.ok) {
saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
saveError.value = '⚠️ changes could not be saved'
}
await refreshPages()
return res.ok
}
@@ -453,7 +473,6 @@ async function uploadBannerMedia(file) {
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) {
status.value = `upload failed (${res.status})`
return
}
const { path: stored } = await res.json()
@@ -464,7 +483,6 @@ async function uploadBannerMedia(file) {
setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner()
save()
status.value = `uploaded ${name}`
}
function onBannerPaste(ev) {
@@ -491,13 +509,12 @@ function onMessage(ev) {
// Overlay this page's own banner on the swapped region. Empty means
// inherited: the server-rendered region already shows the right one.
if (banner.value.trim()) previewBanner()
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}`
saveError.value = ''
pendingSave = null
refreshPages()
} 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.onopen = () => {
status.value = ''
reconnectDelay = 2000
if (everConnected) {
// Reconnected: resend any save attempted while offline.
if (pendingSave) send(pendingSave)
@@ -525,9 +542,11 @@ function connect() {
everConnected = true
}
ws.onclose = () => {
status.value = 'offline — reconnecting…'
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">
<header class="toolbar">
<span class="mode-label">site editor</span>
<span class="status">{{ status }}</span>
<button type="button" class="close" title="close" @click="close"></button>
</header>
<div v-if="saveError">{{ saveError }}</div>
<section class="block">
<label class="field">
@@ -647,11 +666,6 @@ onUnmounted(() => {
white-space: nowrap;
}
.status {
color: var(--muted);
font-size: 0.85rem;
}
/* Window-style close button, top right corner. */
.toolbar .close {
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. */
@import url("/static/fonts/fonts.css");
@import url("/static/pygments.css");
@import url("./fonts/fonts.css");
@import url("./pygments.css");
:root {
color-scheme: dark;
@@ -56,7 +56,7 @@ body {
flex-direction: column;
justify-content: flex-end;
min-height: 11rem;
background: url("/static/banner.svg") center 40% / cover;
background: url("./banner.svg") center 40% / cover;
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
// 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.
// 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.
import { createApp } from 'vue'
import PageEditor from './PageEditor.vue'
@@ -45,7 +47,7 @@ export function closeEditor() {
const shell = document.getElementById('app')
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
createApp(PageEditor, {
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
@@ -1,3 +1,5 @@
import "./assets/style.css";
// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full
// page loads. Real <a href> links are used throughout, so this is pure
// progressive enhancement - without JS every link does a normal load.
@@ -127,7 +129,7 @@
function preload() {
const urls = new Set();
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) {
if (url === location.pathname) continue;
@@ -246,8 +248,7 @@
if (url.origin !== location.origin) return;
// Same-page anchor links (footnotes etc.): let the browser handle them
if (url.pathname === location.pathname && url.hash) return;
if (url.pathname.startsWith("/_/") || url.pathname.startsWith("/static/")
|| url.pathname === "/admin") return;
if (url.pathname.startsWith("/_/")) return;
ev.preventDefault();
load(url);
});
+22 -4
View File
@@ -5,19 +5,37 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
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/
export default defineConfig({
plugins: [
fastapiVue(),
fastapiVue({ paths: ["/_/api", "/_/f"] }),
vue(),
vueDevTools(),
],
server: {
proxy: {
"/_/admin": { target: backendUrl, changeOrigin: false },
[CONTENT_PROXY]: { target: backendUrl, changeOrigin: false },
},
},
build: {
// JS entry only: no index.html in the build (it would shadow our /),
// and a manifest so the backend can resolve hashed asset names.
// Emit hashed assets at the root of frontend-build so the backend can
// serve them under /_/assets/{file} without a nested /assets directory.
manifest: true,
assetsDir: '',
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.
Route ordering matters: our routes are defined before
``frontend.route(app, "/")`` is called, so they take priority over the
asset routes that fastapi-vue inserts at that position during ``load()``.
``frontend.route(app, "/_/assets")`` is called, so they take priority over
the asset routes that fastapi-vue inserts at that position during ``load()``.
The content catch-all (``/{path:path}``) is defined last, so built
frontend assets still win over content slugs; anything unmatched falls
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
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.
data = Data()
kanta = Kanta(DB_PATH, data)
# Vue build assets served at root, no SPA catch-all (assets only).
frontend = Frontend(Path(__file__).with_name("frontend-build"), spa=False)
# Vue build assets served under /_/assets/, no SPA catch-all (assets only).
# 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:
@@ -341,12 +343,12 @@ async def delete_page(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),
"/static" and "/admin" are reserved.
The public URL space belongs to content; only "/_/" is reserved for
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")
@@ -467,17 +469,17 @@ async def editor_ws(ws: WebSocket) -> None:
pass
@app.get("/admin", response_class=HTMLResponse)
@app.get("/_/admin", response_class=HTMLResponse)
async def admin() -> HTMLResponse:
"""Serve the editor app shell (Vue mounts into #app)."""
return HTMLResponse(views.render_editor())
@app.get("/static/{path:path}")
async def static_file(path: str) -> FileResponse:
"""Serve our own static assets (style.css, pagerite.js)."""
file = STATIC / path
if not file.is_file() or not file.resolve().is_relative_to(STATIC):
@app.get("/favicon.ico")
async def favicon() -> FileResponse:
"""Serve the favicon copied from frontend/public by the Vite build."""
file = BUILD_DIR / "favicon.ico"
if not file.is_file():
raise HTTPException(404)
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().
frontend.route(app, "/")
frontend.route(app, "/_/assets")
@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.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"))
_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'); }
+35 -14
View File
@@ -24,12 +24,14 @@ from pagerite.markdown import has_h1, render
SITE_NAME = "Pagerite"
BUILD = Path(__file__).with_name("frontend-build")
Layout = Template(
Document(
E.Title,
lang="en",
_urls=["/static/style.css", "/static/pagerite.js"],
)
def _layout(urls: list[str], modules: list[str] = ()) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts."""
doc = Document(E.Title, lang="en", _urls=urls)
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,
@@ -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",
"title": "edit",
"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,
}
@@ -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."""
node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node)
scripts, styles = _page_assets()
return str(
Layout(
_layout(styles, scripts)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand),
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.
doc.button("🖊️", **_edit_attrs(path))
doc.p(f"No page at /{path}.")
scripts, styles = _page_assets()
return str(
Layout(
_layout(styles, scripts)(
Title=f"Not Found {brand}" if brand else "Not Found",
Brand=_brand_link(brand),
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]]:
"""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
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"):
return (
[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())
entry = manifest["src/main.js"]
styles = [f"/{css}" for css in entry.get("css", [])]
return [f"/{entry['file']}"], ["/static/style.css", *styles]
styles = [f"/_/assets/{css}" for css in entry.get("css", [])]
return [f"/_/assets/{entry['file']}"], styles
def render_editor() -> str:
@@ -248,6 +269,6 @@ def render_editor() -> str:
scripts, styles = _editor_assets()
doc = Document(f"Admin {SITE_NAME}", lang="en", _urls=styles)
for src in scripts:
doc.script("", src=src, type="module")
doc.script(src=src, type="module", defer=True)
doc.div(None, id="app")
return str(doc)