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. - `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`. - `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">`. - `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. 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 ## 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 ### Language selection
Deliberately simple — **q-values are ignored**: 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 page — the site-wide configured languages (`translate_langs`, which the
translator works to fill in): `x-default` first, pointing at the plain translator works to fill in): `x-default` first, pointing at the plain
autodetecting URL, then every language explicitly with `?lang=`, the 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 - The override sticks for the session of clicks: a page requested with
`?lang=` replicates the query onto the navigation links it renders (nav, `?lang=` replicates the query onto the navigation links it renders (nav,
sidebar, cards, brand — in-article links are content and stay as 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 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 the analytics visitor cells), v-modeled on one shell-wide selection
(`editorLang.js`, `''` = the primary language). The page editor lists the (`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, 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 even when the page itself was served in a translation. A note under the
toolbar states the blast radius: toolbar states the blast radius:
@@ -233,9 +247,10 @@ local to that language.
While the editor panel is open, its language selection **overrides the While the editor panel is open, its language selection **overrides the
normal language preferences** for the page preview: EditorShell pins every normal language preferences** for the page preview: EditorShell pins every
in-place re-render and pagerite.js fetch/prefetch to it (`?lang=`the in-place re-render and pagerite.js fetch/prefetch to it (`?lang=`a
primary language pins by its own code, which `select_language` honors), and primary selection pins by the current page's own resolved primary, which
closing the panel restores the normal preferences. `select_language` honors), and closing the panel restores the normal
preferences.
- WS `open` with a `lang` returns the effective **hybrid** Markdown and - WS `open` with a `lang` returns the effective **hybrid** Markdown and
title for that language (ungated by `node.langs` — a language without 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 The **structure editor** selects from the same languages with the same
`LangSelect` (the selection is shared — switching in either tab switches `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 lists (`GET /_api/pages?lang=`) comes back with per-language titles where a
translation exists (`translated` marks those rows; untranslated rows show translation exists (`translated` marks those rows; untranslated rows show
the original title, dimmed). Retitling in a non-primary language posts the 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**: Which languages get translated is **server-configured**:
`Data.translate_langs` (presence-key dict, bootstrapped to Spanish and `Data.translate_langs` (presence-key dict, bootstrapped to Spanish and
Chinese — the original language is never a target — edited in the editor Chinese — edited in the editor shell's localization tab, whose flag grid
shell's localization tab or set via `/_api/settings` as `translate_langs`). lists every language including English, or set via `/_api/settings` as
The dispatcher offers a `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 connection jobs only in `wanted ∩ capable`; a connection without overlap
simply stays idle. simply stays idle.
+15 -7
View File
@@ -8,7 +8,7 @@ import BannerEditor from './BannerEditor.vue'
import SiteEditor from './SiteEditor.vue' import SiteEditor from './SiteEditor.vue'
import StructureEditor from './StructureEditor.vue' import StructureEditor from './StructureEditor.vue'
import LocalizationEditor from './LocalizationEditor.vue' import LocalizationEditor from './LocalizationEditor.vue'
import { editorLang } from './editorLang' import { editorLang, pagePrimary } from './editorLang'
import { loadPlain, setLangOverride } from './swapdoc' import { loadPlain, setLangOverride } from './swapdoc'
const props = defineProps({ const props = defineProps({
@@ -24,11 +24,14 @@ const activeMode = ref(props.initialMode)
// LangSelects) also drives the page preview: while the shell is open it // LangSelects) also drives the page preview: while the shell is open it
// overrides the normal language preferences (?lang= / Accept-Language), // overrides the normal language preferences (?lang= / Accept-Language),
// so the page renders in the language being edited; closing restores. // 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 let pinned = false
function pinPreviewLang() { function pinPreviewLang() {
pinned = true 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) loadPlain(currentPath.value)
} }
function unpinPreviewLang() { function unpinPreviewLang() {
@@ -38,6 +41,9 @@ function unpinPreviewLang() {
loadPlain(currentPath.value) loadPlain(currentPath.value)
} }
watch(editorLang, () => { if (pinned) pinPreviewLang() }) 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 // Tab order: site-wide settings first (site, structure, localization), then
// — after a visual break — the per-page editors (article, banner). // — after a visual break — the per-page editors (article, banner).
@@ -85,12 +91,14 @@ onMounted(() => {
addEventListener('pagerite:switch-editor', onSwitchEvent) addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('pagerite:editor-shown', pinPreviewLang) addEventListener('pagerite:editor-shown', pinPreviewLang)
addEventListener('pagerite:editor-hidden', unpinPreviewLang) addEventListener('pagerite:editor-hidden', unpinPreviewLang)
// The shell mounts visible (openEditor), so pin immediately. The primary // The shell mounts visible (openEditor), so pin immediately. The site
// language's code (for pinning it explicitly) comes from the settings. // 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() pinPreviewLang()
fetch('/_api/settings').then((r) => r.json()).then((s) => { fetch('/_api/settings').then((r) => r.json()).then((s) => {
primaryLang.value = s.primary_lang || 'en' if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).catch(() => { /* keep the default */ }) }).catch(() => { /* keep the fallback */ })
}) })
onUnmounted(() => { onUnmounted(() => {
+3 -2
View File
@@ -8,6 +8,7 @@ import { computed, ref } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
options: { type: Array, required: true }, // [{tag, code, name, flag, primary}] options: { type: Array, required: true }, // [{tag, code, name, flag, primary}]
title: { type: String, default: '' }, // toggle-button tooltip override
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -28,9 +29,9 @@ function select(tag) {
type="button" type="button"
class="lang-current" class="lang-current"
:class="{ open }" :class="{ open }"
:title="current :title="title || (current
? `language: ${current.name}${current.primary ? ' (primary)' : ''}` ? `language: ${current.name}${current.primary ? ' (primary)' : ''}`
: ''" : '')"
@click="open = !open" @click="open = !open"
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button> ><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
<span v-if="open" class="lang-pop" @mouseleave="open = false"> <span v-if="open" class="lang-pop" @mouseleave="open = false">
+46 -34
View File
@@ -1,14 +1,16 @@
<script setup> <script setup>
// Lang tab: the site-wide translation target languages (translate_langs) // Lang tab: the site-wide translation target languages (translate_langs)
// and the translator service WebSocket URL(s) (translate_keys). The primary // and the translator service WebSocket URL(s) (translate_keys). ALL
// language is configured per site hierarchy, not here. Flag clicks toggle // languages are listed, English included — a page whose primary language
// and save immediately; the settings round-trip re-reads the payload, so // (Node.language, configured per row in the structure tab, inherited down
// this tab only ever changes translate_langs. The settings write's // the hierarchy) differs can be translated INTO any other. Flag clicks
// invalidation hook kicks the translation dispatcher. The refresh button // toggle and save immediately; the settings round-trip re-reads the
// drops all machine translations (user patches are kept), making the // payload, so this tab only ever changes translate_langs. The settings
// dispatcher re-translate everything. // 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 { 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' import { dropPageCache } from './swapdoc'
defineProps({ pagePath: { type: String, default: '' } }) defineProps({ pagePath: { type: String, default: '' } })
@@ -16,18 +18,22 @@ defineProps({ pagePath: { type: String, default: '' } })
defineEmits(['close', 'pathChange']) defineEmits(['close', 'pathChange'])
const saveError = ref('') const saveError = ref('')
const primaryLang = ref('en')
const selected = ref(new Set()) const selected = ref(new Set())
const keyUrls = ref([]) const keyUrls = ref([])
// The toggleable targets: every translatable language but the primary, // The toggleable targets: every translatable language, laid out in
// alphabetized by display name. // geographic/cultural groups (one row each) rather than alphabetized —
const options = computed(() => // related languages sit together (a node's own primary is excluded per
Object.keys(TRANSLATABLE) // article, server-side). Any code missing from LANG_GROUPS trails as an
.filter((code) => code !== primaryLang.value) // extra row.
.map((code) => ({ code, name: langName(code), flag: flagFor(code) })) const groups = computed(() => {
.sort((a, b) => a.name.localeCompare(b.name)), 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() { function updateWindowTitle() {
document.title = 'lang 🖊️' document.title = 'lang 🖊️'
@@ -45,7 +51,6 @@ onMounted(async () => {
addEventListener('pagerite:editor-shown', onEditorShown) addEventListener('pagerite:editor-shown', onEditorShown)
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await (await fetch('/_api/settings')).json()
primaryLang.value = s.primary_lang || 'en'
selected.value = new Set(s.translate_langs || []) selected.value = new Set(s.translate_langs || [])
const wsBase = location.origin.replace(/^http/, 'ws') const wsBase = location.origin.replace(/^http/, 'ws')
keyUrls.value = Object.entries(s.translate_keys || {}) keyUrls.value = Object.entries(s.translate_keys || {})
@@ -106,17 +111,19 @@ async function refresh() {
<span class="field-label">languages</span> <span class="field-label">languages</span>
</div> </div>
<div class="flags"> <div class="flags">
<button <div v-for="(row, ri) in groups" :key="ri" class="flag-row">
v-for="o in options" <button
:key="o.code" v-for="o in row"
type="button" :key="o.code"
class="flag-tile" type="button"
:class="{ selected: selected.has(o.code) }" class="flag-tile"
:title="`${o.name} (${o.code})`" :class="{ selected: selected.has(o.code) }"
@click="toggle(o.code)" :title="`${o.name} (${o.code})`"
> @click="toggle(o.code)"
<span class="flag" v-html="o.flag" /> >
</button> <span class="flag" v-html="o.flag" />
</button>
</div>
</div> </div>
</section> </section>
@@ -179,13 +186,20 @@ async function refresh() {
color: var(--muted); color: var(--muted);
} }
/* Flag grid: deselected flags sit dimmed and grayed; a click brings one to /* Flag grid: one geographic group per row. Deselected flags sit dimmed and
full color with an accent ring (selected = a translation target). */ grayed; a click brings one to full color (selected = a translation
target) — the shading alone carries the state, no outline. */
.flags { .flags {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.2rem 0;
}
.flag-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
padding: 0.2rem 0;
} }
.flag-tile { .flag-tile {
@@ -207,8 +221,6 @@ async function refresh() {
.flag-tile.selected { .flag-tile.selected {
opacity: 1; opacity: 1;
filter: none; filter: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
} }
/* Same flag chips as the PageEditor language picker / analytics cells. */ /* 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 { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { flagFor, langName } from './langs' import { flagFor, langName } from './langs'
import { editorLang } from './editorLang' import { editorLang, pagePrimary } from './editorLang'
import LangSelect from './LangSelect.vue' import LangSelect from './LangSelect.vue'
import ConnNote from './ConnNote.vue' import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
@@ -752,6 +752,7 @@ function onMessage(ev) {
title.value = msg.title title.value = msg.title
published.value = msg.published published.value = msg.published
primaryLang.value = msg.primary_lang || 'en' 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 || [] pageLangs.value = msg.langs || []
siteLangs.value = msg.translate_langs || [] siteLangs.value = msg.translate_langs || []
lang.value = msg.lang || '' lang.value = msg.lang || ''
+34 -1
View File
@@ -20,7 +20,7 @@ import StructureTree from './StructureTree.vue'
import LangSelect from './LangSelect.vue' import LangSelect from './LangSelect.vue'
import { slugify } from './slugify' import { slugify } from './slugify'
import { flagFor, langName } from './langs' import { flagFor, langName } from './langs'
import { editorLang } from './editorLang' import { editorLang, pagePrimary } from './editorLang'
import { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({ const props = defineProps({
@@ -59,6 +59,25 @@ const currentLang = computed(
// titles in it (and EditorShell swaps the page preview into it). // titles in it (and EditorShell swaps the page preview into it).
watch(lang, () => refreshPages()) 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) { function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '') return p.trim().replace(/^\/+|\/+$/g, '')
} }
@@ -188,10 +207,22 @@ async function commitPending() {
} }
// --- Site structure tree (drag-and-drop ordering/moving) ---------------- // --- 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() { async function refreshPages() {
try { try {
const q = lang.value ? `?lang=${lang.value}` : '' const q = lang.value ? `?lang=${lang.value}` : ''
tree.value = await (await fetch(`/_api/pages${q}`)).json() 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 */ } } catch { /* list stays stale; not fatal */ }
} }
@@ -305,6 +336,8 @@ provide('structureHandlers', {
commitPending, commitPending,
discardPending, discardPending,
newPage, newPage,
langOptions: rowLangOptions,
setLanguage,
}) })
onMounted(() => { onMounted(() => {
+31 -2
View File
@@ -1,7 +1,10 @@
<script setup> <script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable). // Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as // 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 // With a `lang` prop (StructureEditor's language strip) the titles shown
// are that language's; `translated` marks rows with an actual translation // are that language's; `translated` marks rows with an actual translation
// (untranslated rows show the original title, dimmed). // (untranslated rows show the original title, dimmed).
@@ -27,6 +30,7 @@
import { inject } from 'vue' import { inject } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { slugify } from './slugify' import { slugify } from './slugify'
import LangSelect from './LangSelect.vue'
defineOptions({ name: 'StructureTree' }) defineOptions({ name: 'StructureTree' })
const props = defineProps({ const props = defineProps({
@@ -150,6 +154,17 @@ function onEnd() {
@change="handlers.commitSlug(element, $event)" @change="handlers.commitSlug(element, $event)"
/> />
<span class="acts"> <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> <span v-if="!element.published" class="draft">draft</span>
<button <button
v-if="element.has_content || !element.children.length" v-if="element.has_content || !element.children.length"
@@ -234,7 +249,7 @@ body.tree-dragging .treelist {
level, not across levels). */ level, not across levels). */
.row { .row {
display: grid; 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; align-items: baseline;
gap: 0.35rem; gap: 0.35rem;
/* Vertical spacing widens the drop zones: the exposed top strip is the /* Vertical spacing widens the drop zones: the exposed top strip is the
@@ -303,6 +318,20 @@ body.tree-dragging .treelist {
justify-content: end; 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 { .draft {
color: var(--muted); color: var(--muted);
font-size: 0.75rem; font-size: 0.75rem;
+8
View File
@@ -5,3 +5,11 @@
import { ref } from 'vue' import { ref } from 'vue'
export const editorLang = ref('') 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' import * as flagSvgs from 'country-flag-icons/string/3x2'
// The Seed-X reference translator's languages (scripts/translator.py) — the // The Seed-X reference translator's languages (scripts/translator.py) — the
// translation-target ceiling — each mapped to the country whose flag stands // translation-target ceiling — each mapped to the language's home country
// for it (CLDR likely-subtag regions: the country with the most speakers). // 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 = { 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', 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', 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' }) const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
// English display name for a language tag ("fi" -> "Finnish"). // 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 // Flag SVG string for a language tag: an explicit region variant (en-US)
// tag's most likely region (for languages outside the translatable list). // 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) { export function flagFor(tag) {
const country = TRANSLATABLE[tag] tag = tag || ''
if (country) return flagSvgs[country] || '' if (!tag.includes('-')) {
const country = TRANSLATABLE[tag.split('-')[0].toLowerCase()]
if (country) return flagSvgs[country] || ''
}
try { try {
return flagSvgs[new Intl.Locale(tag).maximize().region] || '' return flagSvgs[new Intl.Locale(tag).maximize().region] || ''
} catch { } 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).""" """Render one of the generated pages (see _html_response)."""
if kind == "page": if kind == "page":
# A selected language without an actual translation renders the # A selected language without an actual translation renders the
# original (translation is None = English; see docs/localization.md). # original (translation is None; see docs/localization.md).
translation = i18n.get_translation(data, path, lang) if lang != i18n.ORIGINAL_LANGUAGE else None 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) 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":
# A category has no Markdown of its own; only the title map # A category has no Markdown of its own; only the title map
# localizes (heading, navigation, card text). # localizes (heading, navigation, card text).
original = i18n.primary_lang(data.menu, path)
translation = ( translation = (
i18n.Translation(titles=i18n.title_map(data, lang)) i18n.Translation(titles=i18n.title_map(data, lang))
if lang != i18n.ORIGINAL_LANGUAGE if lang != original
else None 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) 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]: async def list_pages(lang: str | None = None) -> list[dict]:
"""The site tree for the structure editor (all nodes, drafts included). """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 With a ``?lang=`` translation, titles come out in that language where a
translation exists (``translated`` flags it; the row still falls back translation exists (``translated`` flags it — true trivially for rows
to the original title otherwise) — the structure itself (slugs, order, 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. hierarchy) is language-independent.
""" """
tag = i18n.base_tag(lang or "") tag = i18n.base_tag(lang or "")
titles = ( titles = i18n.title_map(data, tag) if tag else {}
i18n.title_map(data, tag)
if tag and tag != i18n.ORIGINAL_LANGUAGE
else {}
)
def dump(nodes: dict[str, Node], prefix: str) -> list[dict]: def dump(nodes: dict[str, Node], prefix: str, inherited: str) -> list[dict]:
out = [] out = []
for slug, node in sorted_nodes(nodes): for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug path = f"{prefix}/{slug}" if prefix else slug
primary = node.language or inherited
out.append({ out.append({
"slug": slug, "slug": slug,
"path": path, "path": path,
"title": titles.get(path) or node.title, "title": titles.get(path) or node.title,
"translated": path in titles, "translated": path in titles or (bool(tag) and primary == tag),
"order": node.order, "order": node.order,
"published": node.published, "published": node.published,
"has_content": node.chunks is not None, "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 out
return dump(data.menu, "") return dump(data.menu, "", i18n.ORIGINAL_LANGUAGE)
@app.put("/_api/pages/{path:path}", status_code=204) @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("/") path = path.strip("/")
_check_reserved(path) _check_reserved(path)
lang = i18n.base_tag(lang or "") 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) chain = resolve(data.menu, path)
node = chain[-1] if chain else None node = chain[-1] if chain else None
if node is None or node.chunks is 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 page ("/" then redirects to the first nav item), and any childless
top-level node can take the empty slug to become the front page. top-level node can take the empty slug to become the front page.
With `lang` (a translation, not the primary language) a `title` edit With `lang` (a translation, not the node's primary language) a `title`
writes a per-language title fragment instead of the original — the same edit writes a per-language title fragment instead of the original — the
storage as machine title translations (docs/localization.md); sending same storage as machine title translations (docs/localization.md);
the original's text removes the override. Structural fields are not sending the original's text removes the override. Structural fields are
combinable with a translated title edit. 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 path: str
@@ -615,6 +623,7 @@ class StructureOp(BaseModel):
move_to: str | None = None move_to: str | None = None
title: str | None = None title: str | None = None
lang: str | None = None lang: str | None = None
language: str | None = None
@app.post("/_api/structure", status_code=204) @app.post("/_api/structure", status_code=204)
@@ -626,7 +635,17 @@ async def update_structure(op: StructureOp) -> None:
raise HTTPException(404, "no such page") raise HTTPException(404, "no such page")
node = chain[-1] node = chain[-1]
lang = i18n.base_tag(op.lang or "") 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, # Translated title (i18n.set_title_translation): original title,
# slugs and hierarchy stay untouched. # slugs and hierarchy stay untouched.
with kanta.transaction("translate title", extra=path): with kanta.transaction("translate title", extra=path):
@@ -686,7 +705,9 @@ async def get_settings() -> dict:
"transition": data.transition, "transition": data.transition,
"transitions": views._transition_names(), "transitions": views._transition_names(),
"translate_keys": data.translate_keys, "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), "translate_langs": sorted(data.translate_langs),
} }
@@ -712,10 +733,13 @@ async def put_settings(settings: SettingsIn) -> None:
data.custom_css = settings.custom_css data.custom_css = settings.custom_css
data.transition = settings.transition data.transition = settings.transition
if settings.translate_langs is not None: 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 = { data.translate_langs = {
tag: True tag: True
for lang in settings.translate_langs 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() _invalidate_pages()
@@ -1417,8 +1441,11 @@ 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
# 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 "")) lang = i18n.base_tag(str(msg.get("lang") or ""))
if lang == i18n.ORIGINAL_LANGUAGE: if lang == node_lang:
lang = "" lang = ""
markdown = "" markdown = ""
title = node.title if node else "" 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 context for the editor's picker: the
# language this Markdown represents ("" = primary), # language this Markdown represents ("" = primary),
# the site's primary language, the translations this # the page's own primary language, the translations
# page already has, and the site-wide configured # this page already has, and the site-wide
# target languages. # configured target languages.
"lang": lang, "lang": lang,
"primary_lang": i18n.ORIGINAL_LANGUAGE, "primary_lang": node_lang,
"langs": sorted(node.langs) if node else [], "langs": sorted(node.langs) if node else [],
"translate_langs": sorted(data.translate_langs), "translate_langs": sorted(data.translate_langs),
}) })
@@ -1494,7 +1521,7 @@ async def editor_ws(ws: WebSocket) -> None:
case "save": case "save":
move_from = (msg.get("move_from") or path).strip("/") move_from = (msg.get("move_from") or path).strip("/")
lang = i18n.base_tag(str(msg.get("lang") or "")) 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: try:
_check_reserved(move_from) _check_reserved(move_from)
except HTTPException: except HTTPException:
@@ -1722,6 +1749,7 @@ async def show_page(request: Request, path: str) -> Response:
query_lang, query_lang,
accept_language, accept_language,
lambda tag: tag in node.langs, lambda tag: tag in node.langs,
original=i18n.primary_lang(data.menu, path),
) )
# A ?lang= override is replicated onto the page's navigation links # A ?lang= override is replicated onto the page's navigation links
# (link_lang), so clicks and prefetches stay in the chosen language. # (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, query_lang,
accept_language, accept_language,
lambda tag: tag in subtree_langs, lambda tag: tag in subtree_langs,
original=i18n.primary_lang(data.menu, path),
) )
link_lang = i18n.base_tag(query_lang or "") link_lang = i18n.base_tag(query_lang or "")
if _is_trackable_path(path): if _is_trackable_path(path):
+32 -16
View File
@@ -1,7 +1,9 @@
"""Localization: language selection, translation storage and assembly. """Localization: language selection, translation storage and assembly.
See docs/localization.md and docs/migrate.md. The database holds the See docs/localization.md and docs/migrate.md. Each article's primary
original language as content-addressed chunks (``Data.chunks``); per 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``) target language there are machine-translated fragments (``Data.trans``)
and user override patches (``Data.patches``), assembled into the served and user override patches (``Data.patches``), assembled into the served
Markdown at render time, with per-node fallback to the original titles. 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.chunks import chunk_key, chunk_markdown, join_chunks
from pagerite.data import Data, Node, Patch, resolve 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" 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): class Translation(msgspec.Struct, omit_defaults=True):
"""Translated content for one page and language. """Translated content for one page and language.
@@ -37,14 +58,6 @@ def base_tag(tag: str) -> str:
return tag.strip().lower().partition("-")[0] 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]: def parse_accept_language(header: str) -> list[str]:
"""Accept-Language header as an ordered, deduped list of base subtags. """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 Titles are chunks too (docs/migrate.md): keyed by the hash of the
title text, so editing a title invalidates its translations. Nodes 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 = {} 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(): for slug, node in nodes.items():
path = f"{prefix}/{slug}" if prefix else slug 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) t = data.trans.get(chunk_key(node.title), {}).get(lang)
if t: if t:
titles[path] = t titles[path] = t
walk(node.children, path) walk(node.children, path, node_lang)
walk(data.menu, "") walk(data.menu, "", ORIGINAL_LANGUAGE)
return titles return titles
+15 -11
View File
@@ -129,10 +129,13 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
return segs[0][:400] return segs[0][:400]
return "" 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): for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug 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: if node.title:
emit(chunk_key(node.title), node.title, path, "title", emit(chunk_key(node.title), node.title, path, "title",
context=opening(node)) context=opening(node))
@@ -144,9 +147,9 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
and needs_translation(text) and needs_translation(text)
): ):
emit(h, text, path, "chunk") 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 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 data.trans.setdefault(item.key, {})[lang] = item.text
pages: list[str] = [] 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): for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug 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) keys = set(node.chunks)
if node.title: if node.title:
keys.add(chunk_key(node.title)) keys.add(chunk_key(node.title))
if keys & stored: if keys & stored:
node.langs[lang] = True node.langs[lang] = True
pages.append(path) pages.append(path)
walk(node.children, path) walk(node.children, path, node_lang)
walk(data.menu, "") walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
return pages return pages
@@ -263,7 +267,7 @@ class Dispatcher:
wanted = { wanted = {
tag tag
for lang in self.data.translate_langs for lang in self.data.translate_langs
if (tag := i18n.translation_tag(lang)) if (tag := i18n.base_tag(lang))
} }
if not wanted: if not wanted:
return return
@@ -336,12 +340,12 @@ class Dispatcher:
await ws.close(code=1002) await ws.close(code=1002)
return return
state = _Connection({ 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.clients[ws] = state
self.schedule() self.schedule()
else: # Result else: # Result
lang = i18n.translation_tag(msg.lang) lang = i18n.base_tag(msg.lang)
if ( if (
state is None # results before Hello state is None # results before Hello
or state.inflight is None # no job in flight 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. replicated onto the navigation links so the language sticks.
""" """
node = resolve(menu, path)[-1] node = resolve(menu, path)[-1]
original = i18n.primary_lang(menu, path)
if translation is None: if translation is None:
lang = i18n.ORIGINAL_LANGUAGE lang = original
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, 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)
@@ -948,16 +949,16 @@ def render_page(
# or header). The alternates are site-wide, the same set on every # or header). The alternates are site-wide, the same set on every
# page: the configured translate_langs (the translator works to fill # page: the configured translate_langs (the translator works to fill
# them all in), x-default first (the plain, autodetecting URL), then # 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 = "" canonical = ""
alternates = [] alternates = []
if base_url: if base_url:
url = f"{base_url}/{path}" 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: if data.translate_langs:
alternates = [("x-default", url)] + [ alternates = [("x-default", url)] + [
(tag, f"{url}?lang={tag}") (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( return str(
_layout( _layout(
@@ -1002,7 +1003,7 @@ def render_category(
""" """
node = resolve(menu, path)[-1] node = resolve(menu, path)[-1]
if translation is None: if translation is None:
lang = i18n.ORIGINAL_LANGUAGE lang = i18n.primary_lang(menu, path)
title = _title(path.rpartition("/")[2], node, translation, path) title = _title(path.rpartition("/")[2], node, translation, path)
doc = E.article doc = E.article
with doc: with doc: