diff --git a/docs/localization.md b/docs/localization.md index 0e20c3c..4fc231d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -74,6 +74,14 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`). - Navigation/sidebar titles come from the translation's title map, with per-node fallback to the original title (a partially translated tree must still render). +- Category placeholder pages (the 404s for content-less labels) select a + language like content pages, but over the **subtree's** combined + availability (`subtree_languages`) — they have no chunks of their own; + the heading, navigation and card text localize from the title map and + the target articles' translations. +- Card descriptions and cover picks run on the target article's hybrid + Markdown where that page is available in the served language, with + per-card fallback to the original. - Fixed UI strings ("Not Found" etc.) and the editor UI stay English for now. - The markdown typographer (SmartyPants) is English-centric; per-language typographer options are a possible follow-up, not blocking. @@ -211,13 +219,36 @@ def get_translation(path, lang, data) -> Translation | None: ### Editor flow -- `GET` of page Markdown for editing with a `lang` parameter returns the - hybrid (not the raw original) when the article has that language. -- `PUT`/WS save with `lang` does **not** touch `node.chunks`; it diffs - against the hybrid that was served and appends a `Patch`. (Serve a hybrid - generation token with the editor payload so a save based on a stale hybrid - can be rebased or rejected — simplest: recompute the diff against the - *current* hybrid and accept best-effort, matching the patch philosophy.) +The page editor has a language picker (flag + name; the same +country-flag-icons set as the analytics visitor cells) listing the primary +language and the union of the page's translations (`node.langs`) and the +site-wide `translate_langs`. It opens in the language the page was served +in (``). A note under the toolbar states the blast radius: +edits to the primary language re-chunk the original (invalidating the +affected translation fragments everywhere); edits to a translation stay +local to that language. + +- WS `open` with a `lang` returns the effective **hybrid** Markdown and + title for that language (ungated by `node.langs` — a language without + any fragments yet starts from the original text), plus the language + metadata (`lang`, `primary_lang`, `langs`, `translate_langs`). +- The editor keeps a **shadow copy** of the Markdown it opened. WS `save` + with `lang` sends it as `base`; the server diffs `base` → submitted text + (`make_patch`) and appends a `Patch`. Diffing against the shadow (rather + than the current hybrid) keeps hunks correct when the original or the + machine translation moved under an open editor; application against the + then-current hybrid stays best-effort per hunk, as designed. +- A changed **title** on a translated save becomes a fragment in + `Data.trans` keyed by the original title's chunk hash — the same storage + as machine title translations. An untouched title field (holding the + served translation) is not sent, so saving never freezes a stale machine + title into an override. +- Saving never deletes; a translation additionally cannot be emptied (that + would render as a blank page in that language). +- The live preview renders the version being edited, whichever language + the page itself was loaded in (the render is just the edited Markdown + + title). A translated save keeps that preview in place — re-fetching the + page would come back in the header-selected language. - Saving the primary-language version re-chunks the submitted Markdown and updates `Data.chunks` / `node.chunks` — only genuinely new text lands in the kanta change diff (see docs/migrate.md). diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index e85a282..1ed309e 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -11,14 +11,27 @@ // and refreshes the page regions in place — never a reload — so the editor // state (unsaved text included) also survives closing the shell. The editor // always follows the URL: navigating away retargets it to the new page, -// stashing unsaved text per path (unsavedStash) so returning to the page -// restores the working draft; stashes clear on save and on real reload. -import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue' +// stashing unsaved text per path and language (stashes) so returning to the +// page restores the working draft; stashes clear on save and on real reload. +// +// Languages: the editor starts in the language the page was served in and +// the toolbar picker (flags, like the analytics visitor cells) switches +// between the primary language and its translations. A translation is +// edited as its effective (hybrid) Markdown; the hybrid the session +// started from is kept as a shadow copy (shadowBase) and sent along at +// save time, so the server diffs the user's changes only and stores them +// as a patch — edits to a translation never touch the original, while +// edits to the primary language re-chunk the original (and thereby +// invalidate the affected translation fragments). The live preview always +// renders the version being edited, whichever language the page itself +// was loaded in. +import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' import { keymap } from '@codemirror/view' import { indentWithTab } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' +import * as flagSvgs from 'country-flag-icons/string/3x2' import { cmHighlight, cmTheme } from './cmtheme' import { dropPageCache, loadPlain } from './swapdoc' @@ -34,6 +47,20 @@ const saveError = ref('') const editorEl = ref(null) const fileInput = ref(null) +// The language being edited: "" = the primary language. Starts as the +// language this page was served in (); the server normalizes +// the primary to "" in its doc reply. +const lang = ref(document.documentElement.lang || '') +const primaryLang = ref('en') +const pageLangs = ref([]) // translations this page has +const siteLangs = ref([]) // site-wide configured target languages +const langPickerOpen = ref(false) +// The shadow copy: the Markdown this editing session started from, sent as +// "base" on translated saves so the server diffs the user's changes only. +let shadowBase = '' +let titleTouched = false +let docLoaded = false + let ws = null let view = null let savedResolve = null @@ -63,6 +90,55 @@ function normPath(p) { return p.trim().replace(/^\/+|\/+$/g, '') } +// --- Language picker ------------------------------------------------------- +// Flag icons from the same country-flag-icons set the analytics visitor +// cells use, resolved from the language tag's most likely region. +const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }) + +function langName(tag) { + try { + return displayNames.of(tag) || tag + } catch { + return tag + } +} + +function flagFor(tag) { + try { + return flagSvgs[new Intl.Locale(tag).maximize().region] || '' + } catch { + return '' + } +} + +// The picker's options: the primary language first, then the union of the +// page's translations and the site-wide configured targets, sorted. +const langOptions = computed(() => { + const others = [...new Set([...siteLangs.value, ...pageLangs.value])] + .filter((l) => l && l !== primaryLang.value) + .sort() + return [primaryLang.value, ...others].map((code) => ({ + tag: code === primaryLang.value ? '' : code, + code, + name: langName(code), + flag: flagFor(code), + primary: code === primaryLang.value, + })) +}) + +const currentLang = computed( + () => langOptions.value.find((o) => o.tag === lang.value) + ?? { tag: '', code: lang.value || primaryLang.value, name: langName(lang.value || primaryLang.value), flag: flagFor(lang.value || primaryLang.value), primary: !lang.value }, +) + +function switchLang(tag) { + tag = tag || '' + if (tag === lang.value || !view) return + stashCurrent() + lang.value = tag + send({ type: 'open', path: path.value, lang: tag }) +} + function pageLabel() { return title.value.trim() || ('/' + (path.value || '')) } @@ -96,11 +172,17 @@ function save() { // never moves the page. const markdown = view.state.doc.toString() if (markdown.trim() === '') { + if (lang.value) { + // Emptying a translation would render it as a blank page; deleting + // pages is a primary-language action. + saveError.value = '⚠️ a translation cannot be emptied' + return Promise.resolve() + } // Empty text means delete — an explicit choice made here, in the page // editor; the save APIs (REST PUT / WS save) never delete on empty. return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => { saveError.value = res.ok ? '' : '⚠️ changes could not be saved' - if (res.ok) unsavedStash.delete(path.value) + if (res.ok) stashes.delete(stashKey(path.value, lang.value)) }) } const msg = { @@ -110,6 +192,15 @@ function save() { markdown, published: published.value, } + if (lang.value) { + msg.lang = lang.value + // The shadow copy this session started from: the server diffs base → + // markdown and stores only the user's changes as a patch. + msg.base = shadowBase + // An untouched title field is not sent: it holds the served + // translation, which a save must not freeze into an override fragment. + if (!titleTouched) delete msg.title + } pendingSave = msg send(msg) return new Promise((resolve) => { savedResolve = resolve }) @@ -120,9 +211,12 @@ async function saveAndRefresh() { dirty.value = false // Refresh the page regions from the server so nav/sidebar changes apply // (never a reload: the editor keeps its state). Drop the prefetch cache - // first: heading/title changes affect navigation on every page. + // first: heading/title changes affect navigation on every page. A + // translation save keeps the preview as-is — it already shows the saved + // text, and loadPlain would swap the article to the header-selected + // language's render. dropPageCache() - loadPlain(path.value) + if (!lang.value) loadPlain(path.value) } function close() { @@ -572,18 +666,27 @@ function insertTable(cols, rows) { view.focus() } -// Unsaved edits survive navigation within the session: leaving a page -// stashes its working text here, returning restores it (the server doc -// still arrives, for title/published and as the base underneath). -// Entries clear on save and on real reload (the shell is in-memory only). -const unsavedStash = new Map() +// Unsaved edits survive navigation within the session: leaving a page (or +// switching the language) stashes its working text and shadow base here, +// returning restores them (the server doc still arrives, for +// title/published and language metadata). Entries clear on save and on +// real reload (the shell is in-memory only). +const stashes = new Map() +const stashKey = (p, l) => `${p}|${l}` + +function stashCurrent() { + if (dirty.value && path.value) { + stashes.set(stashKey(path.value, lang.value), { + text: view.state.doc.toString(), + base: shadowBase, + }) + } +} function openPath(p) { - if (dirty.value && path.value && p !== path.value) { - unsavedStash.set(path.value, view.state.doc.toString()) - } + if (p !== path.value) stashCurrent() path.value = p - send({ type: 'open', path: p }) + send({ type: 'open', path: p, lang: lang.value }) } function setDocument(text, preserveSelection = false) { @@ -624,13 +727,25 @@ function previewIntoArticle(html, multicol) { function onMessage(ev) { const msg = JSON.parse(ev.data) - if (msg.type === 'doc' && msg.path === path.value) { + // The doc must answer the current path and language; before the first + // doc the language is not yet server-normalized (the primary arrives as + // "" while the picker may have started from an explicit code), so the + // first doc is accepted on path alone and adopts the echoed language. + if (msg.type === 'doc' && msg.path === path.value + && (!docLoaded || (msg.lang || '') === lang.value)) { + docLoaded = true title.value = msg.title published.value = msg.published + primaryLang.value = msg.primary_lang || 'en' + pageLangs.value = msg.langs || [] + siteLangs.value = msg.translate_langs || [] + lang.value = msg.lang || '' + titleTouched = false // Restore stashed unsaved edits over the server doc when returning // to a page left dirty. - const stashed = unsavedStash.get(msg.path) - setDocument(stashed ?? msg.markdown) + const stashed = stashes.get(stashKey(msg.path, lang.value)) + setDocument(stashed ? stashed.text : msg.markdown) + shadowBase = stashed ? stashed.base : msg.markdown dirty.value = stashed != null requestRender() // A section pen's target line survives the open/path-switch here. @@ -638,12 +753,20 @@ function onMessage(ev) { } else if (msg.type === 'html' && msg.path === path.value) { previewIntoArticle(msg.html, msg.multicol) } else if (msg.type === 'saved') { - saveError.value = '' - pendingSave = null - dirty.value = false - unsavedStash.delete(path.value) - savedResolve?.() - savedResolve = null + // A late "saved" for a save sent before a path/language switch must not + // clear the current session's state. + if (pendingSave && pendingSave.path === path.value + && (pendingSave.lang || '') === lang.value) { + saveError.value = '' + // The saved text becomes the shadow base for further saves. + shadowBase = pendingSave.markdown + pendingSave = null + dirty.value = false + titleTouched = false + stashes.delete(stashKey(path.value, lang.value)) + savedResolve?.() + savedResolve = null + } } else if (msg.type === 'error') { saveError.value = '⚠️ changes could not be saved' } @@ -929,11 +1052,34 @@ onUnmounted(() => {