Compare commits

...
2 Commits
18 changed files with 428 additions and 92 deletions
+3 -1
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. - `frontend/src/` — Vue editor and public-page JS entries.
- `main.js` — Vue editor app entry. - `main.js` — Vue editor app entry.
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
- `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. - `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`). - `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. - `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.
@@ -64,6 +66,6 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
## Conventions ## Conventions
- Keep dependencies minimal; add via `uv add` and mention it. - Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content. - The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` (backend redirect to the configured site icon). Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md). - No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
- Update the relevant MarkDown files when architecture, tooling, or conventions change. - Update the relevant MarkDown files when architecture, tooling, or conventions change.
+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 article's own language), `?lang=xx` when serving a translation — however
the language was arrived at (query or header). the language was arrived at (query or header).
- `<link rel="alternate" hreflang="…">` entries follow the canonical - `<link rel="alternate" hreflang="…">` entries follow the canonical
directly (before the social meta tags) and are the same set on every directly (before the social meta tags) and list the languages the page
page — the site-wide configured languages (`translate_langs`, which the is **actually available in**: `x-default` first, pointing at the plain
translator works to fill in): `x-default` first, pointing at the plain autodetecting URL, then every available language — the original again by
autodetecting URL, then every language explicitly with `?lang=`, the its plain URL, translations by `?lang=`. The public language selector
page's own primary language included. 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 - The override sticks for the session of clicks: a page requested with
`?lang=` replicates the query onto the navigation links it renders (nav, `?lang=` replicates the query onto the navigation links it renders (nav,
sidebar, cards, brand — in-article links are content and stay as 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, `history.replaceState` (pretty, shareable URLs), remembers the language,
and adds it to every internal fetch that lacks one (preloads, and adds it to every internal fetch that lacks one (preloads,
fetch-navigations, history traversals); history entries stay query-less. 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 - A full page refresh or a shared link resets to automatic selection (header
only). This gives a clean one-time override without cookies. only). This gives a clean one-time override without cookies.
+1
View File
@@ -20,6 +20,7 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"country-flag-icons": "^1.6.20", "country-flag-icons": "^1.6.20",
"overlayscrollbars": "^2.16.0", "overlayscrollbars": "^2.16.0",
"pinia": "^4.0.3",
"transliteration": "^2.6.1", "transliteration": "^2.6.1",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
+18 -9
View File
@@ -21,11 +21,11 @@ const currentPath = ref(props.pagePath)
const activeMode = ref(props.initialMode) const activeMode = ref(props.initialMode)
// The shared language selection (./editorLang, v-modeled by the tabs' // The shared language selection (./editorLang, v-modeled by the tabs'
// LangSelects) also drives the page preview: while the shell is open it // LangSelects) is linked to the whole-page language: while the shell is
// overrides the normal language preferences (?lang= / Accept-Language), // open it drives the page preview (overrides ?lang= / Accept-Language),
// so the page renders in the language being edited; closing restores. // and closing keeps the pick as the session language. The primary
// The primary selection pins by the CURRENT PAGE's own primary language // selection pins by the CURRENT PAGE's own primary language (pages may
// (pages may differ — Node.language is inherited down the tree). // differ — Node.language is inherited down the tree).
let pinned = false let pinned = false
function pinPreviewLang() { function pinPreviewLang() {
pinned = true pinned = true
@@ -34,6 +34,15 @@ function pinPreviewLang() {
setLangOverride(editorLang.value || pagePrimary.value || 'en') setLangOverride(editorLang.value || pagePrimary.value || 'en')
loadPlain(currentPath.value) 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() { function unpinPreviewLang() {
if (!pinned) return if (!pinned) return
pinned = false pinned = false
@@ -89,13 +98,13 @@ function onSwitchEvent(ev) {
onMounted(() => { onMounted(() => {
document.body.dataset.editorMode = activeMode.value document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent) addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('pagerite:editor-shown', pinPreviewLang) addEventListener('pagerite:editor-shown', openShell)
addEventListener('pagerite:editor-hidden', unpinPreviewLang) 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 // default primary language comes from the settings — it only fills the
// unknown; the page/structure tabs refine pagePrimary per page as they // unknown; the page/structure tabs refine pagePrimary per page as they
// learn it (their knowledge is strictly better). // learn it (their knowledge is strictly better).
pinPreviewLang() openShell()
fetch('/_api/settings').then((r) => r.json()).then((s) => { fetch('/_api/settings').then((r) => r.json()).then((s) => {
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en' if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).catch(() => { /* keep the fallback */ }) }).catch(() => { /* keep the fallback */ })
@@ -103,7 +112,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent) removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('pagerite:editor-shown', pinPreviewLang) removeEventListener('pagerite:editor-shown', openShell)
removeEventListener('pagerite:editor-hidden', unpinPreviewLang) removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
}) })
</script> </script>
+30 -9
View File
@@ -3,7 +3,8 @@
// flag button opening a clean dropdown, v-modeled on the shared editorLang // flag button opening a clean dropdown, v-modeled on the shared editorLang
// ('' = the primary language). The lang tab's flag grid is a different // ('' = the primary language). The lang tab's flag grid is a different
// control (toggles, not a select) and stays as it is. // 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({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -13,8 +14,12 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const open = ref(false) const open = ref(false)
const root = ref(null)
const toggleBtn = ref(null) const toggleBtn = ref(null)
const pop = ref(null)
const popStyle = ref({}) const popStyle = ref({})
// Closes on outside click / Escape (./dropdown), not on mouseleave.
usePopup(open, root)
const current = computed( const current = computed(
() => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0], () => 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. // onto the page area instead of being clipped by it.
const r = toggleBtn.value.getBoundingClientRect() const r = toggleBtn.value.getBoundingClientRect()
popStyle.value = { top: `${r.bottom + 2}px`, left: `${r.left}px` } 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> </script>
<template> <template>
<span v-if="options.length > 1" class="lang-select"> <span v-if="options.length > 1" ref="root" class="lang-select">
<button <button
ref="toggleBtn" ref="toggleBtn"
type="button" type="button"
@@ -47,7 +63,7 @@ function select(tag) {
: '')" : '')"
@click="toggle" @click="toggle"
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button> ><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 <button
v-for="o in options" v-for="o in options"
:key="o.code" :key="o.code"
@@ -66,20 +82,23 @@ function select(tag) {
display: flex; 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 { .lang-current {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 2px; padding: 2px;
background: none; background: none;
border: 1px solid transparent; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
opacity: 0.7;
} }
.lang-current:hover, .lang-current:hover,
.lang-current.open { .lang-current.open {
border-color: var(--line); opacity: 1;
} }
/* The dropdown matches the page's existing popups (.picker-pop look). /* The dropdown matches the page's existing popups (.picker-pop look).
@@ -127,11 +146,13 @@ function select(tag) {
color: var(--muted); 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 { .flag {
display: inline-flex; display: inline-flex;
width: 18px; width: 1.5em;
height: 12px; height: 1em;
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 2px; border-radius: 2px;
overflow: hidden; 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 // renders the version being edited, whichever language the page itself
// was loaded in. // was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePopup } from './dropdown'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { Compartment, EditorState } from '@codemirror/state' import { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view' import { keymap } from '@codemirror/view'
@@ -621,8 +622,15 @@ const TABLE_MAX_ROWS = 6
// Class pickers: popup listing the block class toggles (placement ↔︎, // Class pickers: popup listing the block class toggles (placement ↔︎,
// text size AA), closed after applying. The block's current class of the // text size AA), closed after applying. The block's current class of the
// group is marked; choosing "normal" (or the current class) removes it. // 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 classPicker = ref(null) // 'place' | 'size' | null
const activeClasses = ref(new Set()) 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) { function openClassPicker(which) {
classPicker.value = classPicker.value === which ? null : which classPicker.value = classPicker.value === which ? null : which
@@ -1136,15 +1144,31 @@ onUnmounted(() => {
<div class="format-bar"> <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" 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="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
<button <span class="picker" ref="tableRoot">
type="button" <button
title="table" type="button"
:class="{ active: tablePicker }" title="table"
@click="tablePicker = !tablePicker" :class="{ active: tablePicker }"
></button> @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="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> <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 <button
type="button" type="button"
title="block placement class" title="block placement class"
@@ -1166,7 +1190,7 @@ onUnmounted(() => {
</span> </span>
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button> <button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button> <button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
<span class="picker"> <span class="picker" ref="sizeRoot">
<button <button
type="button" type="button"
title="text size class" title="text size class"
@@ -1368,7 +1392,7 @@ onUnmounted(() => {
.table-picker { .table-picker {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 6.5rem; left: 0;
z-index: 20; z-index: 20;
padding: 0.5rem; padding: 0.5rem;
background: var(--bg); 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): // The editor shell's shared language selection ('' = the primary language):
// one state, v-modeled by the LangSelect of every tab that has one (page, // backed by the app-wide store (./store), so the editor tabs' LangSelects
// structure). While the panel is open it also drives the page preview — // and the public corner selector bind the same value. Linked to the
// EditorShell applies it as the fetch-time language override (swapdoc). // whole-page language: while the panel is open it drives the page preview
import { ref } from 'vue' // (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 // The CURRENT PAGE's primary language ('' = not yet learned): the shell's
// settings fetch fills it with the site default; the page/structure tabs // 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"); url.searchParams.delete("lang");
history.replaceState(history.state, "", url); history.replaceState(history.state, "", url);
} }
// The session language. While the editor panel is open, its language // The session language: the user's explicit pick (initial ?lang=, public
// selection overrides the normal preference (swapdoc.setLangOverride): // selector, editor dropdown) is kept in chosenLang; while the editor is
// internal fetches and prefetches follow it until the panel closes and // open its selection overrides it (swapdoc.setLangOverride), and closing
// the override clears (null restores the initial ?lang=, if any). // 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; let sessionLang = langParam;
window.__pageriteLang = sessionLang;
addEventListener("pagerite:session-lang", (ev) => { 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 // An internal URL as fetched: carries the session's ?lang= unless the
// link already pins a language of its own. With no ?lang= on the initial // link already pins a language of its own. With no ?lang= on the initial
@@ -167,6 +172,22 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
return a; 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() { function removePens() {
document.querySelectorAll(".editor-pens, #main article button.edit-link") document.querySelectorAll(".editor-pens, #main article button.edit-link")
.forEach((el) => el.remove()); .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 // pens that may have been injected while the browser cache made us look
// authenticated. // authenticated.
removePens(); removePens();
if (!authReady) return; if (authReady) {
// Editing is open for admins and, as a dev/no-proxy fallback, when no
// Editing is open for admins and, as a dev/no-proxy fallback, when no // Paskia SSO is detected at all.
// Paskia SSO is detected at all. const canEdit = isAdmin || !ssoAvailable;
const canEdit = isAdmin || !ssoAvailable; // The analytics page is a read-only dashboard: editing pens and the side
// The analytics page is a read-only dashboard: editing pens and the side // panel do not apply there. Login/logout links are still useful.
// panel do not apply there. Login/logout links are still useful. const onAnalytics = currentPath === "/_a";
const onAnalytics = currentPath === "/_a"; if (document.getElementById("page-banner")) {
const banner = document.getElementById("page-banner"); const pens = pensContainer();
if (banner) { if (canEdit && !onAnalytics) {
const pens = document.createElement("div"); // Analytics viewer is now a normal page at /_a.
pens.className = "editor-pens"; const a = document.createElement("a");
if (canEdit && !onAnalytics) { a.className = "edit-link analytics-link";
// Analytics viewer is now a normal page at /_a. a.href = "/_a";
const a = document.createElement("a"); a.title = "analytics";
a.className = "edit-link analytics-link"; a.textContent = "📊";
a.href = "/_a"; pens.append(a);
a.title = "analytics"; pens.append(makePen("site"));
a.textContent = "📊"; }
pens.append(a); if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
pens.append(makePen("site")); if (!pens.firstElementChild) pens.remove();
} }
if (ssoAvailable) pens.append(makeAuthLink(isAdmin)); if (canEdit && !onAnalytics) injectPagePen();
banner.after(pens);
} }
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() { 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 // copy pinned to a language (?lang=) caches under its own key, where
// navigation with the same session language finds it. // navigation with the same session language finds it.
pageCache.set(rawKey(ev.detail.url), ev.detail.html); 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), // 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 ------------------------------------------------ // --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) { async function load(url, push = true, back = false) {
// Navigating with the editor open closes it; unsaved edits are lost // 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")); runScripts(document.getElementById("main"));
applyEffects(); applyEffects();
mountAnalytics(doc); mountAnalytics(doc);
mountLangselect(doc);
}; };
// Rotating cube page transition (styles injected as #pagerite-transition // Rotating cube page transition (styles injected as #pagerite-transition
// from the selected design's transition.css, e.g. themes/cube/); // from the selected design's transition.css, e.g. themes/cube/);
@@ -1076,4 +1152,5 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
setupAuth(); setupAuth();
applyEffects(); applyEffects();
mountAnalytics(document); 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 // The editor's language override (set by EditorShell): while the panel is
// open, its language selection wins over the normal preferences (?lang= / // open, its language selection wins over the normal preferences — every
// Accept-Language) — every in-place re-render asks for that language // in-place re-render asks for that language explicitly, and pagerite.js
// explicitly, and pagerite.js applies it to its own fetches and prefetches // applies it to its own fetches and prefetches (pagerite:session-lang).
// (pagerite:session-lang). The primary selection pins by its code: // The primary selection pins by its code: ?lang=<primary> selects the
// ?lang=<primary> selects the original explicitly (i18n.select_language). // original explicitly (i18n.select_language). Panel closed, the session's
let overrideLang = null // the ?lang= value in force, null = normal prefs // 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) { export function setLangOverride(queryLang) {
overrideLang = queryLang || null overrideLang = queryLang || null
@@ -129,14 +130,16 @@ function swapRegions(doc) {
// Fetch /p, swap its regions into the live page and replaceState to it. // Fetch /p, swap its regions into the live page and replaceState to it.
// Returns the final URL (after redirects), or null when the fetch did not // Returns the final URL (after redirects), or null when the fetch did not
// yield a page. Category and missing URLs render a placeholder 404 page — // yield a page. Category and missing URLs render a placeholder 404 page —
// fine to swap in (new pages are created by editing them). While the // fine to swap in (new pages are created by editing them). The fetch pins
// editor's language override is set the fetch pins that language. // the editor's language override, or — panel closed — the session's chosen
// language (window.__pageriteLang).
export async function loadPlain(p) { export async function loadPlain(p) {
let doc let doc
let finalUrl = `/${p}` let finalUrl = `/${p}`
let html let html
try { try {
const res = await fetch(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') || '' const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url if (res.redirected) finalUrl = res.url
+2 -1
View File
@@ -38,7 +38,7 @@ export default defineConfig({
chunkSizeWarningLimit: 1200, chunkSizeWarningLimit: 1200,
// Mirror the URL space in the build output: hashed files land under // Mirror the URL space in the build output: hashed files land under
// frontend-build/_assets/ and the Frontend serves the build directory // 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, manifest: true,
assetsDir: '_assets', assetsDir: '_assets',
rollupOptions: { rollupOptions: {
@@ -50,6 +50,7 @@ export default defineConfig({
main: fileURLToPath(new URL('./src/main.js', import.meta.url)), main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.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)), 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 // Only the base CSS is built; theme/banner-design stylesheets live
// in pagerite/themes/{name}/ and are served by the backend as-is. // in pagerite/themes/{name}/ and are served by the backend as-is.
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)), pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
+2 -2
View File
@@ -48,7 +48,7 @@ logger = logging.getLogger(__name__)
# Vue build served at the site root, no SPA catch-all (assets only). The # Vue build served at the site root, no SPA catch-all (assets only). The
# build mirrors the URL space: hashed, immutable files live under # build mirrors the URL space: hashed, immutable files live under
# /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico. # /_assets/ (assetsDir: '_/assets').
frontend = Frontend( frontend = Frontend(
Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/" Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/"
) )
@@ -124,7 +124,7 @@ app.include_router(tracking.router)
app.include_router(files.router) app.include_router(files.router)
# Vue build asset routes are inserted at this position during load(): the # Vue build asset routes are inserted at this position during load(): the
# build mirrors the URL space (/_assets/*, /favicon.ico at the root). # build mirrors the URL space (/_assets/*).
frontend.route(app, "/") frontend.route(app, "/")
# The content catch-all goes last: built assets win over content slugs, # The content catch-all goes last: built assets win over content slugs,
+2 -2
View File
@@ -98,8 +98,8 @@ class Data(msgspec.Struct):
#: Trusted author content; not sanitized. #: Trusted author content; not sanitized.
custom_css: str = "" custom_css: str = ""
#: Favicon: content-addressed file name (served at "/_f/{name}"), #: Favicon: content-addressed file name (served at "/_f/{name}"),
#: linked as <link rel="icon"> on every page. Empty = the build's #: linked as <link rel="icon"> on every page; /favicon.ico redirects
#: /favicon.ico. #: to it. Empty = no icon (and /favicon.ico 404s).
favicon: str = "" favicon: str = ""
#: API keys gating the translator service WebSocket (/_translate/{key}; #: API keys gating the translator service WebSocket (/_translate/{key};
#: the external forward-auth does not cover that route): key -> display #: the external forward-auth does not cover that route): key -> display
+19 -3
View File
@@ -6,7 +6,8 @@ when compression shrinks the body), served immutable at ``/_f/``. Raster
images and SVGs are recompressed into AVIF/WebP/JPEG derivatives images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
(``store_image`` and helpers); the untouched original is kept alongside as (``store_image`` and helpers); the untouched original is kept alongside as
``<hash>.orig<ext>`` (never served). Routes: upload/delete under ``<hash>.orig<ext>`` (never served). Routes: upload/delete under
``/_api/files``, the favicon settings endpoints, the ``/_f/`` server with ``/_api/files``, the favicon settings endpoints, the /favicon.ico
redirect to the configured icon, the ``/_f/`` server with
Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``). Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``).
""" """
@@ -19,7 +20,7 @@ from pathlib import Path
import blake3 import blake3
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response from fastapi.responses import RedirectResponse, Response
from mediapreview import dispatch from mediapreview import dispatch
from pagerite import views from pagerite import views
@@ -252,6 +253,20 @@ async def delete_file(name: str) -> None:
file_store.delete(name) file_store.delete(name)
@router.get("/favicon.ico", include_in_schema=False)
async def favicon_ico() -> Response:
"""The conventional /favicon.ico: redirect to the configured site icon.
Browsers request this path on their own (tabs, bookmarks, feeds and
other non-HTML contexts) regardless of the <link rel="icon"> pages
carry. Redirect to the icon's store URL, which negotiates the format
and caches immutably; 404 when no custom icon is configured.
"""
if not data.favicon:
raise HTTPException(404)
return RedirectResponse(f"/_f/{data.favicon}")
@router.put("/_api/settings/favicon") @router.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]: async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it. """Upload a favicon into the content-addressed store and activate it.
@@ -276,7 +291,8 @@ async def put_favicon(request: Request) -> dict[str, str]:
@router.delete("/_api/settings/favicon", status_code=204) @router.delete("/_api/settings/favicon", status_code=204)
async def delete_favicon(request: Request) -> None: async def delete_favicon(request: Request) -> None:
"""Clear the custom favicon (back to the build's /favicon.ico). """Clear the custom favicon (/favicon.ico goes back to 404, pages drop
the <link rel="icon">).
The blob stays in the content-addressed store; only the reference goes. The blob stays in the content-addressed store; only the reference goes.
""" """
+37 -8
View File
@@ -372,8 +372,8 @@ def _layout(
doc.meta(property=key, content=value) doc.meta(property=key, content=value)
else: else:
doc.meta(name=key, content=value) doc.meta(name=key, content=value)
# A custom favicon (from the site editor) is linked explicitly; without # A custom favicon (from the site editor) is linked explicitly;
# one, browsers fall back to the build's /favicon.ico by convention. # /favicon.ico redirects to the same store file for non-HTML contexts.
if favicon: if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon") doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Asset URLs for the on-demand bundles (editor, analytics) for # Asset URLs for the on-demand bundles (editor, analytics) for
@@ -385,10 +385,14 @@ def _layout(
# carries the on-demand URLs in one JSON script instead. # carries the on-demand URLs in one JSON script instead.
vite_url = os.environ.get("PAGERITE_VITE_URL") vite_url = os.environ.get("PAGERITE_VITE_URL")
editor_scripts, editor_css = _editor_assets() editor_scripts, editor_css = _editor_assets()
langselect_scripts, langselect_css = _langselect_assets()
config = { config = {
"pagerite:editor-src": editor_scripts[-1], "pagerite:editor-src": editor_scripts[-1],
"pagerite:analytics-src": _analytics_assets()[0][0], "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: if editor_css:
config["pagerite:editor-css"] = editor_css config["pagerite:editor-css"] = editor_css
if vite_url: if vite_url:
@@ -1050,10 +1054,10 @@ def render_page(
# the actually served language — the plain URL for the original (for # the actually served language — the plain URL for the original (for
# SEO the non-query URL means the article's language), ?lang= for a # SEO the non-query URL means the article's language), ?lang= for a
# translation — regardless of how the language was arrived at (query # translation — regardless of how the language was arrived at (query
# or header). The alternates are site-wide, the same set on every # or header). The alternates list the languages the page is actually
# page: the configured translate_langs (the translator works to fill # available in: x-default first (the plain, autodetecting URL), then
# them all in), x-default first (the plain, autodetecting URL), then # every available language — the original again by its plain URL,
# every language explicitly, the page's own primary included. # translations by ?lang=. The public language selector keys off these.
canonical = "" canonical = ""
alternates = [] alternates = []
if base_url: if base_url:
@@ -1061,8 +1065,8 @@ def render_page(
canonical = url if lang == original else f"{url}?lang={lang}" canonical = url if lang == original else f"{url}?lang={lang}"
if data.translate_langs: if data.translate_langs:
alternates = [("x-default", url)] + [ alternates = [("x-default", url)] + [
(tag, f"{url}?lang={tag}") (tag, url if tag == original else f"{url}?lang={tag}")
for tag in sorted({original, *data.translate_langs}) for tag in sorted({original, *node.langs})
] ]
return str( return str(
_layout( _layout(
@@ -1228,6 +1232,31 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
return _asset_cache["analytics"] 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( def render_analytics(
menu: dict[str, Node], menu: dict[str, Node],
brand: str = SITE_NAME, brand: str = SITE_NAME,