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.
- `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).
+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.
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
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
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 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:
The page and structure editors share one language selector (`LangSelect.vue`:
a small flag button opening a dropdown; the same country-flag-icons set as
the analytics visitor cells), v-modeled on one shell-wide selection
(`editorLang.js`, `''` = the primary language). The page editor lists the
primary language and the union of the page's translations (`node.langs`) and
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
affected translation fragments everywhere); edits to a translation stay
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
title for that language (ungated by `node.langs` — a language without
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
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
translation exists (`translated` marks those rows; untranslated rows show
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 VisitorCharts from './VisitorCharts.vue'
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
// aligns with the chart svg above the natural width.
@@ -40,6 +41,8 @@ const error = ref('')
const now = ref(Date.now())
let ws = null
let reconnectTimeout = null
let connectWatchdog = null
const reconnects = reconnectPolicy()
let timeInterval = null
// The initial range comes from the URL hash (shareable links); without one,
@@ -53,7 +56,12 @@ function connectAnalytics() {
if (ws) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
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) => {
try {
data.value = JSON.parse(event.data)
@@ -75,12 +83,16 @@ function connectAnalytics() {
}
ws.onclose = () => {
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 () => {
connectAnalytics()
// The first connection takes a staggered slot (see ./reconnect).
reconnectTimeout = setTimeout(connectAnalytics, socketSlot())
now.value = Date.now()
timeInterval = setInterval(() => { now.value = Date.now() }, 1000)
// The site tree for the transition map (all pages in menu order). Not
@@ -93,6 +105,7 @@ onMounted(async () => {
onUnmounted(() => {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
if (connectWatchdog) clearTimeout(connectWatchdog)
if (timeInterval) clearInterval(timeInterval)
if (ws) {
ws.onclose = null
+20 -9
View File
@@ -8,6 +8,7 @@ import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
const props = defineProps({
@@ -25,8 +26,9 @@ const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let connectWatchdog = null
// Reconnection pacing lives in ./reconnect (shared with the other sockets).
const reconnects = reconnectPolicy()
let everConnected = false
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
@@ -234,12 +236,20 @@ function onKeydown(ev) {
}
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(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'banner')
ws.onopen = () => {
reconnectDelay = 2000
reconnects.opened()
if (everConnected) {
if (pendingSave) send(pendingSave)
} else {
@@ -248,16 +258,16 @@ function connect() {
everConnected = true
}
ws.onclose = () => {
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
// The wait is the policy's: doubling backoff with jitter (./reconnect),
// reset only by a healthy connection — rapid retries trip the browser's
// WebSocket throttling (sockets stuck "pending" for minutes).
reconnectTimer = setTimeout(connect, reconnects.closed())
}
}
onMounted(async () => {
connect()
// The first connection takes a staggered slot (see ./reconnect).
reconnectTimer = setTimeout(connect, socketSlot())
view = new EditorView({
state: EditorState.create({
doc: '',
@@ -286,6 +296,7 @@ onMounted(async () => {
onUnmounted(() => {
clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
for (const t of Object.values(timers)) clearTimeout(t)
if (ws) {
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 StructureEditor from './StructureEditor.vue'
import LocalizationEditor from './LocalizationEditor.vue'
import { editorLang } from './editorLang'
import { loadPlain, setLangOverride } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -18,6 +20,25 @@ const emit = defineEmits(['close'])
const currentPath = ref(props.pagePath)
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
// — after a visual break — the per-page editors (article, banner).
const MODES = [
@@ -62,15 +83,25 @@ function onSwitchEvent(ev) {
onMounted(() => {
document.body.dataset.editorMode = activeMode.value
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(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('pagerite:editor-shown', pinPreviewLang)
removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
})
</script>
<template>
<div class="editor-root overlay">
<div class="editor-root overlay" lang="en" dir="ltr">
<header class="editor-tabs">
<template v-for="m in MODES" :key="m.key">
<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
// page restores the working draft; stashes clear on save and on real reload.
//
// Languages: the editor always starts in the primary language and the
// toolbar picker (flags, like the analytics visitor cells) switches
// between the primary language and its translations. A translation is
// 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
@@ -33,6 +33,9 @@ import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
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'
const props = defineProps({
@@ -50,25 +53,32 @@ 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.
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 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
// 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 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
@@ -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 },
)
function switchLang(tag) {
tag = tag || ''
if (tag === lang.value || !view) return
stashCurrent()
lang.value = tag
send({ type: 'open', path: path.value, lang: tag })
}
// 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 || ''))
@@ -657,15 +668,19 @@ function insertTable(cols, rows) {
const stashes = new Map()
const stashKey = (p, l) => `${p}|${l}`
function stashCurrent() {
function stashAs(l) {
if (dirty.value && path.value) {
stashes.set(stashKey(path.value, lang.value), {
stashes.set(stashKey(path.value, l), {
text: view.state.doc.toString(),
base: shadowBase,
})
}
}
function stashCurrent() {
stashAs(lang.value)
}
function openPath(p) {
if (p !== path.value) stashCurrent()
path.value = p
@@ -715,14 +730,14 @@ function onMessage(ev) {
// "" 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
&& (!sessionDoc || (msg.lang || '') === lang.value)) {
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 || ''
sessionDoc = { path: msg.path, lang: lang.value }
titleTouched = false
// Restore stashed unsaved edits over the server doc when returning
// to a page left dirty.
@@ -945,6 +960,12 @@ function consumePendingLine() {
}
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(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
@@ -952,35 +973,36 @@ function connect() {
ws.onerror = (ev) => {
console.error('[pagerite] editor socket error', ev)
}
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'editor')
ws.onopen = () => {
reconnectDelay = 2000
if (everConnected && docLoaded) {
// Reconnected with a document loaded: local text is authoritative —
// don't re-open (that would clobber the editor), just resync preview
// and pending saves. Without a doc (the disconnect came first) fall
// through to a normal open, or the editor would stay empty forever.
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 = (ev) => {
// 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade);
// worth seeing since a dead socket before the first doc bricks the
// editor until this retry loop lands one.
// 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 || '')
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
reconnectTimer = setTimeout(connect, reconnects.closed())
}
}
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({
@@ -1035,6 +1057,7 @@ onMounted(() => {
onUnmounted(() => {
clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
@@ -1052,29 +1075,7 @@ onUnmounted(() => {
<template>
<div class="page-editor">
<header class="toolbar">
<span v-if="langOptions.length > 1" class="picker lang-picker">
<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>
<LangSelect v-model="lang" :options="langOptions" />
<label class="title-field">
<span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender(); titleTouched = true" />
@@ -1243,67 +1244,8 @@ onUnmounted(() => {
cursor: default;
}
/* Language picker (toolbar, left): a flag + code button opening a vertical
list of the primary language and its translations. Flags render like in
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;
}
/* 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. */
+15 -69
View File
@@ -8,15 +8,19 @@
// 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.
//
// Languages: the flag strip switches which language the TITLES are shown
// and edited in (rows without a translation show the original, dimmed).
// 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.
// 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 } from './editorLang'
import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({
@@ -31,7 +35,7 @@ const saveError = ref('')
const tree = ref([])
// The language the tree's titles are shown and edited in: "" = primary.
const lang = ref('')
const lang = editorLang
const primaryLang = ref('en')
const siteLangs = ref([])
@@ -51,11 +55,9 @@ const currentLang = computed(
() => langOptions.value.find((o) => o.tag === lang.value) ?? langOptions.value[0],
)
function switchLang(tag) {
if (tag === lang.value) return
lang.value = tag
refreshPages()
}
// 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())
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
@@ -326,21 +328,7 @@ onUnmounted(() => {
<div class="structure-editor">
<div v-if="saveError">{{ saveError }}</div>
<div v-if="langOptions.length > 1" class="block lang-block">
<div class="lang-strip">
<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>
<div><LangSelect v-model="lang" :options="langOptions" /></div>
<small v-if="lang" class="muted">
viewing {{ currentLang.name }} titles dimmed rows are untranslated
(shown in the primary language); slugs never translate
@@ -373,49 +361,7 @@ onUnmounted(() => {
min-height: 0;
}
/* Language strip: the same flag chips as the PageEditor picker /
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;
}
/* The language selector is LangSelect.vue — its styles live there. */
.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.
import { OverlayScrollbars } from "overlayscrollbars";
import "overlayscrollbars/overlayscrollbars.css";
import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
(() => {
// Overlay scrollbars: the native kind reserves a strip of layout (or
@@ -50,13 +51,21 @@ import "overlayscrollbars/overlayscrollbars.css";
url.searchParams.delete("lang");
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
// link already pins a language of its own. With no ?lang= on the initial
// load nothing is ever added.
const pageUrl = (url) => {
const u = new URL(url, location.href);
if (langParam && u.origin === location.origin && !u.searchParams.has("lang")) {
u.searchParams.set("lang", langParam);
if (sessionLang && u.origin === location.origin && !u.searchParams.has("lang")) {
u.searchParams.set("lang", sessionLang);
}
return u;
};
@@ -394,10 +403,9 @@ import "overlayscrollbars/overlayscrollbars.css";
// conditional request); it enters the cache when navigated to.
const pageCache = new Map(); // rawKey/cacheKey(url) -> HTML text
addEventListener("pagerite:page-fetched", (ev) => {
// The editors' re-renders fetch the plain URL (no ?lang=); key by the
// URL as announced. Adding the session language would cache that
// header-language copy under the translated page's key and serve it
// back on navigation.
// Key by the URL as announced, exactly as the editor fetched it: a
// copy pinned to a language (?lang=) caches under its own key, where
// navigation with the same session language finds it.
pageCache.set(rawKey(ev.detail.url), ev.detail.html);
});
@@ -551,8 +559,12 @@ import "overlayscrollbars/overlayscrollbars.css";
// failing in the background.
let ws = null;
const wsQueue = [];
let wsReconnectMs = 1000;
let wsNotBefore = 0;
const wsPolicy = reconnectPolicy({ min: 1000 });
// 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() {
if (ws || Date.now() < wsNotBefore) return;
@@ -563,15 +575,16 @@ import "overlayscrollbars/overlayscrollbars.css";
} catch {
return;
}
clearTimeout(wsWatchdog);
wsWatchdog = watchConnecting(ws, "activity");
ws.onopen = () => {
wsReconnectMs = 1000;
wsPolicy.opened();
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
};
ws.onclose = () => {
ws = null;
// No timer here: the next user activity retries, after the backoff.
wsNotBefore = Date.now() + wsReconnectMs;
wsReconnectMs = Math.min(wsReconnectMs * 2, 30_000);
wsNotBefore = Date.now() + wsPolicy.closed();
};
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'))
}
// 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) {
// Scripts injected via innerHTML do not execute; re-create them.
if (!root) return
@@ -111,13 +124,14 @@ function swapRegions(doc) {
// 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
// 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) {
let doc
let finalUrl = `/${p}`
let html
try {
const res = await fetch(finalUrl)
const res = await fetch(overrideLang ? `${finalUrl}?lang=${overrideLang}` : finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url
@@ -126,10 +140,16 @@ export async function loadPlain(p) {
} catch { return null }
if (!doc.getElementById('main')) return null
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('main'))
// 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:preview')) // re-inject + re-tuck the edit pens
return finalUrl