Localization (#1)

Implement comprehensive content localization, admin panels for editing each language, AI translation interface with automatic updates when base language version is changed.
- SEO tags for all language URLs
- Uses accept-language by default, ?lang=en overrides temporarily
- User edits patched on top of translations
- RTL language supportReviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-03 03:54:27 +00:00
parent b6e6e46cfb
commit d3e2196c83
32 changed files with 4134 additions and 242 deletions
+33 -3
View File
@@ -27,6 +27,8 @@ 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'
import ConnNote from './ConnNote.vue'
// Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width.
@@ -40,8 +42,20 @@ const error = ref('')
const now = ref(Date.now())
let ws = null
let reconnectTimeout = null
let connectWatchdog = null
const reconnects = reconnectPolicy()
let timeInterval = null
// The panel is live data over its socket: while it is connecting or waiting
// to reconnect, say so (ConnNote) instead of showing a silent stale view.
const conn = ref('connecting') // connecting | open | waiting
const retryIn = ref(0)
const connNote = computed(() =>
conn.value === 'connecting' ? 'connecting to the server…'
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
: '',
)
// The initial range comes from the URL hash (shareable links); without one,
// it is derived from the first analytics snapshot: day when the recorded
// history is shorter than 24 h, week otherwise.
@@ -51,9 +65,16 @@ let rangePinned = Boolean(RANGES[hashRange])
function connectAnalytics() {
if (ws) return
conn.value = 'connecting'
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 = () => {
conn.value = 'open'
reconnects.opened()
error.value = ''
}
ws.onmessage = (event) => {
try {
data.value = JSON.parse(event.data)
@@ -75,12 +96,19 @@ 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).
const wait = reconnects.closed()
retryIn.value = Math.max(1, Math.round(wait / 1000))
conn.value = 'waiting'
reconnectTimeout = setTimeout(connectAnalytics, wait)
}
}
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 +121,7 @@ onMounted(async () => {
onUnmounted(() => {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
if (connectWatchdog) clearTimeout(connectWatchdog)
if (timeInterval) clearInterval(timeInterval)
if (ws) {
ws.onclose = null
@@ -151,6 +180,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
</nav>
<a href="/" class="close" title="home"></a>
</header>
<ConnNote :text="connNote" />
<p v-if="error" class="error"> {{ error }}</p>
<p v-else-if="!data" class="loading">loading</p>
<template v-else>
+47 -10
View File
@@ -3,11 +3,13 @@
// the real #page-banner region. Close and tab switching live in EditorShell.
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 { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
const props = defineProps({
@@ -25,9 +27,22 @@ 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
// Connection state drives the note at the top (ConnNote), and locks input
// until the banner's document has arrived (typing before it would be
// clobbered by the doc accept).
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 banner…',
)
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
@@ -90,6 +105,9 @@ function save() {
function openPath(p) {
path.value = 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 })
}
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
@@ -209,6 +227,8 @@ function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '')
docReady.value = true
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
bannerDesign.value = msg.banner_design ?? null
bannerDesignFrom.value = msg.banner_design_from ?? null
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
@@ -234,12 +254,22 @@ function onKeydown(ev) {
}
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
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'banner')
ws.onopen = () => {
reconnectDelay = 2000
conn.value = 'open'
reconnects.opened()
if (everConnected) {
if (pendingSave) send(pendingSave)
} else {
@@ -248,16 +278,19 @@ 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).
const wait = reconnects.closed()
retryIn.value = Math.max(1, Math.round(wait / 1000))
conn.value = 'waiting'
reconnectTimer = setTimeout(connect, wait)
}
}
onMounted(async () => {
connect()
// The first connection takes a staggered slot (see ./reconnect).
reconnectTimer = setTimeout(connect, socketSlot())
view = new EditorView({
state: EditorState.create({
doc: '',
@@ -269,6 +302,8 @@ onMounted(async () => {
cmTheme,
cmHighlight,
EditorView.lineWrapping,
// Locked until the banner's document arrives (docReady/ConnNote).
editable.of(EditorView.editable.of(false)),
EditorView.updateListener.of((u) => {
if (u.docChanged && !syncing) {
banner.value = view.state.doc.toString()
@@ -286,6 +321,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
@@ -300,6 +336,7 @@ onUnmounted(() => {
<template>
<div class="banner-editor">
<div v-if="saveError">{{ saveError }}</div>
<ConnNote :text="connNote" />
<section class="block" @paste="onBannerPaste">
<div class="block-head">
+21
View File
@@ -0,0 +1,21 @@
<script setup>
// Connection-state note for the WebSocket-backed panels (page/banner
// editors, analytics view): while the socket is connecting or waiting to
// reconnect the panel cannot load or save, and this says so. An empty
// text hides the note.
defineProps({ text: { type: String, default: '' } })
</script>
<template>
<div v-if="text" class="conn-note" role="status">{{ text }}</div>
</template>
<style scoped>
.conn-note {
padding: 0.2rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 0.8rem;
}
</style>
+45 -4
View File
@@ -1,5 +1,5 @@
<script setup>
// Tabbed shell for the four admin editors. The individual pens are shorthands
// Tabbed shell for the five admin editors. The individual pens are shorthands
// that open the shell on a given tab; once open, tabs switch instantly without
// closing the panel. Tabs are kept alive so switching preserves state.
import { onMounted, onUnmounted, provide, ref, watch } from 'vue'
@@ -7,6 +7,9 @@ import PageEditor from './PageEditor.vue'
import BannerEditor from './BannerEditor.vue'
import SiteEditor from './SiteEditor.vue'
import StructureEditor from './StructureEditor.vue'
import LocalizationEditor from './LocalizationEditor.vue'
import { editorLang, pagePrimary } from './editorLang'
import { loadPlain, setLangOverride } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -17,11 +20,37 @@ const emit = defineEmits(['close'])
const currentPath = ref(props.pagePath)
const activeMode = ref(props.initialMode)
// Tab order: site-wide settings first (site, structure), then — after a
// visual break — the per-page editors (article, banner).
// 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.
// The primary selection pins by the CURRENT PAGE's own primary language
// (pages may differ — Node.language is inherited down the tree).
let pinned = false
function pinPreviewLang() {
pinned = true
// '' pagePrimary = not yet learned: pin 'en', the server's final fallback
// (i18n.ORIGINAL_LANGUAGE).
setLangOverride(editorLang.value || pagePrimary.value || 'en')
loadPlain(currentPath.value)
}
function unpinPreviewLang() {
if (!pinned) return
pinned = false
setLangOverride(null)
loadPlain(currentPath.value)
}
watch(editorLang, () => { if (pinned) pinPreviewLang() })
// The page's primary may be (re)learned while pinned on it (doc accept,
// tree refresh, a language change on the row) — re-pin with the new code.
watch(pagePrimary, () => { if (pinned && !editorLang.value) pinPreviewLang() })
// Tab order: site-wide settings first (site, structure, localization), then
// — after a visual break — the per-page editors (article, banner).
const MODES = [
{ key: 'site', label: 'site', component: SiteEditor },
{ key: 'structure', label: 'structure', component: StructureEditor },
{ key: 'localization', label: 'lang', component: LocalizationEditor },
{ key: 'page', label: 'article', component: PageEditor, breakBefore: true },
{ key: 'banner', label: 'banner', component: BannerEditor },
]
@@ -60,15 +89,27 @@ 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 site
// default primary language comes from the settings — it only fills the
// unknown; the page/structure tabs refine pagePrimary per page as they
// learn it (their knowledge is strictly better).
pinPreviewLang()
fetch('/_api/settings').then((r) => r.json()).then((s) => {
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).catch(() => { /* keep the fallback */ })
})
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" />
+147
View File
@@ -0,0 +1,147 @@
<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}]
title: { type: String, default: '' }, // toggle-button tooltip override
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const toggleBtn = ref(null)
const popStyle = ref({})
const current = computed(
() => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0],
)
function toggle() {
open.value = !open.value
if (open.value) {
// Position: fixed so the popup overflows the scrolling editor panel
// onto the page area instead of being clipped by it.
const r = toggleBtn.value.getBoundingClientRect()
popStyle.value = { top: `${r.bottom + 2}px`, left: `${r.left}px` }
}
}
function select(tag) {
emit('update:modelValue', tag)
open.value = false
}
</script>
<template>
<span v-if="options.length > 1" class="lang-select">
<button
ref="toggleBtn"
type="button"
class="lang-current"
:class="{ open }"
:title="title || (current
? `language: ${current.name}${current.primary ? ' (primary)' : ''}`
: '')"
@click="toggle"
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
<span v-if="open" class="lang-pop" :style="popStyle" @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).
Fixed-positioned (anchored to the toggle's viewport rect on open) so it
is not clipped by the editor panel's scrolling overflow. */
.lang-pop {
position: fixed;
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>
+281
View File
@@ -0,0 +1,281 @@
<script setup>
// Lang tab: the site-wide translation target languages (translate_langs)
// and the translator service WebSocket URL(s) (translate_keys). ALL
// languages are listed, English included — a page whose primary language
// (Node.language, configured per row in the structure tab, inherited down
// the hierarchy) differs can be translated INTO any other. Flag clicks
// toggle and save immediately; the settings round-trip re-reads the
// payload, so this tab only ever changes translate_langs. The settings
// write's invalidation hook kicks the translation dispatcher. The refresh
// button drops all machine translations (user patches are kept), making
// the dispatcher re-translate everything.
import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
import { dropPageCache } from './swapdoc'
defineProps({ pagePath: { type: String, default: '' } })
// close/path-change are wired by EditorShell; this tab never emits them.
defineEmits(['close', 'pathChange'])
const saveError = ref('')
const selected = ref(new Set())
const keyUrls = ref([])
// The toggleable targets: every translatable language, laid out in
// geographic/cultural groups (one row each) rather than alphabetized —
// related languages sit together (a node's own primary is excluded per
// article, server-side). Any code missing from LANG_GROUPS trails as an
// extra row.
const groups = computed(() => {
const tile = (code) => ({ code, name: langName(code), flag: flagFor(code) })
const rows = LANG_GROUPS.map((g) => g.filter((c) => c in TRANSLATABLE).map(tile))
const covered = new Set(LANG_GROUPS.flat())
const rest = Object.keys(TRANSLATABLE).filter((c) => !covered.has(c)).map(tile)
if (rest.length) rows.push(rest)
return rows.filter((r) => r.length)
})
function updateWindowTitle() {
document.title = 'lang 🖊️'
}
onActivated(updateWindowTitle)
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title.
function onEditorShown() {
if (document.body.dataset.editorMode === 'localization') updateWindowTitle()
}
onMounted(async () => {
addEventListener('pagerite:editor-shown', onEditorShown)
try {
const s = await (await fetch('/_api/settings')).json()
selected.value = new Set(s.translate_langs || [])
const wsBase = location.origin.replace(/^http/, 'ws')
keyUrls.value = Object.entries(s.translate_keys || {})
.map(([key, name]) => ({ name, url: `${wsBase}/_translate/${key}` }))
} catch { /* keep defaults */ }
})
onUnmounted(() => removeEventListener('pagerite:editor-shown', onEditorShown))
async function toggle(code) {
const next = new Set(selected.value)
if (next.has(code)) next.delete(code)
else next.add(code)
selected.value = next
try {
const s = await (await fetch('/_api/settings')).json()
const res = await fetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...s, translate_langs: [...next] }),
})
if (res.ok) {
saveError.value = ''
dropPageCache()
} else {
saveError.value = '⚠️ changes could not be saved'
}
} catch {
saveError.value = '⚠️ changes could not be saved'
}
}
// Delete all machine translations server-side; the dispatcher re-fills
// them (a connected translator starts getting jobs right away). User
// patches survive — they are edits, not machine output.
const refreshing = ref(false)
async function refresh() {
if (refreshing.value) return
refreshing.value = true
try {
const res = await fetch('/_api/translations', { method: 'DELETE' })
saveError.value = res.ok ? '' : '⚠️ translations could not be refreshed'
if (res.ok) dropPageCache()
} catch {
saveError.value = '⚠️ translations could not be refreshed'
} finally {
refreshing.value = false
}
}
</script>
<template>
<div class="localization-editor">
<div v-if="saveError">{{ saveError }}</div>
<section class="block">
<div class="block-head">
<span class="field-label">languages</span>
</div>
<div class="flags">
<div v-for="(row, ri) in groups" :key="ri" class="flag-row">
<button
v-for="o in row"
:key="o.code"
type="button"
class="flag-tile"
:class="{ selected: selected.has(o.code) }"
:title="`${o.name} (${o.code})`"
@click="toggle(o.code)"
>
<span class="flag" v-html="o.flag" />
</button>
</div>
</div>
</section>
<section class="block">
<div class="block-head">
<span class="field-label">translations</span>
<small class="muted">deleting re-translates everything; user edits are kept</small>
</div>
<button
type="button"
class="refresh-btn"
:disabled="refreshing"
title="delete all machine translations and let the translator re-fill them"
@click="refresh"
>
{{ refreshing ? 'refreshing…' : 'refresh all translations' }}
</button>
</section>
<section v-if="keyUrls.length" class="block">
<div class="block-head">
<span class="field-label">translator service</span>
<small class="muted">connect scripts/translator.py to</small>
</div>
<div v-for="k in keyUrls" :key="k.url" class="key-row">
<code>{{ k.url }}</code>
<small class="muted">{{ k.name }}</small>
</div>
</section>
</div>
</template>
<style scoped>
.localization-editor {
overflow-y: auto;
background: var(--surface);
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.block-head {
display: flex;
align-items: center;
gap: 0.6rem;
}
.field-label {
color: var(--muted);
font-size: 0.85rem;
}
.muted {
color: var(--muted);
}
/* Flag grid: one geographic group per row. Deselected flags sit dimmed and
grayed; a click brings one to full color (selected = a translation
target) — the shading alone carries the state, no outline. */
.flags {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.2rem 0;
}
.flag-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.flag-tile {
padding: 3px;
background: none;
border: 2px solid transparent;
border-radius: 5px;
cursor: pointer;
opacity: 0.4;
filter: grayscale(0.8);
transition: opacity 0.15s, filter 0.15s, border-color 0.15s;
}
.flag-tile:hover {
opacity: 0.8;
filter: none;
}
.flag-tile.selected {
opacity: 1;
filter: none;
}
/* Same flag chips as the PageEditor language picker / analytics 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-tile .flag {
width: 36px;
height: 24px;
}
.flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.key-row {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.key-row code {
user-select: all;
}
.refresh-btn {
align-self: flex-start;
margin-bottom: 0.2rem;
padding: 0.3rem 0.8rem;
font: inherit;
font-size: 0.85rem;
color: var(--muted);
background: none;
border: 1px solid var(--line);
border-radius: 5px;
cursor: pointer;
}
.refresh-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--muted);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>
+237 -40
View File
@@ -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(() => {
<template>
<div class="page-editor">
<header class="toolbar">
<LangSelect v-model="lang" :options="langOptions" />
<label class="title-field">
<span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender" />
<input v-model="title" class="title" @input="requestRender(); titleTouched = true" />
</label>
<label><input v-model="published" type="checkbox" /> published</label>
<label :title="lang ? 'translations follow the original pages published state' : ''"><input v-model="published" type="checkbox" :disabled="!!lang" /> published</label>
<input
ref="fileInput"
type="file"
@@ -949,6 +1124,15 @@ onUnmounted(() => {
@click="saveAndRefresh"
>💾</button>
</header>
<ConnNote :text="connNote" />
<div v-if="langOptions.length > 1" class="lang-note">
<template v-if="lang">
{{ currentLang.name }} translation edits affect only this language.
</template>
<template v-else>
{{ currentLang.name }} is the primary language edits here affect all translations.
</template>
</div>
<div class="format-bar">
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
@@ -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;
+96 -6
View File
@@ -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(() => {
<template>
<div class="structure-editor">
<div v-if="saveError">{{ saveError }}</div>
<div v-if="langOptions.length > 1" class="block lang-block">
<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
</small>
</div>
<section class="block structure">
<StructureTree :nodes="tree" />
<StructureTree :nodes="tree" :lang="lang" />
</section>
</div>
</template>
@@ -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);
}
</style>
+50 -3
View File
@@ -1,7 +1,13 @@
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, order, published, has_content, children}.
// {slug, path, title, translated, order, published, has_content, language,
// primary, children}. The row's flag (LangSelect) sets the node's primary
// language (language; '' = inherit — dimmed, showing the resolved flag);
// the setting covers the whole subtree.
// With a `lang` prop (StructureEditor's language strip) the titles shown
// are that language's; `translated` marks rows with an actual translation
// (untranslated rows show the original title, dimmed).
// Every node is real: a label whose title and slug are always editable
// inline — the title saves while typing (and focusing it opens the page),
// the slug commits on blur/Enter since it renames the path, moving the
@@ -24,12 +30,17 @@
import { inject } from 'vue'
import draggable from 'vuedraggable'
import { slugify } from './slugify'
import LangSelect from './LangSelect.vue'
defineOptions({ name: 'StructureTree' })
const props = defineProps({
nodes: { type: Array, required: true },
parentPath: { type: String, default: '' },
depth: { type: Number, default: 0 },
// StructureEditor's selected language ('' = original). Only used for the
// untranslated-title styling here; the fetch and title edits live in the
// parent (handlers.titleInput posts the lang with the op).
lang: { type: String, default: '' },
})
const handlers = inject('structureHandlers')
@@ -126,8 +137,11 @@ function onEnd() {
<template v-else>
<input
class="edit title-edit"
:class="{ untranslated: lang && !element.translated }"
:value="element.title"
title="Label in the navigation — saves while typing; click opens the page"
:title="lang && !element.translated
? 'No translation yet showing the original; typing creates the translated title'
: 'Label in the navigation saves while typing; click opens the page'"
@input="handlers.titleInput(element, $event)"
@focus="handlers.open(element.path)"
/>
@@ -140,6 +154,17 @@ function onEnd() {
@change="handlers.commitSlug(element, $event)"
/>
<span class="acts">
<span
class="row-lang"
:class="{ inherited: !element.language }"
><LangSelect
:model-value="element.language"
:options="handlers.langOptions(element)"
:title="element.language
? `primary language: set on this page (subtree inherits)`
: `primary language: inherited — set it here (subtree inherits)`"
@update:model-value="handlers.setLanguage(element, $event)"
/></span>
<span v-if="!element.published" class="draft">draft</span>
<button
v-if="element.has_content || !element.children.length"
@@ -158,6 +183,7 @@ function onEnd() {
:nodes="element.children"
:parent-path="element.path"
:depth="depth + 1"
:lang="lang"
/>
</div>
</template>
@@ -223,7 +249,7 @@ body.tree-dragging .treelist {
level, not across levels). */
.row {
display: grid;
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem 5rem;
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem auto;
align-items: baseline;
gap: 0.35rem;
/* Vertical spacing widens the drop zones: the exposed top strip is the
@@ -274,6 +300,13 @@ body.tree-dragging .treelist {
cursor: text;
}
/* With a language selected (StructureEditor's strip), rows without an
actual translation show the original title dimmed and italic. */
.title-edit.untranslated {
color: var(--muted);
font-style: italic;
}
.slug-edit {
font-family: var(--font-code);
}
@@ -285,6 +318,20 @@ body.tree-dragging .treelist {
justify-content: end;
}
/* Row language selector (LangSelect): the effective primary language's
flag; dimmed while the setting is inherited rather than set on the row. */
.row-lang {
display: inline-flex;
}
.row-lang.inherited :deep(.lang-current) {
opacity: 0.45;
}
.row-lang.inherited:hover :deep(.lang-current) {
opacity: 0.85;
}
.draft {
color: var(--muted);
font-size: 0.75rem;
+15
View File
@@ -0,0 +1,15 @@
// 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('')
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
// settings fetch fills it with the site default; the page/structure tabs
// then refine it per page (doc accept / tree rows — strictly better
// sources, so they overwrite freely while the settings fetch only fills
// the unknown). EditorShell pins the preview by it when the selection is
// '' (the primary).
export const pagePrimary = ref('')
+57
View File
@@ -0,0 +1,57 @@
// Language helpers shared by the editors (the PageEditor language picker,
// the localization settings tab). Flags come from the country-flag-icons
// set, same as the analytics visitor cells.
import * as flagSvgs from 'country-flag-icons/string/3x2'
// The Seed-X reference translator's languages (scripts/translator.py) — the
// translation-target ceiling — each mapped to the language's home country
// flag (England for English, Portugal for Portuguese — not the most
// populous variant). Internal tags are the bare 2-letter base subtags; the
// translator decides the variant. A variant tag (en-US, pt-BR) is still a
// valid explicit selection for a future translator that distinguishes them
// — flagFor shows its own region then.
export const TRANSLATABLE = {
ar: 'EG', cs: 'CZ', da: 'DK', de: 'DE', el: 'GR', en: 'GB', es: 'ES',
fa: 'IR', fi: 'FI', fr: 'FR', hu: 'HU', id: 'ID', it: 'IT', ja: 'JP',
ko: 'KR', ms: 'MY', nl: 'NL', no: 'NO', pl: 'PL', pt: 'PT', ro: 'RO',
ru: 'RU', sv: 'SE', th: 'TH', tr: 'TR', uk: 'UA', vi: 'VN', zh: 'CN',
}
// The languages in geographic/cultural groups (the lang tab's flag grid
// lays them out one group per row, in this order): English with the
// Nordics, then Western/Central and Eastern Europe, Southern Europe with
// the Middle East, and Asia.
export const LANG_GROUPS = [
['en', 'nl', 'da', 'no', 'sv', 'fi', 'ru'],
['fr', 'de', 'pl', 'cs', 'hu', 'ro', 'uk'],
['es', 'pt', 'it', 'el', 'tr', 'ar', 'fa'],
['zh', 'ja', 'ko', 'vi', 'th', 'id', 'ms'],
]
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
// English display name for a language tag ("fi" -> "Finnish").
export function langName(tag) {
try {
return displayNames.of(tag) || tag
} catch {
return tag
}
}
// Flag SVG string for a language tag: an explicit region variant (en-US)
// gets its own region's flag; a bare base tag maps to the language's home
// country (en → GB, pt → PT); languages outside the list fall back to the
// tag's most likely region.
export function flagFor(tag) {
tag = tag || ''
if (!tag.includes('-')) {
const country = TRANSLATABLE[tag.split('-')[0].toLowerCase()]
if (country) return flagSvgs[country] || ''
}
try {
return flagSvgs[new Intl.Locale(tag).maximize().region] || ''
} catch {
return ''
}
}
+82 -19
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
@@ -34,6 +35,54 @@ import "overlayscrollbars/overlayscrollbars.css";
});
}
// --- Language override (?lang=) ---------------------------------------
// /page?lang=fi serves a translated, indexable version (each language is
// its own canonical). The chosen language sticks for the session of
// clicks: the server replicates ?lang= onto the navigation links it
// renders (nav, sidebar, cards — in-article links are content and stay
// as authored), and pageUrl adds it to internal fetches that lack one.
// The address bar keeps the pretty URL: the query is stripped on load
// and never pushed into history. A full refresh or a shared link resets
// to automatic selection (the browser's own Accept-Language — every
// plain fetch carries it by default). See docs/localization.md.
const langParam = new URL(location.href).searchParams.get("lang");
if (langParam) {
const url = new URL(location.href);
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 (sessionLang && u.origin === location.origin && !u.searchParams.has("lang")) {
u.searchParams.set("lang", sessionLang);
}
return u;
};
// The in-memory page cache is keyed by path + query: the same pathname
// holds different HTML for each language version.
const rawKey = (url) => {
const u = new URL(url, location.href);
return u.pathname + u.search;
};
const cacheKey = (url) => rawKey(pageUrl(url));
// What goes into the address bar and history: the pretty URL, no ?lang=.
const prettyUrl = (url) => {
const u = pageUrl(url);
u.searchParams.delete("lang");
return u;
};
// Regions every page has. #sidebar is NOT among them: it is omitted
// entirely when the section has no sub-navigation, and handled below.
const REGIONS = ["page-banner", "nav", "main"];
@@ -352,9 +401,12 @@ import "overlayscrollbars/overlayscrollbars.css";
// received it as the document (re-fetching would be redundant, and
// browser heuristics may send it without if-none-match, defeating the
// conditional request); it enters the cache when navigated to.
const pageCache = new Map(); // pathname -> HTML text
const pageCache = new Map(); // rawKey/cacheKey(url) -> HTML text
addEventListener("pagerite:page-fetched", (ev) => {
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
// 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);
});
// Editors mutate site-wide state (theme, structure, headings, banners),
@@ -373,21 +425,22 @@ import "overlayscrollbars/overlayscrollbars.css";
});
function preload() {
const urls = new Set();
const urls = new Map(); // cache key -> URL, deduped (hashes collapse)
for (const a of document.querySelectorAll(
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
)) {
urls.add(a.pathname);
const u = pageUrl(a.href);
urls.set(rawKey(u), u);
}
for (const url of urls) {
if (pageCache.has(url)) continue;
for (const [key, u] of urls) {
if (pageCache.has(key)) continue;
// x-pagerite-preload: idle cache warm-up, not a page view — the
// server excludes these GETs from analytics (the navigation message
// sent on actual navigation does the counting).
fetch(url, { headers: { "x-pagerite-preload": "1" } })
fetch(u, { headers: { "x-pagerite-preload": "1" } })
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : ""))
.then((html) => { if (html) pageCache.set(url, html); })
.then((html) => { if (html) pageCache.set(key, html); })
.catch(() => {});
}
}
@@ -506,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;
@@ -518,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();
}
@@ -697,12 +755,12 @@ import "overlayscrollbars/overlayscrollbars.css";
teardownAnalytics();
let doc;
let finalUrl = url;
const cached = !editing && pageCache.get(new URL(url, location.href).pathname);
const cached = !editing && pageCache.get(cacheKey(url));
if (cached) {
doc = new DOMParser().parseFromString(cached, "text/html");
} else {
try {
const res = await fetch(url);
const res = await fetch(pageUrl(url));
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Reflect any redirect the server issued.
@@ -710,15 +768,15 @@ import "overlayscrollbars/overlayscrollbars.css";
const html = await res.text();
// Populate the cache too, so returning here (back/forward, or a
// self-link in the nav) is served from memory.
pageCache.set(new URL(finalUrl, location.href).pathname, html);
pageCache.set(cacheKey(finalUrl), html);
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
location.href = url; // fall back to a normal navigation
location.href = pageUrl(url); // fall back to a normal navigation
return false;
}
}
if (REGIONS.some((id) => !doc.getElementById(id))) {
location.href = url;
location.href = pageUrl(url);
return false;
}
const doit = () => {
@@ -774,6 +832,11 @@ import "overlayscrollbars/overlayscrollbars.css";
// stylesheet after the server-rendered tag.
const userStyle = document.getElementById("pagerite-user");
if (userStyle) document.head.appendChild(userStyle);
// The served language rides on <html> (lang + dir, rtl for e.g.
// Arabic) — follow the swapped page (the editor panel carries its
// own lang="en" dir="ltr", so it is unaffected).
document.documentElement.lang = doc.documentElement.lang;
document.documentElement.dir = doc.documentElement.dir;
document.title = doc.title;
// Banners may contain scripts (canvas etc.), content pages may too.
runScripts(document.getElementById("page-banner"));
@@ -798,7 +861,7 @@ import "overlayscrollbars/overlayscrollbars.css";
doit();
}
currentPath = new URL(finalUrl, location.href).pathname;
if (push) history.pushState({ idx: ++historyIdx }, "", finalUrl);
if (push) history.pushState({ idx: ++historyIdx }, "", prettyUrl(finalUrl));
// The open editor follows the URL: retarget the per-page tabs to the
// navigated-to page (unsaved text of the previous page is discarded —
// the article it previewed into is gone).
+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)
}
+28 -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
@@ -101,6 +114,11 @@ function swapRegions(doc) {
}
anchor = imported
}
// The served language rides on <html> (lang + dir, rtl for e.g. Arabic):
// follow the swapped page. The editor panel carries its own lang="en"
// dir="ltr", so it is unaffected.
document.documentElement.lang = doc.documentElement.lang
document.documentElement.dir = doc.documentElement.dir
// The editor keeps its own title while open; only inherit the server title
// when navigating outside the editor (e.g. fetch-navigation swaps).
if (!document.body.classList.contains('editing')) {
@@ -111,13 +129,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 +145,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