Category pages now localized.
This commit is contained in:
+38
-7
@@ -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).
|
||||
|
||||
+252
-26
@@ -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') {
|
||||
saveError.value = ''
|
||||
pendingSave = null
|
||||
dirty.value = false
|
||||
unsavedStash.delete(path.value)
|
||||
savedResolve?.()
|
||||
savedResolve = null
|
||||
// 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
|
||||
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 page’s 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></></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;
|
||||
|
||||
+85
-21
@@ -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
|
||||
# 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
|
||||
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 "",
|
||||
"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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user