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 - Navigation/sidebar titles come from the translation's title map, with
per-node fallback to the original title (a partially translated tree must per-node fallback to the original title (a partially translated tree must
still render). 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. - Fixed UI strings ("Not Found" etc.) and the editor UI stay English for now.
- The markdown typographer (SmartyPants) is English-centric; per-language - The markdown typographer (SmartyPants) is English-centric; per-language
typographer options are a possible follow-up, not blocking. typographer options are a possible follow-up, not blocking.
@@ -211,13 +219,36 @@ def get_translation(path, lang, data) -> Translation | None:
### Editor flow ### Editor flow
- `GET` of page Markdown for editing with a `lang` parameter returns the The page editor has a language picker (flag + name; the same
hybrid (not the raw original) when the article has that language. country-flag-icons set as the analytics visitor cells) listing the primary
- `PUT`/WS save with `lang` does **not** touch `node.chunks`; it diffs language and the union of the page's translations (`node.langs`) and the
against the hybrid that was served and appends a `Patch`. (Serve a hybrid site-wide `translate_langs`. It opens in the language the page was served
generation token with the editor payload so a save based on a stale hybrid in (`<html lang>`). A note under the toolbar states the blast radius:
can be rebased or rejected — simplest: recompute the diff against the edits to the primary language re-chunk the original (invalidating the
*current* hybrid and accept best-effort, matching the patch philosophy.) 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 - Saving the primary-language version re-chunks the submitted Markdown and
updates `Data.chunks` / `node.chunks` — only genuinely new text lands in updates `Data.chunks` / `node.chunks` — only genuinely new text lands in
the kanta change diff (see docs/migrate.md). the kanta change diff (see docs/migrate.md).
+252 -26
View File
@@ -11,14 +11,27 @@
// and refreshes the page regions in place — never a reload — so the editor // and refreshes the page regions in place — never a reload — so the editor
// state (unsaved text included) also survives closing the shell. 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, // always follows the URL: navigating away retargets it to the new page,
// stashing unsaved text per path (unsavedStash) so returning to the page // stashing unsaved text per path and language (stashes) so returning to the
// restores the working draft; stashes clear on save and on real reload. // page restores the working draft; stashes clear on save and on real reload.
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue' //
// 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 { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view' import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands' import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
import * as flagSvgs from 'country-flag-icons/string/3x2'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
@@ -34,6 +47,20 @@ const saveError = ref('')
const editorEl = ref(null) const editorEl = ref(null)
const fileInput = 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 ws = null
let view = null let view = null
let savedResolve = null let savedResolve = null
@@ -63,6 +90,55 @@ function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '') 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() { function pageLabel() {
return title.value.trim() || ('/' + (path.value || '')) return title.value.trim() || ('/' + (path.value || ''))
} }
@@ -96,11 +172,17 @@ function save() {
// never moves the page. // never moves the page.
const markdown = view.state.doc.toString() const markdown = view.state.doc.toString()
if (markdown.trim() === '') { 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 // Empty text means delete — an explicit choice made here, in the page
// editor; the save APIs (REST PUT / WS save) never delete on empty. // editor; the save APIs (REST PUT / WS save) never delete on empty.
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => { return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
saveError.value = res.ok ? '' : '⚠️ changes could not be saved' 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 = { const msg = {
@@ -110,6 +192,15 @@ function save() {
markdown, markdown,
published: published.value, 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 pendingSave = msg
send(msg) send(msg)
return new Promise((resolve) => { savedResolve = resolve }) return new Promise((resolve) => { savedResolve = resolve })
@@ -120,9 +211,12 @@ async function saveAndRefresh() {
dirty.value = false dirty.value = false
// Refresh the page regions from the server so nav/sidebar changes apply // Refresh the page regions from the server so nav/sidebar changes apply
// (never a reload: the editor keeps its state). Drop the prefetch cache // (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() dropPageCache()
loadPlain(path.value) if (!lang.value) loadPlain(path.value)
} }
function close() { function close() {
@@ -572,18 +666,27 @@ function insertTable(cols, rows) {
view.focus() view.focus()
} }
// Unsaved edits survive navigation within the session: leaving a page // Unsaved edits survive navigation within the session: leaving a page (or
// stashes its working text here, returning restores it (the server doc // switching the language) stashes its working text and shadow base here,
// still arrives, for title/published and as the base underneath). // returning restores them (the server doc still arrives, for
// Entries clear on save and on real reload (the shell is in-memory only). // title/published and language metadata). Entries clear on save and on
const unsavedStash = new Map() // 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) { function openPath(p) {
if (dirty.value && path.value && p !== path.value) { if (p !== path.value) stashCurrent()
unsavedStash.set(path.value, view.state.doc.toString())
}
path.value = p path.value = p
send({ type: 'open', path: p }) send({ type: 'open', path: p, lang: lang.value })
} }
function setDocument(text, preserveSelection = false) { function setDocument(text, preserveSelection = false) {
@@ -624,13 +727,25 @@ function previewIntoArticle(html, multicol) {
function onMessage(ev) { function onMessage(ev) {
const msg = JSON.parse(ev.data) 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 title.value = msg.title
published.value = msg.published 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 // Restore stashed unsaved edits over the server doc when returning
// to a page left dirty. // to a page left dirty.
const stashed = unsavedStash.get(msg.path) const stashed = stashes.get(stashKey(msg.path, lang.value))
setDocument(stashed ?? msg.markdown) setDocument(stashed ? stashed.text : msg.markdown)
shadowBase = stashed ? stashed.base : msg.markdown
dirty.value = stashed != null dirty.value = stashed != null
requestRender() requestRender()
// A section pen's target line survives the open/path-switch here. // 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) { } else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.multicol) previewIntoArticle(msg.html, msg.multicol)
} else if (msg.type === 'saved') { } else if (msg.type === 'saved') {
saveError.value = '' // A late "saved" for a save sent before a path/language switch must not
pendingSave = null // clear the current session's state.
dirty.value = false if (pendingSave && pendingSave.path === path.value
unsavedStash.delete(path.value) && (pendingSave.lang || '') === lang.value) {
savedResolve?.() saveError.value = ''
savedResolve = null // The saved text becomes the shadow base for further saves.
shadowBase = pendingSave.markdown
pendingSave = null
dirty.value = false
titleTouched = false
stashes.delete(stashKey(path.value, lang.value))
savedResolve?.()
savedResolve = null
}
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved' saveError.value = '⚠️ changes could not be saved'
} }
@@ -929,11 +1052,34 @@ onUnmounted(() => {
<template> <template>
<div class="page-editor"> <div class="page-editor">
<header class="toolbar"> <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"> <label class="title-field">
<span class="field-label">title</span> <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>
<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 <input
ref="fileInput" ref="fileInput"
type="file" type="file"
@@ -949,6 +1095,14 @@ onUnmounted(() => {
@click="saveAndRefresh" @click="saveAndRefresh"
>💾</button> >💾</button>
</header> </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"> <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" 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> <button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
@@ -1089,6 +1243,78 @@ onUnmounted(() => {
cursor: default; 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. */ /* Markdown helpers: plain icon buttons under the toolbar. */
.format-bar { .format-bar {
position: relative; position: relative;
+85 -21
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 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) 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": 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": 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_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) 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): Stateless protocol (each message carries the path):
<- {"type": "open", "path", "lang"?} <- {"type": "open", "path", "lang"?}
-> {"type": "doc", "path", "exists", "title", "markdown", "published", -> {"type": "doc", "path", "exists", "title", "markdown", "published",
"banner", "banner_design"} "banner", "banner_design", "lang", "primary_lang", "langs",
"translate_langs"}
<- {"type": "render", "path", "markdown"} <- {"type": "render", "path", "markdown"}
-> {"type": "html", "path", "html"} -> {"type": "html", "path", "html"}
<- {"type": "save", "path", "title"?, "markdown"?, "published"?, <- {"type": "save", "path", "title"?, "markdown"?, "published"?,
"banner"?, "banner_design"?, "move_from"?, "lang"?} (absent fields "banner"?, "banner_design"?, "move_from"?, "lang"?, "base"?}
keep their old values; move_from: rename/move a page, subtree (absent fields keep their old values; move_from: rename/move a
included) page, subtree included)
-> {"type": "saved", "path"} | {"type": "error", "detail"} -> {"type": "saved", "path"} | {"type": "error", "detail"}
With "lang" (a translation, not the primary language), open returns the With "lang" (a translation, not the primary language), open returns the
served hybrid Markdown for that language and save stores a diff against effective hybrid Markdown and title for that language plus the language
it as a user Patch — node.chunks and the other fields stay untouched metadata the picker's UI needs; save diffs the submitted Markdown
(docs/localization.md). 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() await ws.accept()
try: try:
@@ -1483,19 +1494,25 @@ async def editor_ws(ws: WebSocket) -> None:
case "open": case "open":
chain = resolve(data.menu, path) chain = resolve(data.menu, path)
node = chain[-1] if chain else None node = chain[-1] if chain else None
lang = i18n.base_tag(str(msg.get("lang") or ""))
if lang == i18n.ORIGINAL_LANGUAGE:
lang = ""
markdown = "" markdown = ""
title = node.title if node else ""
if node is not None: if node is not None:
markdown = node_markdown(data, node) or "" markdown = node_markdown(data, node) or ""
# ?lang= view: the served hybrid, not the raw if lang and node.chunks is not None:
# original (docs/localization.md editor flow). # Translation view: the effective (hybrid)
lang = i18n.base_tag(str(msg.get("lang") or "")) # Markdown and title for that language —
if lang and (t := i18n.get_translation(path, lang, data)) is not None: # machine fragments + user patches over the
markdown = t.markdown or markdown # 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({ await ws.send_json({
"type": "doc", "type": "doc",
"path": path, "path": path,
"exists": node is not None, "exists": node is not None,
"title": node.title if node else "", "title": title,
"markdown": markdown, "markdown": markdown,
"published": node.published if node else True, "published": node.published if node else True,
"banner": node.banner if node else "", "banner": node.banner if node else "",
@@ -1519,6 +1536,15 @@ async def editor_ws(ws: WebSocket) -> None:
if src is not None if src is not None
else views.theme_banner_design(data.theme) 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": case "render":
markdown = msg.get("markdown", "") markdown = msg.get("markdown", "")
@@ -1584,6 +1610,14 @@ async def editor_ws(ws: WebSocket) -> None:
# original; it cannot create or move pages. # original; it cannot create or move pages.
await ws.send_json({"type": "error", "detail": "no such page"}) await ws.send_json({"type": "error", "detail": "no such page"})
continue 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): with kanta.transaction("editor save", extra=path):
if move_from != path: if move_from != path:
same_menu = ( same_menu = (
@@ -1602,20 +1636,36 @@ async def editor_ws(ws: WebSocket) -> None:
else: else:
node = old if old is not None else _ensure(data.menu, path) node = old if old is not None else _ensure(data.menu, path)
if translated: if translated:
# Diff against the currently served hybrid and # Diff the editor's shadow base (the hybrid the
# append a Patch; node.chunks and the # user started editing, sent along as "base";
# original-language fields stay untouched. # 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: if "markdown" in msg:
patch = i18n.make_patch( base = msg.get("base")
i18n.hybrid_markdown(data, node, path, lang), if not isinstance(base, str):
msg["markdown"], base = i18n.hybrid_markdown(data, node, path, lang)
) patch = i18n.make_patch(base, msg["markdown"])
if patch.hunks: if patch.hunks:
# Patches alone make the translated # Patches alone make the translated
# version exist. # version exist.
data.patches.setdefault(f"{path}:{lang}", []).append(patch) data.patches.setdefault(f"{path}:{lang}", []).append(patch)
node.langs[lang] = True node.langs[lang] = True
_invalidate_pages() _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: else:
if "markdown" in msg: if "markdown" in msg:
# Saving never deletes; empty markdown is an # 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: if node is not None and node.published and node.chunks is None:
# Category label without a landing page: placeholder with the pen # Category label without a landing page: placeholder with the pen
# to create it (404 — no page here, but the node is real). # 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): if _is_trackable_path(path):
flushed = _track_entry(path, request, status=404) flushed = _track_entry(path, request, status=404)
_schedule_client_enrichment(flushed) _schedule_client_enrichment(flushed)
@@ -1804,6 +1866,8 @@ async def show_page(request: Request, path: str) -> Response:
"last-modified": _http_date(node.modified), "last-modified": _http_date(node.modified),
"cache-control": "no-cache", "cache-control": "no-cache",
}, },
lang=lang,
link_lang=link_lang,
) )
if node is None and not path: if node is None and not path:
# No front page (no top-level node with slug ""): "/" opens the # 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 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: def get_translation(path: str, lang: str, data: Data) -> Translation | None:
"""The translation of the page at ``path`` for ``lang``, or 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 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. """Render the contents of the #main element for a page.
A page with published children (a category page) lists them as cards A page with published children (a category page) lists them as cards
after the markdown content. With a translation, its Markdown goes after the markdown content. With a translation, its Markdown goes
through the same render pipeline; missing pieces (markdown=None, absent 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] node = resolve(menu, path)[-1]
content = node_markdown(data, node) or "" 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 doc = E.article(class_="multicol") if rendered.multicol else E.article
with doc: with doc:
doc(HTML(rendered.html)) 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)) 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). """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 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 continue
with doc.div(class_="stack"): with doc.div(class_="stack"):
for epath, enode in entries: 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): def _walk(node: Node, path: str):
@@ -770,16 +771,21 @@ def _walk(node: Node, path: str):
yield from _walk(child, f"{path}/{slug}") 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 """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).
Cover/description heuristics run on the original Markdown even when The card text localizes per target article where that page is
translated (only this page's own Markdown is translated); the title available in the language: the title comes from the translation's
uses the translation's title map.""" title map and the cover/description heuristics run on the target's
hybrid Markdown — with per-card fallback to the original otherwise.
"""
image = description = "" image = description = ""
if node.chunks: 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) image, _ = _media(html)
if not image: if not image:
description = _description(html, 150) description = _description(html, 150)
@@ -933,7 +939,7 @@ def render_page(
if translation is None: if translation is None:
lang = i18n.ORIGINAL_LANGUAGE lang = i18n.ORIGINAL_LANGUAGE
title = _title(path.rpartition("/")[2], node, translation, path) 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) social = _social_meta(node, path, title, str(main), brand, base_url)
# Canonical/hreflang URLs (docs/localization.md): the canonical names # Canonical/hreflang URLs (docs/localization.md): the canonical names
# the actually served language — the plain URL for the original (for # the actually served language — the plain URL for the original (for
@@ -978,6 +984,9 @@ def render_category(
favicon: str = "", favicon: str = "",
brand_html: str = "", brand_html: str = "",
transition: str = "cube", transition: str = "cube",
lang: str = i18n.ORIGINAL_LANGUAGE,
translation: Translation | None = None,
link_lang: str = "",
) -> str: ) -> str:
"""Render the listing for a content-less category label (404). """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. 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 Nav links point straight at the first child, so this is mainly seen
in the site editor, where the pen creates the landing page. 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] 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 doc = E.article
with doc: with doc:
doc.h1(title) doc.h1(title)
if any(c.published for c in node.children.values()): 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: else:
doc.p("This section has no page of its own yet.") doc.p("This section has no page of its own yet.")
return str( 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, Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand, brand_html), Brand=_brand_link(brand, brand_html, link_lang),
Nav=nav_html(menu, path), Nav=nav_html(menu, path, translation, link_lang),
Sidebar=sidebar_html(menu, path), Sidebar=sidebar_html(menu, path, translation, link_lang),
Banner=banner_html(menu, path, theme), Banner=banner_html(menu, path, theme),
Main=HTML(str(doc)), Main=HTML(str(doc)),
), ),
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
"fastapi[standard]>=0.141.1", "fastapi[standard]>=0.141.1",
"html5tagger>=2.0.0", "html5tagger>=2.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"kanta>=0.8.1", "kanta>=0.9.0",
"markdown-it-py>=4.2.0", "markdown-it-py>=4.2.0",
"maxminddb>=3.1.1", "maxminddb>=3.1.1",
"mdit-py-plugins>=0.6.1", "mdit-py-plugins>=0.6.1",