pagerite 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. pagerite-0.1.0/.gitignore +14 -0
  2. pagerite-0.1.0/PKG-INFO +23 -0
  3. pagerite-0.1.0/README.md +5 -0
  4. pagerite-0.1.0/pagerite/__init__.py +1 -0
  5. pagerite-0.1.0/pagerite/__main__.py +33 -0
  6. pagerite-0.1.0/pagerite/app.py +590 -0
  7. pagerite-0.1.0/pagerite/data.py +140 -0
  8. pagerite-0.1.0/pagerite/frontend-build/.vite/manifest.json +129 -0
  9. pagerite-0.1.0/pagerite/frontend-build/_assets/cause-Dg7dLvEQ.woff2 +0 -0
  10. pagerite-0.1.0/pagerite/frontend-build/_assets/cormorant-D7itQ8IL.woff2 +0 -0
  11. pagerite-0.1.0/pagerite/frontend-build/_assets/cormorant-italic-BMf3v8-7.woff2 +0 -0
  12. pagerite-0.1.0/pagerite/frontend-build/_assets/exo2-CQ1lLIdm.woff2 +0 -0
  13. pagerite-0.1.0/pagerite/frontend-build/_assets/exo2-italic-9qdPE589.woff2 +0 -0
  14. pagerite-0.1.0/pagerite/frontend-build/_assets/firacode-CHoedHDv.woff2 +0 -0
  15. pagerite-0.1.0/pagerite/frontend-build/_assets/fraunces-DihXLNYH.woff2 +0 -0
  16. pagerite-0.1.0/pagerite/frontend-build/_assets/inter-Dx4kXJAl.woff2 +0 -0
  17. pagerite-0.1.0/pagerite/frontend-build/_assets/inter-italic-DpCbqKDY.woff2 +0 -0
  18. pagerite-0.1.0/pagerite/frontend-build/_assets/literata-Cedbco83.woff2 +0 -0
  19. pagerite-0.1.0/pagerite/frontend-build/_assets/main-DXObgT79.css +1 -0
  20. pagerite-0.1.0/pagerite/frontend-build/_assets/main-gPvSXR4j.js +59 -0
  21. pagerite-0.1.0/pagerite/frontend-build/_assets/montserrat-italic-jzfX-GOl.woff2 +0 -0
  22. pagerite-0.1.0/pagerite/frontend-build/_assets/montserrat-l_AIctKy.woff2 +0 -0
  23. pagerite-0.1.0/pagerite/frontend-build/_assets/newrocker-Dx1Qcjw-.woff2 +0 -0
  24. pagerite-0.1.0/pagerite/frontend-build/_assets/pagerite-CEGHoVEq.js +1 -0
  25. pagerite-0.1.0/pagerite/frontend-build/_assets/pagerite_base-jYEimsIY.css +1 -0
  26. pagerite-0.1.0/pagerite/frontend-build/_assets/playfairdisplay-BOwq7MWX.woff2 +0 -0
  27. pagerite-0.1.0/pagerite/frontend-build/_assets/playfairdisplay-italic-DmbndNpe.woff2 +0 -0
  28. pagerite-0.1.0/pagerite/frontend-build/_assets/sourcesans3-BqRLTx4X.woff2 +0 -0
  29. pagerite-0.1.0/pagerite/frontend-build/_assets/sourcesans3-italic-Bg-PIjcQ.woff2 +0 -0
  30. pagerite-0.1.0/pagerite/frontend-build/_assets/sourceserif4-D9elroTD.woff2 +0 -0
  31. pagerite-0.1.0/pagerite/frontend-build/_assets/sourceserif4-italic-D2yaqPoE.woff2 +0 -0
  32. pagerite-0.1.0/pagerite/frontend-build/_assets/theme_corporate-L-3IfWKa.css +1 -0
  33. pagerite-0.1.0/pagerite/frontend-build/_assets/theme_nitro-Kdjb3_Wk.css +1 -0
  34. pagerite-0.1.0/pagerite/frontend-build/_assets/theme_purple-DaYq2NRj.css +1 -0
  35. pagerite-0.1.0/pagerite/frontend-build/favicon.ico +0 -0
  36. pagerite-0.1.0/pagerite/markdown.py +176 -0
  37. pagerite-0.1.0/pagerite/seed.py +449 -0
  38. pagerite-0.1.0/pagerite/views.py +404 -0
  39. pagerite-0.1.0/pyproject.toml +51 -0
@@ -0,0 +1,14 @@
1
+ .*
2
+ !.gitignore
3
+ *.lock
4
+ *.kantadb
5
+ /pagerite/frontend-build
6
+ package-lock.json
7
+
8
+ # Python-generated files
9
+ __pycache__/
10
+ *.py[oc]
11
+ build/
12
+ dist/
13
+ wheels/
14
+ *.egg-info
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: pagerite
3
+ Version: 0.1.0
4
+ Summary: A modern website and Content Management System.
5
+ Project-URL: Repository, https://git.zi.fi/LeoVasanko/pagerite
6
+ Author: Leo Vasanko
7
+ Keywords: blog,cms,fastapi,markdown
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: blake3>=1.0.9
10
+ Requires-Dist: fastapi-vue>=1.3.1
11
+ Requires-Dist: fastapi[standard]>=0.141.1
12
+ Requires-Dist: html5tagger>=2.0.0
13
+ Requires-Dist: kanta>=0.8.1
14
+ Requires-Dist: markdown-it-py>=4.2.0
15
+ Requires-Dist: mdit-py-plugins>=0.6.1
16
+ Requires-Dist: pygments>=2.20.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Pagerite
20
+
21
+ A single-user CMS/blog. FastAPI serves HTML rendered in Python with html5tagger,
22
+ content is persisted in a kanta database and rendered on the fly per request.
23
+ Vue is used only for the interactive editing tools, not for the public pages.
@@ -0,0 +1,5 @@
1
+ # Pagerite
2
+
3
+ A single-user CMS/blog. FastAPI serves HTML rendered in Python with html5tagger,
4
+ content is persisted in a kanta database and rendered on the fly per request.
5
+ Vue is used only for the interactive editing tools, not for the public pages.
@@ -0,0 +1 @@
1
+ """Backend package with FastAPI application and Vue frontend integration."""
@@ -0,0 +1,33 @@
1
+ # auto-upgrade@fastapi-vue-setup - remove this if you modify this file
2
+ """Command-line entry point for running the backend server."""
3
+
4
+ import argparse
5
+ import os
6
+
7
+ from fastapi_vue import server
8
+
9
+ DEFAULT_PORT = 3100
10
+ DEVMODE = os.getenv("PAGERITE_DEV") == "1"
11
+
12
+
13
+ def main() -> None:
14
+ """Run the backend server with optional arguments."""
15
+ parser = argparse.ArgumentParser(description="Run the pagerite server.")
16
+ parser.add_argument(
17
+ "-l",
18
+ "--listen",
19
+ action="append",
20
+ help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
21
+ )
22
+ args = parser.parse_args()
23
+ dev = {"reload": True, "reload_dirs": ["pagerite"]} if DEVMODE else {}
24
+ server.run(
25
+ "pagerite.app:app",
26
+ listen=args.listen,
27
+ default_port=DEFAULT_PORT,
28
+ **dev,
29
+ )
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,590 @@
1
+ """FastAPI application: server-rendered content pages plus Vue assets.
2
+
3
+ Route ordering matters: our routes are defined before
4
+ ``frontend.route(app, "/")`` is called, so they take priority over
5
+ the asset routes that fastapi-vue inserts at that position during ``load()``.
6
+ The content catch-all (``/{path:path}``) is defined last, so built
7
+ frontend assets still win over content slugs; anything unmatched falls
8
+ through to content (and 404 if no page exists there).
9
+
10
+ The site structure is a tree of Nodes (see data.py); URL paths resolve by
11
+ walking the tree (``resolve``), moves are slot detach/attach
12
+ (``find_slot``) with a fresh order key from the new siblings.
13
+ """
14
+
15
+ import mimetypes
16
+ import os
17
+ import re
18
+ from collections.abc import AsyncIterator
19
+ from contextlib import asynccontextmanager
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ import blake3
24
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
25
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
26
+ from fastapi_vue import Frontend
27
+ from kanta import Kanta
28
+ from pydantic import BaseModel
29
+
30
+ from pagerite import seed, views
31
+ from pagerite.__main__ import DEVMODE
32
+ from pagerite.data import (
33
+ Data,
34
+ Node,
35
+ append_order,
36
+ find_slot,
37
+ prettify,
38
+ resolve,
39
+ sorted_nodes,
40
+ )
41
+ from pagerite.markdown import has_h1, render, toggle_task
42
+
43
+ DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kantadb")
44
+
45
+ # Our own data root; kanta edits it in place, reads are plain attribute access.
46
+ data = Data()
47
+ kanta = Kanta(DB_PATH, data)
48
+
49
+ # Vue build served at the site root, no SPA catch-all (assets only). The
50
+ # build mirrors the URL space: hashed, immutable files live under
51
+ # /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
52
+ BUILD_DIR = Path(__file__).with_name("frontend-build")
53
+ frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/")
54
+
55
+
56
+ def _hash_name(body: bytes, orig: str) -> str:
57
+ """Content-addressed file name: blake3 hash prefix + original extension."""
58
+ ext = "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
59
+ return blake3.blake3(body).hexdigest()[:12] + ext
60
+
61
+
62
+ def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
63
+ """Store a seed file content-addressed and point references at /_f/."""
64
+ name = _hash_name(body, orig)
65
+ data.files.setdefault(name, body)
66
+ markdown = markdown.replace(f"]({orig}", f"](/_f/{name}")
67
+ banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"')
68
+ banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"')
69
+ return markdown, banner
70
+
71
+
72
+ def _ensure(menu: dict[str, Node], path: str) -> Node:
73
+ """Return the node at ``path``, creating it and any missing ancestors
74
+ (content-less category labels) appended at the end of their level."""
75
+ nodes = menu
76
+ node = None
77
+ for seg in path.split("/"):
78
+ node = nodes.get(seg)
79
+ if node is None:
80
+ node = Node(title=prettify(seg), order=append_order(nodes))
81
+ nodes[seg] = node
82
+ nodes = node.children
83
+ return node
84
+
85
+
86
+ def _remove_page_content(menu: dict[str, Node], path: str) -> None:
87
+ """Delete a page's markdown content.
88
+
89
+ A node with children becomes a content-less category label; a childless
90
+ node is removed entirely. Does nothing if the path does not exist.
91
+ """
92
+ slot = find_slot(menu, path)
93
+ if slot is None:
94
+ return
95
+ node = slot[0].get(slot[1])
96
+ if node is None:
97
+ return
98
+ if node.children:
99
+ node.content = None
100
+ node.modified = datetime.now(UTC)
101
+ else:
102
+ del slot[0][slot[1]]
103
+
104
+
105
+ def _migrate_legacy() -> None:
106
+ """Rebuild the legacy flat page store as a tree (one-time migration)."""
107
+ if not data.pages:
108
+ return
109
+ with kanta.transaction("migrate pages to tree"):
110
+ for path, page in data.pages.items():
111
+ node = _ensure(data.menu, path)
112
+ node.title = page.title
113
+ node.content = page.markdown
114
+ node.banner = page.banner
115
+ node.published = page.published
116
+ node.order = page.order
117
+ node.created = page.created
118
+ node.modified = page.modified
119
+ data.pages.clear()
120
+ data.version += 1
121
+
122
+
123
+ @asynccontextmanager
124
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
125
+ """Open the database, migrate/seed content, load assets."""
126
+ await kanta.open()
127
+ _migrate_legacy()
128
+ missing = [p for p in seed.PAGES if resolve(data.menu, p) is None]
129
+ if missing:
130
+ with kanta.transaction("seed missing pages"):
131
+ for path in missing:
132
+ title, markdown, files, banner, order = seed.PAGES[path]
133
+ for orig, body in files.items():
134
+ markdown, banner = _store_seed_file(markdown, banner, orig, body)
135
+ node = _ensure(data.menu, path)
136
+ node.title = title
137
+ node.content = markdown
138
+ node.banner = banner
139
+ node.order = order
140
+ await frontend.load()
141
+ yield
142
+ await kanta.close()
143
+
144
+
145
+ # docs_url/openapi_url disabled: /docs belongs to our content, and the API
146
+ # is not meant to be browsable by the public anyway.
147
+ app = FastAPI(
148
+ title="Pagerite",
149
+ debug=DEVMODE,
150
+ lifespan=lifespan,
151
+ docs_url=None,
152
+ redoc_url=None,
153
+ openapi_url=None,
154
+ )
155
+
156
+
157
+ class PageIn(BaseModel):
158
+ """Payload for creating or replacing a page."""
159
+
160
+ title: str
161
+ markdown: str
162
+ published: bool = True
163
+ banner: str | None = None # None keeps the existing banner
164
+
165
+
166
+ @app.get("/_api/pages")
167
+ async def list_pages() -> list[dict]:
168
+ """The site tree for the structure editor (all nodes, drafts included).
169
+
170
+ Nested by slug; each node carries its full path, menu order and flags.
171
+ """
172
+
173
+ def dump(nodes: dict[str, Node], prefix: str) -> list[dict]:
174
+ out = []
175
+ for slug, node in sorted_nodes(nodes):
176
+ path = f"{prefix}/{slug}" if prefix else slug
177
+ out.append({
178
+ "slug": slug,
179
+ "path": path,
180
+ "title": node.title,
181
+ "order": node.order,
182
+ "published": node.published,
183
+ "has_content": node.content is not None,
184
+ "children": dump(node.children, path),
185
+ })
186
+ return out
187
+
188
+ return dump(data.menu, "")
189
+
190
+
191
+ @app.put("/_api/pages/{path:path}", status_code=204)
192
+ async def save_page(path: str, page: PageIn) -> None:
193
+ """Create or replace the page at a slug path ("" or "/" = front page).
194
+
195
+ Missing ancestors are created as content-less category labels. Giving
196
+ a category markdown turns it into a landing page. Empty markdown (after
197
+ stripping) creates an empty page that renders with just its title —
198
+ saving never deletes; use DELETE to remove a page (the page editor
199
+ issues DELETE when you save empty text).
200
+ """
201
+ path = path.strip("/")
202
+ _check_reserved(path)
203
+ with kanta.transaction("save page", extra=path):
204
+ node = _ensure(data.menu, path)
205
+ node.title = page.title
206
+ node.content = page.markdown
207
+ node.published = page.published
208
+ if page.banner is not None:
209
+ node.banner = page.banner
210
+ node.modified = datetime.now(UTC)
211
+ data.version += 1
212
+
213
+
214
+ class StructureOp(BaseModel):
215
+ """Rearrange the site tree: reorder, move/rename or retitle a node.
216
+
217
+ `order` is a fresh fractional key computed client-side from the node's
218
+ new siblings (a value halfway between them); all other items keep
219
+ theirs. `move_to` is the full target path — the parent must exist and
220
+ the new slug be free. Moves carry the whole subtree. The front page is
221
+ just the top-level node with slug "": renaming it away leaves no front
222
+ page ("/" then redirects to the first nav item), and any childless
223
+ top-level node can take the empty slug to become the front page.
224
+ """
225
+
226
+ path: str
227
+ order: float | None = None
228
+ move_to: str | None = None
229
+ title: str | None = None
230
+
231
+
232
+ @app.post("/_api/structure", status_code=204)
233
+ async def update_structure(op: StructureOp) -> None:
234
+ """Apply one structure operation (see StructureOp)."""
235
+ path = op.path.strip("/")
236
+ chain = resolve(data.menu, path)
237
+ if chain is None:
238
+ raise HTTPException(404, "no such page")
239
+ node = chain[-1]
240
+ target = op.move_to.strip("/") if op.move_to is not None else None
241
+ if target is not None and target != path:
242
+ _check_reserved(target)
243
+ if path and target.startswith(f"{path}/"):
244
+ raise HTTPException(400, "cannot move a page under itself")
245
+ slot = find_slot(data.menu, target)
246
+ if slot is None:
247
+ raise HTTPException(404, "target parent does not exist")
248
+ tnodes, tslug = slot
249
+ if tslug in tnodes:
250
+ raise HTTPException(400, "target path exists")
251
+ if not tslug and node.children:
252
+ raise HTTPException(400, "the front page cannot have children")
253
+ with kanta.transaction("update structure", extra=path):
254
+ if op.title is not None:
255
+ node.title = op.title
256
+ if target is not None and target != path:
257
+ snodes, sslug = find_slot(data.menu, path)
258
+ del snodes[sslug]
259
+ # A pure rename (same parent) keeps its position; only a move
260
+ # to another level appends at the end (unless an order came
261
+ # with the drop).
262
+ same_level = path.rpartition("/")[0] == target.rpartition("/")[0]
263
+ node.order = (
264
+ op.order
265
+ if op.order is not None
266
+ else node.order if same_level else append_order(tnodes)
267
+ )
268
+ tnodes[tslug] = node
269
+ elif op.order is not None:
270
+ node.order = op.order
271
+ node.modified = datetime.now(UTC)
272
+ data.version += 1
273
+
274
+
275
+ @app.get("/_api/settings")
276
+ async def get_settings() -> dict[str, str]:
277
+ """Site-wide settings (brand, theme and custom CSS)."""
278
+ return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css}
279
+
280
+
281
+ class SettingsIn(BaseModel):
282
+ """Payload for updating site-wide settings."""
283
+
284
+ brand: str
285
+ theme: str
286
+ custom_css: str
287
+
288
+
289
+ @app.put("/_api/settings", status_code=204)
290
+ async def put_settings(settings: SettingsIn) -> None:
291
+ """Update site-wide settings; bumps the version so ETags invalidate."""
292
+ with kanta.transaction("update settings"):
293
+ data.brand = settings.brand
294
+ data.theme = settings.theme
295
+ data.custom_css = settings.custom_css
296
+ data.version += 1
297
+
298
+
299
+ class ToggleTaskIn(BaseModel):
300
+ """Payload for toggling one task-list checkbox."""
301
+
302
+ path: str
303
+ index: int
304
+ markdown: str | None = None
305
+
306
+
307
+ @app.post("/_api/toggle-task")
308
+ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
309
+ """Toggle the Nth task-list checkbox in a page's Markdown source.
310
+
311
+ If ``markdown`` is provided the source is left untouched and the toggled
312
+ Markdown is returned (used while the page editor is open, so the live
313
+ CodeMirror document can be updated). Otherwise the stored page at
314
+ ``path`` is read, toggled, and saved.
315
+ """
316
+ path = body.path.strip("/")
317
+ _check_reserved(path)
318
+ if body.markdown is not None:
319
+ new_markdown = toggle_task(body.markdown, body.index)
320
+ if new_markdown is None:
321
+ raise HTTPException(400, "invalid task index")
322
+ return {"markdown": new_markdown}
323
+ chain = resolve(data.menu, path)
324
+ node = chain[-1] if chain else None
325
+ if node is None or node.content is None:
326
+ raise HTTPException(404, "no such page")
327
+ new_markdown = toggle_task(node.content, body.index)
328
+ if new_markdown is None:
329
+ raise HTTPException(400, "invalid task index")
330
+ with kanta.transaction("toggle task", extra=path):
331
+ node.content = new_markdown
332
+ node.modified = datetime.now(UTC)
333
+ data.version += 1
334
+ return {"markdown": new_markdown}
335
+
336
+
337
+ @app.put("/_api/files/{name}")
338
+ async def upload_file(name: str, request: Request) -> dict[str, str]:
339
+ """Store an upload (image, video...) in the content-addressed store.
340
+
341
+ The stored name is a blake3 hash prefix + the original extension,
342
+ served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
343
+ """
344
+ if "/" in name or name in {".", ".."}:
345
+ raise HTTPException(400, "bad file name")
346
+ body = await request.body()
347
+ stored = _hash_name(body, name)
348
+ with kanta.transaction("upload file", extra=name):
349
+ data.files[stored] = body
350
+ data.version += 1
351
+ return {"path": f"/_f/{stored}"}
352
+
353
+
354
+ @app.delete("/_api/files/{name}", status_code=204)
355
+ async def delete_file(name: str) -> None:
356
+ """Remove a file from the content-addressed store (no refcounting:
357
+ other pages referencing the same content will 404)."""
358
+ if name not in data.files:
359
+ raise HTTPException(404, "no such file")
360
+ with kanta.transaction("delete file", extra=name):
361
+ del data.files[name]
362
+ data.version += 1
363
+
364
+
365
+ @app.get("/_f/{name}")
366
+ async def stored_file(name: str, request: Request) -> Response:
367
+ """Serve a file from the content-addressed store (immutable: the name
368
+ is its own hash, so cache forever)."""
369
+ body = data.files.get(name)
370
+ if body is None:
371
+ raise HTTPException(404)
372
+ if request.headers.get("if-none-match") == name:
373
+ return Response(status_code=304)
374
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
375
+ return Response(
376
+ body,
377
+ media_type=mime,
378
+ headers={"etag": name, "cache-control": "public, max-age=31536000, immutable"},
379
+ )
380
+
381
+
382
+ @app.delete("/_api/pages/{path:path}", status_code=204)
383
+ async def delete_page(path: str) -> None:
384
+ """Delete a node by slug path.
385
+
386
+ A category (node with children) loses only its landing page and stays
387
+ as a content-less label; a childless node is removed entirely.
388
+ """
389
+ path = path.strip("/")
390
+ _check_reserved(path)
391
+ slot = find_slot(data.menu, path)
392
+ node = slot[0].get(slot[1]) if slot else None
393
+ if node is None:
394
+ raise HTTPException(404, "no such page")
395
+ with kanta.transaction("delete page", extra=path):
396
+ if node.children:
397
+ node.content = None
398
+ node.modified = datetime.now(UTC)
399
+ else:
400
+ del slot[0][slot[1]]
401
+ data.version += 1
402
+
403
+
404
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
405
+
406
+
407
+ def _is_reserved(path: str) -> bool:
408
+ """Slug shape that content may never use: each segment must be lower-case
409
+ ASCII letters, digits, hyphens and underscores (underscores may not be
410
+ the first character), and dots are never allowed.
411
+ """
412
+ if path == "":
413
+ return False
414
+ return any(not _SLUG_RE.match(seg) for seg in path.split("/"))
415
+
416
+
417
+ def _check_reserved(path: str) -> None:
418
+ """Reject paths that do not follow the slug charset."""
419
+ if _is_reserved(path):
420
+ raise HTTPException(
421
+ 400,
422
+ 'slugs may only use a-z, 0-9, "-" and "_" (not as the first character), and no dots',
423
+ )
424
+
425
+
426
+ @app.websocket("/_api/ws/editor")
427
+ async def editor_ws(ws: WebSocket) -> None:
428
+ """Editor session: open pages, render previews, save — over one socket.
429
+
430
+ Stateless protocol (each message carries the path):
431
+ <- {"type": "open", "path"}
432
+ -> {"type": "doc", "path", "exists", "title", "markdown", "published",
433
+ "banner"}
434
+ <- {"type": "render", "path", "markdown"}
435
+ -> {"type": "html", "path", "html"}
436
+ <- {"type": "save", "path", "title"?, "markdown"?, "published"?,
437
+ "banner"?, "move_from"?} (absent fields keep their old values;
438
+ move_from: rename/move a page, subtree included)
439
+ -> {"type": "saved", "path"} | {"type": "error", "detail"}
440
+ """
441
+ await ws.accept()
442
+ try:
443
+ while True:
444
+ msg = await ws.receive_json()
445
+ path = msg.get("path", "").strip("/")
446
+ try:
447
+ _check_reserved(path)
448
+ except HTTPException:
449
+ await ws.send_json({"type": "error", "detail": "reserved path"})
450
+ continue
451
+ match msg.get("type"):
452
+ case "open":
453
+ chain = resolve(data.menu, path)
454
+ node = chain[-1] if chain else None
455
+ await ws.send_json({
456
+ "type": "doc",
457
+ "path": path,
458
+ "exists": node is not None,
459
+ "title": node.title if node else "",
460
+ "markdown": node.content if node and node.content is not None else "",
461
+ "published": node.published if node else True,
462
+ "banner": node.banner if node else "",
463
+ # Which node's banner applies here ("" = front page,
464
+ # null = default artwork); the site editor shows it
465
+ # as the banner field's placeholder.
466
+ "banner_from": views.banner_source(data.menu, path),
467
+ })
468
+ case "render":
469
+ markdown = msg.get("markdown", "")
470
+ await ws.send_json({
471
+ "type": "html",
472
+ "path": path,
473
+ "html": render(markdown, path),
474
+ "has_h1": has_h1(markdown),
475
+ })
476
+ case "save":
477
+ move_from = (msg.get("move_from") or path).strip("/")
478
+ try:
479
+ _check_reserved(move_from)
480
+ except HTTPException:
481
+ await ws.send_json({"type": "error", "detail": "reserved path"})
482
+ continue
483
+ old_chain = resolve(data.menu, move_from)
484
+ old = old_chain[-1] if old_chain else None
485
+ if old is None and move_from != path:
486
+ move_from = path # nothing to carry over; plain save
487
+ if move_from != path:
488
+ # Rename/move: detach the node (subtree included)
489
+ # and attach it at the new path. The target slug
490
+ # must be free and the front page childless.
491
+ if move_from and path.startswith(f"{move_from}/"):
492
+ await ws.send_json({
493
+ "type": "error",
494
+ "detail": "cannot move a page under itself",
495
+ })
496
+ continue
497
+ tslug = path.rpartition("/")[2]
498
+ if not tslug and old.children:
499
+ await ws.send_json({
500
+ "type": "error",
501
+ "detail": "the front page cannot have children",
502
+ })
503
+ continue
504
+ tchain = resolve(data.menu, path)
505
+ if tchain is not None:
506
+ await ws.send_json({
507
+ "type": "error",
508
+ "detail": "target path exists",
509
+ })
510
+ continue
511
+ with kanta.transaction("editor save", extra=path):
512
+ if move_from != path:
513
+ same_menu = (
514
+ move_from.rpartition("/")[0] == path.rpartition("/")[0]
515
+ )
516
+ snodes, sslug = find_slot(data.menu, move_from)
517
+ node = snodes.pop(sslug)
518
+ parent = path.rpartition("/")[0]
519
+ if parent:
520
+ _ensure(data.menu, parent)
521
+ tnodes, tslug = find_slot(data.menu, path)
522
+ node.order = (
523
+ node.order if same_menu else append_order(tnodes)
524
+ )
525
+ tnodes[tslug] = node
526
+ else:
527
+ node = old if old is not None else _ensure(data.menu, path)
528
+ if "markdown" in msg:
529
+ # Saving never deletes; empty markdown is an
530
+ # empty page. Deletion is an explicit choice by
531
+ # the page editor (REST DELETE).
532
+ node.content = msg["markdown"]
533
+ if "title" in msg:
534
+ node.title = msg["title"]
535
+ if "published" in msg:
536
+ node.published = bool(msg["published"])
537
+ if "banner" in msg:
538
+ node.banner = msg["banner"]
539
+ node.modified = datetime.now(UTC)
540
+ data.version += 1
541
+ await ws.send_json({"type": "saved", "path": path})
542
+ except WebSocketDisconnect:
543
+ pass
544
+
545
+
546
+ @app.get("/")
547
+ async def front_page(request: Request) -> Response:
548
+ """Render the front page (slug path "")."""
549
+ return await show_page(request, "")
550
+
551
+
552
+ # Vue build asset routes are inserted at this position during load(): the
553
+ # build mirrors the URL space (/_assets/*, /favicon.ico at the root).
554
+ frontend.route(app, "/")
555
+
556
+
557
+ @app.get("/{path:path}", response_model=None)
558
+ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
559
+ """Render the content page at a slug path, or 404.
560
+
561
+ A node without content is a category label: its URL renders a
562
+ placeholder page (nav links point straight at its first child).
563
+ """
564
+ path = path.strip("/")
565
+ if path and _is_reserved(path):
566
+ # Reserved slug shape: never content — no tree lookup.
567
+ return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404)
568
+ chain = resolve(data.menu, path)
569
+ node = chain[-1] if chain else None
570
+ if node is not None and node.published and node.content is not None:
571
+ # ETag on content + render version; clients revalidate cheaply,
572
+ # which keeps prefetched pages warm and current.
573
+ etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
574
+ if request.headers.get("if-none-match") == etag:
575
+ return Response(status_code=304)
576
+ return HTMLResponse(
577
+ views.render_page(data.menu, path, data.brand, data.custom_css, data.theme),
578
+ headers={"etag": etag},
579
+ )
580
+ if node is not None and node.published and node.content is None:
581
+ # Category label without a landing page: placeholder with the pen
582
+ # to create it (404 — no page here, but the node is real).
583
+ return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme), 404)
584
+ if node is None and not path:
585
+ # No front page (no top-level node with slug ""): "/" opens the
586
+ # first item of the navigation instead.
587
+ for slug, item in sorted_nodes(data.menu):
588
+ if item.published:
589
+ return RedirectResponse(f"/{slug}")
590
+ return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404)