Rewire pages to chunk storage; translated-view open/save as patches
This commit is contained in:
+103
-40
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user