Restructure internal URL prefixes from /_/... to /_...

- Move API routes from /_/api/ to /_api/
- Move uploaded files from /_/f/ to /_f/
- Move built assets from /_/assets/ to /_assets/
- Move admin shell from /_/admin to /_admin
- Update Vite proxy/build config, frontend fetches/WebSockets, devserver health URL, seed content, and docs
- Fix README database name to pagerite.kantadb
This commit is contained in:
2026-08-16 20:04:16 +00:00
parent 6de0307856
commit 53d98f0583
15 changed files with 81 additions and 80 deletions
+3 -1
View File
@@ -10,4 +10,6 @@ wheels/
.venv
.ruff_cache/
/pagerite/frontend-build
pagerite.kanta
# Site
*.kantadb
+11 -11
View File
@@ -20,8 +20,8 @@ not for the public pages. See `docs/design-principles.md` for the design.
- Avoid running the server yourself, ask the user to test
- `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/...`,
`/_/admin`) are registered BEFORE `frontend.route(app, "/")` is
our content. Our own routes (content pages, `/_api/...`, `/_f/...`,
`/_admin`) are registered BEFORE `frontend.route(app, "/")` 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
@@ -29,7 +29,7 @@ not for the public pages. See `docs/design-principles.md` for the design.
frontend assets still take priority over content slugs. The `Frontend`
is constructed with `spa=False` explicitly: it only serves the built
files without a catch-all. The build mirrors the URL space — hashed
immutable assets under `/_/assets/`, `favicon.ico` at the site root —
immutable assets under `/_assets/`, `favicon.ico` at the site root —
and an `index.html` in the build would become a `/` route, so leave it
out of the build to keep `/` ours.
- `data.py` — msgspec Structs for the kanta database. The site structure
@@ -49,15 +49,15 @@ not for the public pages. See `docs/design-principles.md` for the design.
the `Data` object; reads are plain attribute access, writes in
`kanta.transaction(...)`.
`Data.files` is a content-addressed store (blake3[:12] + extension)
mapping file names to bytes, served at `/_/f/{name}` with immutable
caching; pages reference files by absolute `/_/f/` URLs so hierarchy
mapping file names to bytes, served at `/_f/{name}` with immutable
caching; pages reference files by absolute `/_f/` URLs so hierarchy
moves never break them. `Node.banner` is a raw trusted HTML snippet
for the header banner (img, styled div, canvas+script...); empty
inherits from the node's ancestors (front page last), then the default
banner.svg artwork. `Data.version` is bumped on every write
and embedded in page ETags so nav-affecting changes invalidate caches.
`Data.brand` is the site name (header link + `<title>` suffix), editable
in the site editor via `/_/api/settings`; empty = no header link and
in the site editor via `/_api/settings`; empty = no header link and
no `<title>` suffix.
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
@@ -80,20 +80,20 @@ not for the public pages. See `docs/design-principles.md` for the design.
code copy buttons. The backend links the shared CSS as two separate
stylesheets (base and theme) so they can be swapped or augmented.
- `assets/` — shared styles and data files built by Vite and served hashed
under `/_/assets/`: `pagerite.css` (base layout + conservative variables),
under `/_assets/`: `pagerite.css` (base layout + conservative variables),
`themes/purple/theme.css` (the purple/dark theme override), `pygments.css`,
`banner.svg` and `fonts/` (self-hosted Fraunces/Literata/Fira Code variable
woff2). The `::view-transition*` block at the end of `pagerite.css` (from
termotohtori.fi) is fragile — do not tweak.
- Vite builds ES-module `.js` outputs; the backend renders `<script
type="module">` for them (module scripts defer by default).
- The database file is `pagerite.kanta` in the cwd (`PAGERITE_DB`
- The database file is `pagerite.kantadb` in the cwd (`PAGERITE_DB`
overrides); gitignored. Do not delete it without asking.
- `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template
(build hook etc.), do not edit.
- `frontend/` — the Vue editor as **two separate apps** mounted in their
own host divs created inside the static document: `PageEditor.vue`
(CodeMirror + server-rendered preview over WebSocket `/_/api/ws/editor`,
(CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`,
previewing into the visible article; editor scroll drives document
scroll) opened by the article pen — it edits content and title only,
never the path — and `SiteEditor.vue` (site brand + banner HTML edited in
@@ -115,7 +115,7 @@ not for the public pages. See `docs/design-principles.md` for the design.
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`),
@@ -172,7 +172,7 @@ not for the public pages. See `docs/design-principles.md` for the design.
- Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve
only `/_/` for the machinery (files, API, built assets, admin), plus
only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`, `/_admin`), plus
`/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits,
hyphens and underscores `[a-z0-9_-]` (the site editor filters input live
via `slugify.js`, built on the `transliteration` npm package — unicode
+1 -1
View File
@@ -12,7 +12,7 @@ uv run pagerite # serves the built frontend
uv run scripts/devserver.py # dev mode with auto reloads (no build needed)
```
The database lives in `pagerite.kanta` in the working directory
The database lives in `pagerite.kantadb` in the working directory
(`PAGERITE_DB` overrides). On startup, demo pages from `pagerite/seed.py`
are added only if missing.
+12 -13
View File
@@ -29,8 +29,7 @@ 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/`, built
assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
lives under `/_` (`/_api/`, `/_f/`, `/_assets/`, `/_admin`). The only
other reserved root path is `/favicon.ico`, served from the build.
Slugs are lowercase ASCII letters, digits, hyphens and underscores
(`[a-z0-9_-]`; input is transliterated and filtered as you type, and a
@@ -55,16 +54,16 @@ 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
`/_/assets/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}`)
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`)
are stored by content hash — blake3, first 6 bytes hex + original
extension — and served immutable from `/_/f/{hash}.ext`. Absolute URLs
extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs
that survive page renames and dedupe identical content; pages no longer
own files. An image with a title becomes a `<figure>` with
`<figcaption>`. Positioning is by attribute classes:
`![alt](/_/f/….avif "Caption"){.right}``{.right}`, `{.left}` float,
`![alt](/_f/….avif "Caption"){.right}``{.right}`, `{.left}` float,
`{.wide}` goes full bleed (viewport edge to edge, or up to the docked
editor; the sidebar stacks on top of it); plain attributes like `width=300`
work too.
@@ -116,7 +115,7 @@ evolves.
fresh value halfway between its new siblings; all other items keep
theirs). New pages append at the end of their menu. Structure edits
(reorder, move/rename with the whole subtree, retitle) go through
`POST /_/api/structure` and the editor's structure panel.
`POST /_api/structure` and the editor's structure panel.
- Unpublished pages are hidden from both nav and URL access (404).
## Reading experience
@@ -139,7 +138,7 @@ evolves.
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 —
`/_assets/` (Fraunces for headings, Literata for body, Fira Code for code —
variable woff2 files with local `@font-face`). No third-party requests.
## Editing
@@ -160,14 +159,14 @@ 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
controls.
Images can be pasted straight into the editor or chosen via a file
input: they upload to the content store (`PUT /_/api/files/...`) and
insert `![alt](/_/f/hash.ext)` at the cursor.
input: they upload to the content store (`PUT /_api/files/...`) and
insert `![alt](/_f/hash.ext)` at the cursor.
- The **structure panel** (vue-draggable tree of the whole site, in site
mode) covers page management: reorder any menu level, drag across
sections, add, delete (two clicks: the button arms, then deletes — no
@@ -187,11 +186,11 @@ evolves.
is the root row with an empty slug — renaming it away leaves no front
page ("/" redirects to the first nav item), and giving another
top-level row the empty slug makes it the front page.
- Preview and saving go over a **WebSocket** (`/_/api/ws/editor`) with a
- Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a
stateless JSON protocol (`open`/`render`/`save`; on save all fields are
optional and absent ones keep their old values, `move_from` renames),
avoiding REST polling and races. Rendering always stays server-side.
- A REST API also exists for scripting, all under `/_/api/`:
- A REST API also exists for scripting, all under `/_api/`:
`GET pages` (the full tree), `PUT/DELETE pages/{path}`,
`GET/PUT settings` (site brand), `POST structure` (reorder/move/
retitle), file upload/removal via `PUT/DELETE files/{name}`.
+4 -4
View File
@@ -1,8 +1,8 @@
<script setup>
// 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.
// (/_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 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
@@ -109,7 +109,7 @@ function insertAtCursor(text) {
async function uploadImage(file) {
if (!file) return
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) {
const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '')
@@ -223,7 +223,7 @@ function syncScroll() {
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
+9 -9
View File
@@ -6,7 +6,7 @@
// no save button, no edit mode. Focusing a page's row navigates to it in
// place (no transitions).
//
// The tree comes from the server nested (GET /_/api/pages); every node is
// The tree comes from the server nested (GET /_api/pages); every node is
// real — a label with a title and slug, with content (landing page) or
// without (category whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
@@ -206,7 +206,7 @@ async function commitPending() {
const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug
const res = await fetch(`/_/api/pages/${newPath}`, {
const res = await fetch(`/_api/pages/${newPath}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -246,7 +246,7 @@ const brand = ref('')
async function loadSettings() {
try {
brand.value = (await (await fetch('/_/api/settings')).json()).brand
brand.value = (await (await fetch('/_api/settings')).json()).brand
} catch { /* keep default */ }
}
@@ -278,7 +278,7 @@ function onBrandInput() {
}
async function saveBrand() {
const res = await fetch('/_/api/settings', {
const res = await fetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ brand: brand.value }),
@@ -308,7 +308,7 @@ function armRemove(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) {
saveError.value = ''
refreshPages()
@@ -329,7 +329,7 @@ async function removePage(node) {
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
async function refreshPages() {
try {
tree.value = await (await fetch('/_/api/pages')).json()
tree.value = await (await fetch('/_api/pages')).json()
} catch { /* list stays stale; not fatal */ }
}
@@ -341,7 +341,7 @@ async function errorDetail(res) {
}
async function postStructure(op) {
const res = await fetch('/_/api/structure', {
const res = await fetch('/_api/structure', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
@@ -469,7 +469,7 @@ async function uploadBannerMedia(file) {
// Banner media goes to the shared content store, like article images.
if (!file || !/^(image|video)\//.test(file.type)) return
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) {
return
}
@@ -526,7 +526,7 @@ function onKeydown(ev) {
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_/api/pages via SiteEditor.vue) as
// Nodes come from the server (GET /_api/pages via SiteEditor.vue) as
// {slug, path, title, order, published, has_content, children}.
// Every node is real: a label whose title and slug are always editable
// inline — the title saves while typing (and focusing it opens the page),
+2 -2
View File
@@ -4,7 +4,7 @@
// 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.
if (import.meta.env.DEV) {
import("./assets/pagerite.css");
@@ -50,7 +50,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(/\/$/, ''),
+3 -3
View File
@@ -132,7 +132,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;
@@ -251,7 +251,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("/_/")) return;
if (url.pathname.startsWith("/_")) return;
ev.preventDefault();
load(url);
});
@@ -270,7 +270,7 @@
try {
const body = { path, index };
if (editor) body.markdown = editor.getMarkdown();
const res = await fetch("/_/api/toggle-task", {
const res = await fetch("/_api/toggle-task", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
+6 -6
View File
@@ -9,29 +9,29 @@ 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.
// 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({ paths: ["/_/api", "/_/f"] }),
fastapiVue({ paths: ["/_api", "/_f"] }),
vue(),
vueDevTools(),
],
server: {
proxy: {
"/_/admin": { target: backendUrl, changeOrigin: false },
"/_admin": { target: backendUrl, changeOrigin: false },
[CONTENT_PROXY]: { target: backendUrl, changeOrigin: false },
},
},
build: {
// Mirror the URL space in the build output: hashed files land under
// frontend-build/_/assets/ and the Frontend serves the build directory
// frontend-build/_assets/ and the Frontend serves the build directory
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
manifest: true,
assetsDir: '_/assets',
assetsDir: '_assets',
rollupOptions: {
input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
+23 -23
View File
@@ -40,7 +40,7 @@ from pagerite.data import (
)
from pagerite.markdown import has_h1, render, toggle_task
DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta")
DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kantadb")
# Our own data root; kanta edits it in place, reads are plain attribute access.
data = Data()
@@ -48,9 +48,9 @@ kanta = Kanta(DB_PATH, data)
# Vue build served at the site root, no SPA catch-all (assets only). The
# build mirrors the URL space: hashed, immutable files live under
# /_/assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
# /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
BUILD_DIR = Path(__file__).with_name("frontend-build")
frontend = Frontend(BUILD_DIR, spa=False, cached="/_/assets/")
frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/")
def _hash_name(body: bytes, orig: str) -> str:
@@ -60,12 +60,12 @@ def _hash_name(body: bytes, orig: str) -> str:
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
"""Store a seed file content-addressed and point references at /_/f/."""
"""Store a seed file content-addressed and point references at /_f/."""
name = _hash_name(body, orig)
data.files.setdefault(name, body)
markdown = markdown.replace(f"]({orig}", f"](/_/f/{name}")
banner = banner.replace(f'src="/{orig}"', f'src="/_/f/{name}"')
banner = banner.replace(f'src="{orig}"', f'src="/_/f/{name}"')
markdown = markdown.replace(f"]({orig}", f"](/_f/{name}")
banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"')
banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"')
return markdown, banner
@@ -163,13 +163,13 @@ class PageIn(BaseModel):
banner: str | None = None # None keeps the existing banner
@app.get("/_/api/health")
@app.get("/_api/health")
async def health_check() -> dict[str, str]:
"""Return backend status for health monitoring."""
return {"status": "ok"}
@app.get("/_/api/pages")
@app.get("/_api/pages")
async def list_pages() -> list[dict]:
"""The site tree for the structure editor (all nodes, drafts included).
@@ -194,7 +194,7 @@ async def list_pages() -> list[dict]:
return dump(data.menu, "")
@app.put("/_/api/pages/{path:path}", status_code=204)
@app.put("/_api/pages/{path:path}", status_code=204)
async def save_page(path: str, page: PageIn) -> None:
"""Create or replace the page at a slug path ("" or "/" = front page).
@@ -236,7 +236,7 @@ class StructureOp(BaseModel):
title: str | None = None
@app.post("/_/api/structure", status_code=204)
@app.post("/_api/structure", status_code=204)
async def update_structure(op: StructureOp) -> None:
"""Apply one structure operation (see StructureOp)."""
path = op.path.strip("/")
@@ -279,7 +279,7 @@ async def update_structure(op: StructureOp) -> None:
data.version += 1
@app.get("/_/api/settings")
@app.get("/_api/settings")
async def get_settings() -> dict[str, str]:
"""Site-wide settings (the brand text)."""
return {"brand": data.brand}
@@ -291,7 +291,7 @@ class SettingsIn(BaseModel):
brand: str
@app.put("/_/api/settings", status_code=204)
@app.put("/_api/settings", status_code=204)
async def put_settings(settings: SettingsIn) -> None:
"""Update site-wide settings; bumps the version so ETags invalidate."""
with kanta.transaction("update settings"):
@@ -307,7 +307,7 @@ class ToggleTaskIn(BaseModel):
markdown: str | None = None
@app.post("/_/api/toggle-task")
@app.post("/_api/toggle-task")
async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
"""Toggle the Nth task-list checkbox in a page's Markdown source.
@@ -337,12 +337,12 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
return {"markdown": new_markdown}
@app.put("/_/api/files/{name}")
@app.put("/_api/files/{name}")
async def upload_file(name: str, request: Request) -> dict[str, str]:
"""Store an upload (image, video...) in the content-addressed store.
The stored name is a blake3 hash prefix + the original extension,
served immutable at "/_/f/{name}"; returns {"path": "/_/f/..."}.
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
"""
if "/" in name or name in {".", ".."}:
raise HTTPException(400, "bad file name")
@@ -351,10 +351,10 @@ async def upload_file(name: str, request: Request) -> dict[str, str]:
with kanta.transaction("upload file", extra=name):
data.files[stored] = body
data.version += 1
return {"path": f"/_/f/{stored}"}
return {"path": f"/_f/{stored}"}
@app.delete("/_/api/files/{name}", status_code=204)
@app.delete("/_api/files/{name}", status_code=204)
async def delete_file(name: str) -> None:
"""Remove a file from the content-addressed store (no refcounting:
other pages referencing the same content will 404)."""
@@ -365,7 +365,7 @@ async def delete_file(name: str) -> None:
data.version += 1
@app.get("/_/f/{name}")
@app.get("/_f/{name}")
async def stored_file(name: str, request: Request) -> Response:
"""Serve a file from the content-addressed store (immutable: the name
is its own hash, so cache forever)."""
@@ -382,7 +382,7 @@ async def stored_file(name: str, request: Request) -> Response:
)
@app.delete("/_/api/pages/{path:path}", status_code=204)
@app.delete("/_api/pages/{path:path}", status_code=204)
async def delete_page(path: str) -> None:
"""Delete a node by slug path.
@@ -426,7 +426,7 @@ def _check_reserved(path: str) -> None:
)
@app.websocket("/_/api/ws/editor")
@app.websocket("/_api/ws/editor")
async def editor_ws(ws: WebSocket) -> None:
"""Editor session: open pages, render previews, save — over one socket.
@@ -549,7 +549,7 @@ 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())
@@ -562,7 +562,7 @@ async def front_page(request: Request) -> Response:
# Vue build asset routes are inserted at this position during load(): the
# build mirrors the URL space (/_/assets/*, /favicon.ico at the root).
# build mirrors the URL space (/_assets/*, /favicon.ico at the root).
frontend.route(app, "/")
+1 -1
View File
@@ -70,7 +70,7 @@ class Data(msgspec.Struct):
#: Top-level menu items by slug; "" is the front page.
menu: dict[str, Node] = {}
#: Content-addressed file store: name (blake3 hash prefix + extension)
#: -> bytes, served immutable at "/_/{name}". Absolute URLs that stay
#: -> bytes, served immutable at "/_f/{name}". Absolute URLs that stay
#: valid when pages move.
files: dict[str, bytes] = {}
#: Bumped on every structure/content write, so page ETags (which embed
+1 -1
View File
@@ -27,7 +27,7 @@ from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
# Styles in /_/assets/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 -3
View File
@@ -53,12 +53,12 @@ site's layout or scroll effects.
## Images
Upload a file (`PUT /_/api/files/{filename}`) and it lands in the
content-addressed store, served immutable from `/_/f/{hash}.ext` — an
Upload a file (`PUT /_api/files/{filename}`) and it lands in the
content-addressed store, served immutable from `/_f/{hash}.ext` — an
absolute URL that survives page moves:
```
![Abstract shapes](/_/f/....svg "A captioned figure"){.right width=280}
![Abstract shapes](/_f/....svg "A captioned figure"){.right width=280}
```
![Abstract shapes](shapes.svg "A captioned figure, floated right with an attribute class"){.right width=280}
+1 -1
View File
@@ -22,7 +22,7 @@ from devutil import (
DEFAULT_VITE_PORT = 3100
DEFAULT_DEV_PORT = 3200
HEALTH = "/_/api/health?from=devserver.py"
HEALTH = "/_api/health?from=devserver.py"
async def run_devserver(