Category pages now localized.

This commit is contained in:
2026-09-02 17:48:54 +00:00
parent 14a37f5ab3
commit af76277d68
6 changed files with 420 additions and 72 deletions
+38 -7
View File
@@ -74,6 +74,14 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
- Navigation/sidebar titles come from the translation's title map, with
per-node fallback to the original title (a partially translated tree must
still render).
- Category placeholder pages (the 404s for content-less labels) select a
language like content pages, but over the **subtree's** combined
availability (`subtree_languages`) — they have no chunks of their own;
the heading, navigation and card text localize from the title map and
the target articles' translations.
- Card descriptions and cover picks run on the target article's hybrid
Markdown where that page is available in the served language, with
per-card fallback to the original.
- Fixed UI strings ("Not Found" etc.) and the editor UI stay English for now.
- The markdown typographer (SmartyPants) is English-centric; per-language
typographer options are a possible follow-up, not blocking.
@@ -211,13 +219,36 @@ def get_translation(path, lang, data) -> Translation | None:
### Editor flow
- `GET` of page Markdown for editing with a `lang` parameter returns the
hybrid (not the raw original) when the article has that language.
- `PUT`/WS save with `lang` does **not** touch `node.chunks`; it diffs
against the hybrid that was served and appends a `Patch`. (Serve a hybrid
generation token with the editor payload so a save based on a stale hybrid
can be rebased or rejected — simplest: recompute the diff against the
*current* hybrid and accept best-effort, matching the patch philosophy.)
The page editor has a language picker (flag + name; the same
country-flag-icons set as the analytics visitor cells) listing the primary
language and the union of the page's translations (`node.langs`) and the
site-wide `translate_langs`. It opens in the language the page was served
in (`<html lang>`). A note under the toolbar states the blast radius:
edits to the primary language re-chunk the original (invalidating the
affected translation fragments everywhere); edits to a translation stay
local to that language.
- WS `open` with a `lang` returns the effective **hybrid** Markdown and
title for that language (ungated by `node.langs` — a language without
any fragments yet starts from the original text), plus the language
metadata (`lang`, `primary_lang`, `langs`, `translate_langs`).
- The editor keeps a **shadow copy** of the Markdown it opened. WS `save`
with `lang` sends it as `base`; the server diffs `base` → submitted text
(`make_patch`) and appends a `Patch`. Diffing against the shadow (rather
than the current hybrid) keeps hunks correct when the original or the
machine translation moved under an open editor; application against the
then-current hybrid stays best-effort per hunk, as designed.
- A changed **title** on a translated save becomes a fragment in
`Data.trans` keyed by the original title's chunk hash — the same storage
as machine title translations. An untouched title field (holding the
served translation) is not sent, so saving never freezes a stale machine
title into an override.
- Saving never deletes; a translation additionally cannot be emptied (that
would render as a blank page in that language).
- The live preview renders the version being edited, whichever language
the page itself was loaded in (the render is just the edited Markdown +
title). A translated save keeps that preview in place — re-fetching the
page would come back in the header-selected language.
- Saving the primary-language version re-chunks the submitted Markdown and
updates `Data.chunks` / `node.chunks` — only genuinely new text lands in
the kanta change diff (see docs/migrate.md).
+247 -21
View File
@@ -11,14 +11,27 @@
// and refreshes the page regions in place — never a reload — so the editor
// state (unsaved text included) also survives closing the shell. The editor
// always follows the URL: navigating away retargets it to the new page,
// stashing unsaved text per path (unsavedStash) so returning to the page
// restores the working draft; stashes clear on save and on real reload.
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
// stashing unsaved text per path and language (stashes) so returning to the
// page restores the working draft; stashes clear on save and on real reload.
//
// Languages: the editor starts in the language the page was served in and
// the toolbar picker (flags, like the analytics visitor cells) switches
// between the primary language and its translations. A translation is
// edited as its effective (hybrid) Markdown; the hybrid the session
// started from is kept as a shadow copy (shadowBase) and sent along at
// save time, so the server diffs the user's changes only and stores them
// as a patch — edits to a translation never touch the original, while
// edits to the primary language re-chunk the original (and thereby
// invalidate the affected translation fragments). The live preview always
// renders the version being edited, whichever language the page itself
// was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import * as flagSvgs from 'country-flag-icons/string/3x2'
import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain } from './swapdoc'
@@ -34,6 +47,20 @@ const saveError = ref('')
const editorEl = ref(null)
const fileInput = ref(null)
// The language being edited: "" = the primary language. Starts as the
// language this page was served in (<html lang>); the server normalizes
// the primary to "" in its doc reply.
const lang = ref(document.documentElement.lang || '')
const primaryLang = ref('en')
const pageLangs = ref([]) // translations this page has
const siteLangs = ref([]) // site-wide configured target languages
const langPickerOpen = ref(false)
// The shadow copy: the Markdown this editing session started from, sent as
// "base" on translated saves so the server diffs the user's changes only.
let shadowBase = ''
let titleTouched = false
let docLoaded = false
let ws = null
let view = null
let savedResolve = null
@@ -63,6 +90,55 @@ function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
// --- Language picker -------------------------------------------------------
// Flag icons from the same country-flag-icons set the analytics visitor
// cells use, resolved from the language tag's most likely region.
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
function langName(tag) {
try {
return displayNames.of(tag) || tag
} catch {
return tag
}
}
function flagFor(tag) {
try {
return flagSvgs[new Intl.Locale(tag).maximize().region] || ''
} catch {
return ''
}
}
// The picker's options: the primary language first, then the union of the
// page's translations and the site-wide configured targets, sorted.
const langOptions = computed(() => {
const others = [...new Set([...siteLangs.value, ...pageLangs.value])]
.filter((l) => l && l !== primaryLang.value)
.sort()
return [primaryLang.value, ...others].map((code) => ({
tag: code === primaryLang.value ? '' : code,
code,
name: langName(code),
flag: flagFor(code),
primary: code === primaryLang.value,
}))
})
const currentLang = computed(
() => langOptions.value.find((o) => o.tag === lang.value)
?? { tag: '', code: lang.value || primaryLang.value, name: langName(lang.value || primaryLang.value), flag: flagFor(lang.value || primaryLang.value), primary: !lang.value },
)
function switchLang(tag) {
tag = tag || ''
if (tag === lang.value || !view) return
stashCurrent()
lang.value = tag
send({ type: 'open', path: path.value, lang: tag })
}
function pageLabel() {
return title.value.trim() || ('/' + (path.value || ''))
}
@@ -96,11 +172,17 @@ function save() {
// never moves the page.
const markdown = view.state.doc.toString()
if (markdown.trim() === '') {
if (lang.value) {
// Emptying a translation would render it as a blank page; deleting
// pages is a primary-language action.
saveError.value = '⚠️ a translation cannot be emptied'
return Promise.resolve()
}
// Empty text means delete — an explicit choice made here, in the page
// editor; the save APIs (REST PUT / WS save) never delete on empty.
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
if (res.ok) unsavedStash.delete(path.value)
if (res.ok) stashes.delete(stashKey(path.value, lang.value))
})
}
const msg = {
@@ -110,6 +192,15 @@ function save() {
markdown,
published: published.value,
}
if (lang.value) {
msg.lang = lang.value
// The shadow copy this session started from: the server diffs base →
// markdown and stores only the user's changes as a patch.
msg.base = shadowBase
// An untouched title field is not sent: it holds the served
// translation, which a save must not freeze into an override fragment.
if (!titleTouched) delete msg.title
}
pendingSave = msg
send(msg)
return new Promise((resolve) => { savedResolve = resolve })
@@ -120,9 +211,12 @@ async function saveAndRefresh() {
dirty.value = false
// Refresh the page regions from the server so nav/sidebar changes apply
// (never a reload: the editor keeps its state). Drop the prefetch cache
// first: heading/title changes affect navigation on every page.
// first: heading/title changes affect navigation on every page. A
// translation save keeps the preview as-is — it already shows the saved
// text, and loadPlain would swap the article to the header-selected
// language's render.
dropPageCache()
loadPlain(path.value)
if (!lang.value) loadPlain(path.value)
}
function close() {
@@ -572,18 +666,27 @@ function insertTable(cols, rows) {
view.focus()
}
// Unsaved edits survive navigation within the session: leaving a page
// stashes its working text here, returning restores it (the server doc
// still arrives, for title/published and as the base underneath).
// Entries clear on save and on real reload (the shell is in-memory only).
const unsavedStash = new Map()
// Unsaved edits survive navigation within the session: leaving a page (or
// switching the language) stashes its working text and shadow base here,
// returning restores them (the server doc still arrives, for
// title/published and language metadata). Entries clear on save and on
// real reload (the shell is in-memory only).
const stashes = new Map()
const stashKey = (p, l) => `${p}|${l}`
function stashCurrent() {
if (dirty.value && path.value) {
stashes.set(stashKey(path.value, lang.value), {
text: view.state.doc.toString(),
base: shadowBase,
})
}
}
function openPath(p) {
if (dirty.value && path.value && p !== path.value) {
unsavedStash.set(path.value, view.state.doc.toString())
}
if (p !== path.value) stashCurrent()
path.value = p
send({ type: 'open', path: p })
send({ type: 'open', path: p, lang: lang.value })
}
function setDocument(text, preserveSelection = false) {
@@ -624,13 +727,25 @@ function previewIntoArticle(html, multicol) {
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
// The doc must answer the current path and language; before the first
// doc the language is not yet server-normalized (the primary arrives as
// "" while the picker may have started from an explicit code), so the
// first doc is accepted on path alone and adopts the echoed language.
if (msg.type === 'doc' && msg.path === path.value
&& (!docLoaded || (msg.lang || '') === lang.value)) {
docLoaded = true
title.value = msg.title
published.value = msg.published
primaryLang.value = msg.primary_lang || 'en'
pageLangs.value = msg.langs || []
siteLangs.value = msg.translate_langs || []
lang.value = msg.lang || ''
titleTouched = false
// Restore stashed unsaved edits over the server doc when returning
// to a page left dirty.
const stashed = unsavedStash.get(msg.path)
setDocument(stashed ?? msg.markdown)
const stashed = stashes.get(stashKey(msg.path, lang.value))
setDocument(stashed ? stashed.text : msg.markdown)
shadowBase = stashed ? stashed.base : msg.markdown
dirty.value = stashed != null
requestRender()
// A section pen's target line survives the open/path-switch here.
@@ -638,12 +753,20 @@ function onMessage(ev) {
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.multicol)
} else if (msg.type === 'saved') {
// A late "saved" for a save sent before a path/language switch must not
// clear the current session's state.
if (pendingSave && pendingSave.path === path.value
&& (pendingSave.lang || '') === lang.value) {
saveError.value = ''
// The saved text becomes the shadow base for further saves.
shadowBase = pendingSave.markdown
pendingSave = null
dirty.value = false
unsavedStash.delete(path.value)
titleTouched = false
stashes.delete(stashKey(path.value, lang.value))
savedResolve?.()
savedResolve = null
}
} else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved'
}
@@ -929,11 +1052,34 @@ onUnmounted(() => {
<template>
<div class="page-editor">
<header class="toolbar">
<span v-if="langOptions.length > 1" class="picker lang-picker">
<button
type="button"
class="lang-current"
:class="{ active: langPickerOpen }"
:title="lang
? `editing the ${currentLang.name} translation`
: `editing the primary language (${currentLang.name})`"
@click="langPickerOpen = !langPickerOpen"
><span v-if="currentLang.flag" class="flag" v-html="currentLang.flag" /> {{ currentLang.code }}</button>
<span v-if="langPickerOpen" class="picker-pop lang-pop" @mouseleave="langPickerOpen = false">
<button
v-for="o in langOptions"
:key="o.code"
type="button"
:class="{ active: o.tag === lang }"
:title="o.primary
? `${o.name} — the primary language`
: `${o.name} — translation`"
@click="switchLang(o.tag); langPickerOpen = false"
><span v-if="o.flag" class="flag" v-html="o.flag" /> {{ o.name }}<small v-if="o.primary"> (primary)</small></button>
</span>
</span>
<label class="title-field">
<span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender" />
<input v-model="title" class="title" @input="requestRender(); titleTouched = true" />
</label>
<label><input v-model="published" type="checkbox" /> published</label>
<label :title="lang ? 'translations follow the original pages published state' : ''"><input v-model="published" type="checkbox" :disabled="!!lang" /> published</label>
<input
ref="fileInput"
type="file"
@@ -949,6 +1095,14 @@ onUnmounted(() => {
@click="saveAndRefresh"
>💾</button>
</header>
<div v-if="langOptions.length > 1" class="lang-note">
<template v-if="lang">
{{ currentLang.name }} translation edits affect only this language.
</template>
<template v-else>
{{ currentLang.name }} is the primary language edits here affect all translations.
</template>
</div>
<div class="format-bar">
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
@@ -1089,6 +1243,78 @@ onUnmounted(() => {
cursor: default;
}
/* Language picker (toolbar, left): a flag + code button opening a vertical
list of the primary language and its translations. Flags render like in
the analytics visitor cells. */
.lang-picker {
position: relative;
display: flex;
}
.lang-picker > button {
transform: none; /* undo the icon-button scale (.picker > button) */
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.4rem;
font: inherit;
font-size: 0.9rem;
color: var(--text);
background: none;
border: 1px solid var(--line);
border-radius: 4px;
cursor: pointer;
}
.lang-pop {
flex-direction: column;
align-items: stretch;
white-space: nowrap;
}
.lang-pop button {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: inherit;
text-align: left;
}
.lang-pop button.active {
color: var(--accent);
}
.lang-pop small {
color: var(--muted);
}
.flag {
display: inline-flex;
width: 18px;
height: 12px;
flex: 0 0 auto;
border-radius: 2px;
overflow: hidden;
border: 1px solid var(--line);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
}
.flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
/* Why a language is highlighted: primary edits fan out to translations,
translation edits stay local to that language. */
.lang-note {
padding: 0.2rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 0.8rem;
}
/* Markdown helpers: plain icon buttons under the toolbar. */
.format-bar {
position: relative;
+84 -20
View File
@@ -396,7 +396,14 @@ def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_
translation = i18n.get_translation(path, lang, data) if lang != i18n.ORIGINAL_LANGUAGE else None
return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
if kind == "category":
return views.render_category(data.menu, data, 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).
translation = (
i18n.Translation(titles=i18n.title_map(data, lang))
if lang != i18n.ORIGINAL_LANGUAGE
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)
@@ -1455,19 +1462,23 @@ async def editor_ws(ws: WebSocket) -> None:
Stateless protocol (each message carries the 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"?, "lang"?} (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
served hybrid Markdown for that language and save stores a diff against
it as a user Patch — node.chunks and the other fields stay untouched
(docs/localization.md).
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:
@@ -1483,19 +1494,25 @@ async def editor_ws(ws: WebSocket) -> None:
case "open":
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
lang = i18n.base_tag(str(msg.get("lang") or ""))
if lang == i18n.ORIGINAL_LANGUAGE:
lang = ""
markdown = ""
title = node.title if node else ""
if node is not None:
markdown = node_markdown(data, node) or ""
# ?lang= view: the served hybrid, not the raw
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).
lang = i18n.base_tag(str(msg.get("lang") or ""))
if lang and (t := i18n.get_translation(path, lang, data)) is not None:
markdown = t.markdown or markdown
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 "",
"title": title,
"markdown": markdown,
"published": node.published if node else True,
"banner": node.banner if node else "",
@@ -1519,6 +1536,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 site's primary language, the translations this
# page already has, and the site-wide configured
# target languages.
"lang": lang,
"primary_lang": i18n.ORIGINAL_LANGUAGE,
"langs": sorted(node.langs) if node else [],
"translate_langs": sorted(data.translate_langs),
})
case "render":
markdown = msg.get("markdown", "")
@@ -1584,6 +1610,14 @@ async def editor_ws(ws: WebSocket) -> None:
# 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 = (
@@ -1602,20 +1636,36 @@ async def editor_ws(ws: WebSocket) -> None:
else:
node = old if old is not None else _ensure(data.menu, path)
if translated:
# Diff against the currently served hybrid and
# append a Patch; node.chunks and the
# original-language fields stay untouched.
# Diff the editor's shadow base (the hybrid the
# user started editing, sent along as "base";
# absent: the current hybrid) against the
# submitted text and append a Patch; node.chunks
# and the original-language fields stay
# untouched.
if "markdown" in msg:
patch = i18n.make_patch(
i18n.hybrid_markdown(data, node, path, lang),
msg["markdown"],
)
base = msg.get("base")
if not isinstance(base, str):
base = i18n.hybrid_markdown(data, node, path, lang)
patch = i18n.make_patch(base, msg["markdown"])
if patch.hunks:
# Patches alone make the translated
# version exist.
data.patches.setdefault(f"{path}:{lang}", []).append(patch)
node.langs[lang] = True
_invalidate_pages()
if "title" in msg and node.title:
# A changed title becomes a fragment in
# Data.trans, keyed by the original title's
# chunk hash — same storage as machine
# title translations.
effective = (
i18n.title_map(data, lang).get(path) or node.title
)
if msg["title"] != effective:
key = i18n.chunk_key(node.title)
data.trans.setdefault(key, {})[lang] = msg["title"]
node.langs[lang] = True
_invalidate_pages()
else:
if "markdown" in msg:
# Saving never deletes; empty markdown is an
@@ -1792,6 +1842,18 @@ async def show_page(request: Request, path: str) -> Response:
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 l: l in subtree_langs,
)
link_lang = i18n.base_tag(query_lang or "")
if _is_trackable_path(path):
flushed = _track_entry(path, request, status=404)
_schedule_client_enrichment(flushed)
@@ -1804,6 +1866,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
+11
View File
@@ -163,6 +163,17 @@ def title_map(data: Data, lang: str) -> dict[str, str]:
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(path: str, lang: str, data: Data) -> Translation | None:
"""The translation of the page at ``path`` for ``lang``, or None.
+33 -17
View File
@@ -701,13 +701,14 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
return None
def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None, link_lang: 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. With a translation, its Markdown goes
through the same render pipeline; missing pieces (markdown=None, absent
title entries) fall back to the original.
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 ""
@@ -726,11 +727,11 @@ def page_content(menu: dict[str, Node], data: Data, path: str, translation: Tran
doc = E.article(class_="multicol") if rendered.multicol else E.article
with doc:
doc(HTML(rendered.html))
_cards(doc, menu, data, node, path, translation, link_lang)
_cards(doc, menu, data, node, path, translation, link_lang, lang)
return HTML(str(doc))
def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: 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
@@ -756,7 +757,7 @@ def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, transl
continue
with doc.div(class_="stack"):
for epath, enode in entries:
_card(doc, data, enode, epath, translation, link_lang)
_card(doc, data, enode, epath, translation, link_lang, lang)
def _walk(node: Node, path: str):
@@ -770,16 +771,21 @@ def _walk(node: Node, path: str):
yield from _walk(child, f"{path}/{slug}")
def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: 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).
Cover/description heuristics run on the original Markdown even when
translated (only this page's own Markdown is translated); the title
uses the translation's title map."""
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.chunks:
html = render(node_markdown(data, node) or "", path, node.created, node.modified).html
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)
@@ -933,7 +939,7 @@ def render_page(
if translation is None:
lang = i18n.ORIGINAL_LANGUAGE
title = _title(path.rpartition("/")[2], node, translation, path)
main = page_content(menu, data, path, translation, link_lang)
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
@@ -978,6 +984,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).
@@ -985,22 +994,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.ORIGINAL_LANGUAGE
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, data, 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)),
),
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
"fastapi[standard]>=0.141.1",
"html5tagger>=2.0.0",
"httpx>=0.28.1",
"kanta>=0.8.1",
"kanta>=0.9.0",
"markdown-it-py>=4.2.0",
"maxminddb>=3.1.1",
"mdit-py-plugins>=0.6.1",