diff --git a/docs/editing.md b/docs/editing.md index f2df883..f117866 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -9,7 +9,7 @@ The shell hosts four kept-alive tabs (ordered site-wide first — site, structur - `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside` → `::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. - `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`. - `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into ``. -- `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. diff --git a/docs/localization.md b/docs/localization.md index 69fbb5b..be14d10 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -12,6 +12,18 @@ parameter or the `Accept-Language` header. ## Phase 1: negotiation and URLs +### The primary language + +Each article has a primary (original) language: `Node.language`, inherited +down the tree like `banner` — "" = the nearest ancestor's, the front page +last (it doubles as the site default), with `en` as the final fallback +(`ORIGINAL_LANGUAGE`, `primary_lang()` in `pagerite/i18n.py`). It is +configured per row in the structure editor. Everything per-article keys +off the resolved value: language selection, ``, canonical URLs, +what counts as a translation, and the translation targets (a node's own +primary is never one — so the target set may include the site default, and +a page in another language can be translated into it). + ### Language selection Deliberately simple — **q-values are ignored**: @@ -45,7 +57,7 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`). page — the site-wide configured languages (`translate_langs`, which the translator works to fill in): `x-default` first, pointing at the plain autodetecting URL, then every language explicitly with `?lang=`, the - default language included. + page's own primary language included. - The override sticks for the session of clicks: a page requested with `?lang=` replicates the query onto the navigation links it renders (nav, sidebar, cards, brand — in-article links are content and stay as @@ -223,7 +235,9 @@ The page and structure editors share one language selector (`LangSelect.vue`: a small flag button opening a dropdown; the same country-flag-icons set as the analytics visitor cells), v-modeled on one shell-wide selection (`editorLang.js`, `''` = the primary language). The page editor lists the -primary language and the union of the page's translations (`node.langs`) and +page's own primary language (`Node.language`, resolved through the +hierarchy and echoed in the WS doc as `primary_lang`) plus the union of +the page's translations (`node.langs`) and the site-wide `translate_langs`; it always opens in the primary language, even when the page itself was served in a translation. A note under the toolbar states the blast radius: @@ -233,9 +247,10 @@ local to that language. While the editor panel is open, its language selection **overrides the normal language preferences** for the page preview: EditorShell pins every -in-place re-render and pagerite.js fetch/prefetch to it (`?lang=` — the -primary language pins by its own code, which `select_language` honors), and -closing the panel restores the normal preferences. +in-place re-render and pagerite.js fetch/prefetch to it (`?lang=` — a +primary selection pins by the current page's own resolved primary, which +`select_language` honors), and closing the panel restores the normal +preferences. - WS `open` with a `lang` returns the effective **hybrid** Markdown and title for that language (ungated by `node.langs` — a language without @@ -264,7 +279,11 @@ closing the panel restores the normal preferences. The **structure editor** selects from the same languages with the same `LangSelect` (the selection is shared — switching in either tab switches -both, and the preview). The tree it +both, and the preview). It is also where a page's **primary language** is +configured: each row carries a small flag dropdown (the resolved flag, +dimmed while inherited) that sets `Node.language` via a structure op — +'' = inherit, so setting it on a section covers the whole subtree. The +tree it lists (`GET /_api/pages?lang=`) comes back with per-language titles where a translation exists (`translated` marks those rows; untranslated rows show the original title, dimmed). Retitling in a non-primary language posts the @@ -308,9 +327,11 @@ Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`; Which languages get translated is **server-configured**: `Data.translate_langs` (presence-key dict, bootstrapped to Spanish and -Chinese — the original language is never a target — edited in the editor -shell's localization tab or set via `/_api/settings` as `translate_langs`). -The dispatcher offers a +Chinese — edited in the editor shell's localization tab, whose flag grid +lists every language including English, or set via `/_api/settings` as +`translate_langs`). A target equal to an article's own primary language is +skipped per article (its original already is that language), so the set +may freely contain the site default. The dispatcher offers a connection jobs only in `wanted ∩ capable`; a connection without overlap simply stays idle. diff --git a/frontend/src/EditorShell.vue b/frontend/src/EditorShell.vue index b75aa11..9729c24 100644 --- a/frontend/src/EditorShell.vue +++ b/frontend/src/EditorShell.vue @@ -8,7 +8,7 @@ import BannerEditor from './BannerEditor.vue' import SiteEditor from './SiteEditor.vue' import StructureEditor from './StructureEditor.vue' import LocalizationEditor from './LocalizationEditor.vue' -import { editorLang } from './editorLang' +import { editorLang, pagePrimary } from './editorLang' import { loadPlain, setLangOverride } from './swapdoc' const props = defineProps({ @@ -24,11 +24,14 @@ const activeMode = ref(props.initialMode) // LangSelects) also drives the page preview: while the shell is open it // overrides the normal language preferences (?lang= / Accept-Language), // so the page renders in the language being edited; closing restores. -const primaryLang = ref('en') +// The primary selection pins by the CURRENT PAGE's own primary language +// (pages may differ — Node.language is inherited down the tree). let pinned = false function pinPreviewLang() { pinned = true - setLangOverride(editorLang.value || primaryLang.value) + // '' pagePrimary = not yet learned: pin 'en', the server's final fallback + // (i18n.ORIGINAL_LANGUAGE). + setLangOverride(editorLang.value || pagePrimary.value || 'en') loadPlain(currentPath.value) } function unpinPreviewLang() { @@ -38,6 +41,9 @@ function unpinPreviewLang() { loadPlain(currentPath.value) } watch(editorLang, () => { if (pinned) pinPreviewLang() }) +// The page's primary may be (re)learned while pinned on it (doc accept, +// tree refresh, a language change on the row) — re-pin with the new code. +watch(pagePrimary, () => { if (pinned && !editorLang.value) pinPreviewLang() }) // Tab order: site-wide settings first (site, structure, localization), then // — after a visual break — the per-page editors (article, banner). @@ -85,12 +91,14 @@ onMounted(() => { addEventListener('pagerite:switch-editor', onSwitchEvent) addEventListener('pagerite:editor-shown', pinPreviewLang) addEventListener('pagerite:editor-hidden', unpinPreviewLang) - // The shell mounts visible (openEditor), so pin immediately. The primary - // language's code (for pinning it explicitly) comes from the settings. + // The shell mounts visible (openEditor), so pin immediately. The site + // default primary language comes from the settings — it only fills the + // unknown; the page/structure tabs refine pagePrimary per page as they + // learn it (their knowledge is strictly better). pinPreviewLang() fetch('/_api/settings').then((r) => r.json()).then((s) => { - primaryLang.value = s.primary_lang || 'en' - }).catch(() => { /* keep the default */ }) + if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en' + }).catch(() => { /* keep the fallback */ }) }) onUnmounted(() => { diff --git a/frontend/src/LangSelect.vue b/frontend/src/LangSelect.vue index c8fddaa..f844774 100644 --- a/frontend/src/LangSelect.vue +++ b/frontend/src/LangSelect.vue @@ -8,6 +8,7 @@ import { computed, ref } from 'vue' const props = defineProps({ modelValue: { type: String, default: '' }, options: { type: Array, required: true }, // [{tag, code, name, flag, primary}] + title: { type: String, default: '' }, // toggle-button tooltip override }) const emit = defineEmits(['update:modelValue']) @@ -28,9 +29,9 @@ function select(tag) { type="button" class="lang-current" :class="{ open }" - :title="current + :title="title || (current ? `language: ${current.name}${current.primary ? ' (primary)' : ''}` - : ''" + : '')" @click="open = !open" > diff --git a/frontend/src/LocalizationEditor.vue b/frontend/src/LocalizationEditor.vue index 39e4897..2ca8f43 100644 --- a/frontend/src/LocalizationEditor.vue +++ b/frontend/src/LocalizationEditor.vue @@ -1,14 +1,16 @@