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:
+3
-1
@@ -10,4 +10,6 @@ wheels/
|
||||
.venv
|
||||
.ruff_cache/
|
||||
/pagerite/frontend-build
|
||||
pagerite.kanta
|
||||
|
||||
# Site
|
||||
*.kantadb
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
`{.right}` — `{.right}`, `{.left}` float,
|
||||
`{.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 `` at the cursor.
|
||||
input: they upload to the content store (`PUT /_api/files/...`) and
|
||||
insert `` 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}`.
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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(/\/$/, ''),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
```
|
||||
{.right width=280}
|
||||
{.right width=280}
|
||||
```
|
||||
|
||||
{.right width=280}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user