diff --git a/AGENTS.md b/AGENTS.md
index 47df64c..5bee8d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,10 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `pagerite/` — Python backend package (hatchling build target).
- `app.py` — FastAPI app and route registration.
- `data.py` — msgspec Structs for the kanta database.
+ - `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md).
+ - `i18n.py` — language selection, translation assembly (chunks + patches) and translated-edit recording (user patches, per-language title overrides, refresh).
+ - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); app.py only registers the route.
+ - `segments.py` — the translation round trip: fragments split into pure-prose wire segments (via markdown.make_md's verbatim parser; link- and formatting-carrying blocks stay whole, link/formatted texts inline, Markdown stripped) and translations spliced back by source offset, link/formatting markdown re-inserted at weight-mapped positions (docs/localization.md).
- `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field.
- `markdown.py` — markdown-it-py renderer.
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
@@ -21,8 +25,11 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `main.js` — Vue editor app entry.
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
- `pagerite.js` — public page entry.
+ - `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`).
+ - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling.
- `assets/` — base CSS, Pygments styles, fonts.
- `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test).
+- `scripts/translator.py` — Seed-X translator service client for the `/_translate/{key}` socket (reference client, runs in its own uv env via PEP 723); stays connected full time, unloads the model after 60 s idle and reloads on the next job.
Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed). Dev mode is `scripts/devserver.py` (auto reloads, no build needed).
@@ -53,5 +60,5 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
- Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
-- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session.
+- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
diff --git a/docs/editing.md b/docs/editing.md
index c3420dd..3ad6f68 100644
--- a/docs/editing.md
+++ b/docs/editing.md
@@ -4,17 +4,22 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
## Tabs
-The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner):
+The shell hosts five kept-alive tabs (ordered site-wide first — site, structure, localization — then, after a visual break, the per-page tabs — article, banner):
- `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).
+- `LocalizationEditor.vue` — the site-wide translation settings: target languages as a flag grid (toggles, grouped in geographic rows; see docs/localization.md), the refresh-all-translations button, and the translator service WebSocket URL(s) to connect `scripts/translator.py` to.
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.
In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes.
+The page and structure tabs share one language selector: `LangSelect.vue` (small flag + dropdown) v-modeled on the shell-wide selection in `editorLang.js` (`''` = primary). While the panel is open that selection overrides the page's normal language preferences: EditorShell calls `swapdoc.setLangOverride`, which pins every `loadPlain` fetch (`?lang=`, the primary by its own code) and pagerite.js's own fetches/prefetches (`pagerite:session-lang`), until the panel closes and the override clears.
+
+All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it).
+
## Saving behavior
Everything saves immediately as you edit (brand/title/CSS debounced, slug on commit since it renames the path), theme change swaps the stylesheet in place, tree rows navigate in place without transitions when focused, and the front page is a root-only row whose empty slug is editable like any other. Saves that can affect other pages drop the prefetch cache; the cache is rebuilt when the editor panel closes so navigation stays instant.
@@ -25,6 +30,6 @@ Dropping ON the lower part of a row moves the page under that row (the child lis
The shell is dynamic-imported onto the content page by pagerite.js when an edit pen is clicked (the pens are injected by pagerite.js after the session validates; they carry `data-editor-src`/`data-editor-css`/`data-editor-mode`). In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), in prod from the hashed build assets resolved via `frontend-build/.vite/manifest.json`.
-`vite.config.js` sets `appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL space; `frontend/public/favicon.ico` lands at the build root and is served at `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus `src/assets/pagerite.css` as a separate stylesheet entry; theme, banner-design and transition CSS are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. There is no `index.html` source (it would shadow `/` and turn missing dev paths into an empty Vue shell). All outputs are ES modules. The build sets `preserveEntrySignatures: 'exports-only'` because main.js is consumed via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite app builds otherwise strip unused entry exports, leaving dead edit pens. In dev the backend links theme/banner-design stylesheets like in prod (`/_themes/...`); only the base CSS is Vite-injected from JS, and pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/`#pagerite-transition`/`#pagerite-user` elements to restore the canonical order (base < theme < design < transition < custom CSS). In production all page assets are inlined instead (styles as `
diff --git a/frontend/src/EditorShell.vue b/frontend/src/EditorShell.vue
index 064a23c..a53d152 100644
--- a/frontend/src/EditorShell.vue
+++ b/frontend/src/EditorShell.vue
@@ -1,5 +1,5 @@
-
+ translations
+ deleting re-translates everything; user edits are kept
+
+
+
+
+
+
+ translator service
+ connect scripts/translator.py to
+
+
+ {{ k.url }}
+ {{ k.name }}
+
+
+
+
+
+
diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue
index e85a282..fd0bfce 100644
--- a/frontend/src/PageEditor.vue
+++ b/frontend/src/PageEditor.vue
@@ -11,15 +11,32 @@
// 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 always starts in the primary language; the
+// toolbar's LangSelect (shared with the structure tab via ./editorLang)
+// 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 { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
+import { flagFor, langName } from './langs'
+import { editorLang, pagePrimary } from './editorLang'
+import LangSelect from './LangSelect.vue'
+import ConnNote from './ConnNote.vue'
+import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({
@@ -34,14 +51,49 @@ const saveError = ref('')
const editorEl = ref(null)
const fileInput = ref(null)
+// The language being edited: "" = the primary language, where the editor
+// always starts (a served translation does not follow it into the editor;
+// the picker switches). The server normalizes the primary to "" anyway.
+// Shared with the other tabs (./editorLang) — one selection for the whole
+// shell, and for the page preview while the shell is open.
+const lang = editorLang
+const primaryLang = ref('en')
+const pageLangs = ref([]) // translations this page has
+const siteLangs = ref([]) // site-wide configured target languages
+// 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
+// The (path, lang) the editor's current content came from: set when a doc
+// is accepted, and the test for whether a reconnected socket must re-open
+// (a dropped open would otherwise leave the editor empty/stale forever).
+let sessionDoc = null
+
+// Connection state drives the note above the editor (ConnNote), and locks
+// input until the page's document has arrived: typing before the accept
+// would be clobbered by it. While merely DISconnected the editor stays
+// editable — text stashes and pending saves flush on reconnect.
+const conn = ref('connecting') // connecting | open | waiting
+const retryIn = ref(0)
+const docReady = ref(false)
+const editable = new Compartment()
+const connNote = computed(() =>
+ conn.value === 'connecting' ? 'connecting to the server…'
+ : conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
+ : docReady.value ? '' : 'loading the page…',
+)
+
let ws = null
let view = null
let savedResolve = null
let pendingSave = null
let reconnectTimer = null
-let reconnectDelay = 2000
-const MAX_RECONNECT_DELAY = 16000
-let everConnected = false
+let connectWatchdog = null
+// Reconnection pacing lives in ./reconnect (shared with the other sockets):
+// doubling backoff with jitter, reset only by a healthy connection — rapid
+// retries trip the browser's WebSocket throttling (sockets stuck "pending"
+// for minutes), which is what kept hollowing out the editor.
+const reconnects = reconnectPolicy()
const dirty = ref(false) // unsaved text exists (drives the 💾 button)
let syncingScroll = false
@@ -63,6 +115,39 @@ function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
+// --- Language picker -------------------------------------------------------
+// Flag icons and display names come from ./langs (shared with the
+// localization settings tab).
+
+// 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 },
+)
+
+// The picker's selection is the shell-wide shared language (./editorLang):
+// a change stashes the working text under the PREVIOUS language (the view
+// still holds that doc) and opens the current page in the new one.
+watch(lang, (tag, prev) => {
+ if (!view) return
+ stashAs(prev || '')
+ openPath(path.value)
+})
+
function pageLabel() {
return title.value.trim() || ('/' + (path.value || ''))
}
@@ -96,11 +181,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 +201,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 +220,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 +675,34 @@ 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 stashAs(l) {
+ if (dirty.value && path.value) {
+ stashes.set(stashKey(path.value, l), {
+ text: view.state.doc.toString(),
+ base: shadowBase,
+ })
+ }
+}
+
+function stashCurrent() {
+ stashAs(lang.value)
+}
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 })
+ // Lock input until the doc arrives (typing would be clobbered by it).
+ docReady.value = false
+ view?.dispatch({ effects: editable.reconfigure(EditorView.editable.of(false)) })
+ send({ type: 'open', path: p, lang: lang.value })
}
function setDocument(text, preserveSelection = false) {
@@ -624,26 +743,57 @@ 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
+ && (!sessionDoc || (msg.lang || '') === lang.value)) {
title.value = msg.title
published.value = msg.published
+ primaryLang.value = msg.primary_lang || 'en'
+ pagePrimary.value = primaryLang.value // the page's own — the shell pins the preview by it
+ pageLangs.value = msg.langs || []
+ siteLangs.value = msg.translate_langs || []
+ lang.value = msg.lang || ''
+ sessionDoc = { path: msg.path, lang: lang.value }
+ docReady.value = true
+ view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
+ 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.
consumePendingLine()
+ } else if (msg.type === 'doc') {
+ // A doc that answered neither path nor language of the current session
+ // (a late reply to a pre-switch open) — visible because it leaves the
+ // editor empty when it's the only doc that ever arrives.
+ console.warn(
+ '[pagerite] doc dropped:', msg.path, msg.lang || '(primary)',
+ '— editor is on', path.value, lang.value || '(primary)',
+ )
} 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'
}
@@ -831,33 +981,54 @@ function consumePendingLine() {
}
function connect() {
+ clearTimeout(reconnectTimer)
+ conn.value = 'connecting'
+ if (ws) {
+ // Replacing a stale socket: detach its handlers so its close is silent.
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
+ if (ws.readyState !== WebSocket.CLOSED) ws.close()
+ }
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
+ ws.onerror = (ev) => {
+ console.error('[pagerite] editor socket error', ev)
+ }
+ clearTimeout(connectWatchdog)
+ connectWatchdog = watchConnecting(ws, 'editor')
ws.onopen = () => {
- reconnectDelay = 2000
- if (everConnected) {
+ conn.value = 'open'
+ reconnects.opened()
+ if (sessionDoc && sessionDoc.path === path.value && sessionDoc.lang === lang.value) {
// Reconnected: local text is authoritative — don't re-open (that
// would clobber the editor), just resync preview and pending saves.
requestRender()
if (pendingSave) send(pendingSave)
} else {
+ // No doc behind the current page+language (first connect, or its
+ // open went down with a previous socket): open fresh, or the editor
+ // would stay empty forever.
openPath(normPath(props.pagePath))
}
- everConnected = true
}
- ws.onclose = () => {
- clearTimeout(reconnectTimer)
- reconnectTimer = setTimeout(() => {
- connect()
- reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
- }, reconnectDelay)
+ ws.onclose = (ev) => {
+ // 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or
+ // the connecting watchdog fired); worth seeing since a dead socket
+ // before the first doc bricks the editor until a retry lands one.
+ // The wait is the policy's: doubling backoff with jitter (./reconnect).
+ console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '')
+ const wait = reconnects.closed()
+ retryIn.value = Math.max(1, Math.round(wait / 1000))
+ conn.value = 'waiting'
+ reconnectTimer = setTimeout(connect, wait)
}
}
onMounted(() => {
- connect()
+ // The first connection takes a staggered slot (see ./reconnect): page
+ // load opens several sockets at once, and the burst trips throttling.
+ reconnectTimer = setTimeout(connect, socketSlot())
updateWindowTitle()
view = new EditorView({
@@ -871,6 +1042,8 @@ onMounted(() => {
cmTheme,
cmHighlight,
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
+ // Locked until the page's document arrives (docReady/ConnNote).
+ editable.of(EditorView.editable.of(false)),
EditorView.updateListener.of((u) => {
if (u.docChanged) requestRender()
// Cursor moves (typing included) drive the page scroll sync.
@@ -912,6 +1085,7 @@ onMounted(() => {
onUnmounted(() => {
clearTimeout(reconnectTimer)
+ clearTimeout(connectWatchdog)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
@@ -929,11 +1103,12 @@ onUnmounted(() => {
+
-
+
{
@click="saveAndRefresh"
>💾
+
+
+
+ {{ currentLang.name }} translation — edits affect only this language.
+
+
+ {{ currentLang.name }} is the primary language — edits here affect all translations.
+
+
@@ -1089,6 +1273,19 @@ onUnmounted(() => {
cursor: default;
}
+/* The language selector (toolbar, left) is LangSelect.vue — its styles
+ live there. */
+
+/* Why a language is highlighted: primary edits fan out to translations,
+ translation edits stay local to that language. */
+.lang-note {
+ padding: 0.2rem 1rem;
+ border-bottom: 1px solid var(--line);
+ background: var(--surface);
+ color: var(--muted);
+ font-size: 0.8rem;
+}
+
/* Markdown helpers: plain icon buttons under the toolbar. */
.format-bar {
position: relative;
diff --git a/frontend/src/StructureEditor.vue b/frontend/src/StructureEditor.vue
index 70fbc17..fec45eb 100644
--- a/frontend/src/StructureEditor.vue
+++ b/frontend/src/StructureEditor.vue
@@ -7,9 +7,20 @@
// real — a label with a title and slug, with content (landing page) or
// without (category whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
-import { inject, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'
+//
+// Languages: the LangSelect switches which language the TITLES are shown
+// and edited in (rows without a translation show the original, dimmed) —
+// the selection is shared shell-wide (./editorLang) with the page editor
+// and the page preview. Translated title edits write a per-language
+// fragment (POST /_api/structure with lang); the structure itself —
+// slugs, order, hierarchy — is language-independent and always edits the
+// same tree.
+import { computed, inject, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import StructureTree from './StructureTree.vue'
+import LangSelect from './LangSelect.vue'
import { slugify } from './slugify'
+import { flagFor, langName } from './langs'
+import { editorLang, pagePrimary } from './editorLang'
import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({
@@ -23,6 +34,50 @@ const path = ref('')
const saveError = ref('')
const tree = ref([])
+// The language the tree's titles are shown and edited in: "" = primary.
+const lang = editorLang
+const primaryLang = ref('en')
+const siteLangs = ref([])
+
+// The strip's options: the primary language first, then the configured
+// translation targets (the lang tab manages that set).
+const langOptions = computed(() =>
+ [primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
+ .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) ?? langOptions.value[0],
+)
+
+// The selection is shared (./editorLang): a change re-fetches the tree's
+// titles in it (and EditorShell swaps the page preview into it).
+watch(lang, () => refreshPages())
+
+// Per-row primary language (Node.language, '' = inherit): the row's
+// dropdown lists "inherit" first (naming what it resolves to), then every
+// site language. Setting it on a section covers its whole subtree.
+const rowLangChoices = computed(() =>
+ [primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
+ .map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })),
+)
+function rowLangOptions(el) {
+ const resolved = el.primary || primaryLang.value
+ return [
+ { tag: '', code: '_inherit', name: `inherit (${langName(resolved)})`, flag: flagFor(resolved), primary: false },
+ ...rowLangChoices.value,
+ ]
+}
+
+async function setLanguage(node, tag) {
+ await postStructure({ path: node.path, language: tag })
+}
+
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
@@ -152,9 +207,22 @@ async function commitPending() {
}
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
+function findNode(nodes, p) {
+ for (const n of nodes) {
+ if (n.path === p) return n
+ const found = findNode(n.children, p)
+ if (found) return found
+ }
+ return null
+}
+
async function refreshPages() {
try {
- tree.value = await (await fetch('/_api/pages')).json()
+ const q = lang.value ? `?lang=${lang.value}` : ''
+ tree.value = await (await fetch(`/_api/pages${q}`)).json()
+ // The tree carries each node's resolved primary language: publish the
+ // current page's (the shell pins the preview by it on '' selection).
+ pagePrimary.value = findNode(tree.value, path.value)?.primary || 'en'
} catch { /* list stays stale; not fatal */ }
}
@@ -207,13 +275,15 @@ async function onReorder(parentPath, list, evt) {
}
// Inline title/slug editing: rows are always editable. Title saves while
-// typing (debounced); the slug commits on blur/Enter, since it renames
-// the path (moving the whole subtree with it).
+// typing (debounced) — in the selected language (a translation writes a
+// title fragment, the primary language the original); the slug commits on
+// blur/Enter, since it renames the path (moving the whole subtree with it).
+// Slugs are language-independent.
function onTitleInput(node, ev) {
const title = ev.target.value.trim()
if (!title || title === node.title) return
debounce(`title:${node.path}`, async () => {
- await postStructure({ path: node.path, title })
+ await postStructure({ path: node.path, title, lang: lang.value })
})
}
@@ -266,12 +336,19 @@ provide('structureHandlers', {
commitPending,
discardPending,
newPage,
+ langOptions: rowLangOptions,
+ setLanguage,
})
onMounted(() => {
path.value = normPath(props.pagePath)
refreshPages()
addEventListener('pagerite:editor-shown', onEditorShown)
+ // The language strip: site primary + configured targets.
+ fetch('/_api/settings').then((r) => r.json()).then((s) => {
+ primaryLang.value = s.primary_lang || 'en'
+ siteLangs.value = s.translate_langs || []
+ }).catch(() => { /* no strip */ })
})
onUnmounted(() => {
@@ -283,8 +360,15 @@ onUnmounted(() => {
{{ saveError }}
+
+
+
+ viewing {{ currentLang.name }} titles — dimmed rows are untranslated
+ (shown in the primary language); slugs never translate
+
+
-
+
@@ -309,4 +393,10 @@ onUnmounted(() => {
overflow-y: auto;
min-height: 0;
}
+
+/* The language selector is LangSelect.vue — its styles live there. */
+
+.muted {
+ color: var(--muted);
+}
diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue
index fe84c10..fe96e1d 100644
--- a/frontend/src/StructureTree.vue
+++ b/frontend/src/StructureTree.vue
@@ -1,7 +1,13 @@