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:
@@ -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
@@ -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
@@ -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(``)
|
||||
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
@@ -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 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user