Shared editor language selection; paced WebSocket reconnects

- LangSelect.vue: one language selector for the page and structure tabs
  (small flag, clean dropdown), v-modeled on the shell-wide editorLang —
  switching in either panel switches both AND the page preview. While the
  panel is open the selection overrides the normal language preferences
  (swapdoc.setLangOverride: loadPlain and pagerite.js fetches/prefetches
  pin ?lang=, the primary language by its own code); closing restores.
- reconnect.js: every socket (page/banner editors, analytics view, the
  activity channel) now connects through a staggered slot — simultaneous
  attempts at page load (Vite's HMR socket plus ours, repeated on every
  refresh) trip the browser's WebSocket throttling, leaving all sockets
  to the host "pending" for minutes, which was the recurring empty
  editor. A watchdog closes sockets stuck CONNECTING so they reschedule
  through the policy (jittered exponential backoff, reset only by a
  healthy connection) instead of hanging forever; a reconnected editor
  re-opens its document when the previous open died with its socket.
This commit is contained in:
2026-09-03 01:47:24 +00:00
parent 43793ef9a4
commit 2a430247e0
13 changed files with 411 additions and 223 deletions
+2
View File
@@ -25,6 +25,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `main.js` — Vue editor app entry. - `main.js` — Vue editor app entry.
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
- `pagerite.js` — public page entry. - `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. - `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/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). - `scripts/translator.py` — Seed-X translator service client for the `/_translate/{key}` socket (reference client, runs in its own uv env via PEP 723).
+4
View File
@@ -15,6 +15,10 @@ Media uploads everywhere use the image icon buttons (pasting into the editor wor
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. 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.
## Saving behavior ## 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. 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.
+17 -7
View File
@@ -219,16 +219,24 @@ def get_translation(data, path, lang) -> Translation | None:
### Editor flow ### Editor flow
The page editor has a language picker (flag + name; the same The page and structure editors share one language selector (`LangSelect.vue`:
country-flag-icons set as the analytics visitor cells) listing the primary a small flag button opening a dropdown; the same country-flag-icons set as
language and the union of the page's translations (`node.langs`) and the the analytics visitor cells), v-modeled on one shell-wide selection
site-wide `translate_langs`. It always opens in the primary language, even (`editorLang.js`, `''` = the primary language). The page editor lists the
when the page itself was served in a translation. A note under the toolbar primary language and the union of the page's translations (`node.langs`) and
states the blast radius: 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:
edits to the primary language re-chunk the original (invalidating the edits to the primary language re-chunk the original (invalidating the
affected translation fragments everywhere); edits to a translation stay affected translation fragments everywhere); edits to a translation stay
local to that language. 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.
- 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
any fragments yet starts from the original text), plus the language any fragments yet starts from the original text), plus the language
@@ -254,7 +262,9 @@ local to that language.
updates `Data.chunks` / `node.chunks` — only genuinely new text lands in updates `Data.chunks` / `node.chunks` — only genuinely new text lands in
the kanta change diff (see docs/migrate.md). the kanta change diff (see docs/migrate.md).
The **structure editor** has the same flag strip for titles. The tree it 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
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
+16 -3
View File
@@ -27,6 +27,7 @@ import VisitorCell from './VisitorCell.vue'
import TransitionGraph from './TransitionGraph.vue' import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue' import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js' import { VIEW_W } from './analytics/chart.js'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
// Same centering margin as the charts, so the totals row's left edge // Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width. // aligns with the chart svg above the natural width.
@@ -40,6 +41,8 @@ const error = ref('')
const now = ref(Date.now()) const now = ref(Date.now())
let ws = null let ws = null
let reconnectTimeout = null let reconnectTimeout = null
let connectWatchdog = null
const reconnects = reconnectPolicy()
let timeInterval = null let timeInterval = null
// The initial range comes from the URL hash (shareable links); without one, // The initial range comes from the URL hash (shareable links); without one,
@@ -53,7 +56,12 @@ function connectAnalytics() {
if (ws) return if (ws) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`) ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
ws.onopen = () => { error.value = '' } clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'analytics')
ws.onopen = () => {
reconnects.opened()
error.value = ''
}
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
data.value = JSON.parse(event.data) data.value = JSON.parse(event.data)
@@ -75,12 +83,16 @@ function connectAnalytics() {
} }
ws.onclose = () => { ws.onclose = () => {
ws = null ws = null
reconnectTimeout = setTimeout(connectAnalytics, 2000) // The policy paces the retry: doubling backoff with jitter, reset only
// by a healthy connection — a fixed rapid loop trips the browser's
// WebSocket throttling (all sockets then sit "pending" for minutes).
reconnectTimeout = setTimeout(connectAnalytics, reconnects.closed())
} }
} }
onMounted(async () => { onMounted(async () => {
connectAnalytics() // The first connection takes a staggered slot (see ./reconnect).
reconnectTimeout = setTimeout(connectAnalytics, socketSlot())
now.value = Date.now() now.value = Date.now()
timeInterval = setInterval(() => { now.value = Date.now() }, 1000) timeInterval = setInterval(() => { now.value = Date.now() }, 1000)
// The site tree for the transition map (all pages in menu order). Not // The site tree for the transition map (all pages in menu order). Not
@@ -93,6 +105,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
if (reconnectTimeout) clearTimeout(reconnectTimeout) if (reconnectTimeout) clearTimeout(reconnectTimeout)
if (connectWatchdog) clearTimeout(connectWatchdog)
if (timeInterval) clearInterval(timeInterval) if (timeInterval) clearInterval(timeInterval)
if (ws) { if (ws) {
ws.onclose = null ws.onclose = null
+20 -9
View File
@@ -8,6 +8,7 @@ import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands' import { indentWithTab } from '@codemirror/commands'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { dropPageCache, loadPlain, runScripts } from './swapdoc'
const props = defineProps({ const props = defineProps({
@@ -25,8 +26,9 @@ const bannerEl = ref(null)
let ws = null let ws = null
let pendingSave = null let pendingSave = null
let reconnectTimer = null let reconnectTimer = null
let reconnectDelay = 2000 let connectWatchdog = null
const MAX_RECONNECT_DELAY = 16000 // Reconnection pacing lives in ./reconnect (shared with the other sockets).
const reconnects = reconnectPolicy()
let everConnected = false let everConnected = false
let view = null // CodeMirror for the banner HTML let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically let syncing = false // set while replacing the document programmatically
@@ -234,12 +236,20 @@ function onKeydown(ev) {
} }
function connect() { function connect() {
clearTimeout(reconnectTimer)
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( ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`, `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
) )
ws.onmessage = onMessage ws.onmessage = onMessage
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'banner')
ws.onopen = () => { ws.onopen = () => {
reconnectDelay = 2000 reconnects.opened()
if (everConnected) { if (everConnected) {
if (pendingSave) send(pendingSave) if (pendingSave) send(pendingSave)
} else { } else {
@@ -248,16 +258,16 @@ function connect() {
everConnected = true everConnected = true
} }
ws.onclose = () => { ws.onclose = () => {
clearTimeout(reconnectTimer) // The wait is the policy's: doubling backoff with jitter (./reconnect),
reconnectTimer = setTimeout(() => { // reset only by a healthy connection — rapid retries trip the browser's
connect() // WebSocket throttling (sockets stuck "pending" for minutes).
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) reconnectTimer = setTimeout(connect, reconnects.closed())
}, reconnectDelay)
} }
} }
onMounted(async () => { onMounted(async () => {
connect() // The first connection takes a staggered slot (see ./reconnect).
reconnectTimer = setTimeout(connect, socketSlot())
view = new EditorView({ view = new EditorView({
state: EditorState.create({ state: EditorState.create({
doc: '', doc: '',
@@ -286,6 +296,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
for (const t of Object.values(timers)) clearTimeout(t) for (const t of Object.values(timers)) clearTimeout(t)
if (ws) { if (ws) {
ws.onclose = null // intentional close, no reconnect ws.onclose = null // intentional close, no reconnect
+32 -1
View File
@@ -8,6 +8,8 @@ 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 { loadPlain, setLangOverride } from './swapdoc'
const props = defineProps({ const props = defineProps({
pagePath: { type: String, default: '' }, pagePath: { type: String, default: '' },
@@ -18,6 +20,25 @@ const emit = defineEmits(['close'])
const currentPath = ref(props.pagePath) const currentPath = ref(props.pagePath)
const activeMode = ref(props.initialMode) const activeMode = ref(props.initialMode)
// The shared language selection (./editorLang, v-modeled by the tabs'
// 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')
let pinned = false
function pinPreviewLang() {
pinned = true
setLangOverride(editorLang.value || primaryLang.value)
loadPlain(currentPath.value)
}
function unpinPreviewLang() {
if (!pinned) return
pinned = false
setLangOverride(null)
loadPlain(currentPath.value)
}
watch(editorLang, () => { if (pinned) 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).
const MODES = [ const MODES = [
@@ -62,15 +83,25 @@ function onSwitchEvent(ev) {
onMounted(() => { onMounted(() => {
document.body.dataset.editorMode = activeMode.value document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent) 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.
pinPreviewLang()
fetch('/_api/settings').then((r) => r.json()).then((s) => {
primaryLang.value = s.primary_lang || 'en'
}).catch(() => { /* keep the default */ })
}) })
onUnmounted(() => { onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent) removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('pagerite:editor-shown', pinPreviewLang)
removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
}) })
</script> </script>
<template> <template>
<div class="editor-root overlay"> <div class="editor-root overlay" lang="en" dir="ltr">
<header class="editor-tabs"> <header class="editor-tabs">
<template v-for="m in MODES" :key="m.key"> <template v-for="m in MODES" :key="m.key">
<span v-if="m.breakBefore" class="tab-break" /> <span v-if="m.breakBefore" class="tab-break" />
+133
View File
@@ -0,0 +1,133 @@
<script setup>
// The editor shell's one language selector (page + structure tabs): a small
// flag button opening a clean dropdown, v-modeled on the shared editorLang
// ('' = the primary language). The lang tab's flag grid is a different
// control (toggles, not a select) and stays as it is.
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, required: true }, // [{tag, code, name, flag, primary}]
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const current = computed(
() => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0],
)
function select(tag) {
emit('update:modelValue', tag)
open.value = false
}
</script>
<template>
<span v-if="options.length > 1" class="lang-select">
<button
type="button"
class="lang-current"
:class="{ open }"
:title="current
? `language: ${current.name}${current.primary ? ' (primary)' : ''}`
: ''"
@click="open = !open"
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
<span v-if="open" class="lang-pop" @mouseleave="open = false">
<button
v-for="o in options"
:key="o.code"
type="button"
:class="{ active: o.tag === modelValue }"
:title="o.primary ? `${o.name} — the primary language` : `${o.name} — translation`"
@click="select(o.tag)"
><span v-if="o.flag" class="flag" v-html="o.flag" /> {{ o.name }}<small v-if="o.primary"> (primary)</small></button>
</span>
</span>
</template>
<style scoped>
.lang-select {
position: relative;
display: flex;
}
/* The closed state is just the small flag — no button chrome until hovered. */
.lang-current {
display: flex;
align-items: center;
padding: 2px;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.lang-current:hover,
.lang-current.open {
border-color: var(--line);
}
/* The dropdown matches the page's existing popups (.picker-pop look). */
.lang-pop {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
padding: 0.3rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
white-space: nowrap;
}
.lang-pop button {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.4rem;
font: inherit;
font-size: 0.9rem;
text-align: left;
color: var(--text);
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
}
.lang-pop button:hover {
background: var(--surface);
}
.lang-pop button.active {
color: var(--accent);
}
.lang-pop small {
color: var(--muted);
}
/* Flags render like in the analytics visitor cells. */
.flag {
display: inline-flex;
width: 18px;
height: 12px;
flex: 0 0 auto;
border-radius: 2px;
overflow: hidden;
border: 1px solid var(--line);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
}
.flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
+62 -120
View File
@@ -14,9 +14,9 @@
// stashing unsaved text per path and language (stashes) so returning to the // 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. // page restores the working draft; stashes clear on save and on real reload.
// //
// Languages: the editor always starts in the primary language and the // Languages: the editor always starts in the primary language; the
// toolbar picker (flags, like the analytics visitor cells) switches // toolbar's LangSelect (shared with the structure tab via ./editorLang)
// between the primary language and its translations. A translation is // switches between the primary language and its translations. A translation is
// edited as its effective (hybrid) Markdown; the hybrid the session // edited as its effective (hybrid) Markdown; the hybrid the session
// started from is kept as a shadow copy (shadowBase) and sent along at // 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 // save time, so the server diffs the user's changes only and stores them
@@ -33,6 +33,9 @@ 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 LangSelect from './LangSelect.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({ const props = defineProps({
@@ -50,25 +53,32 @@ const fileInput = ref(null)
// The language being edited: "" = the primary language, where the editor // The language being edited: "" = the primary language, where the editor
// always starts (a served translation does not follow it into the editor; // always starts (a served translation does not follow it into the editor;
// the picker switches). The server normalizes the primary to "" anyway. // the picker switches). The server normalizes the primary to "" anyway.
const lang = ref('') // 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 primaryLang = ref('en')
const pageLangs = ref([]) // translations this page has const pageLangs = ref([]) // translations this page has
const siteLangs = ref([]) // site-wide configured target languages const siteLangs = ref([]) // site-wide configured target languages
const langPickerOpen = ref(false)
// The shadow copy: the Markdown this editing session started from, sent as // 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. // "base" on translated saves so the server diffs the user's changes only.
let shadowBase = '' let shadowBase = ''
let titleTouched = false let titleTouched = false
let docLoaded = 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
let ws = null let ws = null
let view = null let view = null
let savedResolve = null let savedResolve = null
let pendingSave = null let pendingSave = null
let reconnectTimer = null let reconnectTimer = null
let reconnectDelay = 2000 let connectWatchdog = null
const MAX_RECONNECT_DELAY = 16000 // Reconnection pacing lives in ./reconnect (shared with the other sockets):
let everConnected = false // 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) const dirty = ref(false) // unsaved text exists (drives the 💾 button)
let syncingScroll = false let syncingScroll = false
@@ -114,13 +124,14 @@ const currentLang = computed(
?? { tag: '', code: lang.value || primaryLang.value, name: langName(lang.value || primaryLang.value), flag: flagFor(lang.value || primaryLang.value), primary: !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) { // The picker's selection is the shell-wide shared language (./editorLang):
tag = tag || '' // a change stashes the working text under the PREVIOUS language (the view
if (tag === lang.value || !view) return // still holds that doc) and opens the current page in the new one.
stashCurrent() watch(lang, (tag, prev) => {
lang.value = tag if (!view) return
send({ type: 'open', path: path.value, lang: tag }) stashAs(prev || '')
} openPath(path.value)
})
function pageLabel() { function pageLabel() {
return title.value.trim() || ('/' + (path.value || '')) return title.value.trim() || ('/' + (path.value || ''))
@@ -657,15 +668,19 @@ function insertTable(cols, rows) {
const stashes = new Map() const stashes = new Map()
const stashKey = (p, l) => `${p}|${l}` const stashKey = (p, l) => `${p}|${l}`
function stashCurrent() { function stashAs(l) {
if (dirty.value && path.value) { if (dirty.value && path.value) {
stashes.set(stashKey(path.value, lang.value), { stashes.set(stashKey(path.value, l), {
text: view.state.doc.toString(), text: view.state.doc.toString(),
base: shadowBase, base: shadowBase,
}) })
} }
} }
function stashCurrent() {
stashAs(lang.value)
}
function openPath(p) { function openPath(p) {
if (p !== path.value) stashCurrent() if (p !== path.value) stashCurrent()
path.value = p path.value = p
@@ -715,14 +730,14 @@ function onMessage(ev) {
// "" while the picker may have started from an explicit code), so the // "" while the picker may have started from an explicit code), so the
// first doc is accepted on path alone and adopts the echoed language. // first doc is accepted on path alone and adopts the echoed language.
if (msg.type === 'doc' && msg.path === path.value if (msg.type === 'doc' && msg.path === path.value
&& (!docLoaded || (msg.lang || '') === lang.value)) { && (!sessionDoc || (msg.lang || '') === lang.value)) {
docLoaded = true
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'
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 || ''
sessionDoc = { path: msg.path, lang: lang.value }
titleTouched = false titleTouched = false
// Restore stashed unsaved edits over the server doc when returning // Restore stashed unsaved edits over the server doc when returning
// to a page left dirty. // to a page left dirty.
@@ -945,6 +960,12 @@ function consumePendingLine() {
} }
function connect() { function connect() {
clearTimeout(reconnectTimer)
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( ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`, `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
) )
@@ -952,35 +973,36 @@ function connect() {
ws.onerror = (ev) => { ws.onerror = (ev) => {
console.error('[pagerite] editor socket error', ev) console.error('[pagerite] editor socket error', ev)
} }
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'editor')
ws.onopen = () => { ws.onopen = () => {
reconnectDelay = 2000 reconnects.opened()
if (everConnected && docLoaded) { if (sessionDoc && sessionDoc.path === path.value && sessionDoc.lang === lang.value) {
// Reconnected with a document loaded: local text is authoritative — // Reconnected: local text is authoritative — don't re-open (that
// don't re-open (that would clobber the editor), just resync preview // would clobber the editor), just resync preview and pending saves.
// and pending saves. Without a doc (the disconnect came first) fall
// through to a normal open, or the editor would stay empty forever.
requestRender() requestRender()
if (pendingSave) send(pendingSave) if (pendingSave) send(pendingSave)
} else { } 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)) openPath(normPath(props.pagePath))
} }
everConnected = true
} }
ws.onclose = (ev) => { ws.onclose = (ev) => {
// 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade); // 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or
// worth seeing since a dead socket before the first doc bricks the // the connecting watchdog fired); worth seeing since a dead socket
// editor until this retry loop lands one. // 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 || '') console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '')
clearTimeout(reconnectTimer) reconnectTimer = setTimeout(connect, reconnects.closed())
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
} }
} }
onMounted(() => { 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() updateWindowTitle()
view = new EditorView({ view = new EditorView({
@@ -1035,6 +1057,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
if (ws) { if (ws) {
ws.onclose = null // intentional close, no reconnect ws.onclose = null // intentional close, no reconnect
ws.close() ws.close()
@@ -1052,29 +1075,7 @@ onUnmounted(() => {
<template> <template>
<div class="page-editor"> <div class="page-editor">
<header class="toolbar"> <header class="toolbar">
<span v-if="langOptions.length > 1" class="picker lang-picker"> <LangSelect v-model="lang" :options="langOptions" />
<button
type="button"
class="lang-current"
:class="{ active: langPickerOpen }"
:title="lang
? `editing the ${currentLang.name} translation`
: `editing the primary language (${currentLang.name})`"
@click="langPickerOpen = !langPickerOpen"
><span v-if="currentLang.flag" class="flag" v-html="currentLang.flag" /> {{ currentLang.code }}</button>
<span v-if="langPickerOpen" class="picker-pop lang-pop" @mouseleave="langPickerOpen = false">
<button
v-for="o in langOptions"
:key="o.code"
type="button"
:class="{ active: o.tag === lang }"
:title="o.primary
? `${o.name} — the primary language`
: `${o.name} — translation`"
@click="switchLang(o.tag); langPickerOpen = false"
><span v-if="o.flag" class="flag" v-html="o.flag" /> {{ o.name }}<small v-if="o.primary"> (primary)</small></button>
</span>
</span>
<label class="title-field"> <label class="title-field">
<span class="field-label">title</span> <span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender(); titleTouched = true" /> <input v-model="title" class="title" @input="requestRender(); titleTouched = true" />
@@ -1243,67 +1244,8 @@ onUnmounted(() => {
cursor: default; cursor: default;
} }
/* Language picker (toolbar, left): a flag + code button opening a vertical /* The language selector (toolbar, left) is LangSelect.vue — its styles
list of the primary language and its translations. Flags render like in live there. */
the analytics visitor cells. */
.lang-picker {
position: relative;
display: flex;
}
.lang-picker > button {
transform: none; /* undo the icon-button scale (.picker > button) */
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.4rem;
font: inherit;
font-size: 0.9rem;
color: var(--text);
background: none;
border: 1px solid var(--line);
border-radius: 4px;
cursor: pointer;
}
.lang-pop {
flex-direction: column;
align-items: stretch;
white-space: nowrap;
}
.lang-pop button {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: inherit;
text-align: left;
}
.lang-pop button.active {
color: var(--accent);
}
.lang-pop small {
color: var(--muted);
}
.flag {
display: inline-flex;
width: 18px;
height: 12px;
flex: 0 0 auto;
border-radius: 2px;
overflow: hidden;
border: 1px solid var(--line);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
}
.flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
/* Why a language is highlighted: primary edits fan out to translations, /* Why a language is highlighted: primary edits fan out to translations,
translation edits stay local to that language. */ translation edits stay local to that language. */
+15 -69
View File
@@ -8,15 +8,19 @@
// without (category whose URL renders a placeholder page). The front page // 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. // is a top-level row with an empty slug, not the parent of the others.
// //
// Languages: the flag strip switches which language the TITLES are shown // Languages: the LangSelect switches which language the TITLES are shown
// and edited in (rows without a translation show the original, dimmed). // and edited in (rows without a translation show the original, dimmed)
// Translated title edits write a per-language fragment (POST /_api/structure // the selection is shared shell-wide (./editorLang) with the page editor
// with lang); the structure itself — slugs, order, hierarchy — is // and the page preview. Translated title edits write a per-language
// language-independent and always edits the same tree. // 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 { computed, inject, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import StructureTree from './StructureTree.vue' import StructureTree from './StructureTree.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 { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({ const props = defineProps({
@@ -31,7 +35,7 @@ const saveError = ref('')
const tree = ref([]) const tree = ref([])
// The language the tree's titles are shown and edited in: "" = primary. // The language the tree's titles are shown and edited in: "" = primary.
const lang = ref('') const lang = editorLang
const primaryLang = ref('en') const primaryLang = ref('en')
const siteLangs = ref([]) const siteLangs = ref([])
@@ -51,11 +55,9 @@ const currentLang = computed(
() => langOptions.value.find((o) => o.tag === lang.value) ?? langOptions.value[0], () => langOptions.value.find((o) => o.tag === lang.value) ?? langOptions.value[0],
) )
function switchLang(tag) { // The selection is shared (./editorLang): a change re-fetches the tree's
if (tag === lang.value) return // titles in it (and EditorShell swaps the page preview into it).
lang.value = tag watch(lang, () => refreshPages())
refreshPages()
}
function normPath(p) { function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '') return p.trim().replace(/^\/+|\/+$/g, '')
@@ -326,21 +328,7 @@ onUnmounted(() => {
<div class="structure-editor"> <div class="structure-editor">
<div v-if="saveError">{{ saveError }}</div> <div v-if="saveError">{{ saveError }}</div>
<div v-if="langOptions.length > 1" class="block lang-block"> <div v-if="langOptions.length > 1" class="block lang-block">
<div class="lang-strip"> <div><LangSelect v-model="lang" :options="langOptions" /></div>
<button
v-for="o in langOptions"
:key="o.code"
type="button"
class="lang-flag"
:class="{ active: o.tag === lang }"
:title="o.primary
? `${o.name} — the primary language; title edits affect all translations`
: `${o.name} — title edits affect only this language`"
@click="switchLang(o.tag)"
>
<span class="flag" v-html="o.flag" />
</button>
</div>
<small v-if="lang" class="muted"> <small v-if="lang" class="muted">
viewing {{ currentLang.name }} titles dimmed rows are untranslated viewing {{ currentLang.name }} titles dimmed rows are untranslated
(shown in the primary language); slugs never translate (shown in the primary language); slugs never translate
@@ -373,49 +361,7 @@ onUnmounted(() => {
min-height: 0; min-height: 0;
} }
/* Language strip: the same flag chips as the PageEditor picker / /* The language selector is LangSelect.vue — its styles live there. */
localization tab; active = the language titles are shown/edited in. */
.lang-strip {
display: flex;
gap: 0.4rem;
}
.lang-flag {
padding: 2px;
background: none;
border: 2px solid transparent;
border-radius: 5px;
cursor: pointer;
opacity: 0.45;
filter: grayscale(0.8);
transition: opacity 0.15s, filter 0.15s, border-color 0.15s;
}
.lang-flag:hover {
opacity: 0.85;
filter: none;
}
.lang-flag.active {
opacity: 1;
filter: none;
border-color: var(--accent);
}
.flag {
display: inline-flex;
width: 18px;
height: 12px;
border-radius: 2px;
overflow: hidden;
border: 1px solid var(--line);
}
.flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.muted { .muted {
color: var(--muted); color: var(--muted);
+7
View File
@@ -0,0 +1,7 @@
// The editor shell's shared language selection ('' = the primary language):
// one state, v-modeled by the LangSelect of every tab that has one (page,
// structure). While the panel is open it also drives the page preview —
// EditorShell applies it as the fetch-time language override (swapdoc).
import { ref } from 'vue'
export const editorLang = ref('')
+24 -11
View File
@@ -6,6 +6,7 @@
// support from the article itself and are re-applied after each swap. // support from the article itself and are re-applied after each swap.
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
(() => { (() => {
// Overlay scrollbars: the native kind reserves a strip of layout (or // Overlay scrollbars: the native kind reserves a strip of layout (or
@@ -50,13 +51,21 @@ import "overlayscrollbars/overlayscrollbars.css";
url.searchParams.delete("lang"); url.searchParams.delete("lang");
history.replaceState(history.state, "", url); history.replaceState(history.state, "", url);
} }
// The session language. While the editor panel is open, its language
// selection overrides the normal preference (swapdoc.setLangOverride):
// internal fetches and prefetches follow it until the panel closes and
// the override clears (null restores the initial ?lang=, if any).
let sessionLang = langParam;
addEventListener("pagerite:session-lang", (ev) => {
sessionLang = ev.detail?.lang || langParam;
});
// An internal URL as fetched: carries the session's ?lang= unless the // An internal URL as fetched: carries the session's ?lang= unless the
// link already pins a language of its own. With no ?lang= on the initial // link already pins a language of its own. With no ?lang= on the initial
// load nothing is ever added. // load nothing is ever added.
const pageUrl = (url) => { const pageUrl = (url) => {
const u = new URL(url, location.href); const u = new URL(url, location.href);
if (langParam && u.origin === location.origin && !u.searchParams.has("lang")) { if (sessionLang && u.origin === location.origin && !u.searchParams.has("lang")) {
u.searchParams.set("lang", langParam); u.searchParams.set("lang", sessionLang);
} }
return u; return u;
}; };
@@ -394,10 +403,9 @@ import "overlayscrollbars/overlayscrollbars.css";
// conditional request); it enters the cache when navigated to. // conditional request); it enters the cache when navigated to.
const pageCache = new Map(); // rawKey/cacheKey(url) -> HTML text const pageCache = new Map(); // rawKey/cacheKey(url) -> HTML text
addEventListener("pagerite:page-fetched", (ev) => { addEventListener("pagerite:page-fetched", (ev) => {
// The editors' re-renders fetch the plain URL (no ?lang=); key by the // Key by the URL as announced, exactly as the editor fetched it: a
// URL as announced. Adding the session language would cache that // copy pinned to a language (?lang=) caches under its own key, where
// header-language copy under the translated page's key and serve it // navigation with the same session language finds it.
// back on navigation.
pageCache.set(rawKey(ev.detail.url), ev.detail.html); pageCache.set(rawKey(ev.detail.url), ev.detail.html);
}); });
@@ -551,8 +559,12 @@ import "overlayscrollbars/overlayscrollbars.css";
// failing in the background. // failing in the background.
let ws = null; let ws = null;
const wsQueue = []; const wsQueue = [];
let wsReconnectMs = 1000; const wsPolicy = reconnectPolicy({ min: 1000 });
let wsNotBefore = 0; // The first attempt is staggered too: page load opens several sockets at
// once (Vite's HMR socket, the editors), and the burst trips the browser's
// WebSocket throttling (sockets then sit "pending" for minutes).
let wsNotBefore = Date.now() + socketSlot();
let wsWatchdog = null;
function activityWs() { function activityWs() {
if (ws || Date.now() < wsNotBefore) return; if (ws || Date.now() < wsNotBefore) return;
@@ -563,15 +575,16 @@ import "overlayscrollbars/overlayscrollbars.css";
} catch { } catch {
return; return;
} }
clearTimeout(wsWatchdog);
wsWatchdog = watchConnecting(ws, "activity");
ws.onopen = () => { ws.onopen = () => {
wsReconnectMs = 1000; wsPolicy.opened();
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg)); for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
}; };
ws.onclose = () => { ws.onclose = () => {
ws = null; ws = null;
// No timer here: the next user activity retries, after the backoff. // No timer here: the next user activity retries, after the backoff.
wsNotBefore = Date.now() + wsReconnectMs; wsNotBefore = Date.now() + wsPolicy.closed();
wsReconnectMs = Math.min(wsReconnectMs * 2, 30_000);
}; };
ws.onerror = () => ws.close(); ws.onerror = () => ws.close();
} }
+56
View File
@@ -0,0 +1,56 @@
// Shared reconnect policy for the WebSockets (page/banner editors,
// analytics view, the activity channel). Two things trip a browser's
// WebSocket throttling, after which every socket to the host sits
// "pending" (never opens, never closes) for minutes:
//
// 1. A burst of simultaneous attempts — page load opens Vite's HMR
// socket plus several of ours at the same moment, and every refresh
// repeats the burst. socketSlot() spaces new sockets out.
// 2. Too-frequent retries — so failed attempts back off exponentially
// (a few seconds, doubling to half a minute), reset only after a
// connection stayed open long enough to count as healthy. A socket
// that closes right after opening must NOT reset the backoff.
export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) {
let delay = min
let openedAt = 0
return {
// Stamp a socket that just opened.
opened() {
openedAt = Date.now()
},
// The socket closed: the wait before the next attempt (up to 50%
// jitter; the base doubles per failure). A healthy streak resets it.
closed() {
if (openedAt && Date.now() - openedAt >= healthyAfter) delay = min
openedAt = 0
const wait = Math.round(delay * (1 + Math.random() * 0.5))
delay = Math.min(delay * 2, max)
return wait
},
}
}
// Sockets created at the same moment (page load: Vite's HMR socket plus
// ours) read as one burst to the browser's throttling. Space new sockets
// out: each call reserves a slot a beat after the previous one.
let nextSlot = 0
export function socketSlot() {
const now = Date.now()
const wait = Math.max(0, nextSlot - now)
nextSlot = Math.max(now, nextSlot) + 300
return wait
}
// A socket still CONNECTING after this long counts as a failed attempt:
// browser throttling leaves sockets "pending" (no open, no close) for
// minutes, and without a watchdog the app would wait on one forever (the
// recurring empty editor). Closing it fires onclose, which reschedules
// through the policy's backoff — it never reconnects aggressively itself.
export function watchConnecting(ws, label) {
return setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
console.warn(`[pagerite] ${label} socket stuck connecting — closing it, retrying with backoff`)
ws.close()
}
}, 10_000)
}
+23 -3
View File
@@ -11,6 +11,19 @@ export function dropPageCache() {
dispatchEvent(new CustomEvent('pagerite:drop-page-cache')) dispatchEvent(new CustomEvent('pagerite:drop-page-cache'))
} }
// The editor's language override (set by EditorShell): while the panel is
// open, its language selection wins over the normal preferences (?lang= /
// Accept-Language) — every in-place re-render asks for that language
// explicitly, and pagerite.js applies it to its own fetches and prefetches
// (pagerite:session-lang). The primary selection pins by its code:
// ?lang=<primary> selects the original explicitly (i18n.select_language).
let overrideLang = null // the ?lang= value in force, null = normal prefs
export function setLangOverride(queryLang) {
overrideLang = queryLang || null
dispatchEvent(new CustomEvent('pagerite:session-lang', { detail: { lang: overrideLang } }))
}
export function runScripts(root) { export function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them. // Scripts injected via innerHTML do not execute; re-create them.
if (!root) return if (!root) return
@@ -111,13 +124,14 @@ function swapRegions(doc) {
// Fetch /p, swap its regions into the live page and replaceState to it. // Fetch /p, swap its regions into the live page and replaceState to it.
// Returns the final URL (after redirects), or null when the fetch did not // Returns the final URL (after redirects), or null when the fetch did not
// yield a page. Category and missing URLs render a placeholder 404 page — // yield a page. Category and missing URLs render a placeholder 404 page —
// fine to swap in (new pages are created by editing them). // fine to swap in (new pages are created by editing them). While the
// editor's language override is set the fetch pins that language.
export async function loadPlain(p) { export async function loadPlain(p) {
let doc let doc
let finalUrl = `/${p}` let finalUrl = `/${p}`
let html let html
try { try {
const res = await fetch(finalUrl) const res = await fetch(overrideLang ? `${finalUrl}?lang=${overrideLang}` : finalUrl)
const type = res.headers.get('content-type') || '' const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url if (res.redirected) finalUrl = res.url
@@ -126,10 +140,16 @@ export async function loadPlain(p) {
} catch { return null } } catch { return null }
if (!doc.getElementById('main')) return null if (!doc.getElementById('main')) return null
swapRegions(doc) swapRegions(doc)
history.replaceState(history.state, '', finalUrl) // The address bar keeps the pretty URL: a language query is a fetch
// detail, never shown (pagerite.js's initial ?lang= works the same).
const pretty = new URL(finalUrl, location.href)
pretty.searchParams.delete('lang')
history.replaceState(history.state, '', pretty)
runScripts(document.getElementById('page-banner')) runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main')) runScripts(document.getElementById('main'))
// Keep pagerite.js's in-memory page cache in sync with the fresh copy. // Keep pagerite.js's in-memory page cache in sync with the fresh copy.
// The URL is announced as fetched: a language-pinned copy caches under
// its own ?lang= key, where navigation with the same pin finds it.
dispatchEvent(new CustomEvent('pagerite:page-fetched', { detail: { url: finalUrl, html } })) dispatchEvent(new CustomEvent('pagerite:page-fetched', { detail: { url: finalUrl, html } }))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
return finalUrl return finalUrl