From 98a1dc8d0f66a35de87ffa79557c3ed86e98f995 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 2 Sep 2026 01:46:27 +0000 Subject: [PATCH] Rewire pages to chunk storage; translated-view open/save as patches --- pagerite/app.py | 143 +++++++++++++++++++++++++++++++++------------- pagerite/views.py | 36 ++++++------ 2 files changed, 122 insertions(+), 57 deletions(-) diff --git a/pagerite/app.py b/pagerite/app.py index 0e6af55..31155f0 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -49,11 +49,13 @@ from zstandard import ZstdCompressor from pagerite import analytics, i18n, seed, views from pagerite.__main__ import DEVMODE +from pagerite.chunks import store_chunks from pagerite.data import ( Data, Node, append_order, find_slot, + node_markdown, prettify, resolve, sorted_nodes, @@ -255,7 +257,7 @@ def _remove_page_content(menu: dict[str, Node], path: str) -> None: if node is None: return if node.children: - node.content = None + node.chunks = None node.modified = datetime.now(UTC) else: del slot[0][slot[1]] @@ -271,10 +273,10 @@ def _seed(data: Data) -> None: node = _ensure(data.menu, path) node.title = title # Empty markdown means a pure category label (e.g. "showcase", - # seeded only to carry a banner design): leave content as None so + # seeded only to carry a banner design): leave chunks as None so # the node renders the placeholder and nav points at its children. if markdown: - node.content = markdown + node.chunks = store_chunks(data.chunks, markdown) node.banner = banner node.banner_design = design node.order = order @@ -386,10 +388,10 @@ def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_ if kind == "page": # A selected language without an actual translation renders the # original (translation is None = English; see docs/localization.md). - translation = i18n.get_translation(path, lang) if lang != i18n.ORIGINAL_LANGUAGE else None - return views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation) + translation = i18n.get_translation(path, lang, data) if lang != i18n.ORIGINAL_LANGUAGE else None + return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation) if kind == "category": - return views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition) + return views.render_category(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition) if kind == "not-found": return views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition) return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition) @@ -494,7 +496,7 @@ async def list_pages() -> list[dict]: "title": node.title, "order": node.order, "published": node.published, - "has_content": node.content is not None, + "has_content": node.chunks is not None, "children": dump(node.children, path), }) return out @@ -503,7 +505,7 @@ async def list_pages() -> list[dict]: @app.put("/_api/pages/{path:path}", status_code=204) -async def save_page(path: str, page: PageIn) -> None: +async def save_page(path: str, page: PageIn, lang: str | None = None) -> None: """Create or replace the page at a slug path ("" or "/" = front page). Missing ancestors are created as content-less category labels. Giving @@ -511,13 +513,36 @@ async def save_page(path: str, page: PageIn) -> None: stripping) creates an empty page that renders with just its title — saving never deletes; use DELETE to remove a page (the page editor issues DELETE when you save empty text). + + With a ``?lang=`` query (a translation, not the primary language) the + save is a translated-view edit (docs/localization.md): the markdown is + diffed against the currently served hybrid and the minimal diff is + appended as a Patch under ``patches[f"{path}:{lang}"]`` — node.chunks + and the original-language fields (title, published, banner) stay + untouched. """ path = path.strip("/") _check_reserved(path) + lang = i18n.base_tag(lang or "") + if lang and lang != i18n.ORIGINAL_LANGUAGE: + chain = resolve(data.menu, path) + node = chain[-1] if chain else None + if node is None or node.chunks is None: + raise HTTPException(404, "no such page") + patch = i18n.make_patch( + i18n.hybrid_markdown(data, node, path, lang), page.markdown + ) + if patch.hunks: + with kanta.transaction("save translation", extra=path): + # Patches alone make the translated version exist. + data.patches.setdefault(f"{path}:{lang}", []).append(patch) + node.langs[lang] = True + _invalidate_pages() + return with kanta.transaction("save page", extra=path): node = _ensure(data.menu, path) node.title = page.title - node.content = page.markdown + node.chunks = store_chunks(data.chunks, page.markdown) node.published = page.published if page.banner is not None: node.banner = page.banner @@ -695,13 +720,15 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]: return {"markdown": new_markdown} chain = resolve(data.menu, path) node = chain[-1] if chain else None - if node is None or node.content is None: + if node is None or node.chunks is None: raise HTTPException(404, "no such page") - new_markdown = toggle_task(node.content, body.index) + new_markdown = toggle_task(node_markdown(data, node) or "", body.index) if new_markdown is None: raise HTTPException(400, "invalid task index") with kanta.transaction("toggle task", extra=path): - node.content = new_markdown + # Re-chunk like any save: only the chunk containing the toggled + # checkbox gets a new hash, the rest keep theirs. + node.chunks = store_chunks(data.chunks, new_markdown) node.modified = datetime.now(UTC) _invalidate_pages() return {"markdown": new_markdown} @@ -931,7 +958,7 @@ async def delete_page(path: str) -> None: raise HTTPException(404, "no such page") with kanta.transaction("delete page", extra=path): if node.children: - node.content = None + node.chunks = None node.modified = datetime.now(UTC) else: del slot[0][slot[1]] @@ -1254,15 +1281,21 @@ async def editor_ws(ws: WebSocket) -> None: """Editor session: open pages, render previews, save — over one socket. Stateless protocol (each message carries the path): - <- {"type": "open", "path"} + <- {"type": "open", "path", "lang"?} -> {"type": "doc", "path", "exists", "title", "markdown", "published", "banner", "banner_design"} <- {"type": "render", "path", "markdown"} -> {"type": "html", "path", "html"} <- {"type": "save", "path", "title"?, "markdown"?, "published"?, - "banner"?, "banner_design"?, "move_from"?} (absent fields keep - their old values; move_from: rename/move a page, subtree included) + "banner"?, "banner_design"?, "move_from"?, "lang"?} (absent fields + keep their old values; move_from: rename/move a page, subtree + included) -> {"type": "saved", "path"} | {"type": "error", "detail"} + + With "lang" (a translation, not the primary language), open returns the + served hybrid Markdown for that language and save stores a diff against + it as a user Patch — node.chunks and the other fields stay untouched + (docs/localization.md). """ await ws.accept() try: @@ -1278,12 +1311,20 @@ async def editor_ws(ws: WebSocket) -> None: case "open": chain = resolve(data.menu, path) node = chain[-1] if chain else None + markdown = "" + if node is not None: + markdown = node_markdown(data, node) or "" + # ?lang= view: the served hybrid, not the raw + # original (docs/localization.md editor flow). + lang = i18n.base_tag(str(msg.get("lang") or "")) + if lang and (t := i18n.get_translation(path, lang, data)) is not None: + markdown = t.markdown or markdown await ws.send_json({ "type": "doc", "path": path, "exists": node is not None, "title": node.title if node else "", - "markdown": node.content if node and node.content is not None else "", + "markdown": markdown, "published": node.published if node else True, "banner": node.banner if node else "", # Own banner design setting: null = inherit, @@ -1331,6 +1372,8 @@ async def editor_ws(ws: WebSocket) -> None: }) case "save": move_from = (msg.get("move_from") or path).strip("/") + lang = i18n.base_tag(str(msg.get("lang") or "")) + translated = bool(lang and lang != i18n.ORIGINAL_LANGUAGE) try: _check_reserved(move_from) except HTTPException: @@ -1364,6 +1407,11 @@ async def editor_ws(ws: WebSocket) -> None: "detail": "target path exists", }) continue + if translated and (move_from != path or old is None or old.chunks is None): + # A translated-view save patches an existing + # original; it cannot create or move pages. + await ws.send_json({"type": "error", "detail": "no such page"}) + continue with kanta.transaction("editor save", extra=path): if move_from != path: same_menu = ( @@ -1381,21 +1429,37 @@ async def editor_ws(ws: WebSocket) -> None: tnodes[tslug] = node else: node = old if old is not None else _ensure(data.menu, path) - if "markdown" in msg: - # Saving never deletes; empty markdown is an - # empty page. Deletion is an explicit choice by - # the page editor (REST DELETE). - node.content = msg["markdown"] - if "title" in msg: - node.title = msg["title"] - if "published" in msg: - node.published = bool(msg["published"]) - if "banner" in msg: - node.banner = msg["banner"] - if "banner_design" in msg: - node.banner_design = msg["banner_design"] - node.modified = datetime.now(UTC) - _invalidate_pages() + if translated: + # Diff against the currently served hybrid and + # append a Patch; node.chunks and the + # original-language fields stay untouched. + if "markdown" in msg: + patch = i18n.make_patch( + i18n.hybrid_markdown(data, node, path, lang), + msg["markdown"], + ) + if patch.hunks: + # Patches alone make the translated + # version exist. + data.patches.setdefault(f"{path}:{lang}", []).append(patch) + node.langs[lang] = True + _invalidate_pages() + else: + if "markdown" in msg: + # Saving never deletes; empty markdown is an + # empty page. Deletion is an explicit choice + # by the page editor (REST DELETE). + node.chunks = store_chunks(data.chunks, msg["markdown"]) + if "title" in msg: + node.title = msg["title"] + if "published" in msg: + node.published = bool(msg["published"]) + if "banner" in msg: + node.banner = msg["banner"] + if "banner_design" in msg: + node.banner_design = msg["banner_design"] + node.modified = datetime.now(UTC) + _invalidate_pages() await ws.send_json({"type": "saved", "path": path}) except WebSocketDisconnect: pass @@ -1420,7 +1484,7 @@ async def sitemap(request: Request) -> Response: ( slug for slug, node in sorted_nodes(nodes) - if node.published and node.content is not None + if node.published and node.chunks is not None ), None, ) @@ -1431,14 +1495,14 @@ async def sitemap(request: Request) -> Response: not parent_has_content and slug == first_content_slug and node.published - and node.content is not None + and node.chunks is not None and depth > 0 ): depth -= 1 - if node.published and node.content is not None: + if node.published and node.chunks is not None: entries.append((path, node.modified, depth)) if node.children: - walk(node.children, path, node.content is not None) + walk(node.children, path, node.chunks is not None) walk(data.menu, "") @@ -1514,15 +1578,14 @@ async def show_page(request: Request, path: str) -> Response: raise HTTPException(404) chain = resolve(data.menu, path) node = chain[-1] if chain else None - if node is not None and node.published and node.content is not None: + if node is not None and node.published and node.chunks is not None: # Language selection (docs/localization.md): ?lang= wins when a # translation exists, else header logic. Analytics keep the raw # Accept-Language header regardless of the selection. - languages = i18n.available_languages(path) lang = i18n.select_language( request.query_params.get("lang"), accept_language, - lambda l: l in languages, + lambda l: l in node.langs, ) # no-cache forbids serving a stored page without revalidation # (browsers would otherwise cache heuristically and serve stale @@ -1547,7 +1610,7 @@ async def show_page(request: Request, path: str) -> Response: }, lang=lang, ) - if node is not None and node.published and node.content is None: + if node is not None and node.published and node.chunks is None: # Category label without a landing page: placeholder with the pen # to create it (404 — no page here, but the node is real). if _is_trackable_path(path): diff --git a/pagerite/views.py b/pagerite/views.py index 6cb986c..d4f4af1 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -25,7 +25,7 @@ from html5tagger import HTML, Document, E, Template from platformdirs import site_data_dir, user_data_path from pagerite import i18n -from pagerite.data import Node, prettify, resolve, sorted_nodes +from pagerite.data import Data, Node, node_markdown, prettify, resolve, sorted_nodes from pagerite.i18n import Translation from pagerite.markdown import render @@ -478,7 +478,7 @@ def _nav_link( ancestors_current: bool = True, translation: Translation | None = None, ) -> None: """Render one
  • linking the node. Category labels (no content of - their own — None, or empty markdown as left by the site editor's + their own — chunks None, or an empty page as left by the site editor's page creation) link straight to their first child page, so normal navigation bypasses the placeholder/empty page at their own URL.""" # The navbar highlights a top-level item also when viewing any of its @@ -487,7 +487,7 @@ def _nav_link( ancestors_current and path and current.startswith(f"{path}/") ) href = f"/{path}" - if not node.content and (leaf := first_leaf(menu, path)) is not None: + if not node.chunks and (leaf := first_leaf(menu, path)) is not None: href = f"/{leaf}" doc.li.a( _title(path.rpartition("/")[2], node, translation, path), @@ -562,7 +562,7 @@ def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: st def first_leaf(menu: dict[str, Node], path: str) -> str | None: - """First published descendant page (content set) in menu order. + """First published descendant page (chunks set) in menu order. This is the nav-link target for content-less category labels. """ @@ -577,7 +577,7 @@ def _first_leaf(node: Node, path: str) -> str | None: if not child.published: continue cpath = f"{path}/{slug}" if path else slug - if child.content: + if child.chunks: return cpath if (leaf := _first_leaf(child, cpath)) is not None: return leaf @@ -690,7 +690,7 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None: return None -def page_content(menu: dict[str, Node], path: str, translation: Translation | None = None) -> HTML: +def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None) -> HTML: """Render the contents of the #main element for a page. A page with published children (a category page) lists them as cards @@ -699,7 +699,7 @@ def page_content(menu: dict[str, Node], path: str, translation: Translation | No title entries) fall back to the original. """ node = resolve(menu, path)[-1] - content = node.content or "" + content = node_markdown(data, node) or "" title = node.title if translation: if translation.markdown is not None: @@ -715,11 +715,11 @@ def page_content(menu: dict[str, Node], path: str, translation: Translation | No doc = E.article(class_="multicol") if rendered.multicol else E.article with doc: doc(HTML(rendered.html)) - _cards(doc, menu, node, path, translation) + _cards(doc, menu, data, node, path, translation) return HTML(str(doc)) -def _cards(doc, menu: dict[str, Node], node: Node, path: str, translation: Translation | None = None) -> None: +def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None) -> None: """Card stacks of the node's published children (nothing when childless). One column per direct child, all in a single full-width row (the .wide @@ -745,21 +745,21 @@ def _cards(doc, menu: dict[str, Node], node: Node, path: str, translation: Trans continue with doc.div(class_="stack"): for epath, enode in entries: - _card(doc, enode, epath, translation) + _card(doc, data, enode, epath, translation) def _walk(node: Node, path: str): """Published content pages of a subtree, pre-order in menu order: the node itself first when it has content (the stack's landing card), then its descendants (content-less nodes contribute only their subtree).""" - if node.content: + if node.chunks: yield path, node for slug, child in sorted_nodes(node.children): if child.published: yield from _walk(child, f"{path}/{slug}") -def _card(doc, node: Node, path: str, translation: Translation | None = None) -> None: +def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None) -> None: """One card in a stack: cover + title, plus the description when the page has no image (its card shows a gradient cover instead). @@ -767,8 +767,8 @@ def _card(doc, node: Node, path: str, translation: Translation | None = None) -> translated (only this page's own Markdown is translated); the title uses the translation's title map.""" image = description = "" - if node.content: - html = render(node.content, path, node.created, node.modified).html + if node.chunks: + html = render(node_markdown(data, node) or "", path, node.created, node.modified).html image, _ = _media(html) if not image: description = _description(html, 150) @@ -903,6 +903,7 @@ def _social_meta( def render_page( menu: dict[str, Node], + data: Data, path: str, brand: str = SITE_NAME, custom_css: str = "", @@ -923,13 +924,13 @@ def render_page( if translation is None: lang = i18n.ORIGINAL_LANGUAGE title = _title(path.rpartition("/")[2], node, translation, path) - main = page_content(menu, path, translation) + main = page_content(menu, data, path, translation) social = _social_meta(node, path, title, str(main), brand, base_url, lang) # hreflang alternates: every other language version (with ?lang=) plus # x-default for the plain URL. Emitted only when translations exist. alternates = [] if base_url: - available = i18n.available_languages(path) + available = i18n.available_languages(path, data) alternates = [ (l, f"{base_url}/{path}?lang={l}") for l in available if l != lang ] @@ -952,6 +953,7 @@ def render_page( def render_category( menu: dict[str, Node], + data: Data, path: str, brand: str = SITE_NAME, custom_css: str = "", @@ -973,7 +975,7 @@ def render_category( with doc: doc.h1(title) if any(c.published for c in node.children.values()): - _cards(doc, menu, node, path) + _cards(doc, menu, data, node, path) else: doc.p("This section has no page of its own yet.") return str(