Public language selector, linked with the editor's language selection. Shared popup menu operation with edit toolbar with consistent closing logic.

This commit is contained in:
2026-09-04 17:52:51 +00:00
parent 986e28c220
commit db57125953
15 changed files with 402 additions and 82 deletions
+2
View File
@@ -29,6 +29,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `frontend/src/` — Vue editor and public-page JS entries.
- `main.js` — Vue editor app entry.
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
- `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown).
- `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state.
- `pagerite.js` — public page entry.
- `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`).
- `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling.
+12 -5
View File
@@ -53,11 +53,13 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
article's own language), `?lang=xx` when serving a translation — however
the language was arrived at (query or header).
- `<link rel="alternate" hreflang="…">` entries follow the canonical
directly (before the social meta tags) and are the same set on every
page — the site-wide configured languages (`translate_langs`, which the
translator works to fill in): `x-default` first, pointing at the plain
autodetecting URL, then every language explicitly with `?lang=`, the
page's own primary language included.
directly (before the social meta tags) and list the languages the page
is **actually available in**: `x-default` first, pointing at the plain
autodetecting URL, then every available language — the original again by
its plain URL, translations by `?lang=`. The public language selector
keys off these: pagerite.js mounts the editors' flag dropdown in the
top-right corner when the head advertises x-default plus more than one
language, loading its bundle (Vue + the flag SVG set) on demand.
- The override sticks for the session of clicks: a page requested with
`?lang=` replicates the query onto the navigation links it renders (nav,
sidebar, cards, brand — in-article links are content and stay as
@@ -66,6 +68,11 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
`history.replaceState` (pretty, shareable URLs), remembers the language,
and adds it to every internal fetch that lacks one (preloads,
fetch-navigations, history traversals); history entries stay query-less.
- The public selector's pick is the same override, pure JS state
(`pagerite:set-session-lang`): the session language changes and the page
swaps in place — no `?lang=` in the address bar, no reload. The choice is
linked with the editor panel's language dropdown both ways; closing the
panel keeps the chosen language instead of reverting.
- A full page refresh or a shared link resets to automatic selection (header
only). This gives a clean one-time override without cookies.
+1
View File
@@ -20,6 +20,7 @@
"codemirror": "^6.0.2",
"country-flag-icons": "^1.6.20",
"overlayscrollbars": "^2.16.0",
"pinia": "^4.0.3",
"transliteration": "^2.6.1",
"vue": "^3.5.26",
"vuedraggable": "^4.1.0"
+18 -9
View File
@@ -21,11 +21,11 @@ const currentPath = ref(props.pagePath)
const activeMode = ref(props.initialMode)
// The shared language selection (./editorLang, v-modeled by the tabs'
// LangSelects) also drives the page preview: while the shell is open it
// overrides the normal language preferences (?lang= / Accept-Language),
// so the page renders in the language being edited; closing restores.
// The primary selection pins by the CURRENT PAGE's own primary language
// (pages may differ — Node.language is inherited down the tree).
// LangSelects) is linked to the whole-page language: while the shell is
// open it drives the page preview (overrides ?lang= / Accept-Language),
// and closing keeps the pick as the session language. 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
@@ -34,6 +34,15 @@ function pinPreviewLang() {
setLangOverride(editorLang.value || pagePrimary.value || 'en')
loadPlain(currentPath.value)
}
// Opening the panel must not switch the page's language: adopt the
// session's chosen language (public selector / earlier pick) once, then
// pin. Runs only on (re)open — after that the selection is the user's.
function openShell() {
const session = window.__pageriteLang
if (!editorLang.value && session && session !== (pagePrimary.value || 'en'))
editorLang.value = session
pinPreviewLang()
}
function unpinPreviewLang() {
if (!pinned) return
pinned = false
@@ -89,13 +98,13 @@ function onSwitchEvent(ev) {
onMounted(() => {
document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('pagerite:editor-shown', pinPreviewLang)
addEventListener('pagerite:editor-shown', openShell)
addEventListener('pagerite:editor-hidden', unpinPreviewLang)
// The shell mounts visible (openEditor), so pin immediately. The site
// The shell mounts visible (openEditor), so open 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()
openShell()
fetch('/_api/settings').then((r) => r.json()).then((s) => {
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).catch(() => { /* keep the fallback */ })
@@ -103,7 +112,7 @@ onMounted(() => {
onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('pagerite:editor-shown', pinPreviewLang)
removeEventListener('pagerite:editor-shown', openShell)
removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
})
</script>
+30 -9
View File
@@ -3,7 +3,8 @@
// 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'
import { computed, nextTick, ref } from 'vue'
import { usePopup } from './dropdown'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -13,8 +14,12 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const root = ref(null)
const toggleBtn = ref(null)
const pop = ref(null)
const popStyle = ref({})
// Closes on outside click / Escape (./dropdown), not on mouseleave.
usePopup(open, root)
const current = computed(
() => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0],
)
@@ -26,6 +31,17 @@ function toggle() {
// 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` }
// A toggle mounted near the right window edge (the public page
// selector sits top-right) opens the popup flush against that edge.
nextTick(() => {
const p = pop.value?.getBoundingClientRect()
if (p && p.right > innerWidth - 4) {
popStyle.value = {
...popStyle.value,
left: `${Math.max(4, innerWidth - 4 - p.width)}px`,
}
}
})
}
}
@@ -36,7 +52,7 @@ function select(tag) {
</script>
<template>
<span v-if="options.length > 1" class="lang-select">
<span v-if="options.length > 1" ref="root" class="lang-select">
<button
ref="toggleBtn"
type="button"
@@ -47,7 +63,7 @@ function select(tag) {
: '')"
@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">
<span v-if="open" ref="pop" class="lang-pop" :style="popStyle">
<button
v-for="o in options"
:key="o.code"
@@ -66,20 +82,23 @@ function select(tag) {
display: flex;
}
/* The closed state is just the small flag — no button chrome until hovered. */
/* The closed state is just the small flag — no button chrome at all, on
hover either (it sits among borderless emoji-icon buttons); like them it
rests dimmed and brightens on hover. */
.lang-current {
display: flex;
align-items: center;
padding: 2px;
background: none;
border: 1px solid transparent;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.7;
}
.lang-current:hover,
.lang-current.open {
border-color: var(--line);
opacity: 1;
}
/* The dropdown matches the page's existing popups (.picker-pop look).
@@ -127,11 +146,13 @@ function select(tag) {
color: var(--muted);
}
/* Flags render like in the analytics visitor cells. */
/* em-sized so the chip matches the surrounding text/icon size in each
context; the hairline border delineates white-flagged countries (not
button chrome). */
.flag {
display: inline-flex;
width: 18px;
height: 12px;
width: 1.5em;
height: 1em;
flex: 0 0 auto;
border-radius: 2px;
overflow: hidden;
+41
View File
@@ -0,0 +1,41 @@
<script setup>
// The public page's language selector: the editors' flag dropdown
// (LangSelect) as the first item of the banner's corner container, fed
// from the shared store (pagerite.js sets the page's hreflang alternates
// and served language per navigation). It binds the same store.lang the
// editor's dropdown binds, so both always show the same selection. A pick
// also dispatches pagerite:set-session-lang — pagerite.js swaps the page
// in place when the editor is closed (open, the editor reacts to the
// store and re-renders it).
import { computed } from 'vue'
import LangSelect from './LangSelect.vue'
import { flagFor, langName } from './langs'
import { useStore } from './store'
const store = useStore()
// The "(primary)" marker is admin-panel information; the public selector
// lists plain languages.
const options = computed(() =>
store.langAlternates.map((a) => ({
tag: a.tag,
code: a.tag,
name: langName(a.tag),
flag: flagFor(a.tag),
primary: false,
})),
)
const primaryTag = computed(() => store.langAlternates.find((a) => a.primary)?.tag ?? '')
// The explicit pick, else the served language (header-autodetected pages
// may have neither), else the primary.
const model = computed(() => store.lang || store.servedLang || primaryTag.value)
function go(tag) {
store.lang = tag === primaryTag.value ? '' : tag
dispatchEvent(new CustomEvent('pagerite:set-session-lang', { detail: { lang: tag } }))
}
</script>
<template>
<LangSelect :model-value="model" :options="options" @update:model-value="go" />
</template>
+33 -9
View File
@@ -26,6 +26,7 @@
// renders the version being edited, whichever language the page itself
// was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePopup } from './dropdown'
import { EditorView, basicSetup } from 'codemirror'
import { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
@@ -621,8 +622,15 @@ const TABLE_MAX_ROWS = 6
// Class pickers: popup listing the block class toggles (placement ↔︎,
// text size AA), closed after applying. The block's current class of the
// group is marked; choosing "normal" (or the current class) removes it.
// All popups share the close behavior of ./dropdown (outside click /
// Escape; never mouseleave).
const classPicker = ref(null) // 'place' | 'size' | null
const activeClasses = ref(new Set())
const placeRoot = ref(null)
const sizeRoot = ref(null)
const tableRoot = ref(null)
usePopup(classPicker, computed(() => (classPicker.value === 'place' ? placeRoot : sizeRoot).value))
usePopup(tablePicker, tableRoot)
function openClassPicker(which) {
classPicker.value = classPicker.value === which ? null : which
@@ -1136,15 +1144,31 @@ onUnmounted(() => {
<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>
<button
type="button"
title="table"
:class="{ active: tablePicker }"
@click="tablePicker = !tablePicker"
></button>
<span class="picker" ref="tableRoot">
<button
type="button"
title="table"
:class="{ active: tablePicker }"
@click="tablePicker = !tablePicker"
></button>
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
<button
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
:key="n"
type="button"
class="tp-cell"
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
@click="insertTable(tableSize.cols, tableSize.rows)"
/>
</div>
<div class="tp-size">{{ tableSize.cols || '' }} × {{ tableSize.rows || '' }}</div>
</div>
</span>
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button>
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside"></button>
<span class="picker">
<span class="picker" ref="placeRoot">
<button
type="button"
title="block placement class"
@@ -1166,7 +1190,7 @@ onUnmounted(() => {
</span>
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
<span class="picker">
<span class="picker" ref="sizeRoot">
<button
type="button"
title="text size class"
@@ -1368,7 +1392,7 @@ onUnmounted(() => {
.table-picker {
position: absolute;
top: 100%;
left: 6.5rem;
left: 0;
z-index: 20;
padding: 0.5rem;
background: var(--bg);
+24
View File
@@ -0,0 +1,24 @@
// Shared popup open-state behavior: while `open` (a ref, truthy = open)
// is set, a pointerdown outside `root` (a template ref covering both the
// toggle button and the popup) or Escape resets it to null. One logic for
// every dropdown (LangSelect, the page editor's class/table pickers), so
// they can't drift apart.
import { onBeforeUnmount, watch } from 'vue'
export function usePopup(open, root) {
let off = null
const stop = watch(open, (v) => {
off?.()
off = null
if (!v) return
const down = (ev) => { if (!root.value?.contains(ev.target)) open.value = null }
const key = (ev) => { if (ev.key === 'Escape') open.value = null }
addEventListener('pointerdown', down, true)
addEventListener('keydown', key)
off = () => {
removeEventListener('pointerdown', down, true)
removeEventListener('keydown', key)
}
})
onBeforeUnmount(() => { off?.(); stop() })
}
+10 -5
View File
@@ -1,10 +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'
// backed by the app-wide store (./store), so the editor tabs' LangSelects
// and the public corner selector bind the same value. Linked to the
// whole-page language: while the panel is open it drives the page preview
// (EditorShell applies it as the fetch-time language override, swapdoc).
import { computed, ref } from 'vue'
import { pinia, useStore } from './store'
export const editorLang = ref('')
export const editorLang = computed({
get: () => useStore(pinia).lang,
set: (v) => { useStore(pinia).lang = v },
})
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
// settings fetch fills it with the site default; the page/structure tabs
+50
View File
@@ -0,0 +1,50 @@
// Public language-selector entry: imported on demand by pagerite.js on
// pages advertising more than one language in their hreflang alternates.
// Vue, Pinia and the flag SVG set live in this chunk only — untranslated
// pages never pay for them. The selector's state lives in the shared
// store (./store), not the DOM: the corner container is rebuilt freely
// and ensureMounted re-mounts from the store.
import { createApp } from 'vue'
import LangSelector from './LangSelector.vue'
import { pinia, useStore } from './store'
let app = null
function store() {
return useStore(pinia)
}
// The current page's languages (called on every navigation).
export function setLanguages(alternates, current) {
Object.assign(store(), {
langAlternates: alternates,
servedLang: current,
langSelectorActive: true,
})
}
// The current page is single-language: the selector goes away.
export function hide() {
store().langSelectorActive = false
app?.unmount()
app = null
}
// Mount the selector as the container's first item; re-mount when its
// element went away with a container rebuild (a live app updates from the
// store reactively).
export function ensureMounted(host) {
if (!store().langSelectorActive || !host) {
app?.unmount()
app = null
return
}
if (app && host.contains(app._container)) return
app?.unmount()
const el = document.createElement('div')
el.id = 'lang-selector'
host.prepend(el)
app = createApp(LangSelector)
app.use(pinia)
app.mount(el)
}
+106 -29
View File
@@ -51,13 +51,18 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
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).
// The session language: the user's explicit pick (initial ?lang=, public
// selector, editor dropdown) is kept in chosenLang; while the editor is
// open its selection overrides it (swapdoc.setLangOverride), and closing
// falls back to chosenLang. JS state only — pretty URLs, no reloads.
// window.__pageriteLang is the pin for swapdoc.loadPlain's fetches.
let chosenLang = langParam;
let sessionLang = langParam;
window.__pageriteLang = sessionLang;
addEventListener("pagerite:session-lang", (ev) => {
sessionLang = ev.detail?.lang || langParam;
if (ev.detail?.lang) chosenLang = ev.detail.lang;
sessionLang = ev.detail?.lang || chosenLang;
window.__pageriteLang = sessionLang;
});
// 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
@@ -167,6 +172,22 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
return a;
}
// The banner top-right corner container: the language selector (first
// item) plus the admin pens and auth links. renderAuthUi rebuilds it from
// scratch; the selector's state lives in the shared store, not the DOM,
// so the langselect bundle re-mounts it into the fresh container.
function pensContainer() {
let pens = document.querySelector(".editor-pens");
if (!pens) {
const banner = document.getElementById("page-banner");
if (!banner) return null;
pens = document.createElement("div");
pens.className = "editor-pens";
banner.after(pens);
}
return pens;
}
function removePens() {
document.querySelectorAll(".editor-pens, #main article button.edit-link")
.forEach((el) => el.remove());
@@ -178,32 +199,33 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// pens that may have been injected while the browser cache made us look
// authenticated.
removePens();
if (!authReady) return;
// Editing is open for admins and, as a dev/no-proxy fallback, when no
// Paskia SSO is detected at all.
const canEdit = isAdmin || !ssoAvailable;
// The analytics page is a read-only dashboard: editing pens and the side
// panel do not apply there. Login/logout links are still useful.
const onAnalytics = currentPath === "/_a";
const banner = document.getElementById("page-banner");
if (banner) {
const pens = document.createElement("div");
pens.className = "editor-pens";
if (canEdit && !onAnalytics) {
// Analytics viewer is now a normal page at /_a.
const a = document.createElement("a");
a.className = "edit-link analytics-link";
a.href = "/_a";
a.title = "analytics";
a.textContent = "📊";
pens.append(a);
pens.append(makePen("site"));
if (authReady) {
// Editing is open for admins and, as a dev/no-proxy fallback, when no
// Paskia SSO is detected at all.
const canEdit = isAdmin || !ssoAvailable;
// The analytics page is a read-only dashboard: editing pens and the side
// panel do not apply there. Login/logout links are still useful.
const onAnalytics = currentPath === "/_a";
if (document.getElementById("page-banner")) {
const pens = pensContainer();
if (canEdit && !onAnalytics) {
// Analytics viewer is now a normal page at /_a.
const a = document.createElement("a");
a.className = "edit-link analytics-link";
a.href = "/_a";
a.title = "analytics";
a.textContent = "📊";
pens.append(a);
pens.append(makePen("site"));
}
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
if (!pens.firstElementChild) pens.remove();
}
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
banner.after(pens);
if (canEdit && !onAnalytics) injectPagePen();
}
if (canEdit && !onAnalytics) injectPagePen();
// Re-mount the selector into the fresh container (no-op until the
// bundle has been loaded once).
langselectMod?.ensureMounted(document.querySelector(".editor-pens"));
}
async function setupAuth() {
@@ -407,6 +429,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// 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);
// Editor-driven swaps don't go through load(): re-evaluate the
// language selector from the fresh copy too.
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
});
// Editors mutate site-wide state (theme, structure, headings, banners),
@@ -742,6 +767,56 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
}
// --- Public language selector ------------------------------------------
// Pages translated into more than one language advertise it via hreflang
// alternates (x-default + one link per language). Those pages get the
// editors' flag dropdown as the first item of the corner container; its
// bundle (Vue + the flag SVG set) loads on demand. Re-evaluated from the
// fresh document on every swap (the head's own alternates stay stale).
let langselectMod = null;
async function mountLangselect(doc) {
const links = [...doc.head.querySelectorAll('link[rel="alternate"][hreflang]')];
const dflt = links.find((l) => l.hreflang === "x-default");
const langs = links.filter((l) => l.hreflang && l.hreflang !== "x-default");
if (!dflt || langs.length <= 1) return langselectMod?.hide();
try {
langselectMod ??= await import(/* @vite-ignore */ assets["pagerite:langselect-src"]);
for (const css of (assets["pagerite:langselect-css"] || "").split(",")) {
if (css && !document.querySelector(`link[href="${css}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = css;
link.dataset.pagerite = "langselect-css";
document.head.append(link);
}
}
langselectMod.setLanguages(
// The original's alternate is the plain URL — x-default's href —
// which also marks it as the primary option.
langs.map((l) => ({ tag: l.hreflang, href: l.href, primary: l.href === dflt.href })),
doc.documentElement.lang,
);
langselectMod.ensureMounted(pensContainer());
} catch (e) {
console.error("language selector mount failed:", e);
}
}
// The selector's pick (LangSelector dispatches this): make it the
// session language and swap the page in place. With the editor open the
// pick already landed in the shared store — the editor's watch re-renders
// the page itself, so there is nothing to do here.
addEventListener("pagerite:set-session-lang", async (ev) => {
const tag = ev.detail?.lang;
if (!tag || tag === sessionLang) return;
if (document.body.classList.contains("editing")) return;
chosenLang = sessionLang = tag;
window.__pageriteLang = tag;
const y = scrollY; // a language switch is not a navigation: keep scroll
await load(currentPath, false);
scrollTo(0, y);
});
// --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) {
// Navigating with the editor open closes it; unsaved edits are lost
@@ -843,6 +918,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
runScripts(document.getElementById("main"));
applyEffects();
mountAnalytics(doc);
mountLangselect(doc);
};
// Rotating cube page transition (styles injected as #pagerite-transition
// from the selected design's transition.css, e.g. themes/cube/);
@@ -1076,4 +1152,5 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
setupAuth();
applyEffects();
mountAnalytics(document);
mountLangselect(document);
})();
+26
View File
@@ -0,0 +1,26 @@
// The app's shared Pinia store — cross-bundle UI state lives here. Every
// entry chunk imports its own copy of this module, so the Pinia instance
// is parked on window (Vue itself is a shared chunk, so reactivity works
// across the copies). Pass `pinia` explicitly when calling useStore
// outside a component (module code, no active instance).
import { createPinia, defineStore } from 'pinia'
export const pinia = (window.__pageritePinia ??= createPinia())
export const useStore = defineStore('pagerite', {
state: () => ({
// The ONE language selection, v-modeled by both dropdowns (editor
// tabs, public corner selector): '' = no explicit pick (the page's
// primary / autodetect), else a concrete tag. A pick from either
// dropdown is visible to everyone immediately.
lang: '',
// The language the current page was actually served in (set by
// pagerite.js per navigation) — the selector's highlight fallback
// when there is no explicit pick.
servedLang: '',
// The public selector's page data: hreflang alternates
// ([{tag, href, primary}]) and whether to show at all.
langAlternates: [],
langSelectorActive: false,
}),
})
+12 -9
View File
@@ -12,12 +12,13 @@ export function dropPageCache() {
}
// 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
// open, its language selection wins over the normal preferences — 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). Panel closed, the session's
// chosen language (window.__pageriteLang) takes over — the pick stays.
let overrideLang = null // the ?lang= value in force, null = the session's
export function setLangOverride(queryLang) {
overrideLang = queryLang || null
@@ -129,14 +130,16 @@ 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). While the
// editor's language override is set the fetch pins that language.
// fine to swap in (new pages are created by editing them). The fetch pins
// the editor's language override, or — panel closed — the session's chosen
// language (window.__pageriteLang).
export async function loadPlain(p) {
let doc
let finalUrl = `/${p}`
let html
try {
const res = await fetch(overrideLang ? `${finalUrl}?lang=${overrideLang}` : finalUrl)
const pin = overrideLang || window.__pageriteLang
const res = await fetch(pin ? `${finalUrl}?lang=${pin}` : finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url
+2 -1
View File
@@ -38,7 +38,7 @@ export default defineConfig({
chunkSizeWarningLimit: 1200,
// Mirror the URL space in the build output: hashed files land under
// frontend-build/_assets/ and the Frontend serves the build directory
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
// at the site root.
manifest: true,
assetsDir: '_assets',
rollupOptions: {
@@ -50,6 +50,7 @@ export default defineConfig({
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
langselect: fileURLToPath(new URL('./src/langselect-main.js', import.meta.url)),
// Only the base CSS is built; theme/banner-design stylesheets live
// in pagerite/themes/{name}/ and are served by the backend as-is.
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
+35 -6
View File
@@ -385,10 +385,14 @@ def _layout(
# carries the on-demand URLs in one JSON script instead.
vite_url = os.environ.get("PAGERITE_VITE_URL")
editor_scripts, editor_css = _editor_assets()
langselect_scripts, langselect_css = _langselect_assets()
config = {
"pagerite:editor-src": editor_scripts[-1],
"pagerite:analytics-src": _analytics_assets()[0][0],
"pagerite:langselect-src": langselect_scripts[-1],
}
if langselect_css:
config["pagerite:langselect-css"] = ",".join(langselect_css)
if editor_css:
config["pagerite:editor-css"] = editor_css
if vite_url:
@@ -1050,10 +1054,10 @@ def render_page(
# the actually served language — the plain URL for the original (for
# SEO the non-query URL means the article's language), ?lang= for a
# translation — regardless of how the language was arrived at (query
# or header). The alternates are site-wide, the same set on every
# page: the configured translate_langs (the translator works to fill
# them all in), x-default first (the plain, autodetecting URL), then
# every language explicitly, the page's own primary included.
# or header). The alternates list the languages the page is actually
# available in: x-default first (the plain, autodetecting URL), then
# every available language — the original again by its plain URL,
# translations by ?lang=. The public language selector keys off these.
canonical = ""
alternates = []
if base_url:
@@ -1061,8 +1065,8 @@ def render_page(
canonical = url if lang == original else f"{url}?lang={lang}"
if data.translate_langs:
alternates = [("x-default", url)] + [
(tag, f"{url}?lang={tag}")
for tag in sorted({original, *data.translate_langs})
(tag, url if tag == original else f"{url}?lang={tag}")
for tag in sorted({original, *node.langs})
]
return str(
_layout(
@@ -1228,6 +1232,31 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
return _asset_cache["analytics"]
def _langselect_assets() -> tuple[list[str], list[str]]:
"""Script and stylesheet URLs for the on-demand public language selector."""
vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url:
return [f"{vite_url}/src/langselect-main.js"], []
if "langselect" not in _asset_cache:
manifest = _manifest()
# import() loads no CSS automatically: collect the stylesheets of
# the entry and its imported chunks (LangSelect's ride on the
# shared langs chunk).
scripts, stylesheets, seen = [], [], set()
queue = ["src/langselect-main.js"]
for key in queue: # grows with imported chunks
if key in seen:
continue
seen.add(key)
entry = manifest[key]
if entry.get("isEntry"):
scripts.append(f"/{entry['file']}")
stylesheets += [f"/{css}" for css in entry.get("css", [])]
queue += entry.get("imports", [])
_asset_cache["langselect"] = scripts, stylesheets
return _asset_cache["langselect"]
def render_analytics(
menu: dict[str, Node],
brand: str = SITE_NAME,