Rewire pages to chunk storage; translated-view open/save as patches

This commit is contained in:
2026-09-02 01:46:27 +00:00
parent 6e9f73f83a
commit 98a1dc8d0f
2 changed files with 122 additions and 57 deletions
+103 -40
View File
@@ -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):
+19 -17
View File
@@ -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 <li> 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(