Localization (#1)
Implement comprehensive content localization, admin panels for editing each language, AI translation interface with automatic updates when base language version is changed. - SEO tags for all language URLs - Uses accept-language by default, ?lang=en overrides temporarily - User edits patched on top of translations - RTL language supportReviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
DEFAULT_PORT = 8100
|
||||
DEVMODE = os.getenv("PAGERITE_DEV") == "1"
|
||||
@@ -96,6 +97,12 @@ def main() -> None:
|
||||
# Export the hostname before pagerite.app is imported: it derives the
|
||||
# data directory and public origin from it at import time.
|
||||
os.environ["PAGERITE_HOSTNAME"] = args.hostname
|
||||
# And the listen port: the app prints the translator WS URL at startup,
|
||||
# which for localhost includes the actual port.
|
||||
for endpoint in parse_endpoints(args.listen, DEFAULT_PORT):
|
||||
if "port" in endpoint:
|
||||
os.environ["PAGERITE_PORT"] = str(endpoint["port"])
|
||||
break
|
||||
if args.dbip:
|
||||
_download_dbip()
|
||||
server.run(
|
||||
|
||||
+323
-59
@@ -15,9 +15,11 @@ walking the tree (``resolve``), moves are slot detach/attach
|
||||
import asyncio
|
||||
import gzip
|
||||
import ipaddress
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
@@ -47,19 +49,23 @@ from mediapreview import dispatch
|
||||
from pydantic import BaseModel
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from pagerite import analytics, seed, views
|
||||
from pagerite import analytics, i18n, seed, translate, 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,
|
||||
)
|
||||
from pagerite.markdown import render, toggle_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Site identity: the hostname comes from the CLI (first positional argument,
|
||||
# exported as PAGERITE_HOSTNAME) and names the per-site data directory
|
||||
# ``<hostname>/{content.kantadb, analytics.json, files}`` under the cwd.
|
||||
@@ -255,7 +261,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,19 +277,38 @@ 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
|
||||
|
||||
|
||||
#: Translator key format: 12 lowercase alphanumeric characters — not
|
||||
#: brute-forceable over a WebSocket handshake, still human-manageable.
|
||||
_KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
|
||||
@kanta.bootstrap
|
||||
def _translator_defaults(data: Data) -> None:
|
||||
"""Translator defaults on database creation: the first service key and
|
||||
the wanted target languages (Spanish and Chinese — English is the
|
||||
original language, never a translation target).
|
||||
|
||||
Keys are a dict (key -> display name) with the future reservation that
|
||||
multiple keys could be managed (e.g. via a web interface)."""
|
||||
key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12))
|
||||
data.translate_keys[key] = "default"
|
||||
data.translate_langs = {"es": True, "zh": True}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Open the database (migrations run inside kanta.open), load assets, load GeoIP."""
|
||||
await kanta.open()
|
||||
translate.log_service_urls(data.translate_keys, HOSTNAME)
|
||||
await asyncio.to_thread(file_store.load)
|
||||
await frontend.load()
|
||||
# Decompress/open the DB-IP MMDB once at startup. Lookups are then
|
||||
@@ -381,12 +406,24 @@ class FileStore:
|
||||
file_store = FileStore(FILES_DIR)
|
||||
|
||||
|
||||
def _render_html(kind: str, path: str, base_url: str) -> str:
|
||||
def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_LANGUAGE, link_lang: str = "") -> str:
|
||||
"""Render one of the generated pages (see _html_response)."""
|
||||
if kind == "page":
|
||||
return views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition)
|
||||
# A selected language without an actual translation renders the
|
||||
# original (translation is None; see docs/localization.md).
|
||||
original = i18n.primary_lang(data.menu, path)
|
||||
translation = i18n.get_translation(data, path, lang) if lang != original 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, link_lang=link_lang)
|
||||
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)
|
||||
# A category has no Markdown of its own; only the title map
|
||||
# localizes (heading, navigation, card text).
|
||||
original = i18n.primary_lang(data.menu, path)
|
||||
translation = (
|
||||
i18n.Translation(titles=i18n.title_map(data, lang))
|
||||
if lang != original
|
||||
else None
|
||||
)
|
||||
return views.render_category(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
|
||||
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)
|
||||
@@ -399,20 +436,26 @@ _render_gen = 0
|
||||
|
||||
|
||||
def _invalidate_pages() -> None:
|
||||
"""Drop cached page bodies and bump the render generation (ETags)."""
|
||||
"""Drop cached page bodies and bump the render generation (ETags);
|
||||
any content change also re-runs translation dispatch."""
|
||||
global _render_gen
|
||||
_render_gen += 1
|
||||
_cached_body.cache_clear()
|
||||
dispatcher.schedule()
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _cached_body(kind: str, path: str, base_url: str, zstd: bool) -> bytes:
|
||||
def _cached_body(kind: str, path: str, base_url: str, zstd: bool, lang: str = i18n.ORIGINAL_LANGUAGE, link_lang: str = "") -> bytes:
|
||||
"""Rendered page body; cleared by _invalidate_pages on any
|
||||
content/settings change. base_url feeds the social meta URLs and zstd
|
||||
content/settings change. base_url feeds the social meta URLs, zstd
|
||||
selects the stored encoding (both variants are cached rather than
|
||||
re-compressed).
|
||||
re-compressed) and lang the selected language (not the raw
|
||||
Accept-Language header, which would blow up the cache key space).
|
||||
link_lang is the ?lang= override replicated onto the navigation links:
|
||||
a query render and a header-selected render of the same language differ
|
||||
in their links, so they are cached separately.
|
||||
"""
|
||||
body = _render_html(kind, path, base_url).encode()
|
||||
body = _render_html(kind, path, base_url, lang, link_lang).encode()
|
||||
return _zstd.compress(body) if zstd else body
|
||||
|
||||
|
||||
@@ -423,6 +466,8 @@ def _html_response(
|
||||
status_code: int = 200,
|
||||
headers: dict | None = None,
|
||||
etag: bool = False,
|
||||
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||
link_lang: str = "",
|
||||
) -> Response:
|
||||
"""Response for a generated page, zstd-compressed when the client
|
||||
accepts it (no gzip fallback).
|
||||
@@ -444,14 +489,15 @@ def _html_response(
|
||||
# localhost (varying ports) fall back to the request's own base URL.
|
||||
base_url = SITE_URL or str(request.base_url).rstrip("/")
|
||||
if DEVMODE:
|
||||
identity = _render_html(kind, path, base_url).encode()
|
||||
identity = _render_html(kind, path, base_url, lang, link_lang).encode()
|
||||
body = _zstd.compress(identity) if zstd else identity
|
||||
else:
|
||||
identity = _cached_body(kind, path, base_url, False)
|
||||
body = _cached_body(kind, path, base_url, True) if zstd else identity
|
||||
identity = _cached_body(kind, path, base_url, False, lang, link_lang)
|
||||
body = _cached_body(kind, path, base_url, True, lang, link_lang) if zstd else identity
|
||||
h = dict(headers or {})
|
||||
if zstd:
|
||||
h["vary"] = "accept-encoding"
|
||||
# Content varies by language (Accept-Language selects a translation)
|
||||
# and by encoding; keep caches from mixing either representation.
|
||||
h["vary"] = "accept-language" + (", accept-encoding" if zstd else "")
|
||||
if etag:
|
||||
tag = f'"{blake3.blake3(identity).hexdigest()[:32]}"'
|
||||
h["etag"] = tag
|
||||
@@ -472,32 +518,45 @@ class PageIn(BaseModel):
|
||||
|
||||
|
||||
@app.get("/_api/pages")
|
||||
async def list_pages() -> list[dict]:
|
||||
async def list_pages(lang: str | None = None) -> list[dict]:
|
||||
"""The site tree for the structure editor (all nodes, drafts included).
|
||||
|
||||
Nested by slug; each node carries its full path, menu order and flags.
|
||||
Nested by slug; each node carries its full path, menu order, flags and
|
||||
language settings (``language`` is the node's own primary-language
|
||||
setting, "" = inherit; ``primary`` is the resolved effective one).
|
||||
With a ``?lang=`` translation, titles come out in that language where a
|
||||
translation exists (``translated`` flags it — true trivially for rows
|
||||
whose primary language IS the selected one; other rows fall back to
|
||||
the original title, dimmed) — the structure itself (slugs, order,
|
||||
hierarchy) is language-independent.
|
||||
"""
|
||||
tag = i18n.base_tag(lang or "")
|
||||
titles = i18n.title_map(data, tag) if tag else {}
|
||||
|
||||
def dump(nodes: dict[str, Node], prefix: str) -> list[dict]:
|
||||
def dump(nodes: dict[str, Node], prefix: str, inherited: str) -> list[dict]:
|
||||
out = []
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
primary = node.language or inherited
|
||||
out.append({
|
||||
"slug": slug,
|
||||
"path": path,
|
||||
"title": node.title,
|
||||
"title": titles.get(path) or node.title,
|
||||
"translated": path in titles or (bool(tag) and primary == tag),
|
||||
"order": node.order,
|
||||
"published": node.published,
|
||||
"has_content": node.content is not None,
|
||||
"children": dump(node.children, path),
|
||||
"has_content": node.chunks is not None,
|
||||
"language": node.language,
|
||||
"primary": primary,
|
||||
"children": dump(node.children, path, primary),
|
||||
})
|
||||
return out
|
||||
|
||||
return dump(data.menu, "")
|
||||
return dump(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
||||
|
||||
|
||||
@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
|
||||
@@ -505,13 +564,31 @@ 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.primary_lang(data.menu, path):
|
||||
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")
|
||||
with kanta.transaction("save translation", extra=path):
|
||||
# Patches alone make the translated version exist.
|
||||
if i18n.add_patch(data, node, path, lang, page.markdown):
|
||||
_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
|
||||
@@ -529,12 +606,24 @@ class StructureOp(BaseModel):
|
||||
just the top-level node with slug "": renaming it away leaves no front
|
||||
page ("/" then redirects to the first nav item), and any childless
|
||||
top-level node can take the empty slug to become the front page.
|
||||
|
||||
With `lang` (a translation, not the node's primary language) a `title`
|
||||
edit writes a per-language title fragment instead of the original — the
|
||||
same storage as machine title translations (docs/localization.md);
|
||||
sending the original's text removes the override. Structural fields are
|
||||
not combinable with a translated title edit.
|
||||
|
||||
`language` sets the node's primary language (a BCP-47 base tag; "" =
|
||||
inherit from the nearest ancestor, the front page last, site default
|
||||
"en" final — Node.language), inherited by the whole subtree.
|
||||
"""
|
||||
|
||||
path: str
|
||||
order: float | None = None
|
||||
move_to: str | None = None
|
||||
title: str | None = None
|
||||
lang: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@app.post("/_api/structure", status_code=204)
|
||||
@@ -545,6 +634,24 @@ async def update_structure(op: StructureOp) -> None:
|
||||
if chain is None:
|
||||
raise HTTPException(404, "no such page")
|
||||
node = chain[-1]
|
||||
lang = i18n.base_tag(op.lang or "")
|
||||
if op.language is not None:
|
||||
# Primary-language setting (inherited by the subtree): reselects
|
||||
# what "the original" means for the node — its language is part of
|
||||
# every render, so a change invalidates everywhere.
|
||||
language = i18n.base_tag(op.language)
|
||||
with kanta.transaction("set page language", extra=path):
|
||||
if language != node.language:
|
||||
node.language = language
|
||||
_invalidate_pages()
|
||||
return
|
||||
if op.title is not None and lang and lang != i18n.primary_lang(data.menu, path):
|
||||
# Translated title (i18n.set_title_translation): original title,
|
||||
# slugs and hierarchy stay untouched.
|
||||
with kanta.transaction("translate title", extra=path):
|
||||
if i18n.set_title_translation(data, node, lang, op.title):
|
||||
_invalidate_pages()
|
||||
return
|
||||
target = op.move_to.strip("/") if op.move_to is not None else None
|
||||
if target is not None and target != path:
|
||||
_check_reserved(target)
|
||||
@@ -584,7 +691,8 @@ async def update_structure(op: StructureOp) -> None:
|
||||
async def get_settings() -> dict:
|
||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||
the themes, banner designs and user fonts available on disk for the
|
||||
selectors."""
|
||||
selectors, the translator service keys and the wanted translation
|
||||
languages (for the /_translate socket)."""
|
||||
return {
|
||||
"brand": data.brand,
|
||||
"brand_html": data.brand_html,
|
||||
@@ -596,6 +704,11 @@ async def get_settings() -> dict:
|
||||
"fonts": views._user_fonts(),
|
||||
"transition": data.transition,
|
||||
"transitions": views._transition_names(),
|
||||
"translate_keys": data.translate_keys,
|
||||
# The site default primary language: the front page's resolved
|
||||
# setting (every page may override it, inherited down the tree).
|
||||
"primary_lang": i18n.primary_lang(data.menu, ""),
|
||||
"translate_langs": sorted(data.translate_langs),
|
||||
}
|
||||
|
||||
|
||||
@@ -607,6 +720,7 @@ class SettingsIn(BaseModel):
|
||||
custom_css: str
|
||||
brand_html: str = ""
|
||||
transition: str = "cube"
|
||||
translate_langs: list[str] | None = None # None keeps the current set
|
||||
|
||||
|
||||
@app.put("/_api/settings", status_code=204)
|
||||
@@ -618,9 +732,34 @@ async def put_settings(settings: SettingsIn) -> None:
|
||||
data.theme = settings.theme
|
||||
data.custom_css = settings.custom_css
|
||||
data.transition = settings.transition
|
||||
if settings.translate_langs is not None:
|
||||
# Any language may be a target — including the site default
|
||||
# (an article in another language can be translated INTO it);
|
||||
# a node's own primary is excluded per article, not here.
|
||||
data.translate_langs = {
|
||||
tag: True
|
||||
for lang in settings.translate_langs
|
||||
if (tag := i18n.base_tag(lang))
|
||||
}
|
||||
_invalidate_pages()
|
||||
|
||||
|
||||
@app.delete("/_api/translations", status_code=204)
|
||||
async def delete_translations() -> None:
|
||||
"""Drop all machine translations (Data.trans) so the dispatcher
|
||||
re-translates everything from scratch (a "refresh translations" action:
|
||||
the invalidation hook re-offers every fragment to connected
|
||||
translators). User patches are kept; the availability index
|
||||
(node.langs) is rebuilt from them — patches alone still make a language
|
||||
exist on a page."""
|
||||
with kanta.transaction("refresh translations"):
|
||||
i18n.clear_translations(data)
|
||||
_invalidate_pages()
|
||||
# Fragments rejected this run (segment validation) stay skipped no
|
||||
# longer: a refresh is precisely the "another chance" for them.
|
||||
dispatcher.validation_failures.clear()
|
||||
|
||||
|
||||
@app.put("/_api/settings/favicon")
|
||||
async def put_favicon(request: Request) -> dict[str, str]:
|
||||
"""Upload a favicon into the content-addressed store and activate it.
|
||||
@@ -689,13 +828,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}
|
||||
@@ -925,13 +1066,31 @@ 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]]
|
||||
_invalidate_pages()
|
||||
|
||||
|
||||
# WebSocket API for external translation services (not under /_api: it is keyed
|
||||
# with Data.translate_keys instead of the SSO forward-auth). The dispatcher —
|
||||
# protocol, connected clients and the job pipeline — lives in translate.py.
|
||||
dispatcher = translate.Dispatcher(data, kanta, _invalidate_pages)
|
||||
|
||||
|
||||
@app.websocket("/_translate/{clientkey}")
|
||||
async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
||||
"""Translator service channel (docs/localization.md).
|
||||
|
||||
Deliberately NOT under /_api/: the external forward-auth is skipped;
|
||||
the server-generated client key in the path is the access control
|
||||
(``Data.translate_keys``: key -> display name; the first is generated
|
||||
at bootstrap, all are shown in the admin's /_api/settings).
|
||||
"""
|
||||
await dispatcher.handle_ws(ws, clientkey)
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||
|
||||
|
||||
@@ -1248,15 +1407,25 @@ 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"}
|
||||
"banner", "banner_design", "lang", "primary_lang", "langs",
|
||||
"translate_langs"}
|
||||
<- {"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"?, "base"?}
|
||||
(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
|
||||
effective hybrid Markdown and title for that language plus the language
|
||||
metadata the picker's UI needs; save diffs the submitted Markdown
|
||||
against "base" (the editor's shadow copy of the hybrid it started from
|
||||
— absent: the current hybrid) and stores it as a user Patch, and a
|
||||
changed title becomes a fragment in Data.trans — node.chunks and the
|
||||
other fields stay untouched (docs/localization.md).
|
||||
"""
|
||||
await ws.accept()
|
||||
try:
|
||||
@@ -1272,12 +1441,29 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
case "open":
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
# The article's primary language: its own setting,
|
||||
# inherited down the tree ("en" final fallback).
|
||||
node_lang = i18n.primary_lang(data.menu, path)
|
||||
lang = i18n.base_tag(str(msg.get("lang") or ""))
|
||||
if lang == node_lang:
|
||||
lang = ""
|
||||
markdown = ""
|
||||
title = node.title if node else ""
|
||||
if node is not None:
|
||||
markdown = node_markdown(data, node) or ""
|
||||
if lang and node.chunks is not None:
|
||||
# Translation view: the effective (hybrid)
|
||||
# Markdown and title for that language —
|
||||
# machine fragments + user patches over the
|
||||
# original (docs/localization.md editor flow).
|
||||
markdown = i18n.hybrid_markdown(data, node, path, lang)
|
||||
title = i18n.title_map(data, lang).get(path) or title
|
||||
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 "",
|
||||
"title": title,
|
||||
"markdown": markdown,
|
||||
"published": node.published if node else True,
|
||||
"banner": node.banner if node else "",
|
||||
# Own banner design setting: null = inherit,
|
||||
@@ -1300,6 +1486,15 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
if src is not None
|
||||
else views.theme_banner_design(data.theme)
|
||||
),
|
||||
# Language context for the editor's picker: the
|
||||
# language this Markdown represents ("" = primary),
|
||||
# the page's own primary language, the translations
|
||||
# this page already has, and the site-wide
|
||||
# configured target languages.
|
||||
"lang": lang,
|
||||
"primary_lang": node_lang,
|
||||
"langs": sorted(node.langs) if node else [],
|
||||
"translate_langs": sorted(data.translate_langs),
|
||||
})
|
||||
case "render":
|
||||
markdown = msg.get("markdown", "")
|
||||
@@ -1325,6 +1520,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.primary_lang(data.menu, move_from))
|
||||
try:
|
||||
_check_reserved(move_from)
|
||||
except HTTPException:
|
||||
@@ -1358,6 +1555,19 @@ 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
|
||||
if translated and "markdown" in msg and not msg["markdown"].strip():
|
||||
# Saving never deletes; an emptied translation would
|
||||
# render as a blank page in that language.
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"detail": "a translation cannot be emptied",
|
||||
})
|
||||
continue
|
||||
with kanta.transaction("editor save", extra=path):
|
||||
if move_from != path:
|
||||
same_menu = (
|
||||
@@ -1375,21 +1585,43 @@ 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:
|
||||
# node.chunks and the original-language fields
|
||||
# stay untouched: the markdown diff (against the
|
||||
# editor's shadow "base" — the hybrid it started
|
||||
# from; absent: the current hybrid) is appended
|
||||
# as a Patch, a changed title becomes a
|
||||
# per-language title override (i18n).
|
||||
changed = False
|
||||
if "markdown" in msg:
|
||||
base = msg.get("base")
|
||||
changed = i18n.add_patch(
|
||||
data, node, path, lang, msg["markdown"],
|
||||
base=base if isinstance(base, str) else None,
|
||||
)
|
||||
if "title" in msg and node.title:
|
||||
changed = (
|
||||
i18n.set_title_translation(data, node, lang, msg["title"])
|
||||
or changed
|
||||
)
|
||||
if changed:
|
||||
_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
|
||||
@@ -1414,7 +1646,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,
|
||||
)
|
||||
@@ -1425,14 +1657,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, "")
|
||||
|
||||
@@ -1508,14 +1740,29 @@ 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.
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = i18n.select_language(
|
||||
query_lang,
|
||||
accept_language,
|
||||
lambda tag: tag in node.langs,
|
||||
original=i18n.primary_lang(data.menu, path),
|
||||
)
|
||||
# A ?lang= override is replicated onto the page's navigation links
|
||||
# (link_lang), so clicks and prefetches stay in the chosen language.
|
||||
# Query and header-selected renders of the same language differ in
|
||||
# their links, so link_lang is part of the ETag and body cache key.
|
||||
link_lang = i18n.base_tag(query_lang or "")
|
||||
# no-cache forbids serving a stored page without revalidation
|
||||
# (browsers would otherwise cache heuristically and serve stale
|
||||
# pages, e.g. after a theme change). In-session speed instead comes
|
||||
# from pagerite.js's in-memory page cache (preload everything, never
|
||||
# fetch on navigation); the ETag just makes those one-time preload
|
||||
# fetches and any revalidation cheap.
|
||||
etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}"'
|
||||
etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}l{lang}q{link_lang}"'
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
if _is_trackable_path(path):
|
||||
@@ -1530,10 +1777,25 @@ async def show_page(request: Request, path: str) -> Response:
|
||||
"last-modified": _http_date(node.modified),
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
lang=lang,
|
||||
link_lang=link_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).
|
||||
# Language selection as on content pages, but over the whole
|
||||
# subtree's availability: the category has no chunks of its own —
|
||||
# its heading, the navigation and the cards' text localize from
|
||||
# the title map and the target articles' translations.
|
||||
query_lang = request.query_params.get("lang")
|
||||
subtree_langs = i18n.subtree_languages(node)
|
||||
lang = i18n.select_language(
|
||||
query_lang,
|
||||
accept_language,
|
||||
lambda tag: tag in subtree_langs,
|
||||
original=i18n.primary_lang(data.menu, path),
|
||||
)
|
||||
link_lang = i18n.base_tag(query_lang or "")
|
||||
if _is_trackable_path(path):
|
||||
flushed = _track_entry(path, request, status=404)
|
||||
_schedule_client_enrichment(flushed)
|
||||
@@ -1546,6 +1808,8 @@ async def show_page(request: Request, path: str) -> Response:
|
||||
"last-modified": _http_date(node.modified),
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
lang=lang,
|
||||
link_lang=link_lang,
|
||||
)
|
||||
if node is None and not path:
|
||||
# No front page (no top-level node with slug ""): "/" opens the
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Block-level Markdown chunking for content-addressed storage.
|
||||
|
||||
A page's Markdown is split into deterministic block-level chunks, each
|
||||
stored once under its content hash in ``Data.chunks`` (docs/migrate.md).
|
||||
Shared by the render/save pipeline (app.py, views.py, i18n.py) and the
|
||||
schema migration (migrations.py), so a chunk's key is stable no matter
|
||||
where the split happens.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import blake3
|
||||
|
||||
from pagerite.segments import has_prose
|
||||
|
||||
#: Fenced code block opener/closer: up to 3 spaces indent, then 3+
|
||||
#: backticks or tildes (CommonMark).
|
||||
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
||||
|
||||
#: HTML block openers that may span blank lines (CommonMark types 1-5:
|
||||
#: script/pre/style/textarea, comments, processing instructions,
|
||||
#: declarations, CDATA) with their closing condition. Other HTML blocks
|
||||
#: end at the first blank line, which the generic blank-line split
|
||||
#: already does.
|
||||
_HTML_ATOMIC = (
|
||||
(re.compile(r"^ {0,3}<(?:script|pre|style|textarea)(?:\s|>|$)", re.I),
|
||||
re.compile(r"</(?:script|pre|style|textarea)\s*>", re.I)),
|
||||
(re.compile(r"^ {0,3}<!--"), re.compile(r"-->")),
|
||||
(re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")),
|
||||
(re.compile(r"^ {0,3}<!\[CDATA\["), re.compile(r"\]\]>")),
|
||||
(re.compile(r"^ {0,3}<![A-Za-z]"), re.compile(r">")),
|
||||
)
|
||||
|
||||
#: First line of a generic HTML block (a block-level tag).
|
||||
_HTML_TAG = re.compile(r"^ {0,3}</?[A-Za-z][^>]*>")
|
||||
|
||||
|
||||
def _fence_close(line: str, opener: str) -> bool:
|
||||
"""True when ``line`` closes a code fence opened by ``opener``: the
|
||||
same marker char, at least as many, and nothing else on the line."""
|
||||
stripped = line.strip()
|
||||
return (
|
||||
len(stripped) >= len(opener)
|
||||
and stripped[0] == opener[0]
|
||||
and set(stripped) == {opener[0]}
|
||||
)
|
||||
|
||||
|
||||
def chunk_markdown(markdown: str) -> list[str]:
|
||||
"""Split Markdown into block-level chunks, deterministically.
|
||||
|
||||
Blocks are separated by blank lines; fenced code blocks and the
|
||||
multi-line HTML blocks (comments, script/pre/style, CDATA...) are
|
||||
kept atomic, even across blank lines, and end at their closing
|
||||
condition. Chunks carry no surrounding blank lines and no trailing
|
||||
newline; rejoining with ``join_chunks`` reproduces the source modulo
|
||||
blank-line normalization.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
buf: list[str] = []
|
||||
fence = "" # opener marker of the code fence we are in ("" = outside)
|
||||
html_end: re.Pattern | None = None # closes the atomic HTML block we are in
|
||||
|
||||
def flush() -> None:
|
||||
text = "\n".join(buf).strip("\n")
|
||||
if text.strip():
|
||||
chunks.append(text)
|
||||
buf.clear()
|
||||
|
||||
for line in markdown.split("\n"):
|
||||
if fence:
|
||||
buf.append(line)
|
||||
if _fence_close(line, fence):
|
||||
fence = ""
|
||||
flush()
|
||||
continue
|
||||
if html_end is not None:
|
||||
buf.append(line)
|
||||
if html_end.search(line):
|
||||
html_end = None
|
||||
flush()
|
||||
continue
|
||||
if not line.strip():
|
||||
flush()
|
||||
continue
|
||||
if m := _FENCE_OPEN.match(line):
|
||||
# Fences interrupt paragraphs (CommonMark): start a new block.
|
||||
flush()
|
||||
fence = m.group(1)
|
||||
buf.append(line)
|
||||
continue
|
||||
if not buf:
|
||||
for open_re, close_re in _HTML_ATOMIC:
|
||||
if open_re.match(line):
|
||||
buf.append(line)
|
||||
if close_re.search(line): # opens and closes on one line
|
||||
flush()
|
||||
else:
|
||||
html_end = close_re
|
||||
break
|
||||
else:
|
||||
buf.append(line)
|
||||
continue
|
||||
buf.append(line)
|
||||
flush() # an unterminated fence/HTML block runs to EOF, kept as code/HTML
|
||||
return chunks
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Whitespace-insensitive chunk identity: strip trailing whitespace
|
||||
per line and collapse surrounding blank lines, so whitespace-only
|
||||
source edits don't invalidate translations."""
|
||||
return "\n".join(line.rstrip() for line in text.split("\n")).strip("\n")
|
||||
|
||||
|
||||
def chunk_key(text: str) -> bytes:
|
||||
"""Content key of a chunk: the first 9 bytes of the blake3 digest of
|
||||
the normalized text (72 bits — a site's chunk count stays far below
|
||||
the birthday bound), using the same hasher as app.py's file store.
|
||||
|
||||
Keys are bytes: kanta/msgspec base64-encode them at the JSON
|
||||
persistence level, so the raw database dicts carry 12-char strings.
|
||||
"""
|
||||
return blake3.blake3(_normalize(text).encode()).digest(9)
|
||||
|
||||
|
||||
def needs_translation(chunk: str) -> bool:
|
||||
"""False for chunks without prose: pure code fences, HTML blocks, and
|
||||
anything that yields no translatable segments (pagerite/segments.py) —
|
||||
container fences, lone {placeholders}, reference definitions.
|
||||
|
||||
These are inherently no-translate (docs/migrate.md): derived from the
|
||||
chunk text itself, nothing is stored. Every language renders them from
|
||||
the original chunk via the hybrid fallback.
|
||||
"""
|
||||
if _FENCE_OPEN.match(chunk):
|
||||
return False
|
||||
first = chunk.split("\n", 1)[0]
|
||||
if any(open_re.match(first) for open_re, _ in _HTML_ATOMIC):
|
||||
return False
|
||||
if _HTML_TAG.match(first):
|
||||
return False
|
||||
return has_prose(chunk)
|
||||
|
||||
|
||||
def join_chunks(chunks: list[str]) -> str:
|
||||
"""The stored page form of chunks: blocks joined by a blank line,
|
||||
with a trailing newline ("" for no chunks)."""
|
||||
return "\n\n".join(chunks) + "\n" if chunks else ""
|
||||
|
||||
|
||||
def store_chunks(store: dict[bytes, str], markdown: str) -> list[bytes]:
|
||||
"""Chunk ``markdown`` into ``store`` (hash -> text); return the ordered
|
||||
hashes. Unchanged chunks keep their hashes, so only genuinely new text
|
||||
lands in the kanta change diff. First writer wins: variants sharing a
|
||||
key differ only in insignificant whitespace (see chunk_key)."""
|
||||
hashes = []
|
||||
for chunk in chunk_markdown(markdown):
|
||||
key = chunk_key(chunk)
|
||||
store.setdefault(key, chunk)
|
||||
hashes.append(key)
|
||||
return hashes
|
||||
+65
-5
@@ -2,8 +2,9 @@
|
||||
|
||||
The site structure is a tree of Nodes. Every node is a menu label with a
|
||||
configurable title and slug (its key in the parent's ``children``); the
|
||||
URL path is the chain of slugs from the top level. ``content`` is the
|
||||
node's Markdown page, or None for a pure category label, whose URL renders
|
||||
URL path is the chain of slugs from the top level. ``chunks`` is the
|
||||
node's Markdown page as ordered content-hash keys into ``Data.chunks``
|
||||
(docs/migrate.md), or None for a pure category label, whose URL renders
|
||||
a placeholder page while nav links point at its first child.
|
||||
"""
|
||||
|
||||
@@ -11,6 +12,16 @@ from datetime import UTC, datetime
|
||||
|
||||
import msgspec
|
||||
|
||||
from pagerite.chunks import join_chunks
|
||||
|
||||
|
||||
class Patch(msgspec.Struct, omit_defaults=True):
|
||||
"""One editing session's overrides on a translated view, applied
|
||||
independently per hunk (docs/localization.md)."""
|
||||
|
||||
#: (search, replace) pairs on the served hybrid Markdown.
|
||||
hunks: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
class Node(msgspec.Struct, omit_defaults=True):
|
||||
"""One item of the site hierarchy.
|
||||
@@ -28,9 +39,21 @@ class Node(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
title: str = ""
|
||||
order: float = 0
|
||||
#: Markdown source of the node's page; None = pure category label
|
||||
#: (its URL renders a placeholder page).
|
||||
content: str | None = None
|
||||
#: Ordered chunk hashes (9-byte keys into ``Data.chunks``); None =
|
||||
#: pure category label (its URL renders a placeholder page), a list
|
||||
#: (possibly empty) = a page.
|
||||
chunks: list[bytes] | None = None
|
||||
#: Primary language of the article (BCP-47 base tag). "" = inherit
|
||||
#: (nearest ancestor, front page last, site default "en" final).
|
||||
language: str = ""
|
||||
#: Chunk hashes the editor marked "do not translate" (always served
|
||||
#: from the original). Presence-keys, value always True.
|
||||
no_trans: dict[bytes, bool] = {}
|
||||
#: Languages this article is available in (besides its primary
|
||||
#: language). Presence-keys, value always True — the availability
|
||||
#: index for rendering and language selection; maintained by whoever
|
||||
#: writes translation data (docs/migrate.md).
|
||||
langs: dict[str, bool] = {}
|
||||
#: Raw HTML for the header banner (img, styled div, canvas+script...),
|
||||
#: rendered after the banner design's artwork so author code always
|
||||
#: wins over the design's own styles.
|
||||
@@ -78,6 +101,43 @@ class Data(msgspec.Struct):
|
||||
#: linked as <link rel="icon"> on every page. Empty = the build's
|
||||
#: /favicon.ico.
|
||||
favicon: str = ""
|
||||
#: API keys gating the translator service WebSocket (/_translate/{key};
|
||||
#: the external forward-auth does not cover that route): key -> display
|
||||
#: name. Keys are 12 lowercase alphanumeric characters; the first is
|
||||
#: generated at database bootstrap (see app.py), multiple keys are a
|
||||
#: future reservation (e.g. managed via a web interface).
|
||||
translate_keys: dict[str, str] = {}
|
||||
#: Wanted target languages for the translator service (presence-keys,
|
||||
#: value always True). The dispatcher offers jobs only in the
|
||||
#: intersection of these and a connection's announced capabilities.
|
||||
#: Bootstrapped to es+zh; edited in the editor shell's localization
|
||||
#: tab (or via /_api/settings).
|
||||
translate_langs: dict[str, bool] = {}
|
||||
#: All original-language page text, content-addressed:
|
||||
#: chunk_key (9 bytes; base64 at the JSON level) -> Markdown chunk.
|
||||
#: Shared by every article.
|
||||
chunks: dict[bytes, str] = {}
|
||||
#: Machine translations: chunk hash -> lang -> translated Markdown
|
||||
#: (a nested dict rather than tuple keys, which msgspec's JSON
|
||||
#: serializer does not support). Also used for node titles (hash of
|
||||
#: the title text).
|
||||
trans: dict[bytes, dict[str, str]] = {}
|
||||
#: User override patches per article and language:
|
||||
#: f"{path}:{lang}" -> ordered patches (paths without leading slash).
|
||||
patches: dict[str, list[Patch]] = {}
|
||||
|
||||
|
||||
def node_markdown(data: Data, node: Node) -> str | None:
|
||||
"""The node's original Markdown assembled from the chunk store.
|
||||
|
||||
None for category labels (chunks is None); an empty page gives "".
|
||||
Hashes missing from the store (shouldn't happen) are skipped.
|
||||
"""
|
||||
if node.chunks is None:
|
||||
return None
|
||||
return join_chunks(
|
||||
[t for h in node.chunks if (t := data.chunks.get(h)) is not None]
|
||||
)
|
||||
|
||||
|
||||
def prettify(slug: str) -> str:
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Localization: language selection, translation storage and assembly.
|
||||
|
||||
See docs/localization.md and docs/migrate.md. Each article's primary
|
||||
language is ``Node.language``, inherited down the hierarchy (front page =
|
||||
site default, ORIGINAL_LANGUAGE as the final fallback). The database holds
|
||||
the original language as content-addressed chunks (``Data.chunks``); per
|
||||
target language there are machine-translated fragments (``Data.trans``)
|
||||
and user override patches (``Data.patches``), assembled into the served
|
||||
Markdown at render time, with per-node fallback to the original titles.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
import msgspec
|
||||
|
||||
from pagerite.chunks import chunk_key, chunk_markdown, join_chunks
|
||||
from pagerite.data import Data, Node, Patch, resolve
|
||||
|
||||
#: Final fallback for a page's primary language when neither it nor any
|
||||
#: ancestor (up to the front page) sets one (Node.language, "" = inherit).
|
||||
ORIGINAL_LANGUAGE = "en"
|
||||
|
||||
#: Languages written right-to-left; pages served in one get dir="rtl" on
|
||||
#: <html> (views._layout).
|
||||
RTL_LANGUAGES = frozenset({"ar", "fa", "he", "ur"})
|
||||
|
||||
|
||||
def primary_lang(menu: dict[str, Node], path: str) -> str:
|
||||
"""The primary language of the article at ``path``: its own
|
||||
``language`` setting, else the nearest ancestor's (the front page
|
||||
last — it doubles as the site default), falling back to
|
||||
ORIGINAL_LANGUAGE. Missing tail segments (a page being created)
|
||||
resolve to the nearest existing ancestor."""
|
||||
p = path.strip("/")
|
||||
while True:
|
||||
chain = resolve(menu, p)
|
||||
if chain:
|
||||
for node in reversed(chain):
|
||||
if node.language:
|
||||
return node.language
|
||||
if not p:
|
||||
return ORIGINAL_LANGUAGE
|
||||
p = p.rpartition("/")[0]
|
||||
|
||||
|
||||
class Translation(msgspec.Struct, omit_defaults=True):
|
||||
"""Translated content for one page and language.
|
||||
|
||||
``markdown`` is the translated page source in the same format as the
|
||||
original (None = keep the original Markdown); ``titles`` maps node paths
|
||||
(top-level slug, then slash-joined) to translated navigation titles, so a
|
||||
partially translated tree still renders with per-node English fallback.
|
||||
"""
|
||||
|
||||
markdown: str | None = None
|
||||
titles: dict[str, str] = {}
|
||||
|
||||
|
||||
def base_tag(tag: str) -> str:
|
||||
"""The lowercase base subtag of a language tag (fi-FI -> fi)."""
|
||||
return tag.strip().lower().partition("-")[0]
|
||||
|
||||
|
||||
def parse_accept_language(header: str) -> list[str]:
|
||||
"""Accept-Language header as an ordered, deduped list of base subtags.
|
||||
|
||||
q-values are deliberately ignored: all known implementations send the
|
||||
header in order of preference. Region tags normalize to their base
|
||||
subtag (fi-FI -> fi); "*" and empties are dropped.
|
||||
"""
|
||||
langs = []
|
||||
for part in header.split(","):
|
||||
tag = base_tag(part.split(";", 1)[0])
|
||||
if tag and tag != "*" and tag not in langs:
|
||||
langs.append(tag)
|
||||
return langs
|
||||
|
||||
|
||||
def select_language(
|
||||
query_lang: str | None,
|
||||
accept_language: str | None,
|
||||
is_available: Callable[[str], bool],
|
||||
original: str = ORIGINAL_LANGUAGE,
|
||||
) -> str:
|
||||
"""The language to serve (see docs/localization.md).
|
||||
|
||||
1. ``?lang=`` wins when a translation exists for it (otherwise falls
|
||||
through to the header logic).
|
||||
2. The original language anywhere in the header list wins — an AI
|
||||
translation is strictly worse than the original for anyone who has
|
||||
English configured at all.
|
||||
3. Otherwise the first header language with an available translation.
|
||||
4. Fall back to the original.
|
||||
"""
|
||||
if query_lang:
|
||||
tag = base_tag(query_lang)
|
||||
if tag == original or (tag and is_available(tag)):
|
||||
return tag
|
||||
langs = parse_accept_language(accept_language or "")
|
||||
if original in langs:
|
||||
return original
|
||||
for lang in langs:
|
||||
if lang != original and is_available(lang):
|
||||
return lang
|
||||
return original
|
||||
|
||||
|
||||
def apply_patch(hybrid: str, patch: Patch) -> str:
|
||||
"""Apply one patch to the hybrid Markdown, best effort, each hunk
|
||||
independently: a hunk whose search text no longer exists is stale and
|
||||
silently skipped (docs/localization.md)."""
|
||||
for search, replace in patch.hunks:
|
||||
if search and search in hybrid:
|
||||
hybrid = hybrid.replace(search, replace, 1)
|
||||
return hybrid
|
||||
|
||||
|
||||
def make_patch(base: str, edited: str) -> Patch:
|
||||
"""The minimal diff of ``edited`` against the served ``base`` hybrid as
|
||||
(search, replace) hunks at block granularity (docs/localization.md).
|
||||
|
||||
Blocks are the chunk_markdown split, so hunks align with translation
|
||||
units and code fences never straddle a hunk boundary. Pure inserts
|
||||
anchor on the preceding block (an empty search would never match);
|
||||
inserts at the very top anchor on the first block. autojunk is off:
|
||||
the diff must be deterministic, and pages are small.
|
||||
"""
|
||||
a, b = chunk_markdown(base), chunk_markdown(edited)
|
||||
hunks: list[tuple[str, str]] = []
|
||||
for tag, i1, i2, j1, j2 in SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
|
||||
if tag == "equal":
|
||||
continue
|
||||
search = "\n\n".join(a[i1:i2])
|
||||
replace = "\n\n".join(b[j1:j2])
|
||||
if tag == "insert":
|
||||
if i1:
|
||||
search = a[i1 - 1]
|
||||
replace = f"{a[i1 - 1]}\n\n{replace}"
|
||||
elif a:
|
||||
search = a[0]
|
||||
replace = f"{replace}\n\n{a[0]}"
|
||||
# else: base is empty — the hunk is inert (empty search is
|
||||
# skipped by apply_patch); saving a translation of an empty
|
||||
# page records nothing applicable.
|
||||
hunks.append((search, replace))
|
||||
return Patch(hunks=hunks)
|
||||
|
||||
|
||||
def hybrid_markdown(data: Data, node: Node, path: str, lang: str) -> str:
|
||||
"""The served Markdown for ``lang``: per chunk the translation from
|
||||
``Data.trans``, unless missing or marked no-translate (fallback to the
|
||||
original chunk), then the language's user patches applied in order.
|
||||
|
||||
Not gated on ``node.langs`` (get_translation is the gated view): the
|
||||
editor save path diffs against this even for a language's first patch.
|
||||
"""
|
||||
hybrid = join_chunks([
|
||||
data.chunks.get(h, "")
|
||||
if h in node.no_trans
|
||||
else data.trans.get(h, {}).get(lang) or data.chunks.get(h, "")
|
||||
for h in node.chunks or []
|
||||
])
|
||||
for patch in data.patches.get(f"{path}:{lang}", []):
|
||||
hybrid = apply_patch(hybrid, patch)
|
||||
return hybrid
|
||||
|
||||
|
||||
def add_patch(
|
||||
data: Data, node: Node, path: str, lang: str, edited: str, base: str | None = None
|
||||
) -> bool:
|
||||
"""Record a translated-view edit as a user Patch: the minimal diff of
|
||||
``edited`` against ``base`` (default: the currently served hybrid),
|
||||
appended to the language's patch list. Patches alone make the
|
||||
translated version exist, so ``node.langs`` is set. Returns True when
|
||||
a patch was stored. Pure data ops — the caller wraps in a transaction
|
||||
and invalidates."""
|
||||
patch = make_patch(base if base is not None else hybrid_markdown(data, node, path, lang), edited)
|
||||
if not patch.hunks:
|
||||
return False
|
||||
data.patches.setdefault(f"{path}:{lang}", []).append(patch)
|
||||
node.langs[lang] = True
|
||||
return True
|
||||
|
||||
|
||||
def set_title_translation(data: Data, node: Node, lang: str, title: str) -> bool:
|
||||
"""Record (or drop) a per-language title override: a fragment in
|
||||
``Data.trans`` keyed by the ORIGINAL title's chunk hash — the same
|
||||
storage machine title translations use, overriding them. Sending the
|
||||
original's text drops the override. Returns True when anything changed.
|
||||
Pure data ops — the caller wraps in a transaction and invalidates."""
|
||||
key = chunk_key(node.title)
|
||||
current = data.trans.get(key, {}).get(lang)
|
||||
if title == node.title:
|
||||
if current is None:
|
||||
return False
|
||||
del data.trans[key][lang]
|
||||
return True
|
||||
if current == title:
|
||||
return False
|
||||
data.trans.setdefault(key, {})[lang] = title
|
||||
node.langs[lang] = True
|
||||
return True
|
||||
|
||||
|
||||
def clear_translations(data: Data) -> None:
|
||||
"""Drop all machine translations (``Data.trans``) and rebuild the
|
||||
availability index (``node.langs``) from the surviving user patches —
|
||||
patches alone make a language exist on a page. Pure data ops — the
|
||||
caller wraps in a transaction and invalidates."""
|
||||
data.trans.clear()
|
||||
patch_langs: dict[str, set[str]] = {}
|
||||
for key in data.patches:
|
||||
path, _, lang = key.rpartition(":")
|
||||
patch_langs.setdefault(path, set()).add(lang)
|
||||
|
||||
def walk(nodes: dict[str, Node], prefix: str) -> None:
|
||||
for slug, node in nodes.items():
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
node.langs = {lang: True for lang in patch_langs.get(path, ())}
|
||||
walk(node.children, path)
|
||||
|
||||
walk(data.menu, "")
|
||||
|
||||
|
||||
def title_map(data: Data, lang: str) -> dict[str, str]:
|
||||
"""path -> translated title for every node that has one.
|
||||
|
||||
Titles are chunks too (docs/migrate.md): keyed by the hash of the
|
||||
title text, so editing a title invalidates its translations. Nodes
|
||||
without an entry fall back to their original title in views — as do
|
||||
nodes whose primary language IS ``lang`` (their original title already
|
||||
is in that language).
|
||||
"""
|
||||
titles = {}
|
||||
|
||||
def walk(nodes: dict[str, Node], prefix: str, inherited: str) -> None:
|
||||
for slug, node in nodes.items():
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
node_lang = node.language or inherited
|
||||
if node.title and node_lang != lang:
|
||||
t = data.trans.get(chunk_key(node.title), {}).get(lang)
|
||||
if t:
|
||||
titles[path] = t
|
||||
walk(node.children, path, node_lang)
|
||||
|
||||
walk(data.menu, "", ORIGINAL_LANGUAGE)
|
||||
return titles
|
||||
|
||||
|
||||
def subtree_languages(node: Node) -> set[str]:
|
||||
"""Languages available anywhere in the node's subtree (the union of the
|
||||
``langs`` indexes). Category placeholder pages select their language
|
||||
from this: they have no chunks of their own, but their title,
|
||||
navigation and card text localize wherever a translation exists."""
|
||||
langs = set(node.langs)
|
||||
for child in node.children.values():
|
||||
langs |= subtree_languages(child)
|
||||
return langs
|
||||
|
||||
|
||||
def get_translation(data: Data, path: str, lang: str) -> Translation | None:
|
||||
"""The translation of the page at ``path`` for ``lang``, or None.
|
||||
|
||||
None when the page does not exist or is not available in ``lang``:
|
||||
``node.langs`` is the availability index (a stale key is benign — the
|
||||
"translation" then just renders as the original).
|
||||
"""
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
if node is None or node.chunks is None or lang not in node.langs:
|
||||
return None
|
||||
return Translation(
|
||||
markdown=hybrid_markdown(data, node, path, lang),
|
||||
titles=title_map(data, lang),
|
||||
)
|
||||
+44
-32
@@ -447,39 +447,51 @@ def _heading_ids(state) -> None:
|
||||
wrap(i, token, f"#{hid}")
|
||||
|
||||
|
||||
md = (
|
||||
MarkdownIt(
|
||||
"default",
|
||||
{
|
||||
"html": True,
|
||||
"highlight": _highlight,
|
||||
"typographer": True,
|
||||
"breaks": True,
|
||||
},
|
||||
def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
"""A fully configured parser. The module-level ``md`` (below) is the
|
||||
render instance; ``verbatim=True`` builds the segmentation instance for
|
||||
segments.py, where token text must stay byte-identical to the source so
|
||||
prose spans can be spliced back by offset: no typographer (quotes and
|
||||
dashes stay straight), no tasklist label wrapping (the item text stays
|
||||
a plain text token), and soft line breaks (wrapped prose merges into
|
||||
one segment instead of splitting at hardbreaks)."""
|
||||
parser = (
|
||||
MarkdownIt(
|
||||
"default",
|
||||
{
|
||||
"html": True,
|
||||
"highlight": _highlight,
|
||||
"typographer": not verbatim,
|
||||
"breaks": not verbatim,
|
||||
},
|
||||
)
|
||||
.use(attrs_plugin)
|
||||
.use(admon_plugin)
|
||||
.use(container_plugin, "block", validate=_container_validate)
|
||||
.use(footnote_plugin)
|
||||
.use(deflist_plugin)
|
||||
# label wrapping (render) puts the item text inside the checkbox
|
||||
# <label> html_inline; without it the text stays a plain token.
|
||||
.use(tasklists_plugin, enabled=True, label=not verbatim, label_after=not verbatim)
|
||||
.use(gfm_autolink_plugin)
|
||||
.use(sub_plugin)
|
||||
.use(superscript_plugin)
|
||||
)
|
||||
.use(attrs_plugin)
|
||||
.use(admon_plugin)
|
||||
.use(container_plugin, "block", validate=_container_validate)
|
||||
.use(footnote_plugin)
|
||||
.use(deflist_plugin)
|
||||
# label_after: the item text is wrapped in <label for> after the
|
||||
# checkbox, so clicking the text toggles it.
|
||||
.use(tasklists_plugin, enabled=True, label=True, label_after=True)
|
||||
.use(gfm_autolink_plugin)
|
||||
.use(sub_plugin)
|
||||
.use(superscript_plugin)
|
||||
)
|
||||
md.add_render_rule("image", _image_rule)
|
||||
md.add_render_rule("fence", _fence_rule)
|
||||
# GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule.
|
||||
md.options["alerts"] = True
|
||||
# Block attrs must be stripped before the typographer curlifies their quotes.
|
||||
md.core.ruler.before("replacements", "block_attrs", _block_attrs)
|
||||
md.core.ruler.push("container_attrs", _container_attrs)
|
||||
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
||||
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||
md.core.ruler.push("heading_ids", _heading_ids)
|
||||
parser.add_render_rule("image", _image_rule)
|
||||
parser.add_render_rule("fence", _fence_rule)
|
||||
# GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule.
|
||||
parser.options["alerts"] = True
|
||||
# Block attrs must be stripped before the typographer curlifies their quotes.
|
||||
parser.core.ruler.before("replacements", "block_attrs", _block_attrs)
|
||||
parser.core.ruler.push("container_attrs", _container_attrs)
|
||||
parser.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
||||
parser.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||
parser.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||
parser.core.ruler.push("heading_ids", _heading_ids)
|
||||
return parser
|
||||
|
||||
|
||||
md = make_md()
|
||||
|
||||
|
||||
# Text-length thresholds (visible characters, code blocks excluded) for the
|
||||
|
||||
@@ -15,6 +15,7 @@ import base64
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pagerite.chunks import chunk_key, chunk_markdown
|
||||
from pagerite.data import prettify
|
||||
|
||||
|
||||
@@ -131,3 +132,40 @@ def migrate_v2(d: dict) -> None:
|
||||
walk(d.get("menu") or {})
|
||||
d.pop("version", None)
|
||||
_backfill_derivatives()
|
||||
|
||||
|
||||
def migrate_v3(d: dict) -> None:
|
||||
"""Content-addressed chunk storage (docs/migrate.md): split every
|
||||
node's string ``content`` into block chunks stored once per content
|
||||
hash in the new ``chunks`` store; the node keeps the ordered hash
|
||||
list as ``chunks`` (an absent content stays absent, i.e. None = a
|
||||
pure category label; "" chunks to an empty list = an empty page).
|
||||
|
||||
Chunk keys are 9-byte blake3 digests; at this raw JSON level they are
|
||||
base64 strings (decoding into the structs restores ``bytes`` keys).
|
||||
``trans``/``patches`` start empty; the translator job fills them and
|
||||
maintains the ``langs`` index as translations land. ``language``,
|
||||
``no_trans`` and ``langs`` need nothing — struct defaults cover them.
|
||||
"""
|
||||
store = d.setdefault("chunks", {})
|
||||
d.setdefault("trans", {})
|
||||
patches = d.setdefault("patches", {})
|
||||
|
||||
def walk(nodes: dict) -> None:
|
||||
for node in nodes.values():
|
||||
content = node.pop("content", None)
|
||||
if isinstance(content, str):
|
||||
hashes = []
|
||||
for chunk in chunk_markdown(content):
|
||||
key = base64.b64encode(chunk_key(chunk)).decode()
|
||||
store.setdefault(key, chunk)
|
||||
hashes.append(key)
|
||||
node["chunks"] = hashes
|
||||
walk(node.get("children") or {})
|
||||
|
||||
walk(d.get("menu") or {})
|
||||
# Article paths never carry a leading slash in keys (docs/migrate.md).
|
||||
# The only path-keyed store starts empty here, so this is defensive
|
||||
# for databases that went through a downgrade/upgrade cycle.
|
||||
for key in [k for k in patches if k.startswith("/")]:
|
||||
patches[key.lstrip("/")] = patches.pop(key)
|
||||
|
||||
+2
-2
@@ -114,7 +114,7 @@ Headings from `##` down organize the article. On pages with at least three of th
|
||||
> and a blank `>` line starts a new paragraph.
|
||||
|
||||
> [!NOTE]
|
||||
> GitHub-style alerts — NOTE, TIP, IMPORTANT, WARNING, CAUTION —
|
||||
> GitHub-style alerts — `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION` —
|
||||
> render as callout boxes.
|
||||
```
|
||||
|
||||
@@ -122,7 +122,7 @@ Headings from `##` down organize the article. On pages with at least three of th
|
||||
> and a blank `>` line starts a new paragraph.
|
||||
|
||||
> [!NOTE]
|
||||
> GitHub-style alerts — NOTE, TIP, IMPORTANT, WARNING, CAUTION —
|
||||
> GitHub-style alerts — `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION` —
|
||||
> render as callout boxes.
|
||||
|
||||
## Code
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Segmented translation round trip: prose out, translations back in.
|
||||
|
||||
A translator model mangles anything that is not plain prose — sentinels get
|
||||
renumbered, ``![`` becomes sentence punctuation, stray ``<br>`` tags appear.
|
||||
So the model is never shown any of it: a fragment (a Markdown chunk or a
|
||||
node title) is parsed with the project's own markdown-it setup
|
||||
(``markdown.make_md(verbatim=True)`` — extensions included, so container,
|
||||
attrs, footnote and tasklist syntax never leaks into text tokens) and split
|
||||
into **prose segments**: the merged text runs, plus image alt texts and
|
||||
link/image titles. Only those cross the wire, as a plain list of strings
|
||||
(Job.texts / Result.texts in translate.py) — accompanied, per segment, by
|
||||
a CONTEXT (Job.contexts): a segment carved out of a larger block (a link
|
||||
text, a partial run) carries the block's plain text, so the model sees the
|
||||
sentence it lives in; whole-block segments are self-contextualizing and
|
||||
carry "". Title fragments carry the article's opening instead (assigned by
|
||||
the dispatcher from TransItem.context).
|
||||
|
||||
Reassembly is server-side offset splicing, not text the model produced:
|
||||
each segment's source span was located at dispatch (``split``), and
|
||||
``join`` swaps in the translations. Markup therefore cannot break — it
|
||||
never left the server. A returned segment must still be pure prose itself
|
||||
(the model could inject markup INTO a segment); anything else — count
|
||||
mismatch, empty segment, markup tokens — rejects the whole result and the
|
||||
fragment stays pending.
|
||||
|
||||
A block of plain text, prose links and paired text formatting
|
||||
(strong/em/s) crosses as ONE segment — link texts and formatted text
|
||||
inline, in sentence context, with the Markdown stripped (the model
|
||||
mangles it: sentinels get renumbered, ``**`` gets dropped or moved) —
|
||||
because a label translated apart from its sentence comes back
|
||||
grammatically incompatible with it (case government, particles, word
|
||||
order). ``join`` re-inserts the link/formatting markdown into the
|
||||
translated block at weight-mapped positions (``_place_marks``): no
|
||||
markers on the wire, the boundaries are found by text processing alone —
|
||||
each mark's word/CJK-char weight ratio in the source applied to the
|
||||
translation's units. Placement is approximate and CJK-safe: better a
|
||||
coherent sentence with a slightly shifted link than separately translated
|
||||
snippets that don't fit together. Blocks with any other inline markup
|
||||
(code, images, HTML) still split into runs at those boundaries.
|
||||
|
||||
Locating is best effort: a run that is not a verbatim source substring
|
||||
(entity-decoded text, backslash escapes) is skipped — it simply stays in
|
||||
the original language. So is any piece containing "<": "<" is the
|
||||
prose/markup boundary on the wire — translators cut their output there,
|
||||
so such pieces could not survive the round trip.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from pagerite.markdown import make_md
|
||||
|
||||
#: The segmentation parser: the project's own markdown-it, verbatim flavor
|
||||
#: (see make_md). Never used for rendering.
|
||||
_MD = make_md(verbatim=True)
|
||||
|
||||
#: Any Unicode letter (digits and underscore are not prose).
|
||||
_LETTER = re.compile(r"[^\W\d_]")
|
||||
|
||||
#: A GFM alert marker ([!NOTE] etc.) at the start of a blockquote's first
|
||||
#: paragraph: syntax, not prose — stripped from the first segment.
|
||||
_ALERT = re.compile(r"^\[![A-Za-z]+\][ \t]*")
|
||||
|
||||
#: Any {...} span: {placeholders} and attrs that ended up inside prose
|
||||
#: (inline attrs are consumed by the parser; a lone {dates} is not).
|
||||
_BRACES = re.compile(r"\{[^{}\n]*\}")
|
||||
|
||||
#: A link's tail after its text: "](dest)", "](dest \"title\")", "][ref]",
|
||||
#: "[]" or a bare "]" (shortcut reference); the destination may nest one
|
||||
#: level of parens. Best effort — a mis-scan fails the span-reconstruction
|
||||
#: check in _linked_block and the block falls back to per-run segments.
|
||||
_LINK_TAIL = re.compile(r"\](?:\((?:\\.|[^()\\]|\([^()]*\))*\)|\[(?:\\.|[^\]])*\])?")
|
||||
|
||||
#: Weight units for mapping link boundaries from source to translation:
|
||||
#: a word counts 1 and so does every single CJK ideograph (kana runs count
|
||||
#: as one) — CJK has no spaces to count words by. Punctuation and
|
||||
#: whitespace count nothing, so mapped boundaries always land on unit
|
||||
#: starts.
|
||||
_UNIT = re.compile(
|
||||
r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]" # CJK ideographs: one unit each
|
||||
r"|[\u3040-\u309f\u30a0-\u30ff]+" # kana runs: one unit each
|
||||
r"|\w+" # anything else word-like (Latin, Cyrillic, Hangul, digits)
|
||||
)
|
||||
|
||||
|
||||
class Mark(NamedTuple):
|
||||
"""One inline link or paired formatting (strong/em/s) inside a
|
||||
whole-block segment: the source weight (unit count, see _UNIT) at the
|
||||
inner text's start and end for mapping the boundaries into the
|
||||
translation, the exact source syntax around the text ("[" / "](url)",
|
||||
"**" / "**", ...) and the source text itself, used as the fallback when
|
||||
the mapped slice comes out empty (better an untranslated label than a
|
||||
broken "[](url)")."""
|
||||
|
||||
w_start: int
|
||||
w_end: int
|
||||
pre: str
|
||||
post: str
|
||||
inner: str
|
||||
|
||||
|
||||
class Span(NamedTuple):
|
||||
"""A segment's source span in the fragment: offsets for splicing the
|
||||
translation back, the segment's source weight and the links to
|
||||
re-insert into its translation (empty = a plain prose segment)."""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
weight: int
|
||||
marks: list[Mark]
|
||||
|
||||
|
||||
def _weight(text: str) -> int:
|
||||
"""The text's weight in translation-mapping units (see _UNIT)."""
|
||||
return len(_UNIT.findall(text))
|
||||
|
||||
|
||||
def _unit_bounds(text: str) -> list[int]:
|
||||
"""Unit-start offsets of the text, plus its end as the last bound."""
|
||||
return [m.start() for m in _UNIT.finditer(text)] + [len(text)]
|
||||
|
||||
|
||||
def _runs(children: list) -> list[str]:
|
||||
"""Prose runs of an inline token's children, in order.
|
||||
|
||||
Text tokens merge across soft breaks into one run; every markup token
|
||||
(emphasis, links, code, images, HTML, footnote refs, hard breaks) is a
|
||||
run boundary. Link and image *text* is prose; autolink text (the URL
|
||||
itself) is not. Image tokens contribute their alt-text children and
|
||||
their title attribute.
|
||||
"""
|
||||
runs: list[str] = []
|
||||
cur: list[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
if cur:
|
||||
s = "".join(cur)
|
||||
cur.clear()
|
||||
if _LETTER.search(s):
|
||||
runs.append(s)
|
||||
|
||||
skip = 0 # inside an autolink (its text is the URL — not prose)
|
||||
for t in children:
|
||||
if skip:
|
||||
if t.type == "link_close":
|
||||
skip -= 1
|
||||
continue
|
||||
if t.type == "text":
|
||||
cur.append(t.content)
|
||||
elif t.type == "softbreak":
|
||||
cur.append("\n")
|
||||
elif t.type == "link_open" and t.markup == "autolink":
|
||||
flush()
|
||||
skip = 1
|
||||
elif t.type == "image":
|
||||
flush()
|
||||
if t.children:
|
||||
runs.extend(_runs(t.children))
|
||||
title = t.attrGet("title")
|
||||
if title and _LETTER.search(title):
|
||||
runs.append(title)
|
||||
else:
|
||||
flush()
|
||||
if t.children:
|
||||
runs.extend(_runs(t.children))
|
||||
flush()
|
||||
return runs
|
||||
|
||||
|
||||
def _block_text(children: list) -> str:
|
||||
"""The block's text as a reader sees it: text runs and link texts
|
||||
merged (softbreaks as newlines); image alts, autolink URLs, code and
|
||||
other markup content excluded. Used as the translation CONTEXT for
|
||||
segments carved out of the block (link texts, partial runs): a lone
|
||||
word translates differently than the same word inside its sentence."""
|
||||
parts: list[str] = []
|
||||
skip = 0 # inside an autolink (its text is the URL)
|
||||
for t in children:
|
||||
if skip:
|
||||
if t.type == "link_close":
|
||||
skip -= 1
|
||||
continue
|
||||
if t.type == "text":
|
||||
parts.append(t.content)
|
||||
elif t.type == "softbreak":
|
||||
parts.append("\n")
|
||||
elif t.type == "link_open" and t.markup == "autolink":
|
||||
skip = 1
|
||||
elif t.type == "image":
|
||||
continue
|
||||
elif t.children:
|
||||
parts.append(_block_text(t.children))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _locate(source: str, needle: str, cursor: int) -> int:
|
||||
"""The needle's offset in source at/after cursor, -1 when absent.
|
||||
|
||||
An occurrence preceded by a backslash is an escaped character, not the
|
||||
token's source: keep looking (failing that, the run is skipped — it
|
||||
stays in the original language).
|
||||
"""
|
||||
pos = source.find(needle, cursor)
|
||||
while pos > 0 and source[pos - 1] == "\\":
|
||||
pos = source.find(needle, pos + 1)
|
||||
return pos
|
||||
|
||||
|
||||
def _linked_block(
|
||||
source: str, kids: list, cursor: int, strip_alert: bool
|
||||
) -> tuple[Span, str] | None:
|
||||
"""A whole-block segment for an inline of plain text, prose links and
|
||||
paired text formatting (strong/em/s): (Span, wire text) with the links
|
||||
and formatting as marks, or None when the block has any other shape —
|
||||
the caller then falls back to per-run segments.
|
||||
|
||||
The block crosses the wire as one prose piece, link texts and formatted
|
||||
text inline (the model is never shown any Markdown — it mangles it),
|
||||
so a translation that inflects or reorders around them stays coherent;
|
||||
join re-inserts the link/formatting syntax at weight-mapped positions.
|
||||
The source span is located piece by piece and verified by
|
||||
reconstruction; anything not byte-exact (entities, escapes, an odd
|
||||
link tail) bails to the fallback.
|
||||
"""
|
||||
pieces: list[tuple[str, str]] = [] # (text, mark): "" plain, "link", else the delimiter
|
||||
buf: list[str] = [] # current plain piece
|
||||
link: list[str] | None = None # current mark's text parts
|
||||
mark_kind = "" # the current mark's opener ("link" or the delimiter)
|
||||
for tok in kids:
|
||||
if tok.type in ("link_open", "strong_open", "em_open", "s_open"):
|
||||
if link is not None or tok.markup == "autolink":
|
||||
return None
|
||||
if buf:
|
||||
pieces.append(("".join(buf), ""))
|
||||
buf = []
|
||||
link = []
|
||||
mark_kind = "link" if tok.type == "link_open" else tok.markup
|
||||
elif tok.type in ("link_close", "strong_close", "em_close", "s_close"):
|
||||
if link is None or ("link" if tok.type == "link_close" else tok.markup) != mark_kind:
|
||||
return None
|
||||
inner = "".join(link)
|
||||
if not _LETTER.search(inner):
|
||||
return None
|
||||
pieces.append((inner, mark_kind))
|
||||
link = None
|
||||
elif tok.type in ("text", "softbreak"):
|
||||
(link if link is not None else buf).append(
|
||||
"\n" if tok.type == "softbreak" else tok.content
|
||||
)
|
||||
else: # code, images, HTML, footnote refs: run boundaries
|
||||
return None
|
||||
if link is not None:
|
||||
return None # unbalanced (the parser should not do this)
|
||||
if buf:
|
||||
pieces.append(("".join(buf), ""))
|
||||
if not any(mark for _, mark in pieces):
|
||||
return None
|
||||
if strip_alert and pieces and not pieces[0][1]:
|
||||
# A GFM alert marker leading the blockquote's first paragraph is
|
||||
# syntax; strip it from the wire text (it stays out of the span).
|
||||
first = _ALERT.sub("", pieces[0][0], count=1)
|
||||
if first.strip():
|
||||
pieces[0] = (first, "")
|
||||
else:
|
||||
pieces.pop(0)
|
||||
if not pieces:
|
||||
return None
|
||||
raw = "".join(text for text, _ in pieces)
|
||||
lead = len(raw) - len(raw.lstrip())
|
||||
wire = raw.strip()
|
||||
if not _LETTER.search(wire) or "<" in wire or _BRACES.search(wire):
|
||||
return None
|
||||
# Locate each piece verbatim, in order; the source slices between the
|
||||
# located pieces are then the link syntax, exact by construction.
|
||||
located: list[tuple[int, int]] = []
|
||||
pos = cursor
|
||||
for text_, _ in pieces:
|
||||
at = _locate(source, text_, pos)
|
||||
if at == -1:
|
||||
return None
|
||||
located.append((at, at + len(text_)))
|
||||
pos = at + len(text_)
|
||||
span_start, span_end = located[0][0], located[-1][1]
|
||||
marks: list[Mark] = []
|
||||
offset = 0 # raw (pre-strip) plain-text offset of the current piece
|
||||
for i, ((text_, kind), (s, e)) in enumerate(zip(pieces, located)):
|
||||
if not kind:
|
||||
offset += len(text_)
|
||||
continue
|
||||
# The syntax around the text: the gap between pieces goes to the
|
||||
# mark on its left as post (so between two marks the whole "](u)["
|
||||
# or "**" is the first's post); a block-leading mark takes its
|
||||
# opener in front of its text ("[" or the delimiter), a
|
||||
# block-trailing one the scanned link tail or the close delimiter.
|
||||
if i == 0:
|
||||
opener = "[" if kind == "link" else kind
|
||||
if s < len(opener) or source[s - len(opener):s] != opener:
|
||||
return None
|
||||
pre, span_start = opener, s - len(opener)
|
||||
elif pieces[i - 1][1]:
|
||||
pre = "" # the previous mark's post covers the whole gap
|
||||
else:
|
||||
pre = source[located[i - 1][1]:s]
|
||||
if i + 1 < len(pieces):
|
||||
post = source[e:located[i + 1][0]]
|
||||
elif kind == "link":
|
||||
m = _LINK_TAIL.match(source, e)
|
||||
if m is None:
|
||||
return None
|
||||
post, span_end = m.group(), m.end()
|
||||
else:
|
||||
if source[e:e + len(kind)] != kind:
|
||||
return None
|
||||
post, span_end = kind, e + len(kind)
|
||||
ps = min(max(offset - lead, 0), len(wire))
|
||||
pe = min(max(offset + len(text_) - lead, 0), len(wire))
|
||||
if pe <= ps:
|
||||
return None
|
||||
marks.append(Mark(_weight(wire[:ps]), _weight(wire[:pe]), pre, post, wire[ps:pe]))
|
||||
offset += len(text_)
|
||||
# Verify: the marks must reconstruct the source span exactly (the only
|
||||
# real risk is the guessed tail of a trailing link).
|
||||
rec: list[str] = []
|
||||
mi = 0
|
||||
for text_, kind in pieces:
|
||||
if kind:
|
||||
mark = marks[mi]
|
||||
mi += 1
|
||||
rec += [mark.pre, text_, mark.post]
|
||||
else:
|
||||
rec.append(text_)
|
||||
if source[span_start:span_end] != "".join(rec):
|
||||
return None
|
||||
return Span(span_start, span_end, _weight(wire), marks), wire
|
||||
|
||||
|
||||
def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
||||
"""Split a fragment into (spans, segments, contexts): prose segments to
|
||||
translate, their source spans in ``text`` for splicing the translations
|
||||
back, and per-segment translation context.
|
||||
|
||||
A block of plain text, prose links and paired formatting (strong/em/s)
|
||||
becomes ONE segment (link/formatted text inline, in context, Markdown
|
||||
stripped), the links and formatting recorded as marks on its Span for
|
||||
weight-mapped re-insertion in join. Other blocks split into text runs
|
||||
at markup boundaries; runs containing {...} spans are carved further —
|
||||
the braces stay out of the wire text. A run that cannot be located
|
||||
verbatim in the source contributes no segment. A segment's context is
|
||||
its block's plain text when the segment was carved OUT of a larger
|
||||
block (a partial run); a segment that IS the whole block (a plain
|
||||
paragraph, a heading, a linked block) is self-contextualizing and gets
|
||||
"".
|
||||
"""
|
||||
spans: list[Span] = []
|
||||
segments: list[str] = []
|
||||
contexts: list[str] = []
|
||||
cursor = 0
|
||||
blockquote_fresh = 0 # blockquote depth whose first inline is upcoming
|
||||
|
||||
def emit(run: str, at: int, ctx: str) -> None:
|
||||
"""Carve {...} spans out of the located run; emit the prose pieces,
|
||||
stripped — padding whitespace stays in the template, off the wire.
|
||||
Pieces containing "<" are never emitted: translators cut output at
|
||||
the first "<" (the prose/markup boundary, scripts/translator.py),
|
||||
so such a piece could not survive the round trip — it stays in the
|
||||
original language instead."""
|
||||
pieces = []
|
||||
pos = 0
|
||||
for m in _BRACES.finditer(run):
|
||||
pieces.append((pos, m.start()))
|
||||
pos = m.end()
|
||||
pieces.append((pos, len(run)))
|
||||
for p0, p1 in pieces:
|
||||
raw = run[p0:p1]
|
||||
piece = raw.strip()
|
||||
if _LETTER.search(piece) and "<" not in piece:
|
||||
start = at + p0 + (len(raw) - len(raw.lstrip()))
|
||||
spans.append(Span(start, start + len(piece), 0, []))
|
||||
segments.append(piece)
|
||||
contexts.append(ctx)
|
||||
|
||||
tokens = _MD.parse(text)
|
||||
for t in tokens:
|
||||
if t.type == "blockquote_open":
|
||||
blockquote_fresh += 1
|
||||
elif t.type == "blockquote_close":
|
||||
blockquote_fresh -= 1
|
||||
elif t.type == "inline":
|
||||
kids = t.children or []
|
||||
# An alert marker ([!NOTE]) leading a blockquote's first
|
||||
# paragraph is syntax; both paths strip it. (Only the first
|
||||
# inline of the blockquote can carry it — the flag clears on
|
||||
# the first inline seen.)
|
||||
alert = bool(blockquote_fresh)
|
||||
blockquote_fresh = 0
|
||||
linked = _linked_block(text, kids, cursor, strip_alert=alert)
|
||||
if linked is not None:
|
||||
span, wire = linked
|
||||
spans.append(span)
|
||||
segments.append(wire)
|
||||
contexts.append("")
|
||||
cursor = span.end
|
||||
continue
|
||||
runs = _runs(kids)
|
||||
block = _block_text(kids).strip()
|
||||
if alert and runs:
|
||||
run = _ALERT.sub("", runs[0], count=1)
|
||||
if _LETTER.search(run):
|
||||
runs[0] = run
|
||||
else:
|
||||
runs.pop(0)
|
||||
for run in runs:
|
||||
ctx = block if block and run.strip() != block else ""
|
||||
pos = _locate(text, run, cursor)
|
||||
if pos != -1:
|
||||
emit(run, pos, ctx)
|
||||
cursor = pos + len(run)
|
||||
elif "\n" in run:
|
||||
# Indented continuation lines etc. break the verbatim
|
||||
# match: locate each line separately instead.
|
||||
for part in run.split("\n"):
|
||||
if not _LETTER.search(part):
|
||||
continue
|
||||
pos = _locate(text, part, cursor)
|
||||
if pos != -1:
|
||||
emit(part, pos, ctx)
|
||||
cursor = pos + len(part)
|
||||
return spans, segments, contexts
|
||||
|
||||
|
||||
def pure_prose(text: str) -> bool:
|
||||
"""True when the text parses as nothing but prose (text and softbreak
|
||||
tokens) — the acceptance test for a translated segment: the model may
|
||||
not return markup of its own (a `<br>` here would splice live HTML into
|
||||
the fragment)."""
|
||||
children = _MD.parseInline(text)[0].children or []
|
||||
return all(t.type in ("text", "softbreak") for t in children)
|
||||
|
||||
|
||||
def _place_marks(translation: str, weight: int, marks: list[Mark]) -> str | None:
|
||||
"""Re-insert a whole-block segment's links into its translation.
|
||||
|
||||
Each mark's source weight ratio (units before the boundary / total) is
|
||||
applied to the translation's units — a rough bilingual alignment that
|
||||
needs no markers in the wire text (sentinels never survived the model)
|
||||
and works for CJK, where exact placement matters less. A boundary
|
||||
landing empty degrades to the source link text: better an untranslated
|
||||
label than a broken "[](url)". None when the translation has no units
|
||||
to map onto (the caller rejects the result).
|
||||
"""
|
||||
bounds = _unit_bounds(translation)
|
||||
total = len(bounds) - 1
|
||||
if not total or not weight:
|
||||
return None
|
||||
out: list[str] = []
|
||||
cur = 0
|
||||
for mark in marks:
|
||||
x1 = bounds[min(round(mark.w_start / weight * total), total)]
|
||||
x2 = bounds[min(round(mark.w_end / weight * total), total)]
|
||||
x1 = max(x1, cur) # monotonic: never before the previous mark's end
|
||||
x2 = max(x2, x1)
|
||||
# The slice ends at the next unit's start, so the whitespace and
|
||||
# punctuation before that unit is inside it — but it belongs
|
||||
# BETWEEN the mark and the following word, not in the inner text:
|
||||
# end the inner text at its last unit and leave the rest for the
|
||||
# following slice (the cursor stays ahead of it).
|
||||
raw = translation[x1:x2]
|
||||
units = list(_UNIT.finditer(raw))
|
||||
inner_end = x1 + units[-1].end() if units else x1
|
||||
inner = translation[x1:inner_end].strip() or mark.inner
|
||||
out += [translation[cur:x1], mark.pre, inner, mark.post]
|
||||
cur = inner_end
|
||||
out.append(translation[cur:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def join(original: str, spans: list[Span], texts: list[str]) -> str | None:
|
||||
"""Splice translated segments back into the original fragment; None on
|
||||
any validation failure (count mismatch, empty or non-prose segment) —
|
||||
the caller drops the result and the fragment stays pending. Segments
|
||||
with marks (a block that crossed as one piece) get their links
|
||||
re-inserted at weight-mapped positions after the prose check."""
|
||||
if len(texts) != len(spans):
|
||||
return None
|
||||
out: list[str] = []
|
||||
cursor = 0
|
||||
for span, translation in zip(spans, texts):
|
||||
if not translation.strip() or not pure_prose(translation):
|
||||
return None
|
||||
if span.marks:
|
||||
translation = _place_marks(translation, span.weight, span.marks)
|
||||
if translation is None:
|
||||
return None
|
||||
out.append(original[cursor:span.start])
|
||||
out.append(translation)
|
||||
cursor = span.end
|
||||
out.append(original[cursor:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def has_prose(text: str) -> bool:
|
||||
"""True when the fragment yields at least one translatable segment.
|
||||
Chunks that are all markup, code, placeholders or reference definitions
|
||||
have no business reaching the model: every language renders them from
|
||||
the original chunk."""
|
||||
return bool(split(text)[1])
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Translator service protocol, dispatcher and its transport-independent core.
|
||||
|
||||
The external machine-translation service connects over WebSocket
|
||||
(``/_translate/<key>``, the route itself is in app.py) and exchanges JSON
|
||||
frames decoded into the tagged msgspec structs below (``bytes`` fields ride
|
||||
as base64 — no manual encoding anywhere). This module holds everything
|
||||
else: the message structs, the connected-client dispatcher (``Dispatcher``
|
||||
— one job at a time per connection, wanted ∩ capable language matching,
|
||||
requeue on disconnect), which fragments are pending for a language
|
||||
(``pending_items``), storing a result (``store_results``) and the startup
|
||||
URL listing (``log_service_urls``).
|
||||
|
||||
Fragments cross the wire as **prose segments**: the model only ever
|
||||
receives plain text runs (Job.texts) plus per-segment context surrounds
|
||||
(Job.contexts) and returns their translations (Result.texts, same order);
|
||||
markup never leaves the server — reassembly is offset splicing
|
||||
(``pagerite/segments.py``).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import msgspec
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from kanta import Kanta
|
||||
|
||||
from pagerite import i18n
|
||||
from pagerite.__main__ import DEFAULT_PORT
|
||||
from pagerite.chunks import chunk_key, needs_translation
|
||||
from pagerite.data import Data, Node, sorted_nodes
|
||||
from pagerite.segments import Span, join, split
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Hello(msgspec.Struct, tag="hello"):
|
||||
"""Client greeting on connect: the language codes its model CAN produce
|
||||
(capabilities). The server offers jobs only in the intersection with
|
||||
the wanted target languages (``Data.translate_langs``)."""
|
||||
|
||||
langs: list[str]
|
||||
|
||||
|
||||
class TransItem(msgspec.Struct):
|
||||
"""One fragment to translate: original Markdown (or a node title)."""
|
||||
|
||||
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
||||
text: str
|
||||
path: str #: article it came from ("" = front page), no leading slash
|
||||
kind: str #: "chunk" | "title"
|
||||
#: Title jobs only: the article's opening prose, so the model sees the
|
||||
#: title as a heading in context, not a lone sentence.
|
||||
context: str = ""
|
||||
|
||||
|
||||
class Job(msgspec.Struct, tag="job"):
|
||||
"""Server push: ONE fragment to translate.
|
||||
|
||||
Exactly one job is in flight per connection — the next is sent only
|
||||
after this one's Result. Clients wanting parallelism open multiple
|
||||
connections."""
|
||||
|
||||
lang: str
|
||||
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
||||
#: The fragment's prose segments (pagerite/segments.py): plain text
|
||||
#: runs only — no markup, URLs, code or placeholders ever cross the
|
||||
#: wire. Translate each element independently.
|
||||
texts: list[str]
|
||||
path: str #: article it came from ("" = front page), no leading slash
|
||||
kind: str #: "chunk" | "title"
|
||||
#: Per segment (parallel to texts; "" = none): the surround to
|
||||
#: translate it in — a carved-out segment (link text, partial run)
|
||||
#: carries its block's plain text, a title the article's opening.
|
||||
#: Reference client behavior (scripts/translator.py): translate
|
||||
#: segment+context together, keep the segment's part (its own line /
|
||||
#: paragraph); fall back to the segment alone when the output holds no
|
||||
#: separator. Contexts are not part of the result.
|
||||
contexts: list[str] = msgspec.field(default_factory=list)
|
||||
|
||||
|
||||
class TransResult(msgspec.Struct):
|
||||
"""One translated fragment (storage level, see store_results)."""
|
||||
|
||||
key: bytes
|
||||
text: str
|
||||
|
||||
|
||||
class Result(msgspec.Struct, tag="result"):
|
||||
"""Client reply: the translation of the connection's current Job
|
||||
(must match its lang and key exactly)."""
|
||||
|
||||
lang: str
|
||||
key: bytes
|
||||
#: The job's segments, translated, same order and count. Each must be
|
||||
#: pure prose — the server rejects the result otherwise.
|
||||
texts: list[str]
|
||||
|
||||
|
||||
#: Union of the client -> server frames (the "type" tag selects).
|
||||
ClientMsg = Hello | Result
|
||||
|
||||
|
||||
def pending_items(data: Data, lang: str) -> list[TransItem]:
|
||||
"""Fragments of the site still untranslated for ``lang``, deduped by key.
|
||||
|
||||
Every page node (published or not) contributes its title and each chunk
|
||||
that needs translation (``needs_translation``), is not editor-flagged
|
||||
no-translate (``node.no_trans``) and has no ``trans`` entry for ``lang``
|
||||
yet. Content-addressed text (shared paragraphs, repeated titles) appears
|
||||
once, under the first page in menu order that has it.
|
||||
"""
|
||||
items: list[TransItem] = []
|
||||
seen: set[bytes] = set()
|
||||
|
||||
def emit(key: bytes, text: str, path: str, kind: str, context: str = "") -> None:
|
||||
if key in seen or lang in data.trans.get(key, {}):
|
||||
return
|
||||
seen.add(key)
|
||||
items.append(TransItem(key=key, text=text, path=path, kind=kind, context=context))
|
||||
|
||||
def opening(node: Node) -> str:
|
||||
"""The article's opening prose (first segment, capped): the title
|
||||
job's context — a lone word like "About" reads as a heading on top
|
||||
of an article, not as a sentence. Empty when there's no prose."""
|
||||
for h in node.chunks or ():
|
||||
text = data.chunks.get(h)
|
||||
if text and (segs := split(text)[1]):
|
||||
return segs[0][:400]
|
||||
return ""
|
||||
|
||||
def walk(nodes: dict[str, Node], prefix: str, inherited: str) -> None:
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
# An article whose primary language IS the target needs no
|
||||
# translation into it — skip its title and chunks entirely.
|
||||
node_lang = node.language or inherited
|
||||
if node.chunks is not None and node_lang != lang:
|
||||
if node.title:
|
||||
emit(chunk_key(node.title), node.title, path, "title",
|
||||
context=opening(node))
|
||||
for h in node.chunks:
|
||||
text = data.chunks.get(h)
|
||||
if (
|
||||
text is not None
|
||||
and h not in node.no_trans
|
||||
and needs_translation(text)
|
||||
):
|
||||
emit(h, text, path, "chunk")
|
||||
walk(node.children, path, node_lang)
|
||||
|
||||
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
||||
return items
|
||||
|
||||
|
||||
def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
|
||||
"""Store machine translations for ``lang``; return the paths of the
|
||||
articles that gained at least one entry.
|
||||
|
||||
Pure data operations: the caller wraps this in a kanta transaction and
|
||||
invalidates pages. Unknown keys are stored anyway (unreferenced hashes
|
||||
are never read, and the content may simply have moved on since the job
|
||||
was pushed); re-storing an existing key overwrites, last wins. Every
|
||||
article that gained an entry gets ``node.langs[lang]`` set (the
|
||||
availability index, docs/migrate.md) — because chunks are
|
||||
content-addressed, that includes pages merely sharing a fragment.
|
||||
"""
|
||||
stored = {item.key for item in items}
|
||||
for item in items:
|
||||
data.trans.setdefault(item.key, {})[lang] = item.text
|
||||
pages: list[str] = []
|
||||
|
||||
def walk(nodes: dict[str, Node], prefix: str, inherited: str) -> None:
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
node_lang = node.language or inherited
|
||||
if node.chunks is not None and node_lang != lang:
|
||||
keys = set(node.chunks)
|
||||
if node.title:
|
||||
keys.add(chunk_key(node.title))
|
||||
if keys & stored:
|
||||
node.langs[lang] = True
|
||||
pages.append(path)
|
||||
walk(node.children, path, node_lang)
|
||||
|
||||
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
||||
return pages
|
||||
|
||||
|
||||
def log_service_urls(keys: dict[str, str], hostname: str) -> None:
|
||||
"""Log the translator WebSocket URL(s) for the admin at startup, one
|
||||
line: "…/_translate/<key> (<name>)", comma-joined — ws:// with the port
|
||||
on localhost, wss:// without a port on a public hostname (the port comes
|
||||
from the CLI via the PAGERITE_PORT env). No-op without keys."""
|
||||
if not keys:
|
||||
return
|
||||
port = os.getenv("PAGERITE_PORT", DEFAULT_PORT)
|
||||
base = f"ws://localhost:{port}" if hostname == "localhost" else f"wss://{hostname}"
|
||||
urls = ", ".join(f"{base}/_translate/{key} ({name})" for key, name in keys.items())
|
||||
logger.info("Translator %s", urls)
|
||||
|
||||
|
||||
class _Connection:
|
||||
"""One connected translator socket: the language codes it announced as
|
||||
capabilities (Hello) and the (lang, chunk-key) job currently in flight
|
||||
on it, with the segment spans to splice its Result into
|
||||
(pagerite/segments.py) — one at a time, the next is sent only after its
|
||||
Result.
|
||||
|
||||
Per-connection only: in-flight lives solely here, so on disconnect the
|
||||
item simply becomes pending again and is re-offered to any free capable
|
||||
connection."""
|
||||
|
||||
def __init__(self, capable: set[str]) -> None:
|
||||
self.capable = capable
|
||||
self.inflight: tuple[str, bytes] | None = None
|
||||
#: Source spans of the in-flight job's segments (splice offsets
|
||||
#: and link marks).
|
||||
self.spans: list[Span] = []
|
||||
self.original: str = "" # its full source text (for the splicing)
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
"""The translator dispatcher: connected client sockets and the job
|
||||
pipeline (docs/localization.md).
|
||||
|
||||
One single-item job at a time per connection, offered in the
|
||||
intersection of the wanted languages (``Data.translate_langs``) and the
|
||||
connection's announced capabilities. Pending work is derived from the
|
||||
``trans`` store (``pending_items``) minus the items in flight on any
|
||||
connection, so a dropped connection's in-flight item is simply
|
||||
re-offered. Results are matched to content by chunk key alone. A
|
||||
(lang, key) whose Result fails segment validation is skipped for the
|
||||
rest of the run — generation is near-deterministic, so an immediate
|
||||
retry would just re-fail.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Data, db: Kanta, invalidate) -> None:
|
||||
self.data = data
|
||||
self.db = db
|
||||
#: Sync content-change hook (app._invalidate_pages), called inside
|
||||
#: transactions; schedules the next dispatch pass.
|
||||
self.invalidate = invalidate
|
||||
#: Connected translator sockets and their per-connection state.
|
||||
self.clients: dict[WebSocket, _Connection] = {}
|
||||
#: (lang, chunk key) of fragments whose result failed validation
|
||||
#: (segment count, empty or non-prose segments, segments.py) this run.
|
||||
self.validation_failures: set[tuple[str, bytes]] = set()
|
||||
|
||||
def schedule(self) -> None:
|
||||
"""Schedule a dispatch pass, if any translator is connected.
|
||||
|
||||
The invalidate hook is sync and called inside transactions: the
|
||||
task first runs once the current coroutine awaits again, i.e. after
|
||||
the transaction has committed. No-op without a running loop (CLI
|
||||
use)."""
|
||||
if not self.clients:
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
asyncio.create_task(self._dispatch())
|
||||
|
||||
async def _dispatch(self) -> None:
|
||||
"""Offer one pending item to every free capable connection."""
|
||||
wanted = {
|
||||
tag
|
||||
for lang in self.data.translate_langs
|
||||
if (tag := i18n.base_tag(lang))
|
||||
}
|
||||
if not wanted:
|
||||
return
|
||||
for ws, state in list(self.clients.items()):
|
||||
if state.inflight is not None:
|
||||
continue
|
||||
langs = wanted & state.capable
|
||||
if not langs:
|
||||
continue
|
||||
inflight = {s.inflight for s in self.clients.values() if s.inflight}
|
||||
job = None
|
||||
spans: list[Span] = []
|
||||
original = ""
|
||||
for lang in sorted(langs):
|
||||
for item in pending_items(self.data, lang):
|
||||
if (lang, item.key) in inflight or (lang, item.key) in self.validation_failures:
|
||||
continue
|
||||
spans, texts, contexts = split(item.text)
|
||||
if not texts:
|
||||
continue # prose that could not be located for splicing
|
||||
original = item.text
|
||||
if item.kind == "title" and item.context:
|
||||
# A title's surround is the article's opening prose
|
||||
# (TransItem.context), not its own one-word block.
|
||||
contexts = [item.context] * len(texts)
|
||||
job = Job(
|
||||
lang=lang, key=item.key, texts=texts,
|
||||
path=item.path, kind=item.kind, contexts=contexts,
|
||||
)
|
||||
break
|
||||
if job is not None:
|
||||
break
|
||||
if job is None:
|
||||
continue
|
||||
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
||||
state.spans = spans
|
||||
state.original = original
|
||||
try:
|
||||
await ws.send_text(msgspec.json.encode(job).decode())
|
||||
except Exception: # send failed: the receive loop cleans up
|
||||
self.clients.pop(ws, None)
|
||||
|
||||
async def handle_ws(self, ws: WebSocket, clientkey: str) -> None:
|
||||
"""The /_translate/<key> channel (docs/localization.md).
|
||||
|
||||
A wrong/empty key rejects the handshake (closing before accept
|
||||
makes Starlette answer HTTP 403). Protocol (JSON frames): the
|
||||
client opens with Hello(langs) announcing its CAPABILITIES — the
|
||||
language codes its model can produce (normalized to translation
|
||||
tags; "en"/empty dropped) — then answers each Job with its
|
||||
Result(lang, key, texts). A Result without an in-flight job or with
|
||||
a different (lang, key), a duplicate Hello, or any malformed frame
|
||||
closes the socket with a protocol error.
|
||||
"""
|
||||
if clientkey not in self.data.translate_keys:
|
||||
await ws.close(code=1008) # policy violation; pre-accept = HTTP 403
|
||||
return
|
||||
await ws.accept()
|
||||
state: _Connection | None = None
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
try:
|
||||
msg = msgspec.json.decode(raw.encode(), type=ClientMsg)
|
||||
except msgspec.DecodeError:
|
||||
await ws.close(code=1002) # protocol error
|
||||
return
|
||||
if isinstance(msg, Hello):
|
||||
if state is not None: # one Hello per connection
|
||||
await ws.close(code=1002)
|
||||
return
|
||||
state = _Connection({
|
||||
tag for lang in msg.langs if (tag := i18n.base_tag(lang))
|
||||
})
|
||||
self.clients[ws] = state
|
||||
self.schedule()
|
||||
else: # Result
|
||||
lang = i18n.base_tag(msg.lang)
|
||||
if (
|
||||
state is None # results before Hello
|
||||
or state.inflight is None # no job in flight
|
||||
or (lang, msg.key) != state.inflight # wrong job
|
||||
):
|
||||
await ws.close(code=1002)
|
||||
return
|
||||
texts, spans, original = msg.texts, state.spans, state.original
|
||||
state.inflight = None
|
||||
state.spans = []
|
||||
state.original = ""
|
||||
text = join(original, spans, texts) if len(texts) == len(spans) else None
|
||||
if text is None:
|
||||
# The model broke the segment contract (count
|
||||
# mismatch, empty or non-prose segment): drop the
|
||||
# result and skip the fragment for this run (it
|
||||
# stays pending; a restart, a refresh or a model
|
||||
# change gets another chance).
|
||||
self.validation_failures.add((lang, msg.key))
|
||||
logger.warning(
|
||||
"[%s] result for chunk %s rejected: invalid segments",
|
||||
lang, msg.key.hex(),
|
||||
)
|
||||
self.schedule()
|
||||
continue
|
||||
with self.db.transaction("translator results", user=clientkey, extra=lang):
|
||||
paths = store_results(
|
||||
self.data, lang, [TransResult(key=msg.key, text=text)]
|
||||
)
|
||||
self.invalidate() # schedules the next dispatch
|
||||
if paths:
|
||||
logger.info(
|
||||
"[%s] now available for %d page(s): %s",
|
||||
lang, len(paths), ", ".join(sorted(paths)),
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if self.clients.pop(ws, None) is not None:
|
||||
# The in-flight item (if any) is pending again; offer it around.
|
||||
self.schedule()
|
||||
+138
-50
@@ -24,7 +24,9 @@ import re
|
||||
from html5tagger import HTML, Document, E, Template
|
||||
from platformdirs import site_data_dir, user_data_path
|
||||
|
||||
from pagerite.data import Node, prettify, resolve, sorted_nodes
|
||||
from pagerite import i18n
|
||||
from pagerite.data import Data, Node, node_markdown, prettify, resolve, sorted_nodes
|
||||
from pagerite.i18n import Translation
|
||||
from pagerite.markdown import render
|
||||
|
||||
SITE_NAME = "Pagerite"
|
||||
@@ -309,6 +311,9 @@ def _layout(
|
||||
transition: str = "cube",
|
||||
favicon: str = "",
|
||||
social: dict[str, str] | None = None,
|
||||
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||
canonical: str = "",
|
||||
alternates: list[tuple[str, str]] = (),
|
||||
) -> Template:
|
||||
"""Page layout template with standard assets and ES-module scripts.
|
||||
|
||||
@@ -333,17 +338,26 @@ def _layout(
|
||||
|
||||
``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as
|
||||
property attributes, everything else (description, twitter:*) as name.
|
||||
|
||||
``lang`` is the served language for <html lang>; an RTL language (ar,
|
||||
fa, ...) also puts dir="rtl" on <html> (the editor panel carries its own
|
||||
lang="en" dir="ltr", so it is unaffected). ``canonical`` and
|
||||
``alternates`` ((hreflang, href) pairs) are the page's language URLs
|
||||
(see docs/localization.md), emitted right after the viewport and before
|
||||
the social tags: canonical first, then the hreflang alternates.
|
||||
"""
|
||||
doc = Document(E.Title, lang="en")
|
||||
doc = Document(E.Title, lang=lang, dir="rtl" if lang in i18n.RTL_LANGUAGES else "ltr")
|
||||
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
|
||||
# the real device width, not the default 980px layout viewport.
|
||||
doc.meta(name="viewport", content="width=device-width, initial-scale=1")
|
||||
if canonical:
|
||||
doc.link(rel="canonical", href=canonical)
|
||||
for hreflang, href in alternates:
|
||||
doc.link(rel="alternate", hreflang=hreflang, href=href)
|
||||
for key, value in (social or {}).items():
|
||||
if value:
|
||||
if key.startswith(("og:", "article:")):
|
||||
doc.meta(property=key, content=value)
|
||||
elif key == "canonical":
|
||||
doc.link(rel="canonical", href=value)
|
||||
else:
|
||||
doc.meta(name=key, content=value)
|
||||
# A custom favicon (from the site editor) is linked explicitly; without
|
||||
@@ -443,26 +457,41 @@ def _layout(
|
||||
return Template(body)
|
||||
|
||||
|
||||
def _brand_link(brand: str, brand_html: str = "") -> HTML:
|
||||
def _brand_link(brand: str, brand_html: str = "", link_lang: str = "") -> HTML:
|
||||
"""Header brand: custom HTML (in a #brand wrapper, rendered instead of
|
||||
the link) when configured, else the plain brand link; omitted entirely
|
||||
when neither is set."""
|
||||
if brand_html.strip():
|
||||
return HTML(str(E.div(HTML(brand_html), id="brand")))
|
||||
return HTML(str(E.a(brand, href="/", id="brand"))) if brand else HTML("")
|
||||
return HTML(str(E.a(brand, href=_href("", link_lang), id="brand"))) if brand else HTML("")
|
||||
|
||||
|
||||
def _title(slug: str, node: Node) -> str:
|
||||
"""Menu label: the configured title, prettified slug, "Home" fallback."""
|
||||
def _title(slug: str, node: Node, translation: Translation | None = None, path: str = "") -> str:
|
||||
"""Menu label: the configured title, prettified slug, "Home" fallback.
|
||||
|
||||
With a translation, its title map (keyed by node path) wins, falling
|
||||
back per node to the original English title.
|
||||
"""
|
||||
if translation and (t := translation.titles.get(path)):
|
||||
return t
|
||||
return node.title or prettify(slug) or "Home"
|
||||
|
||||
|
||||
def _href(path: str, link_lang: str = "") -> str:
|
||||
"""Site-chrome link to a page: when the page was requested with a
|
||||
?lang= override the query is replicated onto the navigation links it
|
||||
renders, so clicks and prefetches (which take the href as-is) stay in
|
||||
the chosen language — even without JS (docs/localization.md)."""
|
||||
return f"/{path}?lang={link_lang}" if link_lang else f"/{path}"
|
||||
|
||||
|
||||
def _nav_link(
|
||||
doc, menu: dict[str, Node], node: Node, path: str, current: str,
|
||||
ancestors_current: bool = True,
|
||||
ancestors_current: bool = True, translation: Translation | None = None,
|
||||
link_lang: str = "",
|
||||
) -> 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
|
||||
@@ -470,17 +499,17 @@ def _nav_link(
|
||||
is_current = current == path or (
|
||||
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:
|
||||
href = f"/{leaf}"
|
||||
href = _href(path, link_lang)
|
||||
if not node.chunks and (leaf := first_leaf(menu, path)) is not None:
|
||||
href = _href(leaf, link_lang)
|
||||
doc.li.a(
|
||||
_title(path.rpartition("/")[2], node),
|
||||
_title(path.rpartition("/")[2], node, translation, path),
|
||||
href=href,
|
||||
**{"class": "current"} if is_current else {},
|
||||
)
|
||||
|
||||
|
||||
def nav_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
def nav_html(menu: dict[str, Node], current: str, translation: Translation | None = None, link_lang: str = "") -> HTML:
|
||||
"""Render the contents of the #nav element for the current path.
|
||||
|
||||
Top-level items in menu order; the front page (slug "", href "/")
|
||||
@@ -491,11 +520,11 @@ def nav_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
with nav:
|
||||
for slug, node in sorted_nodes(menu):
|
||||
if node.published:
|
||||
_nav_link(nav, menu, node, slug, current)
|
||||
_nav_link(nav, menu, node, slug, current, translation=translation, link_lang=link_lang)
|
||||
return HTML(str(nav))
|
||||
|
||||
|
||||
def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
def sidebar_html(menu: dict[str, Node], current: str, translation: Translation | None = None, link_lang: str = "") -> HTML:
|
||||
"""Render the #sidebar element for the current path (empty when none).
|
||||
|
||||
The sidebar is the current main level section's sub-navigation: the
|
||||
@@ -529,24 +558,24 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
nav = E.ul
|
||||
with nav:
|
||||
for slug, child in items:
|
||||
_sidebar_item(nav, menu, child, f"{section}/{slug}", current)
|
||||
_sidebar_item(nav, menu, child, f"{section}/{slug}", current, translation, link_lang)
|
||||
return HTML(str(E.aside(nav, id="sidebar")))
|
||||
|
||||
|
||||
def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None:
|
||||
def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: str, translation: Translation | None = None, link_lang: str = "") -> None:
|
||||
"""One sidebar <li>: the node link, with its published children as a
|
||||
nested list (third level and deeper, recursively)."""
|
||||
_nav_link(doc, menu, node, path, current, ancestors_current=False)
|
||||
_nav_link(doc, menu, node, path, current, ancestors_current=False, translation=translation, link_lang=link_lang)
|
||||
sub = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
|
||||
if sub:
|
||||
# doc.li.a(...) above left the <li> open for nesting.
|
||||
with doc.ul:
|
||||
for slug, child in sub:
|
||||
_sidebar_item(doc, menu, child, f"{path}/{slug}", current)
|
||||
_sidebar_item(doc, menu, child, f"{path}/{slug}", current, translation, link_lang)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -561,7 +590,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
|
||||
@@ -674,16 +703,25 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||
def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None, link_lang: str = "", lang: str = "") -> HTML:
|
||||
"""Render the contents of the #main element for a page.
|
||||
|
||||
A page with published children (a category page) lists them as cards
|
||||
after the markdown content.
|
||||
after the markdown content. With a translation, its Markdown goes
|
||||
through the same render pipeline; missing pieces (markdown=None, absent
|
||||
title entries) fall back to the original. ``lang`` feeds the cards'
|
||||
per-target localization.
|
||||
"""
|
||||
node = resolve(menu, path)[-1]
|
||||
content = node_markdown(data, node) or ""
|
||||
title = node.title
|
||||
if translation:
|
||||
if translation.markdown is not None:
|
||||
content = translation.markdown
|
||||
title = _title(path.rpartition("/")[2], node, translation, path) if node.title else title
|
||||
# The title is injected into the markdown (as # title when it has no
|
||||
# h1 of its own), so title and content render as one article.
|
||||
rendered = render(node.content or "", path, node.created, node.modified, title=node.title)
|
||||
rendered = render(content, path, node.created, node.modified, title=title)
|
||||
# Long articles get .multicol: the article column cap lifts (see the
|
||||
# #content grid in pagerite.css) and the .cols segments lay out in at
|
||||
# most two columns. The html is already segmented by render() — the
|
||||
@@ -691,11 +729,11 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
||||
with doc:
|
||||
doc(HTML(rendered.html))
|
||||
_cards(doc, menu, node, path)
|
||||
_cards(doc, menu, data, node, path, translation, link_lang, lang)
|
||||
return HTML(str(doc))
|
||||
|
||||
|
||||
def _cards(doc, menu: dict[str, Node], node: Node, path: str) -> None:
|
||||
def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "", lang: str = "") -> 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
|
||||
@@ -721,35 +759,44 @@ def _cards(doc, menu: dict[str, Node], node: Node, path: str) -> None:
|
||||
continue
|
||||
with doc.div(class_="stack"):
|
||||
for epath, enode in entries:
|
||||
_card(doc, enode, epath)
|
||||
_card(doc, data, enode, epath, translation, link_lang, lang)
|
||||
|
||||
|
||||
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) -> None:
|
||||
def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "", lang: str = "") -> None:
|
||||
"""One card in a stack: cover + title, plus the description when the
|
||||
page has no image (its card shows a gradient cover instead)."""
|
||||
page has no image (its card shows a gradient cover instead).
|
||||
|
||||
The card text localizes per target article where that page is
|
||||
available in the language: the title comes from the translation's
|
||||
title map and the cover/description heuristics run on the target's
|
||||
hybrid Markdown — with per-card fallback to the original otherwise.
|
||||
"""
|
||||
image = description = ""
|
||||
if node.content:
|
||||
html = render(node.content, path, node.created, node.modified).html
|
||||
if node.chunks:
|
||||
md = node_markdown(data, node) or ""
|
||||
if lang and lang in node.langs:
|
||||
md = i18n.hybrid_markdown(data, node, path, lang)
|
||||
html = render(md, path, node.created, node.modified).html
|
||||
image, _ = _media(html)
|
||||
if not image:
|
||||
description = _description(html, 150)
|
||||
with doc.a(href=f"/{path}", class_="card"):
|
||||
with doc.a(href=_href(path, link_lang), class_="card"):
|
||||
if image:
|
||||
doc.span(class_="cover", style=f'background-image: url("{image}")')
|
||||
else:
|
||||
doc.span(class_="cover")
|
||||
doc.span(_title(path.rpartition("/")[2], node), class_="title")
|
||||
doc.span(_title(path.rpartition("/")[2], node, translation, path), class_="title")
|
||||
if description:
|
||||
doc.span(description, class_="desc")
|
||||
|
||||
@@ -854,7 +901,6 @@ def _social_meta(
|
||||
)
|
||||
return {
|
||||
"description": text,
|
||||
"canonical": url,
|
||||
"og:type": "article",
|
||||
"og:title": title,
|
||||
"og:description": text,
|
||||
@@ -871,6 +917,7 @@ def _social_meta(
|
||||
|
||||
def render_page(
|
||||
menu: dict[str, Node],
|
||||
data: Data,
|
||||
path: str,
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
@@ -879,21 +926,51 @@ def render_page(
|
||||
brand_html: str = "",
|
||||
base_url: str = "",
|
||||
transition: str = "cube",
|
||||
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||
translation: Translation | None = None,
|
||||
link_lang: str = "",
|
||||
) -> str:
|
||||
"""Render a full HTML page for the slug path."""
|
||||
"""Render a full HTML page for the slug path.
|
||||
|
||||
``lang``/``translation`` serve a translated version (see
|
||||
docs/localization.md): None translation = the English original.
|
||||
``link_lang`` is the ?lang= override the page was requested with,
|
||||
replicated onto the navigation links so the language sticks.
|
||||
"""
|
||||
node = resolve(menu, path)[-1]
|
||||
title = _title(path.rpartition("/")[2], node)
|
||||
main = page_content(menu, path)
|
||||
original = i18n.primary_lang(menu, path)
|
||||
if translation is None:
|
||||
lang = original
|
||||
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||
main = page_content(menu, data, path, translation, link_lang, lang)
|
||||
social = _social_meta(node, path, title, str(main), brand, base_url)
|
||||
# Canonical/hreflang URLs (docs/localization.md): the canonical names
|
||||
# the actually served language — the plain URL for the original (for
|
||||
# SEO the non-query URL means the article's language), ?lang= for a
|
||||
# translation — regardless of how the language was arrived at (query
|
||||
# or header). The alternates are site-wide, the same set on every
|
||||
# page: the configured translate_langs (the translator works to fill
|
||||
# them all in), x-default first (the plain, autodetecting URL), then
|
||||
# every language explicitly, the page's own primary included.
|
||||
canonical = ""
|
||||
alternates = []
|
||||
if base_url:
|
||||
url = f"{base_url}/{path}"
|
||||
canonical = url if lang == original else f"{url}?lang={lang}"
|
||||
if data.translate_langs:
|
||||
alternates = [("x-default", url)] + [
|
||||
(tag, f"{url}?lang={tag}")
|
||||
for tag in sorted({original, *data.translate_langs})
|
||||
]
|
||||
return str(
|
||||
_layout(
|
||||
*_page_assets(), custom_css, theme, banner_design(menu, path, theme),
|
||||
transition, favicon, social,
|
||||
transition, favicon, social, lang, canonical, alternates,
|
||||
)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar_html(menu, path),
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
Nav=nav_html(menu, path, translation, link_lang),
|
||||
Sidebar=sidebar_html(menu, path, translation, link_lang),
|
||||
Banner=banner_html(menu, path, theme),
|
||||
Main=main,
|
||||
),
|
||||
@@ -902,6 +979,7 @@ def render_page(
|
||||
|
||||
def render_category(
|
||||
menu: dict[str, Node],
|
||||
data: Data,
|
||||
path: str,
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
@@ -909,6 +987,9 @@ def render_category(
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
transition: str = "cube",
|
||||
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||
translation: Translation | None = None,
|
||||
link_lang: str = "",
|
||||
) -> str:
|
||||
"""Render the listing for a content-less category label (404).
|
||||
|
||||
@@ -916,22 +997,29 @@ def render_category(
|
||||
children are listed as cards, like on a category page with content.
|
||||
Nav links point straight at the first child, so this is mainly seen
|
||||
in the site editor, where the pen creates the landing page.
|
||||
|
||||
With a translation (titles only — the category has no Markdown) the
|
||||
heading, navigation and card text localize per target article
|
||||
(docs/localization.md); ``link_lang`` replicates the ?lang= override
|
||||
onto the navigation links as on content pages.
|
||||
"""
|
||||
node = resolve(menu, path)[-1]
|
||||
title = _title(path.rpartition("/")[2], node)
|
||||
if translation is None:
|
||||
lang = i18n.primary_lang(menu, path)
|
||||
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||
doc = E.article
|
||||
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, translation, link_lang, lang)
|
||||
else:
|
||||
doc.p("This section has no page of its own yet.")
|
||||
return str(
|
||||
_layout(*_page_assets(), custom_css, theme, banner_design(menu, path, theme), transition, favicon)(
|
||||
_layout(*_page_assets(), custom_css, theme, banner_design(menu, path, theme), transition, favicon, lang=lang)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar_html(menu, path),
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
Nav=nav_html(menu, path, translation, link_lang),
|
||||
Sidebar=sidebar_html(menu, path, translation, link_lang),
|
||||
Banner=banner_html(menu, path, theme),
|
||||
Main=HTML(str(doc)),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user