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.
- pagerite-0.1.0/.gitignore +14 -0
- pagerite-0.1.0/PKG-INFO +23 -0
- pagerite-0.1.0/README.md +5 -0
- pagerite-0.1.0/pagerite/__init__.py +1 -0
- pagerite-0.1.0/pagerite/__main__.py +33 -0
- pagerite-0.1.0/pagerite/app.py +590 -0
- pagerite-0.1.0/pagerite/data.py +140 -0
- pagerite-0.1.0/pagerite/frontend-build/.vite/manifest.json +129 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/cause-Dg7dLvEQ.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/cormorant-D7itQ8IL.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/cormorant-italic-BMf3v8-7.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/exo2-CQ1lLIdm.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/exo2-italic-9qdPE589.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/firacode-CHoedHDv.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/fraunces-DihXLNYH.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/inter-Dx4kXJAl.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/inter-italic-DpCbqKDY.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/literata-Cedbco83.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/main-DXObgT79.css +1 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/main-gPvSXR4j.js +59 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/montserrat-italic-jzfX-GOl.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/montserrat-l_AIctKy.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/newrocker-Dx1Qcjw-.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/pagerite-CEGHoVEq.js +1 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/pagerite_base-jYEimsIY.css +1 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/playfairdisplay-BOwq7MWX.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/playfairdisplay-italic-DmbndNpe.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/sourcesans3-BqRLTx4X.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/sourcesans3-italic-Bg-PIjcQ.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/sourceserif4-D9elroTD.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/sourceserif4-italic-D2yaqPoE.woff2 +0 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/theme_corporate-L-3IfWKa.css +1 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/theme_nitro-Kdjb3_Wk.css +1 -0
- pagerite-0.1.0/pagerite/frontend-build/_assets/theme_purple-DaYq2NRj.css +1 -0
- pagerite-0.1.0/pagerite/frontend-build/favicon.ico +0 -0
- pagerite-0.1.0/pagerite/markdown.py +176 -0
- pagerite-0.1.0/pagerite/seed.py +449 -0
- pagerite-0.1.0/pagerite/views.py +404 -0
- pagerite-0.1.0/pyproject.toml +51 -0
pagerite-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
pagerite-0.1.0/README.md
ADDED
|
@@ -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)
|