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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 page’s 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></></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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('')
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a"] }),
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_translate"] }),
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user