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:
+1
-1
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user