Per-node primary language; home-country flags; grouped lang grid

- Node.language sets an article's primary language, inherited down the
  hierarchy, configured per row in the structure tab; English is now a
  regular language in the lang tab, still the root fallback
- Flags map a base tag to the language's home country (en → GB,
  pt → PT); explicit variant tags resolve their own region
- Lang tab flag grid: geographic group rows, shading-only selection
This commit is contained in:
2026-09-03 03:01:13 +00:00
parent 652f1f82ac
commit fb159f0cb0
14 changed files with 310 additions and 127 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ The shell hosts four kept-alive tabs (ordered site-wide first — site, structur
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside``::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`.
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row, plus a per-row flag dropdown setting the page's primary language (`Node.language`, inherited by the subtree).
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (deliberately NOT Escape — it fired too easily by accident); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
+30 -9
View File
@@ -12,6 +12,18 @@ parameter or the `Accept-Language` header.
## Phase 1: negotiation and URLs
### The primary language
Each article has a primary (original) language: `Node.language`, inherited
down the tree like `banner` — "" = the nearest ancestor's, the front page
last (it doubles as the site default), with `en` as the final fallback
(`ORIGINAL_LANGUAGE`, `primary_lang()` in `pagerite/i18n.py`). It is
configured per row in the structure editor. Everything per-article keys
off the resolved value: language selection, `<html lang>`, canonical URLs,
what counts as a translation, and the translation targets (a node's own
primary is never one — so the target set may include the site default, and
a page in another language can be translated into it).
### Language selection
Deliberately simple — **q-values are ignored**:
@@ -45,7 +57,7 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
page — the site-wide configured languages (`translate_langs`, which the
translator works to fill in): `x-default` first, pointing at the plain
autodetecting URL, then every language explicitly with `?lang=`, the
default language included.
page's own primary language included.
- The override sticks for the session of clicks: a page requested with
`?lang=` replicates the query onto the navigation links it renders (nav,
sidebar, cards, brand — in-article links are content and stay as
@@ -223,7 +235,9 @@ The page and structure editors share one language selector (`LangSelect.vue`:
a small flag button opening a dropdown; the same country-flag-icons set as
the analytics visitor cells), v-modeled on one shell-wide selection
(`editorLang.js`, `''` = the primary language). The page editor lists the
primary language and the union of the page's translations (`node.langs`) and
page's own primary language (`Node.language`, resolved through the
hierarchy and echoed in the WS doc as `primary_lang`) plus the union of
the page's translations (`node.langs`) and
the site-wide `translate_langs`; it always opens in the primary language,
even when the page itself was served in a translation. A note under the
toolbar states the blast radius:
@@ -233,9 +247,10 @@ local to that language.
While the editor panel is open, its language selection **overrides the
normal language preferences** for the page preview: EditorShell pins every
in-place re-render and pagerite.js fetch/prefetch to it (`?lang=`the
primary language pins by its own code, which `select_language` honors), and
closing the panel restores the normal preferences.
in-place re-render and pagerite.js fetch/prefetch to it (`?lang=`a
primary selection pins by the current page's own resolved primary, which
`select_language` honors), and closing the panel restores the normal
preferences.
- WS `open` with a `lang` returns the effective **hybrid** Markdown and
title for that language (ungated by `node.langs` — a language without
@@ -264,7 +279,11 @@ closing the panel restores the normal preferences.
The **structure editor** selects from the same languages with the same
`LangSelect` (the selection is shared — switching in either tab switches
both, and the preview). The tree it
both, and the preview). It is also where a page's **primary language** is
configured: each row carries a small flag dropdown (the resolved flag,
dimmed while inherited) that sets `Node.language` via a structure op —
'' = inherit, so setting it on a section covers the whole subtree. The
tree it
lists (`GET /_api/pages?lang=`) comes back with per-language titles where a
translation exists (`translated` marks those rows; untranslated rows show
the original title, dimmed). Retitling in a non-primary language posts the
@@ -308,9 +327,11 @@ Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
Which languages get translated is **server-configured**:
`Data.translate_langs` (presence-key dict, bootstrapped to Spanish and
Chinese — the original language is never a target — edited in the editor
shell's localization tab or set via `/_api/settings` as `translate_langs`).
The dispatcher offers a
Chinese — edited in the editor shell's localization tab, whose flag grid
lists every language including English, or set via `/_api/settings` as
`translate_langs`). A target equal to an article's own primary language is
skipped per article (its original already is that language), so the set
may freely contain the site default. The dispatcher offers a
connection jobs only in `wanted ∩ capable`; a connection without overlap
simply stays idle.
+15 -7
View File
@@ -8,7 +8,7 @@ import BannerEditor from './BannerEditor.vue'
import SiteEditor from './SiteEditor.vue'
import StructureEditor from './StructureEditor.vue'
import LocalizationEditor from './LocalizationEditor.vue'
import { editorLang } from './editorLang'
import { editorLang, pagePrimary } from './editorLang'
import { loadPlain, setLangOverride } from './swapdoc'
const props = defineProps({
@@ -24,11 +24,14 @@ const activeMode = ref(props.initialMode)
// LangSelects) also drives the page preview: while the shell is open it
// overrides the normal language preferences (?lang= / Accept-Language),
// so the page renders in the language being edited; closing restores.
const primaryLang = ref('en')
// The primary selection pins by the CURRENT PAGE's own primary language
// (pages may differ — Node.language is inherited down the tree).
let pinned = false
function pinPreviewLang() {
pinned = true
setLangOverride(editorLang.value || primaryLang.value)
// '' pagePrimary = not yet learned: pin 'en', the server's final fallback
// (i18n.ORIGINAL_LANGUAGE).
setLangOverride(editorLang.value || pagePrimary.value || 'en')
loadPlain(currentPath.value)
}
function unpinPreviewLang() {
@@ -38,6 +41,9 @@ function unpinPreviewLang() {
loadPlain(currentPath.value)
}
watch(editorLang, () => { if (pinned) pinPreviewLang() })
// The page's primary may be (re)learned while pinned on it (doc accept,
// tree refresh, a language change on the row) — re-pin with the new code.
watch(pagePrimary, () => { if (pinned && !editorLang.value) pinPreviewLang() })
// Tab order: site-wide settings first (site, structure, localization), then
// — after a visual break — the per-page editors (article, banner).
@@ -85,12 +91,14 @@ onMounted(() => {
addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('pagerite:editor-shown', pinPreviewLang)
addEventListener('pagerite:editor-hidden', unpinPreviewLang)
// The shell mounts visible (openEditor), so pin immediately. The primary
// language's code (for pinning it explicitly) comes from the settings.
// The shell mounts visible (openEditor), so pin immediately. The site
// default primary language comes from the settings — it only fills the
// unknown; the page/structure tabs refine pagePrimary per page as they
// learn it (their knowledge is strictly better).
pinPreviewLang()
fetch('/_api/settings').then((r) => r.json()).then((s) => {
primaryLang.value = s.primary_lang || 'en'
}).catch(() => { /* keep the default */ })
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).catch(() => { /* keep the fallback */ })
})
onUnmounted(() => {
+3 -2
View File
@@ -8,6 +8,7 @@ import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, required: true }, // [{tag, code, name, flag, primary}]
title: { type: String, default: '' }, // toggle-button tooltip override
})
const emit = defineEmits(['update:modelValue'])
@@ -28,9 +29,9 @@ function select(tag) {
type="button"
class="lang-current"
:class="{ open }"
:title="current
:title="title || (current
? `language: ${current.name}${current.primary ? ' (primary)' : ''}`
: ''"
: '')"
@click="open = !open"
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
<span v-if="open" class="lang-pop" @mouseleave="open = false">
+46 -34
View File
@@ -1,14 +1,16 @@
<script setup>
// Lang tab: the site-wide translation target languages (translate_langs)
// and the translator service WebSocket URL(s) (translate_keys). The primary
// language is configured per site hierarchy, not here. Flag clicks toggle
// and save immediately; the settings round-trip re-reads the payload, so
// this tab only ever changes translate_langs. The settings write's
// invalidation hook kicks the translation dispatcher. The refresh button
// drops all machine translations (user patches are kept), making the
// dispatcher re-translate everything.
// and the translator service WebSocket URL(s) (translate_keys). ALL
// languages are listed, English included — a page whose primary language
// (Node.language, configured per row in the structure tab, inherited down
// the hierarchy) differs can be translated INTO any other. Flag clicks
// toggle and save immediately; the settings round-trip re-reads the
// payload, so this tab only ever changes translate_langs. The settings
// write's invalidation hook kicks the translation dispatcher. The refresh
// button drops all machine translations (user patches are kept), making
// the dispatcher re-translate everything.
import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
import { TRANSLATABLE, flagFor, langName } from './langs'
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
import { dropPageCache } from './swapdoc'
defineProps({ pagePath: { type: String, default: '' } })
@@ -16,18 +18,22 @@ defineProps({ pagePath: { type: String, default: '' } })
defineEmits(['close', 'pathChange'])
const saveError = ref('')
const primaryLang = ref('en')
const selected = ref(new Set())
const keyUrls = ref([])
// The toggleable targets: every translatable language but the primary,
// alphabetized by display name.
const options = computed(() =>
Object.keys(TRANSLATABLE)
.filter((code) => code !== primaryLang.value)
.map((code) => ({ code, name: langName(code), flag: flagFor(code) }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
// The toggleable targets: every translatable language, laid out in
// geographic/cultural groups (one row each) rather than alphabetized —
// related languages sit together (a node's own primary is excluded per
// article, server-side). Any code missing from LANG_GROUPS trails as an
// extra row.
const groups = computed(() => {
const tile = (code) => ({ code, name: langName(code), flag: flagFor(code) })
const rows = LANG_GROUPS.map((g) => g.filter((c) => c in TRANSLATABLE).map(tile))
const covered = new Set(LANG_GROUPS.flat())
const rest = Object.keys(TRANSLATABLE).filter((c) => !covered.has(c)).map(tile)
if (rest.length) rows.push(rest)
return rows.filter((r) => r.length)
})
function updateWindowTitle() {
document.title = 'lang 🖊️'
@@ -45,7 +51,6 @@ onMounted(async () => {
addEventListener('pagerite:editor-shown', onEditorShown)
try {
const s = await (await fetch('/_api/settings')).json()
primaryLang.value = s.primary_lang || 'en'
selected.value = new Set(s.translate_langs || [])
const wsBase = location.origin.replace(/^http/, 'ws')
keyUrls.value = Object.entries(s.translate_keys || {})
@@ -106,17 +111,19 @@ async function refresh() {
<span class="field-label">languages</span>
</div>
<div class="flags">
<button
v-for="o in options"
:key="o.code"
type="button"
class="flag-tile"
:class="{ selected: selected.has(o.code) }"
:title="`${o.name} (${o.code})`"
@click="toggle(o.code)"
>
<span class="flag" v-html="o.flag" />
</button>
<div v-for="(row, ri) in groups" :key="ri" class="flag-row">
<button
v-for="o in row"
:key="o.code"
type="button"
class="flag-tile"
:class="{ selected: selected.has(o.code) }"
:title="`${o.name} (${o.code})`"
@click="toggle(o.code)"
>
<span class="flag" v-html="o.flag" />
</button>
</div>
</div>
</section>
@@ -179,13 +186,20 @@ async function refresh() {
color: var(--muted);
}
/* Flag grid: deselected flags sit dimmed and grayed; a click brings one to
full color with an accent ring (selected = a translation target). */
/* Flag grid: one geographic group per row. Deselected flags sit dimmed and
grayed; a click brings one to full color (selected = a translation
target) — the shading alone carries the state, no outline. */
.flags {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.2rem 0;
}
.flag-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.2rem 0;
}
.flag-tile {
@@ -207,8 +221,6 @@ async function refresh() {
.flag-tile.selected {
opacity: 1;
filter: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
/* Same flag chips as the PageEditor language picker / analytics cells. */
+2 -1
View File
@@ -33,7 +33,7 @@ import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
import { flagFor, langName } from './langs'
import { editorLang } from './editorLang'
import { editorLang, pagePrimary } from './editorLang'
import LangSelect from './LangSelect.vue'
import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
@@ -752,6 +752,7 @@ function onMessage(ev) {
title.value = msg.title
published.value = msg.published
primaryLang.value = msg.primary_lang || 'en'
pagePrimary.value = primaryLang.value // the page's own — the shell pins the preview by it
pageLangs.value = msg.langs || []
siteLangs.value = msg.translate_langs || []
lang.value = msg.lang || ''
+34 -1
View File
@@ -20,7 +20,7 @@ import StructureTree from './StructureTree.vue'
import LangSelect from './LangSelect.vue'
import { slugify } from './slugify'
import { flagFor, langName } from './langs'
import { editorLang } from './editorLang'
import { editorLang, pagePrimary } from './editorLang'
import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({
@@ -59,6 +59,25 @@ const currentLang = computed(
// titles in it (and EditorShell swaps the page preview into it).
watch(lang, () => refreshPages())
// Per-row primary language (Node.language, '' = inherit): the row's
// dropdown lists "inherit" first (naming what it resolves to), then every
// site language. Setting it on a section covers its whole subtree.
const rowLangChoices = computed(() =>
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
.map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })),
)
function rowLangOptions(el) {
const resolved = el.primary || primaryLang.value
return [
{ tag: '', code: '_inherit', name: `inherit (${langName(resolved)})`, flag: flagFor(resolved), primary: false },
...rowLangChoices.value,
]
}
async function setLanguage(node, tag) {
await postStructure({ path: node.path, language: tag })
}
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
@@ -188,10 +207,22 @@ async function commitPending() {
}
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
function findNode(nodes, p) {
for (const n of nodes) {
if (n.path === p) return n
const found = findNode(n.children, p)
if (found) return found
}
return null
}
async function refreshPages() {
try {
const q = lang.value ? `?lang=${lang.value}` : ''
tree.value = await (await fetch(`/_api/pages${q}`)).json()
// The tree carries each node's resolved primary language: publish the
// current page's (the shell pins the preview by it on '' selection).
pagePrimary.value = findNode(tree.value, path.value)?.primary || 'en'
} catch { /* list stays stale; not fatal */ }
}
@@ -305,6 +336,8 @@ provide('structureHandlers', {
commitPending,
discardPending,
newPage,
langOptions: rowLangOptions,
setLanguage,
})
onMounted(() => {
+31 -2
View File
@@ -1,7 +1,10 @@
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, translated, order, published, has_content, children}.
// {slug, path, title, translated, order, published, has_content, language,
// primary, children}. The row's flag (LangSelect) sets the node's primary
// language (language; '' = inherit — dimmed, showing the resolved flag);
// the setting covers the whole subtree.
// With a `lang` prop (StructureEditor's language strip) the titles shown
// are that language's; `translated` marks rows with an actual translation
// (untranslated rows show the original title, dimmed).
@@ -27,6 +30,7 @@
import { inject } from 'vue'
import draggable from 'vuedraggable'
import { slugify } from './slugify'
import LangSelect from './LangSelect.vue'
defineOptions({ name: 'StructureTree' })
const props = defineProps({
@@ -150,6 +154,17 @@ function onEnd() {
@change="handlers.commitSlug(element, $event)"
/>
<span class="acts">
<span
class="row-lang"
:class="{ inherited: !element.language }"
><LangSelect
:model-value="element.language"
:options="handlers.langOptions(element)"
:title="element.language
? `primary language: set on this page (subtree inherits)`
: `primary language: inherited — set it here (subtree inherits)`"
@update:model-value="handlers.setLanguage(element, $event)"
/></span>
<span v-if="!element.published" class="draft">draft</span>
<button
v-if="element.has_content || !element.children.length"
@@ -234,7 +249,7 @@ body.tree-dragging .treelist {
level, not across levels). */
.row {
display: grid;
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem 5rem;
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem auto;
align-items: baseline;
gap: 0.35rem;
/* Vertical spacing widens the drop zones: the exposed top strip is the
@@ -303,6 +318,20 @@ body.tree-dragging .treelist {
justify-content: end;
}
/* Row language selector (LangSelect): the effective primary language's
flag; dimmed while the setting is inherited rather than set on the row. */
.row-lang {
display: inline-flex;
}
.row-lang.inherited :deep(.lang-current) {
opacity: 0.45;
}
.row-lang.inherited:hover :deep(.lang-current) {
opacity: 0.85;
}
.draft {
color: var(--muted);
font-size: 0.75rem;
+8
View File
@@ -5,3 +5,11 @@
import { ref } from 'vue'
export const editorLang = ref('')
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
// settings fetch fills it with the site default; the page/structure tabs
// then refine it per page (doc accept / tree rows — strictly better
// sources, so they overwrite freely while the settings fetch only fills
// the unknown). EditorShell pins the preview by it when the selection is
// '' (the primary).
export const pagePrimary = ref('')
+28 -8
View File
@@ -4,15 +4,30 @@
import * as flagSvgs from 'country-flag-icons/string/3x2'
// The Seed-X reference translator's languages (scripts/translator.py) — the
// translation-target ceiling — each mapped to the country whose flag stands
// for it (CLDR likely-subtag regions: the country with the most speakers).
// translation-target ceiling — each mapped to the language's home country
// flag (England for English, Portugal for Portuguese — not the most
// populous variant). Internal tags are the bare 2-letter base subtags; the
// translator decides the variant. A variant tag (en-US, pt-BR) is still a
// valid explicit selection for a future translator that distinguishes them
// — flagFor shows its own region then.
export const TRANSLATABLE = {
ar: 'EG', cs: 'CZ', da: 'DK', de: 'DE', el: 'GR', en: 'US', es: 'ES',
ar: 'EG', cs: 'CZ', da: 'DK', de: 'DE', el: 'GR', en: 'GB', es: 'ES',
fa: 'IR', fi: 'FI', fr: 'FR', hu: 'HU', id: 'ID', it: 'IT', ja: 'JP',
ko: 'KR', ms: 'MY', nl: 'NL', no: 'NO', pl: 'PL', pt: 'BR', ro: 'RO',
ko: 'KR', ms: 'MY', nl: 'NL', no: 'NO', pl: 'PL', pt: 'PT', ro: 'RO',
ru: 'RU', sv: 'SE', th: 'TH', tr: 'TR', uk: 'UA', vi: 'VN', zh: 'CN',
}
// The languages in geographic/cultural groups (the lang tab's flag grid
// lays them out one group per row, in this order): English with the
// Nordics, then Western/Central and Eastern Europe, Southern Europe with
// the Middle East, and Asia.
export const LANG_GROUPS = [
['en', 'nl', 'da', 'no', 'sv', 'fi', 'ru'],
['fr', 'de', 'pl', 'cs', 'hu', 'ro', 'uk'],
['es', 'pt', 'it', 'el', 'tr', 'ar', 'fa'],
['zh', 'ja', 'ko', 'vi', 'th', 'id', 'ms'],
]
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
// English display name for a language tag ("fi" -> "Finnish").
@@ -24,11 +39,16 @@ export function langName(tag) {
}
}
// Flag SVG string for a language tag: the explicit mapping first, then the
// tag's most likely region (for languages outside the translatable list).
// Flag SVG string for a language tag: an explicit region variant (en-US)
// gets its own region's flag; a bare base tag maps to the language's home
// country (en → GB, pt → PT); languages outside the list fall back to the
// tag's most likely region.
export function flagFor(tag) {
const country = TRANSLATABLE[tag]
if (country) return flagSvgs[country] || ''
tag = tag || ''
if (!tag.includes('-')) {
const country = TRANSLATABLE[tag.split('-')[0].toLowerCase()]
if (country) return flagSvgs[country] || ''
}
try {
return flagSvgs[new Intl.Locale(tag).maximize().region] || ''
} catch {
+59 -30
View File
@@ -410,15 +410,17 @@ def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_
"""Render one of the generated pages (see _html_response)."""
if kind == "page":
# A selected language without an actual translation renders the
# original (translation is None = English; see docs/localization.md).
translation = i18n.get_translation(data, path, lang) if lang != i18n.ORIGINAL_LANGUAGE else None
# original (translation is None; see docs/localization.md).
original = i18n.primary_lang(data.menu, path)
translation = i18n.get_translation(data, path, lang) if lang != original else None
return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
if kind == "category":
# A category has no Markdown of its own; only the title map
# localizes (heading, navigation, card text).
original = i18n.primary_lang(data.menu, path)
translation = (
i18n.Translation(titles=i18n.title_map(data, lang))
if lang != i18n.ORIGINAL_LANGUAGE
if lang != original
else None
)
return views.render_category(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
@@ -519,36 +521,38 @@ class PageIn(BaseModel):
async def list_pages(lang: str | None = None) -> list[dict]:
"""The site tree for the structure editor (all nodes, drafts included).
Nested by slug; each node carries its full path, menu order and flags.
Nested by slug; each node carries its full path, menu order, flags and
language settings (``language`` is the node's own primary-language
setting, "" = inherit; ``primary`` is the resolved effective one).
With a ``?lang=`` translation, titles come out in that language where a
translation exists (``translated`` flags it; the row still falls back
to the original title otherwise) — the structure itself (slugs, order,
translation exists (``translated`` flags it — true trivially for rows
whose primary language IS the selected one; other rows fall back to
the original title, dimmed) — the structure itself (slugs, order,
hierarchy) is language-independent.
"""
tag = i18n.base_tag(lang or "")
titles = (
i18n.title_map(data, tag)
if tag and tag != i18n.ORIGINAL_LANGUAGE
else {}
)
titles = i18n.title_map(data, tag) if tag else {}
def dump(nodes: dict[str, Node], prefix: str) -> list[dict]:
def dump(nodes: dict[str, Node], prefix: str, inherited: str) -> list[dict]:
out = []
for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug
primary = node.language or inherited
out.append({
"slug": slug,
"path": path,
"title": titles.get(path) or node.title,
"translated": path in titles,
"translated": path in titles or (bool(tag) and primary == tag),
"order": node.order,
"published": node.published,
"has_content": node.chunks is not None,
"children": dump(node.children, path),
"language": node.language,
"primary": primary,
"children": dump(node.children, path, primary),
})
return out
return dump(data.menu, "")
return dump(data.menu, "", i18n.ORIGINAL_LANGUAGE)
@app.put("/_api/pages/{path:path}", status_code=204)
@@ -571,7 +575,7 @@ async def save_page(path: str, page: PageIn, lang: str | None = None) -> None:
path = path.strip("/")
_check_reserved(path)
lang = i18n.base_tag(lang or "")
if lang and lang != i18n.ORIGINAL_LANGUAGE:
if lang and lang != i18n.primary_lang(data.menu, path):
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
if node is None or node.chunks is None:
@@ -603,11 +607,15 @@ class StructureOp(BaseModel):
page ("/" then redirects to the first nav item), and any childless
top-level node can take the empty slug to become the front page.
With `lang` (a translation, not the primary language) a `title` edit
writes a per-language title fragment instead of the original — the same
storage as machine title translations (docs/localization.md); sending
the original's text removes the override. Structural fields are not
combinable with a translated title edit.
With `lang` (a translation, not the node's primary language) a `title`
edit writes a per-language title fragment instead of the original — the
same storage as machine title translations (docs/localization.md);
sending the original's text removes the override. Structural fields are
not combinable with a translated title edit.
`language` sets the node's primary language (a BCP-47 base tag; "" =
inherit from the nearest ancestor, the front page last, site default
"en" final — Node.language), inherited by the whole subtree.
"""
path: str
@@ -615,6 +623,7 @@ class StructureOp(BaseModel):
move_to: str | None = None
title: str | None = None
lang: str | None = None
language: str | None = None
@app.post("/_api/structure", status_code=204)
@@ -626,7 +635,17 @@ async def update_structure(op: StructureOp) -> None:
raise HTTPException(404, "no such page")
node = chain[-1]
lang = i18n.base_tag(op.lang or "")
if op.title is not None and lang and lang != i18n.ORIGINAL_LANGUAGE:
if op.language is not None:
# Primary-language setting (inherited by the subtree): reselects
# what "the original" means for the node — its language is part of
# every render, so a change invalidates everywhere.
language = i18n.base_tag(op.language)
with kanta.transaction("set page language", extra=path):
if language != node.language:
node.language = language
_invalidate_pages()
return
if op.title is not None and lang and lang != i18n.primary_lang(data.menu, path):
# Translated title (i18n.set_title_translation): original title,
# slugs and hierarchy stay untouched.
with kanta.transaction("translate title", extra=path):
@@ -686,7 +705,9 @@ async def get_settings() -> dict:
"transition": data.transition,
"transitions": views._transition_names(),
"translate_keys": data.translate_keys,
"primary_lang": i18n.ORIGINAL_LANGUAGE,
# The site default primary language: the front page's resolved
# setting (every page may override it, inherited down the tree).
"primary_lang": i18n.primary_lang(data.menu, ""),
"translate_langs": sorted(data.translate_langs),
}
@@ -712,10 +733,13 @@ async def put_settings(settings: SettingsIn) -> None:
data.custom_css = settings.custom_css
data.transition = settings.transition
if settings.translate_langs is not None:
# Any language may be a target — including the site default
# (an article in another language can be translated INTO it);
# a node's own primary is excluded per article, not here.
data.translate_langs = {
tag: True
for lang in settings.translate_langs
if (tag := i18n.base_tag(lang)) and tag != i18n.ORIGINAL_LANGUAGE
if (tag := i18n.base_tag(lang))
}
_invalidate_pages()
@@ -1417,8 +1441,11 @@ async def editor_ws(ws: WebSocket) -> None:
case "open":
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
# The article's primary language: its own setting,
# inherited down the tree ("en" final fallback).
node_lang = i18n.primary_lang(data.menu, path)
lang = i18n.base_tag(str(msg.get("lang") or ""))
if lang == i18n.ORIGINAL_LANGUAGE:
if lang == node_lang:
lang = ""
markdown = ""
title = node.title if node else ""
@@ -1461,11 +1488,11 @@ async def editor_ws(ws: WebSocket) -> None:
),
# 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.
# the page's own primary language, the translations
# this page already has, and the site-wide
# configured target languages.
"lang": lang,
"primary_lang": i18n.ORIGINAL_LANGUAGE,
"primary_lang": node_lang,
"langs": sorted(node.langs) if node else [],
"translate_langs": sorted(data.translate_langs),
})
@@ -1494,7 +1521,7 @@ async def editor_ws(ws: WebSocket) -> None:
case "save":
move_from = (msg.get("move_from") or path).strip("/")
lang = i18n.base_tag(str(msg.get("lang") or ""))
translated = bool(lang and lang != i18n.ORIGINAL_LANGUAGE)
translated = bool(lang and lang != i18n.primary_lang(data.menu, move_from))
try:
_check_reserved(move_from)
except HTTPException:
@@ -1722,6 +1749,7 @@ async def show_page(request: Request, path: str) -> Response:
query_lang,
accept_language,
lambda tag: tag in node.langs,
original=i18n.primary_lang(data.menu, path),
)
# A ?lang= override is replicated onto the page's navigation links
# (link_lang), so clicks and prefetches stay in the chosen language.
@@ -1765,6 +1793,7 @@ async def show_page(request: Request, path: str) -> Response:
query_lang,
accept_language,
lambda tag: tag in subtree_langs,
original=i18n.primary_lang(data.menu, path),
)
link_lang = i18n.base_tag(query_lang or "")
if _is_trackable_path(path):
+32 -16
View File
@@ -1,7 +1,9 @@
"""Localization: language selection, translation storage and assembly.
See docs/localization.md and docs/migrate.md. The database holds the
original language as content-addressed chunks (``Data.chunks``); per
See docs/localization.md and docs/migrate.md. Each article's primary
language is ``Node.language``, inherited down the hierarchy (front page =
site default, ORIGINAL_LANGUAGE as the final fallback). The database holds
the original language as content-addressed chunks (``Data.chunks``); per
target language there are machine-translated fragments (``Data.trans``)
and user override patches (``Data.patches``), assembled into the served
Markdown at render time, with per-node fallback to the original titles.
@@ -15,10 +17,29 @@ import msgspec
from pagerite.chunks import chunk_key, chunk_markdown, join_chunks
from pagerite.data import Data, Node, Patch, resolve
#: Language of the database originals (and the default <html lang>).
#: Final fallback for a page's primary language when neither it nor any
#: ancestor (up to the front page) sets one (Node.language, "" = inherit).
ORIGINAL_LANGUAGE = "en"
def primary_lang(menu: dict[str, Node], path: str) -> str:
"""The primary language of the article at ``path``: its own
``language`` setting, else the nearest ancestor's (the front page
last — it doubles as the site default), falling back to
ORIGINAL_LANGUAGE. Missing tail segments (a page being created)
resolve to the nearest existing ancestor."""
p = path.strip("/")
while True:
chain = resolve(menu, p)
if chain:
for node in reversed(chain):
if node.language:
return node.language
if not p:
return ORIGINAL_LANGUAGE
p = p.rpartition("/")[0]
class Translation(msgspec.Struct, omit_defaults=True):
"""Translated content for one page and language.
@@ -37,14 +58,6 @@ def base_tag(tag: str) -> str:
return tag.strip().lower().partition("-")[0]
def translation_tag(lang: str | None) -> str:
"""The normalized translation selector: the base subtag of ``lang``, or
"" when it is absent or the original language (the original is never a
translation target)."""
tag = base_tag(lang or "")
return tag if tag != ORIGINAL_LANGUAGE else ""
def parse_accept_language(header: str) -> list[str]:
"""Accept-Language header as an ordered, deduped list of base subtags.
@@ -211,20 +224,23 @@ def title_map(data: Data, lang: str) -> dict[str, str]:
Titles are chunks too (docs/migrate.md): keyed by the hash of the
title text, so editing a title invalidates its translations. Nodes
without an entry fall back to their original title in views.
without an entry fall back to their original title in views — as do
nodes whose primary language IS ``lang`` (their original title already
is in that language).
"""
titles = {}
def walk(nodes: dict[str, Node], prefix: str) -> None:
def walk(nodes: dict[str, Node], prefix: str, inherited: str) -> None:
for slug, node in nodes.items():
path = f"{prefix}/{slug}" if prefix else slug
if node.title:
node_lang = node.language or inherited
if node.title and node_lang != lang:
t = data.trans.get(chunk_key(node.title), {}).get(lang)
if t:
titles[path] = t
walk(node.children, path)
walk(node.children, path, node_lang)
walk(data.menu, "")
walk(data.menu, "", ORIGINAL_LANGUAGE)
return titles
+15 -11
View File
@@ -129,10 +129,13 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
return segs[0][:400]
return ""
def walk(nodes: dict[str, Node], prefix: str) -> None:
def walk(nodes: dict[str, Node], prefix: str, inherited: str) -> None:
for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug
if node.chunks is not None:
# An article whose primary language IS the target needs no
# translation into it — skip its title and chunks entirely.
node_lang = node.language or inherited
if node.chunks is not None and node_lang != lang:
if node.title:
emit(chunk_key(node.title), node.title, path, "title",
context=opening(node))
@@ -144,9 +147,9 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
and needs_translation(text)
):
emit(h, text, path, "chunk")
walk(node.children, path)
walk(node.children, path, node_lang)
walk(data.menu, "")
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
return items
@@ -167,19 +170,20 @@ def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
data.trans.setdefault(item.key, {})[lang] = item.text
pages: list[str] = []
def walk(nodes: dict[str, Node], prefix: str) -> None:
def walk(nodes: dict[str, Node], prefix: str, inherited: str) -> None:
for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug
if node.chunks is not None:
node_lang = node.language or inherited
if node.chunks is not None and node_lang != lang:
keys = set(node.chunks)
if node.title:
keys.add(chunk_key(node.title))
if keys & stored:
node.langs[lang] = True
pages.append(path)
walk(node.children, path)
walk(node.children, path, node_lang)
walk(data.menu, "")
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
return pages
@@ -263,7 +267,7 @@ class Dispatcher:
wanted = {
tag
for lang in self.data.translate_langs
if (tag := i18n.translation_tag(lang))
if (tag := i18n.base_tag(lang))
}
if not wanted:
return
@@ -336,12 +340,12 @@ class Dispatcher:
await ws.close(code=1002)
return
state = _Connection({
tag for lang in msg.langs if (tag := i18n.translation_tag(lang))
tag for lang in msg.langs if (tag := i18n.base_tag(lang))
})
self.clients[ws] = state
self.schedule()
else: # Result
lang = i18n.translation_tag(msg.lang)
lang = i18n.base_tag(msg.lang)
if (
state is None # results before Hello
or state.inflight is None # no job in flight
+6 -5
View File
@@ -936,8 +936,9 @@ def render_page(
replicated onto the navigation links so the language sticks.
"""
node = resolve(menu, path)[-1]
original = i18n.primary_lang(menu, path)
if translation is None:
lang = i18n.ORIGINAL_LANGUAGE
lang = original
title = _title(path.rpartition("/")[2], node, translation, path)
main = page_content(menu, data, path, translation, link_lang, lang)
social = _social_meta(node, path, title, str(main), brand, base_url)
@@ -948,16 +949,16 @@ def render_page(
# or header). The alternates are site-wide, the same set on every
# page: the configured translate_langs (the translator works to fill
# them all in), x-default first (the plain, autodetecting URL), then
# every language explicitly, the default language included.
# every language explicitly, the page's own primary included.
canonical = ""
alternates = []
if base_url:
url = f"{base_url}/{path}"
canonical = url if lang == i18n.ORIGINAL_LANGUAGE else f"{url}?lang={lang}"
canonical = url if lang == original else f"{url}?lang={lang}"
if data.translate_langs:
alternates = [("x-default", url)] + [
(tag, f"{url}?lang={tag}")
for tag in [i18n.ORIGINAL_LANGUAGE, *sorted(data.translate_langs)]
for tag in sorted({original, *data.translate_langs})
]
return str(
_layout(
@@ -1002,7 +1003,7 @@ def render_category(
"""
node = resolve(menu, path)[-1]
if translation is None:
lang = i18n.ORIGINAL_LANGUAGE
lang = i18n.primary_lang(menu, path)
title = _title(path.rpartition("/")[2], node, translation, path)
doc = E.article
with doc: