diff --git a/AGENTS.md b/AGENTS.md
index cf26734..c992f4d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,17 +10,18 @@ Please instead ask the user to see from dev tools what you need, e.g. to look up
Pagerite is a CMS. See `docs` for the full design and implementation details. Key files for code changes:
- `pagerite/` — Python backend package (hatchling build target).
- - `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/files routers, then `frontend.route(app, "/")`, then the pages catch-all last).
+ - `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/messages/files routers, then `frontend.route(app, "/")`, then the pages catch-all last).
- `state.py` — shared core, no routes: env-derived site constants, `data`/`kanta`, `analytics_store`, the fastapi-vue `frontend`, the render cache (`_html_response`, `_invalidate_pages`), the translator `dispatcher`, slug helpers, `@kanta.bootstrap` hooks.
- - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, favicon settings).
+ - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings).
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
- - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md).
- - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all.
- - `feeds.py` — machine-readable exports: `/llms.txt`, `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); all published articles, full content, linked from every page `
` and the sitemap, recorded in analytics.
+ - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and holds the per-connection easypow challenge (a validated proof grants the per-session message/upload quota held in messages.py, reveals the site contact email, and records the visit's contact trail marker), the analytics socket the admin inbox.
+ - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW challenges live in `tracking.activity_ws` as a per-connection local; the granted quota here as a per-session RAM budget shared with the upload route; browser solver served at `/_contact/easypow.js`), the admin unread count for `/_api/settings`, and the public `/_contact/files` upload route (PoW quota + pending-upload quota, unreferenced files deleted when the session's last socket closes).
+ - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ` `, and llms.txt links point at the `.md` URLs.
+ - `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `` and the sitemap, recorded in analytics.
- `data.py` — msgspec Structs for the kanta database.
- `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md).
- - `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh).
- - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route.
+ - `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh), plus the site-string translations (`site_string`/`set_string_translation`) for the contact label/subject (docs/messages.md).
+ - `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route. Pending title/nav work includes the site contact strings, so the contact chrome localizes automatically.
- `segments.py` — the translation round trip: fragments split into pure-prose wire segments (via markdown.make_md's verbatim parser; link- and formatting-carrying blocks stay whole, link/formatted texts inline, Markdown stripped) and translations spliced back by source offset, link/formatting markdown re-inserted at weight-mapped positions (docs/localization.md).
- `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field.
- `markdown.py` — markdown-it-py renderer.
@@ -31,10 +32,12 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `main.js` — Vue editor app entry.
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
- `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).
+ - `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked, and at page load when a message session is live: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected while open — a background `watchAdmin` connection keeps the badge and notifications live at all times). A visitor's open conversation is flagged in localStorage and resumed on page load; closing it archives the thread server-side and rotates the token. Both sides notify new messages via the browser Notification API (`notify.js`), permission asked after the first message of a fresh thread.
+ - `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly.
- `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.
+ - `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. Also the auth-aware close handling for the admin sockets (`authReconnect`): probe `/_api/settings` on close, run the paskia login flow on 401/403 and reconnect at once, pace only genuine network/server failures.
- `assets/` — base CSS, Pygments styles, fonts.
- `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test).
- `scripts/translator.py` — Seed-X translator service client for the `/_translate/{key}` socket (reference client, runs in its own uv env via PEP 723); stays connected full time, unloads the model after 60 s idle and reloads on the next job.
@@ -45,7 +48,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
## Toolchain
-- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`).
+- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `easypow`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`).
- Key libraries:
- **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML).
- To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header
diff --git a/docs/analytics.md b/docs/analytics.md
index 3e4284c..43a4b23 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -119,7 +119,11 @@ transition applies the new `` only after the ping goes out) and
follows the session, so the activity of a visit stays tied together, and
while the user is active the accumulated reading time is flushed every few
seconds: the times are incremental, so a disconnection simply leaves the
-last reported time in place (no close beacon). After 5 minutes without
+last reported time in place (no close beacon). A sleeping computer accrues
+no reading time: the inactivity-cutoff timer cannot fire while asleep (and
+`performance.now()` keeps running through sleep on some platforms), so a
+gap longer than the timeout closes the span at its intended cutoff, and a
+late-firing cutoff timer clamps its accumulation the same way. After 5 minutes without
any activity the client closes the socket itself — a sleeping browser tab
would lose it anyway — and the next activity reconnects; reconnects are
attempted only on user activity, with an exponential backoff between
@@ -195,7 +199,11 @@ for misses.
re-visited in a different language becomes a distinct trail step instead
of merging into the existing item. The entry page's referer and
`utm_*` tags come from the GET that loaded it (within 10 s before the
- first message).
+ first message). A server-originated contact marker (the visitor opened
+ the contact panel and solved its proof of work, docs/messages.md —
+ recorded by the `/_ws` handler on the validated solution, never by the
+ client) flags the trail step it happened on, the first one per visit,
+ rendered with a ✉️ marker in the viewer.
- **Crawler hits**: a document GET no activity message matched within
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
documents never register as visits. JS-running crawlers (Googlebot,
@@ -341,8 +349,8 @@ the edit pens) links to `/_a`, the analytics page. It is a normal site page:
the standard banner, navigation and footer stay in place, and the analytics
content is rendered inside `#main`. The page itself is public, but the data
stream comes from `WebSocket /_api/ws/analytics`, which remains admin-gated
-like the rest of the management API; visitors without access see the viewer
-with a "could not be loaded" message.
+like the rest of the management API; without a session the viewer starts the
+paskia login flow instead of loading data.
Because it is a real page, fetch-navigation handles it like any other internal
link: clicking the 📊 pen (or any link to `/_a`) fetches the server-rendered
@@ -355,6 +363,24 @@ spans less than 24 hours, otherwise `week`.
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
page-chrome hiding and `#/analytics/` hash routing have been removed.
+The viewer owns its connection lifecycle (the probe/login mechanics are the
+shared `authReconnect` in `reconnect.js`, also used by the editor sockets). When the socket drops it probes
+`/_api/settings` (plain `fetchJson`, never pops a dialog) to tell an expired
+session from a dead network: a 401/403 runs the paskia login flow instead of
+retrying (the WS handshake cannot open the login dialog) and the stream
+reconnects the moment the login completes; an aborted login (declined, or a
+proxy offering no paskia flow) leaves for the front page with a history
+replace, so Back does not return to `/_a` just to pop the login again.
+Anything else retries with the shared paced backoff (`reconnect.js`).
+paskia's `auth-success`/`auth-logout` window messages are followed too: a
+login anywhere in the page (the banner's 🔑 button included) reconnects the
+stream at once, never waiting out a backoff timer, and a logout leaves for
+the front page. Waking the page (`visibilitychange`, `online`, a bfcache
+restore) recycles the socket immediately — one that survived sleep still
+looks open but is dead — so the fresh snapshot lands right away, and the
+stats window slides on a minute clock so an open page never leaves
+"last 24 hours" frozen at mount.
+
Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian — a
change-point detector splits the series at traffic-level shifts, then each
segment is smoothed independently with a fixed sigma chosen so N events in
@@ -416,8 +442,15 @@ by a top lane labeled 🏠︎ beside the home pill (50% thicker than
the branch lanes, its label font and guide offset scaled along), each item's
subtree fanning out below it in menu order along a large-radius circular
arc that leaves heading
-straight down and gradually bends right, index pages without views omitted
-and their children promoted in their place. The submenu structure is drawn
+straight down and gradually bends right. Every page of the tree is shown
+whether or not it had views in the window — only index pages without
+content of their own (category labels and empty pages — the ones the
+navigation itself bypasses in favor of the first child) are omitted,
+their children
+promoted in their place, the first visible child taking the slot. Paths
+the traffic names but the tree no longer has (deleted pages) are
+inserted as ghosts where they would have been, missing ancestors
+materialized as category lanes. The submenu structure is drawn
as wide branch lanes: one per path prefix with at least two visible
nodes, running behind the branch's node pills as circle arcs concentric
with the fan (parent levels one radius step outward, so all lanes of a
@@ -436,7 +469,10 @@ flow along each edge, persisting across data reloads (emitters are keyed
per edge direction and beads tracked by progress, so an unrelated count
change never reshuffles them), emitted at a rate linearly proportional
to the directional count with no in-flight limit, opposing directions
-offset onto parallel lanes. External sources and exits whose connectors are
+offset onto parallel lanes; each bead renders as an elongated pulse
+(gradient-faded tail) along its direction of travel, colored like its
+connector — accent for internal edges, plain text color for external
+ones. External sources and exits whose connectors are
all culled by the width threshold are dropped from their rows themselves
(the site's own page nodes always stay, connected or not). External sources show as a node row above the
map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
diff --git a/docs/backend.md b/docs/backend.md
index 4804fdf..488374c 100644
--- a/docs/backend.md
+++ b/docs/backend.md
@@ -10,8 +10,8 @@ Thin FastAPI assembly: lifespan (open the kanta database, load the file store, t
- `files.py` — the `FileStore` and image derivative helpers, and the file routes: `/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, the favicon settings endpoints.
- `api.py` — the editor REST API and WebSockets: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, and the translator channel `/_translate/{clientkey}`.
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetching, debounced broadcasts, the `/_ws` activity socket, the admin stream `/_api/ws/analytics`, and the `/_a` viewer page.
-- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), all carrying every published article with full content (relative URLs absolutized), linked from every page's `` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation).
-- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all.
+- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents, linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), the feeds carrying every published article with full content (relative URLs absolutized), linked from every page's `` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation).
+- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all. Every article also has a Markdown counterpart: a `.md` suffix on the page URL (or an Accept header explicitly naming `text/markdown` on the plain URL — the HTML response carries `vary: accept`) serves the page's source Markdown (per-language hybrid assembly via `?lang=`/Accept-Language, page title injected as `# title` when the content has no h1, same ETag/Last-Modified scheme as the HTML). Pages advertise it with ` `.
Route ordering is load-bearing and lives in `app.py`: the api/tracking/files routers are included BEFORE `frontend.route(app, "/")` is called — fastapi-vue inserts its file routes at the position where `route()` was called (during `load()` in the lifespan), so anything registered earlier wins. The content catch-all `/{path:path}` is included AFTER `frontend.route()` so that built frontend assets still take priority over content slugs. The `Frontend` is constructed with `spa=False` explicitly: it only serves the built files without a catch-all.
diff --git a/docs/editing.md b/docs/editing.md
index bc9a2f3..e8361ad 100644
--- a/docs/editing.md
+++ b/docs/editing.md
@@ -18,7 +18,7 @@ In-place page re-rendering shared by the banner/site/structure tabs lives in `sw
The page and structure tabs share one language selector: `LangSelect.vue` (small flag + dropdown) v-modeled on the shell-wide selection in `editorLang.js` (`''` = primary). While the panel is open that selection overrides the page's normal language preferences: EditorShell calls `swapdoc.setLangOverride`, which pins every `loadPlain` fetch (`?lang=`, the primary by its own code) and pagerite.js's own fetches/prefetches (`pagerite:session-lang`), until the panel closes and the override clears.
-All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it).
+All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. The admin sockets (`/_api/...`) also close through `authReconnect`: an expired session fails the WS handshake like a dead network but can never open the login dialog from a socket, so the close handler probes `/_api/settings` first — a 401/403 runs the paskia login flow and reconnects the moment it completes (a declined login holds at a "session expired" note; a login anywhere in the page reconnects the panel, via paskia's `auth-success` window message), and only genuine network/server failures take the paced backoff. While a socket is connecting or waiting to reconnect the panel says so (`ConnNote.vue`), and the CodeMirror editors stay locked until their document arrives (typing before the doc accept would be clobbered by it).
## Saving behavior
diff --git a/docs/frontend-runtime.md b/docs/frontend-runtime.md
index 5c86b46..d0f56aa 100644
--- a/docs/frontend-runtime.md
+++ b/docs/frontend-runtime.md
@@ -14,7 +14,7 @@ It first probes `GET /auth/api/settings` to detect whether Paskia SSO is availab
The editor/analytics components (everything except pagerite.js and main.js) make their `/_api` calls through paskia-js's `apiFetch`/`apiJson` instead of plain `fetch`: when a call gets a 401/403 carrying an `auth.iframe` hint (expired session), the login dialog opens in place and the request retries transparently after authentication. pagerite.js uses `apiJson` only for the task-checkbox toggle (ticking a box is an explicit edit attempt, so a login dialog is welcome there; any failure — including a cancelled login — reverts the checkbox) and `fetchJson` for its auth probes (plain fetch with JSON handling and errors on non-OK, never a dialog); page-cache and navigation fetches stay on plain `fetch`, so anonymous visitors never get a login popup uninvited.
-Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, ` ` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `
diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue
new file mode 100644
index 0000000..223c77f
--- /dev/null
+++ b/frontend/src/ContactOverlay.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue
new file mode 100644
index 0000000..76380e0
--- /dev/null
+++ b/frontend/src/ContactPanel.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue
new file mode 100644
index 0000000..7ea90fa
--- /dev/null
+++ b/frontend/src/MessagesView.vue
@@ -0,0 +1,495 @@
+
+
+
+
+
+
+ no messages yet
+
+
+
+ {{ t.name || t.id.slice(0, 8) }}
+ archived
+ {{ preview(t) }}
+
+ {{ fmtTime(lastMsg(t)?.t || t.created) }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue
index 1a78ec6..83746f6 100644
--- a/frontend/src/PageEditor.vue
+++ b/frontend/src/PageEditor.vue
@@ -38,7 +38,7 @@ import { flagFor, langName, langSort } from './langs'
import { editorLang, pagePrimary } from './editorLang'
import LangSelect from './LangSelect.vue'
import ConnNote from './ConnNote.vue'
-import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
+import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain } from './swapdoc'
const props = defineProps({
@@ -75,13 +75,14 @@ let sessionDoc = null
// 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 conn = ref('connecting') // connecting | open | waiting | login
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…`
+ : conn.value === 'login' ? 'session expired — log in to continue editing…'
: docReady.value ? '' : 'loading the page…',
)
@@ -96,6 +97,9 @@ let connectWatchdog = null
// retries trip the browser's WebSocket throttling (sockets stuck "pending"
// for minutes), which is what kept hollowing out the editor.
const reconnects = reconnectPolicy()
+// Bumped by every connect()/unmount: the auth probe in onclose is async, so
+// a stale tail checks it did not get superseded before acting.
+let connectEpoch = 0
const dirty = ref(false) // unsaved text exists (drives the 💾 button)
let syncingScroll = false
@@ -990,6 +994,7 @@ function consumePendingLine() {
}
function connect() {
+ connectEpoch++
clearTimeout(reconnectTimer)
conn.value = 'connecting'
if (ws) {
@@ -1021,19 +1026,40 @@ function connect() {
openPath(normPath(props.pagePath))
}
}
- ws.onclose = (ev) => {
+ ws.onclose = async (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 gen = connectEpoch
const wait = reconnects.closed()
- retryIn.value = Math.max(1, Math.round(wait / 1000))
- conn.value = 'waiting'
- reconnectTimer = setTimeout(connect, wait)
+ // An expired session fails the handshake like a dead network, but blind
+ // retries loop forever — the WS API cannot open the login dialog. Probe
+ // /_api (./reconnect's authReconnect): a lost session runs the paskia
+ // login flow and reconnects the moment it completes; a declined login
+ // holds at the 'login' note (a later login anywhere in the page, or a
+ // save attempt, reconnects); only transient failures take the backoff.
+ const outcome = await authReconnect(() => { conn.value = 'login' })
+ if (gen !== connectEpoch) return
+ if (outcome === 'auth') {
+ connect()
+ } else if (outcome === 'abort') {
+ conn.value = 'login'
+ } else {
+ retryIn.value = Math.max(1, Math.round(wait / 1000))
+ conn.value = 'waiting'
+ reconnectTimer = setTimeout(connect, wait)
+ }
}
}
+// A login completed anywhere in the page (the banner's 🔑 button, another
+// panel's flow) reconnects a waiting editor at once instead of waiting out
+// the backoff. paskia's auth iframe announces it as a window message.
+function onAuthMessage(ev) {
+ if (ev.data?.type === 'auth-success' && conn.value !== 'open') connect()
+}
+
onMounted(() => {
// The first connection takes a staggered slot (see ./reconnect): page
// load opens several sockets at once, and the burst trips throttling.
@@ -1090,9 +1116,11 @@ onMounted(() => {
addEventListener('pagerite:editor-shown', onEditorShown)
// A section pen clicked while the page editor is already open.
addEventListener('pagerite:edit-section', consumePendingLine)
+ addEventListener('message', onAuthMessage)
})
onUnmounted(() => {
+ connectEpoch++ // invalidate a pending onclose auth probe
clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
if (ws) {
@@ -1106,6 +1134,7 @@ onUnmounted(() => {
removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown)
removeEventListener('pagerite:edit-section', consumePendingLine)
+ removeEventListener('message', onAuthMessage)
})
diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue
index 4d871e8..221aa60 100644
--- a/frontend/src/SiteEditor.vue
+++ b/frontend/src/SiteEditor.vue
@@ -12,6 +12,9 @@ import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
+import { editorLang } from './editorLang'
+import LangSelect from './LangSelect.vue'
+import { flagFor, langName, langSort } from './langs'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -57,6 +60,103 @@ async function errorDetail(res) {
// empty brand removes the header link and the title suffix entirely.
const brand = ref('')
const theme = ref('')
+
+// --- Messaging (contact) ---------------------------------------------------
+// Contact messaging settings: a two-row grid (labels above, the flag
+// selector and the boxes below). The label (the {contact:} buttons'
+// label and the visitor dialog's title) doubles as the on/off switch:
+// empty = disabled. The label and email subject are per-language
+// strings — the LangSelect (the article editor's flag dropdown, on the
+// shared shell-wide editorLang) switches the two fields between the
+// original and a translation override (stored in the chunk-keyed trans
+// store; auto translators fill the same store, so this is the
+// manual-override UI). The email and mail server are global: the
+// email's reveal after the PoW solve is server-side, and the mail
+// server (a mailbridge URL, docs/messages.md; empty = no email) is
+// reserved for email notifications.
+const contactEmail = ref('')
+const contactLabel = ref('')
+const contactSubject = ref('')
+const contactMailserver = ref('')
+const contactLabelOrig = ref('')
+const contactSubjectOrig = ref('')
+// Password managers offer credential autofill on these boxes no matter
+// the autocomplete setting; readonly until the first interaction keeps
+// them off (the vendors' ignore attributes on the inputs back it up).
+const pmGuard = ref(true)
+const pmUnlock = () => { pmGuard.value = false }
+const lang = editorLang // '' = the original strings (the primary language)
+const primaryLang = ref('en')
+const translateLangs = ref([])
+const translating = computed(() => lang.value && lang.value !== primaryLang.value)
+
+// The flag selector's options, built like the article editor's: the
+// primary language first (tag ''), then the configured targets in the
+// geographic grouping.
+const contactLangOptions = computed(() => {
+ const others = langSort(translateLangs.value.filter((l) => l && l !== primaryLang.value))
+ return [primaryLang.value, ...others].map((code) => ({
+ tag: code === primaryLang.value ? '' : code,
+ code,
+ name: langName(code),
+ flag: flagFor(code),
+ primary: code === primaryLang.value,
+ }))
+})
+
+async function onContactLangChange() {
+ if (!translating.value) {
+ contactLabel.value = contactLabelOrig.value
+ contactSubject.value = contactSubjectOrig.value
+ return
+ }
+ try {
+ const s = await apiJson(`/_api/settings?lang=${lang.value}`)
+ contactLabel.value = s.contact_label_tr || ''
+ contactSubject.value = s.contact_subject_tr || ''
+ } catch { /* keep empty */ }
+}
+watch(lang, onContactLangChange)
+
+function onContactInput() {
+ // Capture the language being edited: a debounced save must not land in
+ // whatever language happens to be selected when the timer fires.
+ const tag = lang.value
+ if (!tag || tag === primaryLang.value) {
+ contactLabelOrig.value = contactLabel.value
+ contactSubjectOrig.value = contactSubject.value
+ }
+ debounce('contact', () => saveContact(tag, contactLabel.value, contactSubject.value))
+}
+
+async function saveContact(tag = lang.value, label = '', subject = '') {
+ if (tag && tag !== primaryLang.value) {
+ // Translation override: only the label/subject apply (empty = drop).
+ const res = await apiFetch(`/_api/settings?lang=${tag}`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ brand: brand.value,
+ theme: theme.value,
+ custom_css: customCss.value,
+ contact_label: label,
+ contact_subject: subject,
+ }),
+ })
+ saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
+ if (res.ok) rerender()
+ return
+ }
+ if (await saveSettings()) rerender()
+}
+
+// The rendered {contact:} buttons are server HTML: after a save the page
+// re-renders (like a theme change), updating buttons and dialog strings
+// without a reload.
+async function rerender() {
+ dropPageCache()
+ loadPlain(path.value)
+}
// Theme options come from the backend (theme folders on disk, see GET
// /_api/settings), so added themes need no frontend changes.
const themeOptions = ref([{ value: '', label: '☀️ none' }])
@@ -88,6 +188,16 @@ async function loadSettings() {
]
transition.value = s.transition || 'cube'
transitionOptions.value = s.transitions || []
+ contactEmail.value = s.contact_email || ''
+ contactMailserver.value = s.contact_mailserver || ''
+ contactLabelOrig.value = s.contact_label || ''
+ contactSubjectOrig.value = s.contact_subject || ''
+ if (!translating.value) {
+ contactLabel.value = contactLabelOrig.value
+ contactSubject.value = contactSubjectOrig.value
+ }
+ primaryLang.value = s.primary_lang || 'en'
+ translateLangs.value = s.translate_langs || []
fontOptions.value = [
...BASE_FONT_OPTIONS,
...(s.fonts || []).map((f) => ({
@@ -255,6 +365,12 @@ async function saveSettings(opts = {}) {
custom_css: customCss.value,
brand_html: brandHtml.value,
transition: transition.value,
+ contact_email: contactEmail.value,
+ contact_mailserver: contactMailserver.value,
+ // The originals: while a translation override is being edited the
+ // input refs hold the override, not the original.
+ contact_label: contactLabelOrig.value,
+ contact_subject: contactSubjectOrig.value,
...opts,
}),
})
@@ -264,6 +380,7 @@ async function saveSettings(opts = {}) {
} else {
saveError.value = '⚠️ changes could not be saved'
}
+ return res.ok
}
async function onThemeChange() {
@@ -528,6 +645,9 @@ onMounted(async () => {
parent: brandEl.value,
})
await loadSettings()
+ // The shared editorLang may already point at a translation (picked in
+ // another tab): load its label/subject overrides.
+ await onContactLangChange()
parseFonts(customCss.value)
setCssDocument(customCss.value)
applyCustomCss(customCss.value)
@@ -655,6 +775,88 @@ onUnmounted(() => {
+
+
brand code (replaces the brand link)
@@ -785,6 +987,48 @@ onUnmounted(() => {
font-size: 1rem;
}
+/* The messaging block: a two-row grid — labels on the first row, the
+ article editor's flag selector and the boxes on the second, so the
+ flag centers with the boxes and every label sits directly above its
+ own field (contact button label = the enable switch, email subject,
+ our email; the hidden mail-server field joins the email group once the
+ backend lands). The flag column sizes
+ to the flag; when only one language is configured the selector renders
+ nothing and the column collapses.
+ The boxes are readonly until first interaction (+ vendor ignore
+ attributes): password managers offer credential autofill on them no
+ matter the autocomplete setting. */
+.msg-grid {
+ display: grid;
+ /* The hidden mail-server field (template comment) re-adds a column. */
+ grid-template-columns: auto repeat(3, minmax(0, 1fr));
+ align-items: center;
+ gap: 0.15rem 0.6rem;
+}
+
+/* Field wrappers vanish into the grid: labels land on row 1 (the
+ flag's empty spacer keeps the fields in the 1fr columns whether or
+ not the flag renders), the flag and boxes on row 2. */
+.msg-grid .field,
+.msg-grid .msg-flag {
+ display: contents;
+}
+
+.msg-grid .field-label {
+ grid-row: 1;
+ min-width: 0;
+}
+
+.msg-grid .lang-select,
+.msg-grid .text-input {
+ grid-row: 2;
+}
+
+.msg-grid .text-input {
+ flex: none;
+ font-size: 0.85rem;
+}
+
.text-input {
flex: 1;
min-width: 4rem;
diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue
index 4d47e82..882f51f 100644
--- a/frontend/src/StructureTree.vue
+++ b/frontend/src/StructureTree.vue
@@ -1,7 +1,8 @@
@@ -39,6 +42,7 @@ const title = computed(() => {
{{ formatCount(count) }}×
{{ step.slug }}
+
✉️
@@ -51,6 +55,12 @@ const title = computed(() => {
vertical-align: -0.1em;
}
+/* The contact-panel marker (PoW solved on this step). */
+.contact-mark {
+ margin-left: 0.25em;
+ font-size: 0.85em;
+}
+
/* Same flag chip as the visitor cells (VisitorCell.vue). */
.flag {
display: inline-flex;
diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue
index fea6913..f13c4af 100644
--- a/frontend/src/TransitionGraph.vue
+++ b/frontend/src/TransitionGraph.vue
@@ -13,6 +13,7 @@ import {
TNODE_W,
TNODE_H,
BEAD_R,
+ BEAD_LEN,
buildTransitionGraph,
} from './analytics/transitions.js'
@@ -121,9 +122,16 @@ const syncBeads = (flows) => {
}
const tick = (t) => {
- const dt = lastTick ? (t - lastTick) / 1000 : 0
+ // Long gaps (sleep, hidden tab) must not spawn a bead per missed interval
+ // just to drop them all in the same tick: clamp the step (every in-flight
+ // bead simply completes) and fast-forward each emitter's schedule to its
+ // next future slot, keeping the phase.
+ const dt = lastTick ? Math.min((t - lastTick) / 1000, TRAVERSAL_S) : 0
lastTick = t
for (const e of emitters.values()) {
+ if (e.next < t - e.interval) {
+ e.next = t + (((e.next - t) % e.interval) + e.interval) % e.interval
+ }
while (e.next <= t) {
live.push({ e, p: 0 })
e.next += e.interval
@@ -138,7 +146,17 @@ const tick = (t) => {
continue
}
const f = b.e.flow
- out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p })
+ // The pulse is elongated along the direction of travel (constant per
+ // flow) and colored like its connector (external edges use --text).
+ if (f.deg === undefined) {
+ f.deg = (Math.atan2(f.y2 - f.y1, f.x2 - f.x1) * 180) / Math.PI
+ }
+ out.push({
+ x: f.x1 + (f.x2 - f.x1) * b.p,
+ y: f.y1 + (f.y2 - f.y1) * b.p,
+ deg: f.deg,
+ ext: f.external,
+ })
}
beads.value = out
rafId = requestAnimationFrame(tick)
@@ -177,6 +195,21 @@ const countLabel = (n) =>
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -187,8 +220,11 @@ const countLabel = (n) =>
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
{{ e.title }}
-
+
fill: var(--text);
}
.tmap .tbead {
- fill: var(--accent);
- opacity: 0.85;
filter: drop-shadow(0 0 2.5px var(--accent));
}
+.tmap .tbead-exit {
+ filter: drop-shadow(0 0 2.5px var(--text));
+}
+.tmap .tbead-stop { stop-color: var(--accent); }
+.tmap .tbead-stop-exit { stop-color: var(--text); }
.tmap .txnode {
/* External source/exit pills: plain white on every theme, with a hairline
so the pill stays visible on a white page. */
diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js
index 09020e2..61ee60c 100644
--- a/frontend/src/analytics/format.js
+++ b/frontend/src/analytics/format.js
@@ -656,6 +656,8 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
if (item.read) step.readSeconds = item.read
if (item.status) step.status = item.status
if (item.lang) step.lang = item.lang
+ // Contact panel opened here and its PoW solved (✉️ marker).
+ if (item.contact) step.contact = true
}
return step
})
@@ -667,6 +669,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
if (step.status) prev.status = step.status
+ if (step.contact) prev.contact = true
} else {
step.langs = step.lang ? [step.lang] : []
trail.push(step)
diff --git a/frontend/src/analytics/time.js b/frontend/src/analytics/time.js
index eb711e6..68d6465 100644
--- a/frontend/src/analytics/time.js
+++ b/frontend/src/analytics/time.js
@@ -190,11 +190,11 @@ export function makeSeries(buckets, rangeKey) {
* Every bounded range is a rolling span ending at now; the charts instead
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
* to UTC day boundaries, so their x windows differ from the stats range
- * on purpose.
+ * on purpose. `now` is passed in by reactive callers so the window slides
+ * as time passes instead of freezing at mount.
* Returns { t0, t1 } where null means unbounded.
*/
-export function rangeWindow(rangeKey) {
- const now = Date.now()
+export function rangeWindow(rangeKey, now = Date.now()) {
if (rangeKey === 'all') {
return { t0: null, t1: null }
}
diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js
index 7862996..ee0033b 100644
--- a/frontend/src/analytics/transitions.js
+++ b/frontend/src/analytics/transitions.js
@@ -3,11 +3,16 @@
*
* Site map following the menu structure: top-level items in a row at the
* top (below the external source row), each item's subtree fanning out
- * below it in menu order along a slightly circular downward arc. Index
- * pages with no views are omitted, their children moving up in their
- * place. All pages of the site are shown (from /_api/pages), plus any
- * extra paths seen in transitions (deleted pages); these form their own
- * top-level groups. Internal path -> path transitions join opposite
+ * below it in menu order along a slightly circular downward arc. Every
+ * page of the site is shown (from /_api/pages), whether or not it had
+ * views in the window — only index pages without content of their own
+ * (category labels and empty pages — the ones the site navigation itself
+ * bypasses in favor of the first child) are omitted, their children
+ * moving up in their place, the first visible child taking the slot.
+ * Paths the traffic
+ * names but the tree no longer has (deleted pages) are inserted as
+ * ghosts where they would have been, materializing missing ancestors as
+ * content-less category lanes. Internal path -> path transitions join opposite
* directions into straight connections (middle width = total
* count; connectors flare into the node pills at both ends and wrap
* around their backs, surrounding them; the pills are drawn on top). Connection width grows
@@ -144,8 +149,10 @@ const MIN_WMID = 0.4
// from real time to keep the animation lightweight. The component
// simulates every bead independently in JS with a constant traversal
// time per edge (speed relative to span length), with no limit on beads
-// in flight.
+// in flight. A bead renders as an elongated pulse trailing its direction
+// of travel: BEAD_R is its half-thickness, BEAD_LEN its full length.
export const BEAD_R = 3.2
+export const BEAD_LEN = BEAD_R * 5.5
const BEAD_RATE = 0.0084 // beads per second per hit/day
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
@@ -153,33 +160,6 @@ const MAX_EXT_IN = 8 // referer nodes in the top row
const MAX_EXT_OUT = 12 // exit nodes in the bottom row
const EXT_GAP = 12 // vertical margin of the source/exit rows to the map
-/** Flatten the site tree into navigation order via DFS. */
-function buildNavigationOrder(pageTree) {
- const order = new Map()
- const walk = (items) => {
- for (const item of items || []) {
- const p = `/${item.path}`
- if (!order.has(p)) order.set(p, order.size)
- walk(item.children)
- }
- }
- walk(pageTree)
- return order
-}
-
-/** Map page paths to their article titles from the site tree. */
-function buildTitleMap(pageTree) {
- const titles = new Map()
- const walk = (items) => {
- for (const item of items || []) {
- titles.set(`/${item.path}`, item.title)
- walk(item.children)
- }
- }
- walk(pageTree)
- return titles
-}
-
/** Extract internal page-to-page transitions, excluding self-loops. */
function collectInternalTransitions(transitions) {
const internal = []
@@ -222,43 +202,70 @@ function collectExitPairs(transitions) {
return [...pairs.values()]
}
-/** Build nodes with depth and a path lookup map; children are wired to parents. */
-function buildNodeTree(internal, navOrder) {
- const paths = new Set(['/', ...navOrder.keys()])
- for (const e of internal) { paths.add(e.fr); paths.add(e.to) }
+/**
+ * Build the node tree: every page of the current site tree (from
+ * /_api/pages), nested by URL and ordered by the menu, plus a ghost for
+ * every path the traffic names but the tree no longer has (deleted or
+ * renamed pages). A ghost is inserted where it would have been: under
+ * its URL parent, with missing ancestors materialized as content-less
+ * ghost categories — never drawn themselves, their children move up in
+ * their place and they still define branch lanes, exactly like live
+ * index pages without content (see annotateNodes/layoutGroups).
+ * Ghost pills sort after the real siblings, alphabetically among
+ * themselves (their original menu position is unknowable).
+ */
+function buildNodeTree(pageTree, extraPaths) {
+ // The front page anchors the map and is always shown, even with no
+ // views in the window (and even when the site has no front page node).
+ const root = { path: '/', depth: 0, title: '', content: true, ghost: false, children: [] }
+ const byPath = new Map([['/', root]])
+ const nodes = [root]
- const depth = (p) => (p === '/' ? 0 : p.split('/').length - 1)
- const nodes = [...paths].map((p) => ({
- path: p, depth: depth(p), angle: 0, children: [],
- }))
- const byPath = new Map(nodes.map((n) => [n.path, n]))
-
- // Parent is the nearest ancestor present in the map, front page last.
- const parentOf = (p) => {
- let q = p
- while (q !== '/') {
- q = q.slice(0, q.lastIndexOf('/')) || '/'
- if (byPath.has(q)) return byPath.get(q)
+ const walk = (items, parent) => {
+ for (const item of items || []) {
+ const p = `/${item.path}`
+ let n = byPath.get(p) // the front page item (path "") maps to "/"
+ if (!n) {
+ n = {
+ path: p, depth: parent.depth + 1, title: '',
+ content: !item.empty, ghost: false, children: [],
+ }
+ byPath.set(p, n)
+ parent.children.push(n)
+ nodes.push(n)
+ }
+ n.title = item.title
+ walk(item.children, n)
}
- return byPath.get('/')
}
- for (const n of nodes) {
- if (n.path !== '/') parentOf(n.path).children.push(n)
+ walk(pageTree, root)
+
+ // Ghosts: placed under their URL parent, materializing missing
+ // ancestors as content-less ghost categories (not drawn; their
+ // children move up and they still define branch lanes).
+ const materialize = (p) => {
+ let n = byPath.get(p)
+ if (n) return n
+ const parent = materialize(p.slice(0, p.lastIndexOf('/')) || '/')
+ n = { path: p, depth: parent.depth + 1, title: '', content: false, ghost: true, children: [] }
+ byPath.set(p, n)
+ parent.children.push(n)
+ nodes.push(n)
+ return n
+ }
+ for (const p of extraPaths) {
+ const n = materialize(p)
+ if (n.ghost) n.content = true // traffic seen: show the pill
}
- return { nodes, byPath, root: byPath.get('/') }
-}
-
-/** Sort each node's children by navigation order, recursively. */
-function sortByNav(root, navOrder) {
- const byNav = (a, b) =>
- (navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
- || a.path.localeCompare(b.path)
- const walk = (n) => {
- n.children.sort(byNav)
- n.children.forEach(walk)
+ // Real siblings keep menu order; ghosts follow them, alphabetical.
+ const sortChildren = (n) => {
+ n.children.sort((a, b) => (a.ghost - b.ghost) || (a.ghost ? a.path.localeCompare(b.path) : 0))
+ n.children.forEach(sortChildren)
}
- walk(root)
+ sortChildren(root)
+
+ return { nodes, byPath, root }
}
/** Compute median reading time per article in seconds. */
@@ -283,7 +290,7 @@ function buildReadSeconds(visits) {
}
/** Compute view counts, labels and hidden flags for each node. */
-function annotateNodes(nodes, viewsData, titles, readSeconds) {
+function annotateNodes(nodes, viewsData, readSeconds) {
const viewCount = (p) => {
let n = 0
for (const c of Object.values(viewsData?.[p] || {})) n += c
@@ -294,12 +301,17 @@ function annotateNodes(nodes, viewsData, titles, readSeconds) {
n.views = viewCount(n.path)
n.readSec = readSeconds[n.path] || 0
// Article title inside the pill (clipped at the pill border on
- // render), slug as fallback for pages missing from the site tree.
- n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
- n.title = titles.get(n.path) || ''
- // Category (non-leaf) pages with no views in this window are omitted:
- // their children move up in their place (see layoutGroups).
- n.hidden = n.children.length > 0 && n.views === 0
+ // render), slug as fallback for ghost pages missing from the tree.
+ n.label = n.title || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
+ // Index pages without content of their own — category labels (404
+ // placeholders), empty pages and materialized ghost categories — are
+ // the nodes the site navigation itself bypasses in favor of the first
+ // child page, so they are omitted from the map too, their children
+ // moving up in their place (see layoutGroups). A childless empty page
+ // is a real navigation stop and stays, and every real page stays
+ // whether or not it had views in the window: the map always shows
+ // the full site structure.
+ n.hidden = !n.content && n.children.length > 0
}
}
@@ -628,10 +640,12 @@ function buildRibbon(a, b, ab, ba, wMid, external = false) {
* at the surround), plus the emission interval (seconds between beads,
* inverse of the daily hit rate * BEAD_RATE). Each segment is offset to the
* right-hand side of its travel direction, so opposing flows on the same
- * edge run on parallel lanes instead of colliding. The component turns
- * these into independently simulated beads.
+ * edge run on parallel lanes instead of colliding. `external` tags flows on
+ * external-link edges so the component can render their beads in the
+ * connector color (the exit/source connectors use --text, not --accent).
+ * The component turns these into independently simulated beads.
*/
-function buildFlows(a, b, ab, ba, dayScale = 1) {
+function buildFlows(a, b, ab, ba, dayScale = 1, external = false) {
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
const ux = (b.x - a.x) / len
const uy = (b.y - a.y) / len
@@ -655,6 +669,7 @@ function buildFlows(a, b, ab, ba, dayScale = 1) {
y2: a.y + toT * uy + s * ry,
len: span,
interval: 1 / (count * BEAD_RATE * dayScale),
+ external,
}
}
const flows = []
@@ -884,7 +899,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
const wMid = width(p.in)
if (wMid < MIN_WMID) continue
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
- flows.push(...buildFlows(xn, page, p.in, 0, dayScale))
+ flows.push(...buildFlows(xn, page, p.in, 0, dayScale, true))
}
})
}
@@ -934,7 +949,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
const wMid = width(p.out)
if (wMid < MIN_WMID) continue
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
- flows.push(...buildFlows(page, xn, p.out, 0, dayScale))
+ flows.push(...buildFlows(page, xn, p.out, 0, dayScale, true))
}
})
}
@@ -953,15 +968,19 @@ export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1)
const internal = collectInternalTransitions(data?.transitions)
const sources = collectSourcePairs(visits)
const exits = collectExitPairs(data?.transitions)
- const navOrder = buildNavigationOrder(pageTree)
- const titles = buildTitleMap(pageTree)
const readSeconds = buildReadSeconds(visits)
- if (!internal.length && !navOrder.size) return null
+ // Every internal path the traffic names, for ghost placement:
+ // transition endpoints, plus pages with views but no internal
+ // transition (entries from outside — a deleted page still visited).
+ const extraPaths = new Set()
+ for (const e of internal) { extraPaths.add(e.fr); extraPaths.add(e.to) }
+ for (const p of Object.keys(data?.views || {})) extraPaths.add(p)
- const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
- sortByNav(root, navOrder)
- annotateNodes(nodes, data?.views, titles, readSeconds)
+ if (!pageTree?.length && !extraPaths.size) return null
+
+ const { nodes, byPath, root } = buildNodeTree(pageTree, extraPaths)
+ annotateNodes(nodes, data?.views, readSeconds)
const { arcs, arcLeft } = layoutGroups(root)
const placed = nodes.filter((n) => !n.hidden)
const pairs = aggregatePairs(internal)
diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css
index ab095bc..6416d79 100644
--- a/frontend/src/assets/pagerite.css
+++ b/frontend/src/assets/pagerite.css
@@ -72,6 +72,10 @@
--font-heading: var(--font-source-serif);
/* The brand follows the heading font unless overridden separately. */
--font-brand: var(--font-heading);
+ /* Navbar typography (family and weight only — size/letter-case stay
+ per-theme #nav rules); the {contact:} trigger button matches it. */
+ --font-nav: var(--font-body);
+ --nav-weight: 400;
--font-code: var(--font-fira-code);
/* Target x-height ratio for code text: set to the body font's ratio
(here Source Sans 3's 0.478) so font-size-adjust can scale the code
@@ -258,6 +262,8 @@ body {
/* Nav overlaid at the bottom of the banner */
#nav {
+ font-family: var(--font-nav);
+ font-weight: var(--nav-weight);
font-size: 1.3em;
padding: 0.35rem 1.25rem;
}
@@ -407,6 +413,22 @@ body.editing #sidebar {
text-decoration: none;
}
+/* The corner ✉️ (docs/messages.md) is in the pens row for everyone, so
+ needs its own positioning context. Unread (a new reply for visitors,
+ unanswered threads for admins) lifts it out of the icon-btn dimming
+ and animates it (see contact-nudge by the trigger button).
+ ✉️'s envelope glyph runs smaller than its emoji neighbors (📊, 🔐, the
+ flag) at the same font size; bump it to match their height (scoped to
+ .editor-pens to out-specify the row's `font: inherit` reset). */
+.editor-pens .contact-icon {
+ position: relative;
+ font-size: 1.38em;
+}
+
+.contact-icon.has-unread {
+ opacity: 1;
+}
+
#sidebar {
grid-column: 1;
/* Pinned to the page's start edge (not the article's) and kept in view
@@ -878,6 +900,76 @@ article {
opacity: 1;
}
+/* {contact: Label} directive: the button opening the visitor contact
+ panel (docs/messages.md). A standalone accent button in the article
+ flow; .has-unread (set by contact-main) marks a new admin reply.
+ Filled with the accent and hovered like the accent-filled navbar tab:
+ text in --ink (nitro; --bg elsewhere), hover switches to the bright
+ --tab-hover with the same glow (text-shadow, which fades dimmer, so
+ the color stays bright). */
+.contact-trigger {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ margin: 0.3em 0.1em;
+ padding: 0.35em 1.1em;
+ /* Same font and weight as the navbar, size/leading as the article text. */
+ font-size: inherit;
+ line-height: inherit;
+ font-family: var(--font-nav);
+ font-weight: var(--nav-weight);
+ color: var(--ink, var(--bg));
+ background: var(--accent);
+ border: 1px solid var(--accent);
+ border-radius: 1.2em;
+ cursor: pointer;
+}
+
+.contact-trigger .icon {
+ /* Match the corner ✉️'s optical size against the button text. The
+ reduced line-height keeps the larger glyph from inflating the
+ button: 1.38 × 1.2 ≈ the label text's own 1.65 line box. */
+ font-size: 1.38em;
+ line-height: 1.2;
+}
+
+.contact-trigger:hover {
+ color: var(--tab-hover, var(--bg));
+ text-shadow: 0 0 0.1em;
+}
+
+/* Unread (a new admin reply, or unanswered threads on the corner icon —
+ the .has-unread class, set by contact-main): a scale-up with a shake
+ every few seconds to draw the eye, stopping when read (the class goes
+ away). No static badge; reduced-motion users just get the lift above. */
+@media (prefers-reduced-motion: no-preference) {
+ .contact-trigger.has-unread,
+ .contact-icon.has-unread {
+ animation: contact-nudge 4.5s ease-in-out infinite;
+ }
+
+ @keyframes contact-nudge {
+ 0%, 16%, 100% { transform: none; }
+ 2% { transform: scale(1.15) rotate(-8deg); }
+ 5% { transform: scale(1.15) rotate(7deg); }
+ 8% { transform: scale(1.15) rotate(-5deg); }
+ 11% { transform: scale(1.15) rotate(4deg); }
+ 14% { transform: scale(1.12); }
+ }
+}
+
+/* Any button in the article flow (the {contact:} trigger and raw
+ s in the markup — not the .icon-btn editor pens or .copy):
+ a slight lift on hover. */
+article button:not(.icon-btn):not(.copy) {
+ transition: transform 0.15s;
+}
+
+article button:not(.icon-btn):not(.copy):hover {
+ transform: scale(1.01);
+}
+
.edit-link {
position: absolute;
top: 0.2rem;
diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js
new file mode 100644
index 0000000..508eb86
--- /dev/null
+++ b/frontend/src/contact-main.js
@@ -0,0 +1,409 @@
+// Messaging overlay entry: imported on demand by pagerite.js when a
+// {contact:} trigger button or the corner ✉️ icon is clicked — and right
+// at page load when there is a live message session (a visitor's open
+// conversation, flagged in localStorage, resumes checking for replies
+// without waiting for the panel; for admins the always-on inbox watch
+// keeps the badge and notifications live while messaging is enabled).
+// The icon opens the admin inbox for admins, the visitor thread for
+// visitors; the trigger buttons always open the visitor thread (so
+// admins can test both sides). The visitor thread rides the activity
+// socket through the bridge pagerite.js hands over ({send, register}) —
+// no extra connection; the admin inbox connects its own admin-gated
+// socket (MessagesView while open, the background watch otherwise). The
+// session token is a localStorage random; the server stores only its
+// hash. Closing a conversation archives the thread server-side, wipes
+// the local session keys and rotates the token, so the next message
+// starts an all-new thread.
+import { createApp, reactive, watch } from 'vue'
+import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
+import ContactOverlay from './ContactOverlay.vue'
+import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
+import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect.js'
+
+const TOKEN_KEY = 'pagerite-contact-token'
+const SEEN_KEY = 'pagerite-contact-seen'
+// The newest admin reply seen, so the corner icon's unread dot survives
+// page loads without any socket activity (pagerite.js reads it too).
+const LASTADMIN_KEY = 'pagerite-contact-lastadmin'
+// An open conversation: set while the visitor has a live thread, so page
+// loads resume checking for replies immediately (cleared on close).
+const SESSION_KEY = 'pagerite-contact-session'
+
+let app = null
+const state = reactive({
+ open: false,
+ mode: 'visitor', // 'visitor' | 'admin'
+ thread: null,
+ unread: false, // visitor: an unread admin reply
+ adminUnread: 0, // admin: unanswered threads
+ viewerAdmin: false, // set once the inbox has been opened: icon badge = adminUnread
+ // The session token (a localStorage random; the server stores only its
+ // hash). Reactive in state because closing a conversation rotates it
+ // and the composer's uploads send it as a header.
+ token: '',
+ // Localized site contact strings (pagerite:contact-* asset config,
+ // refreshed on every open — they change with the page language):
+ // label doubles as the visitor dialog's title; email gets a mailto
+ // link with subject prefilled.
+ label: 'Contact us',
+ email: '',
+ mailtoTo: '', // RFC-encoded "Brand " recipient for the mailto href
+ subject: '',
+})
+
+// The localized contact strings ride the page's asset config (the
+// production JSON block, or pagerite:* meta tags in dev).
+function readContactStrings() {
+ let assets = {}
+ const el = document.getElementById('pagerite-assets')
+ if (el) {
+ try {
+ assets = JSON.parse(el.textContent)
+ } catch { /* keep empty */ }
+ } else {
+ for (const m of document.querySelectorAll('meta[name^="pagerite:contact-"]')) {
+ assets[m.name] = m.content
+ }
+ }
+ state.label = assets['pagerite:contact-label'] || 'Contact us'
+ state.subject = assets['pagerite:contact-subject'] || state.label
+ // state.email is NOT reset here: it arrives over the socket after the
+ // PoW is solved and stays valid for the session.
+}
+
+// Page swaps (fetch navigation, editor re-renders after settings saves)
+// carry fresh per-language config: keep the dialog strings in sync
+// without reopening.
+addEventListener('pagerite:page-fetched', readContactStrings)
+
+// The panel is main content, not a sidebar: while open it holds paskia's
+// shared blur backdrop (refcounted, z-index 1099; the overlay itself sits
+// at 1100, below the auth iframe's 9999).
+watch(() => state.open, (open) => {
+ if (open) holdGlobalBackdrop()
+ else releaseGlobalBackdrop()
+})
+
+watch(() => state.adminUnread, () => refreshBadges())
+
+function ensureToken() {
+ let token = localStorage.getItem(TOKEN_KEY)
+ if (!token) {
+ token = crypto.randomUUID()
+ localStorage.setItem(TOKEN_KEY, token)
+ }
+ return token
+}
+
+function seenAt() {
+ return Number(localStorage.getItem(SEEN_KEY) || 0)
+}
+
+function markSeen() {
+ localStorage.setItem(SEEN_KEY, String(Date.now()))
+ state.unread = false
+ refreshBadges()
+}
+
+// PoW (easypow, docs/messages.md): the server issues a fresh single-use
+// challenge on registration/resync and whenever the quota runs out. The
+// solver runs in the background and the solution is submitted as soon as
+// it is ready; a validated proof grants a quota of messages and uploads
+// and reveals the site's contact email (never carried in the page's
+// asset config). The solver module is served by the backend from the
+// easypow package, so both sides always match.
+const SOLVER_URL = '/_contact/easypow.js' // backend-served; kept in a variable so Rollup leaves the import dynamic
+let bridge = null
+let solverPromise = null
+let powSolution = null // Promise solving the current challenge
+let powAborter = null
+let unsent = null // last message frame, retried once when its send races the quota
+let quotaWaiters = [] // uploads waiting for a re-granted quota
+
+// The solved solution goes out immediately, ahead of any send.
+async function submitProof() {
+ const p = powSolution
+ if (!p) return
+ try {
+ const pow = await p
+ if (pow && p === powSolution) bridge.send({ token: state.token, pow })
+ } catch { /* aborted: a newer solve is in flight */ }
+}
+
+function sendMessage(md, name = '', email = '') {
+ // The first message of a fresh thread doubles as the
+ // notification-permission gesture (denied is never re-asked; a
+ // dismissed prompt comes up again on the next fresh thread).
+ if (!localStorage.getItem(SESSION_KEY)) askNotifyPermission()
+ unsent = { md, name, email, retried: false }
+ bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
+}
+
+function onPow(msg) {
+ if (typeof msg.quota === 'number') {
+ // Proof accepted, quota granted: queued uploads and the racing send
+ // (rejected for a stale/empty quota) continue.
+ const waiters = quotaWaiters
+ quotaWaiters = []
+ for (const resolve of waiters) resolve(true)
+ if (unsent && !unsent.retried) {
+ const { md, name, email } = unsent
+ unsent.retried = true
+ bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
+ }
+ return
+ }
+ powAborter?.abort() // superseded challenge: stop the stale solve
+ const ac = (powAborter = new AbortController())
+ solverPromise ??= import(/* @vite-ignore */ SOLVER_URL)
+ solverPromise.then(({ solve }) => {
+ powSolution = solve(msg.challenge, ac.signal)
+ powSolution.catch(() => {}) // aborts are expected
+ submitProof()
+ })
+}
+
+function onEmail(msg) {
+ state.email = msg.email || ''
+ state.mailtoTo = msg.to || '' // RFC-encoded "Brand " recipient
+}
+
+// An upload hit the end of the PoW quota: re-register (the server pushes
+// a fresh challenge, solved and proven in the background) and resolve
+// once the new quota lands.
+function requestQuota() {
+ return new Promise((resolve) => {
+ quotaWaiters.push(resolve)
+ bridge.send({ token: state.token }) // re-registration: a fresh challenge follows
+ })
+}
+
+// Unread markers: the has-unread class (a periodic nudge animation, see
+// pagerite.css) on every contact trigger button and on the corner ✉️ icon
+// — a new admin reply for visitors, any unanswered thread for admins.
+// Called on thread pushes and (from pagerite.js) after navigations, whose
+// swap rebuilds the buttons.
+export function refreshBadges() {
+ for (const btn of document.querySelectorAll('.contact-trigger')) {
+ btn.classList.toggle('has-unread', state.unread)
+ }
+ const icon = document.getElementById('contact-icon')
+ if (!icon) return
+ icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread)
+}
+
+let notifyBaseline = '' // the first push after load is history, only baselines
+
+function onThread(thread) {
+ state.thread = thread
+ unsent = null // the send landed (a thread push acknowledges it)
+ // A thread with messages is a live session: page loads resume checking
+ // it right away (cleared when the conversation is closed).
+ if (thread.messages.length) localStorage.setItem(SESSION_KEY, '1')
+ const lastAdmin = [...thread.messages].reverse().find((m) => m.admin)
+ if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t)
+ const viewing = state.open && state.mode === 'visitor'
+ state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !viewing
+ // ISO timestamps compare lexicographically.
+ if (lastAdmin && lastAdmin.t > notifyBaseline) {
+ const fresh = Boolean(notifyBaseline)
+ notifyBaseline = lastAdmin.t
+ if (fresh && !viewing) {
+ notifyMessage(state.label, messageText(lastAdmin.html), () => open(bridge))
+ }
+ }
+ if (viewing) markSeen()
+ else refreshBadges()
+}
+
+// The visitor ended the conversation: archive it server-side (the admin
+// inbox keeps it), wipe the local session and rotate the token, so the
+// next message starts an all-new thread. The panel stays open on the
+// fresh empty state.
+function closeConversation() {
+ if (!confirm('Close this conversation? A fresh thread starts with your next message.')) return
+ bridge.send({ token: state.token, close: true })
+ for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) {
+ localStorage.removeItem(key)
+ }
+ state.token = ensureToken() // TOKEN_KEY is gone: a fresh random
+ state.thread = null
+ state.unread = false
+ notifyBaseline = ''
+ unsent = null
+ bridge.register(state.token, onThread, onPow, onEmail)
+ refreshBadges()
+}
+
+let registered = false
+
+// Registration doubles as the resync: the server pushes the thread state
+// back, and pagerite.js re-registers on every socket reconnect. Done once
+// per page (also without opening the panel — resume()), in either mode —
+// the visitor thread is also the admin's own (the trigger buttons open it
+// for them).
+function ensureRegistered(br) {
+ bridge = br
+ if (registered) return
+ registered = true
+ state.token = ensureToken()
+ bridge.register(state.token, onThread, onPow, onEmail)
+}
+
+// A live session found at page load: check for replies immediately,
+// without opening the panel (pagerite.js calls this when the session flag
+// is set; with a granted notification permission new replies notify right
+// away).
+export function resume(br) {
+ ensureRegistered(br)
+}
+
+export function open(br, { admin = false } = {}) {
+ ensureRegistered(br)
+ if (admin) state.viewerAdmin = true
+ if (!app) {
+ app = createApp(ContactOverlay, {
+ state,
+ reauthorize: requestQuota,
+ onSend: ({ md, name, email }) => sendMessage(md, name, email),
+ onClose: () => {
+ state.open = false
+ refreshBadges()
+ },
+ onCloseThread: closeConversation,
+ })
+ const el = document.createElement('div')
+ el.id = 'contact-root'
+ document.body.append(el)
+ app.mount(el)
+ }
+ state.mode = admin ? 'admin' : 'visitor'
+ readContactStrings()
+ state.open = true
+ if (!admin) markSeen()
+ else refreshBadges()
+}
+
+
+// --- Admin watch -------------------------------------------------------------
+// For editors, while messaging is enabled on the site: a background
+// /_api/ws/analytics connection keeps the corner icon's badge live and
+// notifies new visitor messages right away — the admin always checks,
+// without waiting for the inbox to open (pagerite.js starts the watch at
+// page load). While the inbox is open MessagesView owns its own socket and
+// the notifications; the watch then only tracks the badge. Reconnect
+// pacing and the auth-aware close handling are the shared reconnect.js
+// policy, same as MessagesView's.
+let watchWs = null
+let watchStarted = false
+let watchTimer = null
+let watchEpoch = 0
+let watchHiddenAt = 0
+const watchReconnects = reconnectPolicy()
+const watchThreads = new Map() // id -> thread payload (badge + notify baseline)
+const WATCH_WAKE_MS = 30_000
+
+function watchBadge() {
+ state.adminUnread = [...watchThreads.values()].filter(
+ (t) => t.unread && !t.closed
+ ).length
+}
+
+function watchThread(t) {
+ const last = t.messages[t.messages.length - 1]
+ const prev = watchThreads.get(t.id)?.messages?.at(-1)?.id
+ watchThreads.set(t.id, t)
+ watchBadge()
+ // No notification for history (the first 'threads' push baselines), the
+ // admin's own replies, non-message updates (prev === last.id — or an
+ // unread:false push, e.g. a read marking for a thread beyond the
+ // baseline cap), closed threads — or while the inbox is open
+ // (MessagesView notifies then).
+ if (!last || last.admin || !t.unread || prev === last.id || t.closed) return
+ if (state.open && state.mode === 'admin') return
+ notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () =>
+ open(bridge, { admin: true })
+ )
+}
+
+function watchConnect() {
+ if (watchWs) return
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
+ const ws = (watchWs = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`))
+ watchConnecting(ws, 'contact-watch')
+ ws.onopen = () => watchReconnects.opened()
+ ws.onmessage = (ev) => {
+ let msg = null
+ try {
+ msg = JSON.parse(ev.data)
+ } catch {
+ return
+ }
+ if (msg?.type === 'threads') {
+ watchThreads.clear()
+ for (const t of msg.threads) watchThreads.set(t.id, t)
+ watchBadge()
+ } else if (msg?.type === 'thread') {
+ watchThread(msg)
+ }
+ }
+ ws.onclose = async () => {
+ if (watchWs !== ws) return
+ watchWs = null
+ const gen = watchEpoch
+ const wait = watchReconnects.closed()
+ const outcome = await authReconnect()
+ if (gen !== watchEpoch) return
+ if (outcome === 'auth') watchConnect()
+ else if (outcome === 'retry') watchTimer = setTimeout(watchConnect, wait)
+ // 'abort': no session — the next auth-success restarts the watch.
+ }
+ ws.onerror = () => ws.close()
+}
+
+function watchConnectNow() {
+ watchEpoch++
+ clearTimeout(watchTimer)
+ if (watchWs) {
+ watchWs.onclose = null
+ watchWs.close()
+ watchWs = null
+ }
+ watchConnect()
+}
+
+export function watchAdmin(br) {
+ bridge = br
+ if (watchStarted) return
+ watchStarted = true
+ watchTimer = setTimeout(watchConnect, socketSlot())
+ addEventListener('online', () => {
+ if (!watchWs) watchConnectNow()
+ })
+ addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') {
+ watchHiddenAt = Date.now()
+ return
+ }
+ // A long-hidden tab's socket is a zombie: reconnect to catch up.
+ if (watchHiddenAt && Date.now() - watchHiddenAt > WATCH_WAKE_MS) watchConnectNow()
+ watchHiddenAt = 0
+ })
+ // A completed login (re)starts the watch at once; a logout stops it.
+ addEventListener('message', (ev) => {
+ const type = ev.data?.type
+ if (type === 'auth-success') {
+ if (!watchWs) watchConnectNow()
+ } else if (type === 'auth-logout') {
+ watchEpoch++
+ clearTimeout(watchTimer)
+ if (watchWs) {
+ watchWs.onclose = null
+ watchWs.close()
+ watchWs = null
+ }
+ watchThreads.clear()
+ state.adminUnread = 0
+ }
+ })
+}
diff --git a/frontend/src/notify.js b/frontend/src/notify.js
new file mode 100644
index 0000000..2a1e97a
--- /dev/null
+++ b/frontend/src/notify.js
@@ -0,0 +1,33 @@
+// Browser notifications for contact messaging (docs/messages.md): the
+// visitor is notified of admin replies, the admin of new visitor
+// messages. Permission is requested at one entry point only — right
+// after the first message of a fresh thread (the Notification API
+// requires a user gesture). A denied permission is permanent in the
+// browser, so we never re-ask; a dismissed prompt stays "default" and is
+// asked again on the next fresh thread.
+
+export function askNotifyPermission() {
+ if (!('Notification' in window) || Notification.permission !== 'default') return
+ Notification.requestPermission()
+}
+
+// Fire a notification (a no-op without a granted permission). Clicking
+// it focuses the site tab and runs onclick (opening the thread/inbox
+// view), so the user reliably lands on what was new.
+export function notifyMessage(title, body, onclick) {
+ if (!('Notification' in window) || Notification.permission !== 'granted') return
+ const n = new Notification(title, { body, icon: '/favicon.ico' })
+ n.onclick = () => {
+ window.focus()
+ onclick?.()
+ n.close()
+ }
+}
+
+// The visible text of a server-rendered strict-markdown message (safe to
+// parse: the stored html is markdown-it output, never user markup).
+export function messageText(html) {
+ const div = document.createElement('div')
+ div.innerHTML = html
+ return (div.textContent || '').trim()
+}
diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js
index ae6cf86..8752f85 100644
--- a/frontend/src/pagerite.js
+++ b/frontend/src/pagerite.js
@@ -182,6 +182,37 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
return btn;
}
+ // The corner ✉️ (for everyone): opens the messaging overlay — the
+ // visitor's own thread, or the admin inbox for editors (the {contact:}
+ // trigger buttons always open the visitor side, so admins can test it).
+ // The unread marker is seeded without any socket: for visitors from
+ // localStorage (the newest admin reply vs. the last look, both written
+ // by the contact bundle), for editors from the /_api/settings probe's
+ // contact_unread. Once the bundle has loaded, its refreshBadges keeps
+ // the marker live.
+ let adminUnreadSeed = 0;
+ // Messaging disabled in the site settings (no pagerite:contact-label in
+ // the asset config): no icon for visitors — admins keep theirs, the
+ // inbox still holds the existing threads.
+ const contactEnabled = !!assets["pagerite:contact-label"];
+ function makeContactIcon(admin) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.id = "contact-icon";
+ b.className = "contact-icon icon-btn";
+ b.title = admin ? "messages" : (assets["pagerite:contact-label"] || "contact us");
+ b.textContent = "✉️";
+ if (admin) {
+ if (adminUnreadSeed > 0) b.classList.add("has-unread");
+ } else {
+ const seen = Number(localStorage.getItem("pagerite-contact-seen") || 0);
+ const last = localStorage.getItem("pagerite-contact-lastadmin") || "";
+ if (last && Date.parse(last) > seen) b.classList.add("has-unread");
+ }
+ b.addEventListener("click", () => openContactPanel({ admin }));
+ return b;
+ }
+
// 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,
@@ -213,13 +244,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// 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";
+ // 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 onToolPage = currentPath === "/_a";
if (document.getElementById("page-banner")) {
const pens = pensContainer();
- if (canEdit && !onAnalytics) {
- // Analytics viewer is now a normal page at /_a.
+ // The contact icon is there for everyone — except visitors when
+ // messaging is disabled (admins keep the inbox).
+ if (canEdit || contactEnabled) pens.append(makeContactIcon(canEdit));
+ if (canEdit && !onToolPage) {
+ // Analytics viewer is a normal page at /_a.
const a = document.createElement("a");
a.className = "edit-link analytics-link icon-btn";
a.href = "/_a";
@@ -231,7 +265,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
if (!pens.firstElementChild) pens.remove();
}
- if (canEdit && !onAnalytics) injectPagePen();
+ if (canEdit && !onToolPage) injectPagePen();
}
// Re-mount the selector into the fresh container (no-op until the
// bundle has been loaded once).
@@ -263,8 +297,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
isAdmin = false;
try {
- await fetchJson("/_api/settings");
+ const settings = await fetchJson("/_api/settings");
isAdmin = true;
+ // Seeds the contact icon's admin badge (docs/messages.md).
+ adminUnreadSeed = settings.contact_unread || 0;
} catch {
// Anonymous, no pagerite:admin, or no auth proxy / dev.
}
@@ -285,6 +321,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
renderAuthUi();
placeEditPen();
pingEntryOnce();
+ contactAutoStart();
}
// Returning to the page via history back/forward may restore a cached
@@ -293,6 +330,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
if (ev.persisted) setupAuth();
});
+ // paskia's auth iframe announces its result as a window message: a login
+ // or logout completed anywhere in the page (e.g. the analytics view's
+ // login flow) re-probes auth and re-renders the pens. setupAuth is
+ // idempotent, so the profile button's own re-probe after the same login
+ // is a harmless duplicate.
+ addEventListener("message", (ev) => {
+ const type = ev.data?.type;
+ if (type === "auth-success" || type === "auth-logout") setupAuth();
+ });
+
function runScripts(root) {
// Scripts inserted via DOM swapping do not execute; re-create them.
for (const old of root.querySelectorAll("script")) {
@@ -446,6 +493,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// 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"));
+ contactMod?.refreshBadges();
});
// Editors mutate site-wide state (theme, structure, headings, banners),
@@ -619,6 +667,22 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
ws.onopen = () => {
wsPolicy.opened();
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
+ // Re-registering resyncs the contact thread after a reconnect.
+ if (contactToken) ws.send(JSON.stringify({ token: contactToken }));
+ };
+ ws.onmessage = (ev) => {
+ // Contact thread pushes ride this socket (docs/messages.md).
+ if (!contactListener) return;
+ let msg = null;
+ try {
+ msg = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+ if (msg?.type === "thread") contactListener(msg);
+ else if (msg?.type === "pow") contactPowListener?.(msg);
+ else if (msg?.type === "contact-email") contactEmailListener?.(msg);
+ else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
};
ws.onclose = () => {
ws = null;
@@ -630,7 +694,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
function report(msg) {
if (document.body.classList.contains("editing")) return;
- if (msg.to === "/_a") return;
+ if (isToolPage(msg.to)) return;
if (ssoAvailable && isAdmin) msg.hide = true;
activityWs();
if (ws?.readyState === WebSocket.OPEN) {
@@ -643,9 +707,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
- // Reading-time updates from the analytics page itself are not tracked
- // (/_a is admin machinery; the server would reject the path anyway).
- if (!to && currentPath === "/_a") return;
+ // Reading-time updates from the tool pages themselves are not tracked
+ // (admin machinery; the server would reject the path anyway).
+ if (!to && isToolPage(currentPath)) return;
const msg = {};
if (fr) msg.fr = fr;
if (to) msg.to = to;
@@ -674,16 +738,34 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
let reading = true;
let readInactivityTimer = null;
let idleTimer = null;
+ let lastActivityAt = performance.now();
function markReadActivity() {
- if (!reading) {
+ const t = performance.now();
+ if (reading) {
+ // A gap longer than the inactivity timeout means the cutoff timer
+ // could not run on time (the computer slept, or the tab was throttled
+ // hard): close the span at its intended cutoff — the gap itself was
+ // not reading time, and performance.now() keeps running through sleep
+ // on some platforms.
+ const cutoff = lastActivityAt + INACTIVE_MS;
+ if (t > cutoff) {
+ readElapsed += Math.max(0, cutoff - readStart);
+ readStart = t;
+ }
+ } else {
reading = true;
- readStart = performance.now();
+ readStart = t;
}
+ lastActivityAt = t;
clearTimeout(readInactivityTimer);
+ // Same sleep guard, other ordering: when this timer itself fires late
+ // (waking from sleep before any user event), clamp the accumulation to
+ // its intended fire time.
+ const stopAt = t + INACTIVE_MS;
readInactivityTimer = setTimeout(() => {
if (reading) {
- readElapsed += performance.now() - readStart;
+ readElapsed += Math.max(0, Math.min(performance.now(), stopAt) - readStart);
reading = false;
}
}, INACTIVE_MS);
@@ -746,27 +828,39 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
ping({ to: currentPath, fr: "" });
}
- // --- Analytics page mount/unmount --------------------------------------
- // The analytics page is a normal page whose body is rendered by the server
- // but whose content is a Vue app. In dev the entry module is imported from
- // the Vite dev server on demand; in production it is inlined into the /_a
- // page as script#pagerite-js-analytics, which a fetch-navigation swap does
- // not execute — re-create the element so the fresh module auto-mounts on
- // #analytics-app (see analytics-main.js). The module exposes its unmount
- // as window.__pageriteAnalyticsUnmount.
- function teardownAnalytics() {
- // Remove even the server-rendered script element so a later return to
- // /_a re-mounts from a fresh copy (the module has torn itself down).
- document.getElementById("pagerite-js-analytics")?.remove();
- window.__pageriteAnalyticsUnmount?.();
- window.__pageriteAnalyticsUnmount = null;
+ // --- Admin tool pages mount/unmount ------------------------------------
+ // /_a (analytics) is a normal page whose body is rendered by the server
+ // but whose content is a Vue app. In dev the
+ // entry module is imported from the Vite dev server on demand; in
+ // production it is inlined into the page as a script element, which a
+ // fetch-navigation swap does not execute — re-create the element so the
+ // fresh module auto-mounts on its container. Each module exposes its
+ // unmount as a window global (see analytics-main.js).
+ const TOOL_PAGES = {
+ "/_a": { container: "analytics-app", script: "pagerite-js-analytics",
+ asset: "pagerite:analytics-src", unmount: "__pageriteAnalyticsUnmount" },
+ };
+
+ function isToolPage(path) {
+ return Object.hasOwn(TOOL_PAGES, path || "");
}
- async function mountAnalytics(doc) {
- if (!doc.getElementById("analytics-app")) return;
- // Already mounted: on a full /_a load the inline script has run.
- if (document.getElementById("pagerite-js-analytics")) return;
- const inline = doc.getElementById("pagerite-js-analytics");
+ function teardownTools() {
+ // Remove even the server-rendered script element so a later return
+ // re-mounts from a fresh copy (the module has torn itself down).
+ for (const p of Object.values(TOOL_PAGES)) {
+ document.getElementById(p.script)?.remove();
+ window[p.unmount]?.();
+ window[p.unmount] = null;
+ }
+ }
+
+ async function mountTool(doc) {
+ const page = Object.values(TOOL_PAGES).find((p) => doc.getElementById(p.container));
+ if (!page) return;
+ // Already mounted: on a full page load the inline script has run.
+ if (document.getElementById(page.script)) return;
+ const inline = doc.getElementById(page.script);
if (inline) {
const s = document.createElement("script");
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
@@ -778,11 +872,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// Dev: the cached module auto-mounts only on its first evaluation,
// so call mount() explicitly for repeat visits (it no-ops when the
// app is already up).
- const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]);
- const container = document.getElementById("analytics-app");
+ const mod = await import(/* @vite-ignore */ assets[page.asset]);
+ const container = document.getElementById(page.container);
if (container) mod.mount(container);
} catch (e) {
- console.error("analytics mount failed:", e);
+ console.error("tool page mount failed:", e);
}
}
@@ -841,6 +935,91 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
ping({ to: currentPath, lang: tag });
});
+ // --- Messaging overlay ---------------------------------------------------
+ // {contact: Label} directives in articles render .contact-trigger buttons,
+ // and the corner ✉️ icon (rendered by renderAuthUi for everyone) opens the
+ // same overlay: the visitor's thread, or the admin inbox for editors
+ // (docs/messages.md). The bundle (Vue overlay + composer) loads on first
+ // click and rides the activity socket for realtime thread state — no extra
+ // connection for visitors. Registration re-sends on every socket
+ // (re)connect, and the server pushes the thread back as the resync.
+ let contactMod = null;
+ let contactToken = "";
+ let contactListener = null;
+ let contactPowListener = null;
+ let contactEmailListener = null;
+ const contactBridge = {
+ register(token, onThread, onPow, onEmail) {
+ contactToken = token;
+ contactListener = onThread;
+ contactPowListener = onPow;
+ contactEmailListener = onEmail;
+ contactBridge.send({ token });
+ },
+ send(msg) {
+ msg.fr = currentPath;
+ activityWs();
+ if (ws?.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(JSON.stringify(msg));
+ return;
+ } catch { /* fall through to queueing */ }
+ }
+ wsQueue.push(msg);
+ },
+ };
+
+ async function loadContact() {
+ contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]);
+ for (const css of (assets["pagerite:contact-css"] || "").split(",")) {
+ if (css && !document.querySelector(`link[href="${css}"]`)) {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = css;
+ link.dataset.pagerite = "contact-css";
+ document.head.append(link);
+ }
+ }
+ return contactMod;
+ }
+
+ async function openContactPanel(opts) {
+ try {
+ (await loadContact()).open(contactBridge, opts);
+ } catch (e) {
+ console.error("contact panel load failed:", e);
+ }
+ }
+
+ // Message sessions follow the page load, not the panel: a visitor with
+ // an open conversation (the bundle's localStorage session flag) resumes
+ // checking for replies right away — with a granted notification
+ // permission new replies notify immediately; an admin's inbox watch
+ // (badge + notifications) runs whenever messaging is enabled.
+ async function contactAutoStart() {
+ if (!contactEnabled) return;
+ const wantAdmin = isAdmin;
+ const wantResume = !!localStorage.getItem("pagerite-contact-session");
+ if (!wantAdmin && !wantResume) return;
+ try {
+ const mod = await loadContact();
+ if (wantAdmin) mod.watchAdmin(contactBridge);
+ if (wantResume) mod.resume(contactBridge);
+ } catch (e) {
+ console.error("contact session resume failed:", e);
+ }
+ }
+
+ addEventListener("click", (ev) => {
+ const btn = ev.target.closest?.(".contact-trigger");
+ if (!btn) return;
+ ev.preventDefault();
+ // In the editor preview the button is inert.
+ if (document.body.classList.contains("editing")) return;
+ // Always the visitor side, even for admins (testing both sides).
+ openContactPanel({ admin: false });
+ });
+
// --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) {
// Navigating with the editor open keeps it open: the shell retargets to
@@ -851,10 +1030,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// bypassed while editing because the editor may have invalidated the
// prefetched copies of other pages.
const editing = document.body.classList.contains("editing");
- if (editing && new URL(url, location.href).pathname === "/_a") {
+ if (editing && isToolPage(new URL(url, location.href).pathname)) {
await editorModule?.then((m) => m.closeEditor({ navigating: true }));
}
- teardownAnalytics();
+ teardownTools();
let doc;
let finalUrl = url;
const cached = !editing && pageCache.get(cacheKey(url));
@@ -948,8 +1127,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
runScripts(document.getElementById("page-banner"));
runScripts(document.getElementById("main"));
applyEffects();
- mountAnalytics(doc);
+ mountTool(doc);
mountLangselect(doc);
+ // The swap rebuilt any contact-trigger buttons: re-apply the
+ // "new reply" dot to the fresh elements.
+ contactMod?.refreshBadges();
};
// Rotating cube page transition (styles injected as #pagerite-transition
// from the selected design's transition.css, e.g. themes/cube/);
@@ -1057,7 +1239,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
// Machinery and auth endpoints are never fetch-navigated, except the
// public analytics viewer page at /_a.
- if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
+ if ((url.pathname.startsWith("/_") && !isToolPage(url.pathname))
|| url.pathname.startsWith("/auth")) return;
ev.preventDefault();
// Capture the source now: load() updates currentPath before pinging.
@@ -1177,6 +1359,6 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
setupAuth();
applyEffects();
- mountAnalytics(document);
+ mountTool(document);
mountLangselect(document);
})();
diff --git a/frontend/src/reconnect.js b/frontend/src/reconnect.js
index 2656204..be9821b 100644
--- a/frontend/src/reconnect.js
+++ b/frontend/src/reconnect.js
@@ -10,6 +10,8 @@
// (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.
+import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia'
+
export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) {
let delay = min
let openedAt = 0
@@ -54,3 +56,36 @@ export function watchConnecting(ws, label) {
}
}, 10_000)
}
+
+// Auth-aware close handling for the admin sockets (/_api/...). An expired
+// session fails the WS handshake exactly like a dead network, but retrying
+// the socket loops forever — the WS API cannot open the login dialog. So
+// probe /_api first (plain fetchJson, which never pops a dialog) and:
+//
+// 'retry' — the session is fine or the trouble is a network/server one:
+// take the policy's paced backoff.
+// 'auth' — the session was gone, so the paskia login flow ran (apiJson
+// pops the auth iframe and retries through it) and completed:
+// reconnect at once, no backoff.
+// 'abort' — the login was declined or the proxy offers no paskia flow:
+// stop retrying; the caller decides what its UI does next.
+//
+// onLogin fires when the login flow starts, so the caller can say so.
+export async function authReconnect(onLogin) {
+ try {
+ await fetchJson('/_api/settings')
+ return 'retry'
+ } catch (e) {
+ if (!(e instanceof ApiError && (e.status === 401 || e.status === 403))) return 'retry'
+ }
+ onLogin?.()
+ try {
+ await apiJson('/_api/settings')
+ return 'auth'
+ } catch (e) {
+ return e instanceof NetworkError
+ || (e instanceof ApiError && e.status !== 401 && e.status !== 403)
+ ? 'retry'
+ : 'abort'
+ }
+}
diff --git a/frontend/src/richtext.js b/frontend/src/richtext.js
new file mode 100644
index 0000000..80475af
--- /dev/null
+++ b/frontend/src/richtext.js
@@ -0,0 +1,119 @@
+// Serialize the constrained contenteditable composer DOM to the contact
+// message Markdown subset (emphasis, links, lists, code, /_f/ images and
+// file chips). The wire format is text: the server re-renders it with a
+// strict, html-less MarkdownIt (pagerite/messages.py), so no user markup
+// of any kind ever reaches a page. This serializer is for fidelity, not
+// safety — anything it does not know is simply unwrapped.
+
+const ESCAPE_RE = /([\\`*_[\]])/g
+
+export function escapeMd(text) {
+ return text.replace(ESCAPE_RE, '\\$1')
+}
+
+// Tags that open a new block at the composer's top level. Everything else
+// there — text, B/I/A/SPAN formatting elements, inline images — is an
+// inline run and joins the paragraph being accumulated: browsers happily
+// put formatting directly under the editable root (a bolded segment of an
+// undivided first line is a top-level ), and treating those as blocks
+// would split the paragraph and drop the formatting.
+const BLOCK_TAGS = new Set([
+ 'DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE',
+ 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'TABLE',
+])
+
+// One message's Markdown from the composer root: inline runs become
+// paragraphs, block elements become their own blocks, blank lines between.
+export function serializeRich(root) {
+ const blocks = []
+ let run = []
+ const flush = () => {
+ const text = run.join('').trim()
+ run = []
+ if (text) blocks.push(text)
+ }
+ for (const node of root.childNodes) {
+ if (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName)) {
+ flush()
+ block(node, blocks)
+ } else {
+ run.push(inline(node))
+ }
+ }
+ flush()
+ return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim()
+}
+
+function block(node, out) {
+ switch (node.tagName) {
+ case 'UL':
+ out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n'))
+ break
+ case 'OL':
+ out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n'))
+ break
+ case 'BLOCKQUOTE':
+ out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n'))
+ break
+ case 'PRE':
+ out.push('```\n' + node.textContent + '\n```')
+ break
+ case 'HR':
+ break
+ default: {
+ // DIV, P, headings: a paragraph of inline content.
+ const text = inlineChildren(node).trim()
+ if (text) out.push(text)
+ }
+ }
+}
+
+function inlineChildren(node) {
+ return [...node.childNodes].map(inline).join('')
+}
+
+function inline(node) {
+ if (node.nodeType === Node.TEXT_NODE) return escapeMd(node.textContent)
+ if (node.nodeType !== Node.ELEMENT_NODE) return ''
+ switch (node.tagName) {
+ case 'B':
+ case 'STRONG':
+ return `**${inlineChildren(node)}**`
+ case 'I':
+ case 'EM':
+ return `*${inlineChildren(node)}*`
+ case 'CODE':
+ return '`' + node.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
+ case 'BR':
+ return '\n'
+ case 'A': {
+ const text = inlineChildren(node)
+ const href = node.getAttribute('href') || ''
+ return /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
+ }
+ case 'IMG':
+ // Uploaded image (data-path set by the composer on insert).
+ return node.dataset.path ? `` : ''
+ case 'SPAN':
+ // Attachment chip (data-path + data-name); other spans unwrap.
+ if (node.dataset.path && node.dataset.name) {
+ return `[📎 ${escapeMd(node.dataset.name)}](${node.dataset.path})`
+ }
+ return wrapStyle(node, inlineChildren(node))
+ default:
+ return wrapStyle(node, inlineChildren(node))
+ }
+}
+
+// Some browsers style formatting instead of tagging it (Firefox's
+// execCommand produces ): keep span-level
+// bold/italic alive into the Markdown.
+function wrapStyle(node, text) {
+ if (!text.trim()) return text
+ const s = node.style
+ if (s) {
+ if (/bold|bolder|[6-9]00/.test(s.fontWeight)) text = `**${text}**`
+ if (s.fontStyle === 'italic' || s.fontStyle === 'oblique') text = `*${text}*`
+ }
+ return text
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 2ecf691..32deb64 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -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", "/_ws", "/_translate"] }),
+ fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }),
vue(),
vueDevTools(),
],
@@ -51,6 +51,7 @@ export default defineConfig({
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)),
+ contact: fileURLToPath(new URL('./src/contact-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)),
diff --git a/pagerite/analytics.py b/pagerite/analytics.py
index 45ad4f5..f841aed 100644
--- a/pagerite/analytics.py
+++ b/pagerite/analytics.py
@@ -148,6 +148,10 @@ class Msg(msgspec.Struct, omit_defaults=True):
#: Rendered language reported by the client for the page the activity
#: happened on.
lang: str = ""
+ #: Server-originated contact marker (docs/messages.md): the visitor
+ #: opened the contact panel and solved its proof of work on ``fr``.
+ #: Never sent by clients — recorded by the /_ws handler.
+ contact: bool = False
class Client(msgspec.Struct, omit_defaults=True):
@@ -217,6 +221,9 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
#: page falling back to its GET's rendered language; "" when unknown
#: (old clients or data from before language recording).
lang: str = ""
+ #: The visitor opened the contact panel and solved its proof of work
+ #: on this page (docs/messages.md) — the trail's ✉️ marker.
+ contact: bool = False
class Visit(msgspec.Struct, omit_defaults=True):
@@ -711,6 +718,34 @@ class Store:
self._save()
return client_hash if new else None
+ def record_contact(
+ self, fr: str, ip: str, ua: str, accept_language: str = ""
+ ) -> bytes | None:
+ """Append one server-originated contact marker to the raw log: the
+ visitor opened the contact panel on ``fr`` and solved its proof of
+ work (docs/messages.md). Recorded by the /_ws handler on a
+ validated solution — an unsolved panel open leaves no trace.
+ Display lands the marker on the visit's trail step for ``fr``.
+
+ Returns the client hash when the client record was just created (so
+ the caller can schedule async enrichment), else None.
+ """
+ client_lang, country = _parse_accept_language(accept_language)
+ client_hash = _client_hash(ip, ua, client_lang)
+ new = client_hash not in self.data.clients
+ if new:
+ self._ensure_client(ip, ua, client_lang, country=country)
+ self.data.msgs.append(
+ Msg(
+ t=datetime.now(UTC),
+ client=client_hash,
+ fr=(_internal_path(fr) or "") if fr else "",
+ contact=True,
+ )
+ )
+ self._save()
+ return client_hash if new else None
+
def favicon_origins_needed(self) -> list[str]:
"""External https origins seen in the raw events whose favicon needs
fetching.
@@ -775,6 +810,9 @@ class Store:
rendered language), and a page re-visited in a different rendered
language becomes a distinct trail step. The entry referer and
UTM tags come from the GET that loaded the entry page.
+ Server-originated contact markers (the contact panel's proof of
+ work solved, docs/messages.md) flag the trail step they happened
+ on — the first one per visit.
Hidden (admin) clients are excluded from every list and aggregate.
``multilingual`` and ``primary_lang`` are site context (translation
@@ -871,6 +909,19 @@ class Store:
visit: Visit | None = None
last_t: datetime | None = None
for m in msgs:
+ if m.contact:
+ # Contact PoW proven (docs/messages.md): mark the trail
+ # step it happened on — the first one per visit only.
+ if visit is not None and not any(
+ item.contact for item in visit.trail.values()
+ ):
+ want = m.fr or visit.entry
+ for item in visit.trail.values():
+ if item.to == want:
+ item.contact = True
+ break
+ last_t = m.t
+ continue
if m.to:
if visit is None or (
last_t is not None and m.t - last_t > _SESSION_GAP
diff --git a/pagerite/api.py b/pagerite/api.py
index be66e66..bddef77 100644
--- a/pagerite/api.py
+++ b/pagerite/api.py
@@ -23,8 +23,8 @@ from fastapi import (
from html5tagger import E
from pydantic import BaseModel
-from pagerite import i18n, views
-from pagerite.chunks import store_chunks
+from pagerite import i18n, messages, views
+from pagerite.chunks import chunk_key, store_chunks
from pagerite.data import (
Node,
append_order,
@@ -65,6 +65,10 @@ async def list_pages(lang: str | None = None) -> list[dict]:
Nested by slug; each node carries its full path, menu order, flags and
language settings (``language`` is the node's own primary-language
setting, "" = inherit; ``primary`` is the resolved effective one).
+ ``has_content`` tells whether a landing page exists at all (even an
+ empty one — the structure editor offers deleting it); ``empty`` is the
+ navigation's notion: no content of its own (None or empty markdown),
+ so nav links bypass the node to its first child page.
With a ``?lang=`` translation, titles come out in that language where a
translation exists (``translated`` flags it — true trivially for rows
whose primary language IS the selected one; other rows fall back to
@@ -88,6 +92,10 @@ async def list_pages(lang: str | None = None) -> list[dict]:
"order": node.order,
"published": node.published,
"has_content": node.chunks is not None,
+ # No content of its own (None or empty markdown): the
+ # navigation bypasses the node, linking its first child
+ # page instead (views nav_html/first_leaf).
+ "empty": not node.chunks,
"language": node.language,
"primary": primary,
"children": dump(node.children, path, primary),
@@ -268,12 +276,18 @@ async def update_structure(op: StructureOp, request: Request) -> None:
@router.get("/_api/settings")
-async def get_settings() -> dict:
+async def get_settings(lang: str | None = None) -> dict:
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
the themes, banner designs and user fonts available on disk for the
selectors, the translator service keys and the wanted translation
- languages (for the /_translate socket)."""
- return {
+ languages (for the /_translate socket), and the contact-messaging
+ configuration.
+
+ With a ``?lang=`` translation language the contact label/subject also
+ come out as ``*_tr`` (their per-language override, "" = none — the
+ site settings' messaging section edits those, falling back to the
+ original it edits without the query)."""
+ out = {
"brand": data.brand,
"brand_html": data.brand_html,
"theme": data.theme,
@@ -285,11 +299,27 @@ async def get_settings() -> dict:
"transition": data.transition,
"transitions": views._transition_names(),
"translate_keys": data.translate_keys,
+ # Unanswered contact threads: the corner ✉️ icon's badge for
+ # admins (this probe runs on every page load; no polling).
+ "contact_unread": messages.unread_count(),
+ # Messaging is enabled by configuring a label (empty = off).
+ "contact_label": data.contact_label,
+ "contact_email": data.contact_email,
+ "contact_subject": data.contact_subject,
+ "contact_mailserver": data.contact_mailserver,
# The site default primary language: the front page's resolved
# setting (every page may override it, inherited down the tree).
"primary_lang": i18n.primary_lang(data.menu, ""),
"translate_langs": sorted(data.translate_langs),
}
+ tag = i18n.base_tag(lang or "")
+ if tag and tag != out["primary_lang"]:
+ for field in ("contact_label", "contact_subject"):
+ original = getattr(data, field)
+ out[f"{field}_tr"] = (
+ data.trans.get(chunk_key(original), {}).get(tag, "") if original else ""
+ )
+ return out
class SettingsIn(BaseModel):
@@ -302,17 +332,54 @@ class SettingsIn(BaseModel):
transition: str = "cube"
translate_langs: list[str] | None = None # None keeps the current set
translate_keys: dict[str, str] | None = None # None keeps the current keys
+ contact_label: str | None = None
+ contact_email: str | None = None
+ contact_subject: str | None = None
+ contact_mailserver: str | None = None
@router.put("/_api/settings", status_code=204)
-async def put_settings(settings: SettingsIn, request: Request) -> None:
- """Update site-wide settings; invalidates cached pages and ETags."""
+async def put_settings(
+ settings: SettingsIn, request: Request, lang: str | None = None
+) -> None:
+ """Update site-wide settings; invalidates cached pages and ETags.
+
+ With a ``?lang=`` translation language only the contact label/subject
+ apply — as per-language overrides in the chunk-keyed trans store, the
+ same storage machine translations use (i18n.set_string_translation);
+ empty or the original's text drops the override."""
+ tag = i18n.base_tag(lang or "")
+ if tag and tag != i18n.primary_lang(data.menu, ""):
+ with kanta.transaction(
+ f"settings:{tag}", user=request.headers.get("remote-user")
+ ):
+ changed = False
+ for field in ("contact_label", "contact_subject"):
+ value = getattr(settings, field)
+ if value is None:
+ continue
+ original = getattr(data, field)
+ if original:
+ changed |= i18n.set_string_translation(
+ data, tag, original, value.strip()
+ )
+ if changed:
+ _invalidate_pages()
+ return
with kanta.transaction("settings", user=request.headers.get("remote-user")):
data.brand = settings.brand
data.brand_html = settings.brand_html
data.theme = settings.theme
data.custom_css = settings.custom_css
data.transition = settings.transition
+ if settings.contact_label is not None:
+ data.contact_label = settings.contact_label.strip()
+ if settings.contact_email is not None:
+ data.contact_email = settings.contact_email.strip()
+ if settings.contact_subject is not None:
+ data.contact_subject = settings.contact_subject.strip()
+ if settings.contact_mailserver is not None:
+ data.contact_mailserver = settings.contact_mailserver.strip()
if settings.translate_langs is not None:
# Any language may be a target — including the site default
# (an article in another language can be translated INTO it);
@@ -536,11 +603,26 @@ async def editor_ws(ws: WebSocket) -> None:
markdown = msg.get("markdown", "")
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
- # Expand {cards} like page_content does, so the preview
- # shows real cards, not the literal tag. No translation
- # context: the preview has no lang of its own, so cards
- # render in their originals.
has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None
+ # {contact} buttons preview like the served page: inert,
+ # and nothing at all when messaging is disabled (no
+ # configured label). {cards} expands like page_content
+ # does, so the preview shows real cards, not the literal
+ # tag. No translation context: the preview has no lang of
+ # its own, so cards render in their originals.
+ directives: dict = {
+ "contact": (
+ (lambda args, _env: views._contact_tag(args, _env, data))
+ if data.contact_label.strip()
+ else (lambda _args, _env: "")
+ )
+ }
+ if node is not None and has_cards_tag:
+ directives["cards"] = (
+ lambda args, _env, node=node, path=path: views._cards_tag(
+ data.menu, data, node, path, args
+ )
+ )
rendered = render(
markdown,
path,
@@ -557,15 +639,7 @@ async def editor_ws(ws: WebSocket) -> None:
if node
else None
),
- directives=(
- {
- "cards": lambda args, _env, node=node, path=path: (
- views._cards_tag(data.menu, data, node, path, args)
- )
- }
- if node is not None and has_cards_tag
- else None
- ),
+ directives=directives,
)
html = rendered.html
if node is not None and not has_cards_tag:
diff --git a/pagerite/app.py b/pagerite/app.py
index 5858268..8a2cf29 100644
--- a/pagerite/app.py
+++ b/pagerite/app.py
@@ -11,6 +11,9 @@ The routes live in specialized modules, included below as APIRouters:
(``/_api/*``, ``/_translate/{clientkey}``).
- ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``,
the ``/_a`` viewer page).
+- ``pagerite.messages`` — visitor contact threads (docs/messages.md): the
+ ``/_contact/files`` upload route; the messaging protocol itself rides
+ the tracking sockets.
- ``pagerite.feeds`` — machine-readable site exports: ``/llms.txt``,
``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS).
- ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``,
@@ -41,7 +44,7 @@ from fastapi.responses import Response
from fastapi_vue import Frontend, env
from starlette.types import ASGIApp, Receive, Scope, Send
-from pagerite import api, feeds, files, pages, tracking
+from pagerite import api, feeds, files, messages, pages, tracking
from pagerite.files import file_store
from pagerite.state import analytics_store, config, kanta
@@ -119,9 +122,10 @@ async def _headers(request: Request, call_next) -> Response:
# Our own routes first: the editor API and translator socket, the analytics
-# machinery, and the file store/user assets.
+# machinery, the contact-message upload route, and the file store/user assets.
app.include_router(api.router)
app.include_router(tracking.router)
+app.include_router(messages.router)
app.include_router(files.router)
# Vue build asset routes are inserted at this position during load(): the
diff --git a/pagerite/data.py b/pagerite/data.py
index cd6c017..c2ac2e1 100644
--- a/pagerite/data.py
+++ b/pagerite/data.py
@@ -50,6 +50,50 @@ class LangEdits(msgspec.Struct, omit_defaults=True):
adds: dict[str, str] = {}
+class ContactMessage(msgspec.Struct, omit_defaults=True):
+ """One message in a visitor contact thread (docs/messages.md).
+
+ ``md`` is the Markdown-subset source serialized by the composer;
+ ``html`` its rendering by the strict parser in pagerite.messages
+ (html=False — no user markup ever reaches a page, only markdown-it
+ output from the constrained grammar). Both are stored: the source for
+ future edits/moderation, the rendering so panels inject it verbatim.
+ """
+
+ t: datetime
+ #: Markdown-subset source.
+ md: str = ""
+ #: Strict server-side rendering of ``md``.
+ html: str = ""
+ #: Author: "" = the visitor, otherwise the admin's SSO remote-user.
+ admin: str = ""
+ #: Page path the message was sent from ("" for admin replies).
+ page: str = ""
+
+
+class ContactThread(msgspec.Struct, omit_defaults=True):
+ """One visitor's message thread, keyed in Data.contact by token hash.
+
+ Messages are keyed (never a list) by a sortable timestamp-random id so
+ a save's database diff touches only the appended message.
+ """
+
+ created: datetime
+ messages: dict[str, ContactMessage] = {}
+ #: When an admin last looked at the thread; visitor messages newer
+ #: than this are unread in the admin inbox.
+ admin_read: datetime | None = None
+ #: The visitor's self-entered identity (name required, email optional
+ #: and unvalidated), captured with their first message and fixed
+ #: afterwards ("" until then).
+ name: str = ""
+ email: str = ""
+ #: The visitor closed the conversation (their next message starts a
+ #: fresh thread under a new token); archived in the admin inbox,
+ #: excluded from the unread count.
+ closed: bool = False
+
+
class Node(msgspec.Struct, omit_defaults=True):
"""One item of the site hierarchy.
@@ -167,6 +211,27 @@ class Data(msgspec.Struct):
#: path -> lang -> LangEdits (paths without leading slash). Replaces
#: the old "patches" key (ignored on decode, discarding that data).
overrides: dict[str, dict[str, LangEdits]] = {}
+ #: Visitor contact threads (docs/messages.md), keyed by the blake3
+ #: hash of the visitor's session token (never the token itself).
+ contact: dict[bytes, ContactThread] = {}
+ #: The {contact:} button label and visitor dialog title — and the
+ #: feature's on/off switch: EMPTY disables messaging (no buttons, no
+ #: visitor corner icon, sends rejected; admins keep the inbox). A
+ #: per-button {contact: Label} arg overrides it. Translated per
+ #: language in Data.trans, keyed by chunk hash like titles
+ #: (i18n.site_string).
+ contact_label: str = ""
+ #: Contact email shown as a mailto link in the visitor dialog (with
+ #: the subject prefilled), revealed over the socket only after the
+ #: proof of work is solved. Empty = no link.
+ contact_email: str = ""
+ #: Subject for the mailto link; empty = the (localized) label.
+ #: Translated like contact_label.
+ contact_subject: str = ""
+ #: Mail server for email notifications (docs/messages.md): a mailbridge
+ #: URL — a bare host[:port] or smtp://user:pass@host:port for plain
+ #: SMTP, or a backend URL like sendgrid:API-KEY. Empty = no email.
+ contact_mailserver: str = ""
def node_markdown(data: Data, node: Node) -> str | None:
diff --git a/pagerite/feeds.py b/pagerite/feeds.py
index 24a5692..7032457 100644
--- a/pagerite/feeds.py
+++ b/pagerite/feeds.py
@@ -2,7 +2,8 @@
- ``/llms.txt`` (llmstxt.org convention): a Markdown map of the site for
LLM agents — the brand as title, then every published article as a link
- with a short excerpt.
+ to its ``.md`` Markdown counterpart (pages.show_page) with a short
+ excerpt.
- ``/feed.json``: JSON Feed 1.1 of all published articles, full content.
- ``/feed.xml``: the same as RSS 2.0 (with an atom:link self reference)
for older feed readers.
@@ -78,7 +79,7 @@ def _iso(node: Node) -> str:
def _render_llms(base: str) -> str:
lines = [f"# {data.brand}", "", "## Pages", ""]
for path, node in _articles():
- url = f"{base}/{path}" if path else base
+ url = f"{base}/{path}.md" if path else f"{base}/.md"
excerpt = _description(_body_html(node, base), 120)
suffix = f": {excerpt}" if excerpt else ""
lines.append(f"- [{node.title or path}]({url}){suffix}")
diff --git a/pagerite/files.py b/pagerite/files.py
index 17ec5f6..54f274b 100644
--- a/pagerite/files.py
+++ b/pagerite/files.py
@@ -8,7 +8,11 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
``.orig`` (never served). Routes: upload/delete under
``/_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 (``/_f/{name}/{filename}`` serves ``{name}.file``
+attachment blobs), and the user assets (``/_themes/``, ``/_fonts/``).
+All ``/_f/`` responses are forced downloads (``content-disposition:
+attachment`` — ignored by / subresource fetches) with
+``nosniff``.
"""
import asyncio
@@ -215,6 +219,20 @@ def store_image(
return digest
+def store_attachment(body: bytes) -> str:
+ """Store a non-media upload as an inert ``.file`` blob.
+
+ The original extension is deliberately dropped on disk (a stored
+ ``.exe``/``.html`` could otherwise be accidentally run from the
+ filesystem); the original name lives only in the serving URL
+ (``/_f/{hash}/{name}``), and ``.file`` blobs are served nowhere
+ else. Returns the store name.
+ """
+ name = blake3.blake3(body).hexdigest()[:12] + ".file"
+ file_store.put(name, body)
+ return name
+
+
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
FETCH_MAXSIZE = 20 * 1024 * 1024
@@ -369,6 +387,20 @@ async def user_font_file(name: str, filename: str, request: Request) -> Response
@router.get("/_f/{name}")
async def stored_file(name: str, request: Request) -> Response:
+ return _stored_response(name, request)
+
+
+@router.get("/_f/{name}/{filename}")
+async def stored_file_download(name: str, filename: str, request: Request) -> Response:
+ """The attachment URL shape. ``{name}`` identifies a ``{name}.file``
+ blob (inert on disk, never served without the filename segment);
+ ``{filename}`` is the original upload name and lives only in this
+ URL — the browser names the download after the last path segment.
+ """
+ return _stored_response(name, request, attachment=True)
+
+
+def _stored_response(name: str, request: Request, *, attachment: bool = False) -> Response:
"""Serve a file from the content-addressed store (immutable: the name
is its own hash, so cache forever). Bodies are served from the RAM
cache, zstd-compressed when the client accepts it and compression
@@ -379,33 +411,51 @@ async def stored_file(name: str, request: Request) -> Response:
only when the Accept header lists it explicitly — ``image/avif`` →
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
- originals are internal (they may carry EXIF data) and never served."""
+ originals are internal (they may carry EXIF data) and never served,
+ and ``.file`` attachment blobs are only served through the
+ filename-carrying URL (stored_file_download), never by hash.
+
+ Every response is ``content-disposition: attachment`` with
+ ``nosniff``: subresource fetches ( , , favicons) ignore
+ the disposition, but a direct navigation always downloads and the
+ body is never sniffed into something executable in this origin."""
if ".orig." in name:
raise HTTPException(404)
etag = name
vary = ""
- entry = file_store.get(name)
- if entry is None and "." not in name:
- # Extension-less image link: negotiate avif/webp/jpg by Accept.
- vary = "accept"
- accept = request.headers.get("accept", "")
- if "image/avif" in accept:
- order = ("avif", "webp", "jpg")
- elif "image/webp" in accept:
- order = ("webp", "jpg", "avif")
- else:
- order = ("jpg", "webp", "avif")
- for ext in order:
- etag = f"{name}.{ext}"
- entry = file_store.get(etag)
- if entry is not None:
- break
+ if attachment:
+ etag = f"{name}.file"
+ entry = file_store.get(etag)
+ else:
+ if name.endswith(".file"):
+ raise HTTPException(404)
+ entry = file_store.get(name)
+ if entry is None and "." not in name:
+ # Extension-less image link: negotiate avif/webp/jpg by Accept.
+ vary = "accept"
+ accept = request.headers.get("accept", "")
+ if "image/avif" in accept:
+ order = ("avif", "webp", "jpg")
+ elif "image/webp" in accept:
+ order = ("webp", "jpg", "avif")
+ else:
+ order = ("jpg", "webp", "avif")
+ for ext in order:
+ etag = f"{name}.{ext}"
+ entry = file_store.get(etag)
+ if entry is not None:
+ break
if entry is None:
raise HTTPException(404)
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
body, compressed = entry
- headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
+ headers = {
+ "etag": etag,
+ "cache-control": "public, max-age=31536000, immutable",
+ "content-disposition": "attachment",
+ "x-content-type-options": "nosniff",
+ }
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
headers["content-encoding"] = "zstd"
vary = f"{vary}, accept-encoding".lstrip(", ")
diff --git a/pagerite/i18n.py b/pagerite/i18n.py
index 55d7c92..e2dd6b0 100644
--- a/pagerite/i18n.py
+++ b/pagerite/i18n.py
@@ -407,6 +407,36 @@ def set_title_translation(data: Data, node: Node, lang: str, title: str) -> bool
return True
+def site_string(data: Data, lang: str, text: str) -> str:
+ """A site-level settings string (not article content — the contact
+ label/subject) in ``lang``: its translation from the trans store,
+ chunk-keyed like titles (the translator service picks these up via
+ pending_items), falling back to the original. The site default
+ language always gets the original."""
+ if not text or not lang or lang == primary_lang(data.menu, ""):
+ return text
+ return data.trans.get(chunk_key(text), {}).get(lang) or text
+
+
+def set_string_translation(data: Data, lang: str, original: str, text: str) -> bool:
+ """Record (or drop) a site-string translation (i18n.site_string): a
+ fragment in ``Data.trans`` keyed by the original's chunk hash — the
+ same storage machine translations use, overriding them. Empty or the
+ original's text drops the override. Returns True when anything changed.
+ Pure data ops — the caller wraps in a transaction and invalidates."""
+ key = chunk_key(original)
+ current = data.trans.get(key, {}).get(lang)
+ if not text or text == original:
+ if current is None:
+ return False
+ del data.trans[key][lang]
+ return True
+ if current == text:
+ return False
+ data.trans.setdefault(key, {})[lang] = text
+ return True
+
+
def clear_translations(data: Data) -> None:
"""Drop all machine translations (``Data.trans``) and rebuild the
availability index (``node.langs``) from the surviving user overrides —
diff --git a/pagerite/messages.py b/pagerite/messages.py
new file mode 100644
index 0000000..544bbf3
--- /dev/null
+++ b/pagerite/messages.py
@@ -0,0 +1,557 @@
+"""Visitor contact messages: storage, strict rendering, socket protocol.
+
+Anonymous visitors open a panel from a ``{contact: Label}`` button in an
+article (views._contact_tag). The panel rides the public /_ws activity
+socket: tracking.activity_ws routes frames carrying a ``token`` field to
+``visitor_frame``. The token (a localStorage random, generated on first
+use) identifies the visitor's thread; only its blake3 hash is stored.
+Admins watch and reply from the same overlay panel (the corner ✉️ icon
+opens the inbox for them, the visitor thread for visitors) over the
+analytics socket (tracking.analytics_websocket), admin-gated like the
+rest of /_api; their frames go to ``admin_frame``. Both sides get
+pushed full thread payloads (threads are small); there is no REST
+polling anywhere.
+
+Sends are proof-of-work gated (easypow): the /_ws handler
+(tracking.activity_ws) issues a fresh single-use challenge on
+registration/resync and whenever the quota runs out; the browser solves
+it in the background (bundled solver, served at /_contact/easypow.js)
+and submits the solution as soon as it is ready (``visitor_proven``),
+which grants a quota of messages and uploads (``_quota``, RAM only,
+keyed by token hash so the HTTP upload route shares it). The solution
+is single-use — a fresh challenge is pushed when the quota hits zero,
+which the client solves before continuing. A validated proof also
+unlocks the site's contact email (sent over the socket, never in the
+page's asset config) and records the visit's contact trail marker in
+analytics. Uploads without quota are answered 429; the client re-proves
+and retries.
+
+Message bodies are a Markdown subset (emphasis, links, lists, code plus
+/_f/ image and file references) serialized client-side from a constrained
+contenteditable composer. They are rendered here with a strict MarkdownIt
+instance: html=False, so not a single byte of user markup can reach a
+page — the stored html is always markdown-it output from the constrained
+grammar, never sanitized user HTML (sanitization is unwinnable; grammar
+restriction is not). Links get rel="nofollow ugc noopener"; images are
+limited to /_f/ store names (the upload endpoint is the only way in).
+"""
+
+import asyncio
+import logging
+import re
+import secrets
+from datetime import UTC, datetime
+from pathlib import Path
+from urllib.parse import quote
+
+import blake3
+import easypow
+from fastapi import APIRouter, HTTPException, Request, WebSocket
+from fastapi.responses import Response
+from markdown_it import MarkdownIt
+from markdown_it.common.utils import escapeHtml
+
+from pagerite.data import ContactMessage, ContactThread
+from pagerite.files import _ext, file_store, store_attachment, store_image
+from pagerite.state import data, kanta
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+#: Session-token shape: crypto-random from the browser (uuid or hex).
+TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
+
+#: Message source length cap (the rendered html is bounded by it too).
+MAX_MESSAGE = 8000
+
+#: Live sockets: visitor token hash -> panels, and every connected admin.
+_visitor_ws: dict[bytes, set[WebSocket]] = {}
+_admin_ws: set[WebSocket] = set()
+
+#: Uploaded-but-unreferenced file digests per session (RAM only) — deleted
+#: when the session's last socket closes. The PoW challenge state is a
+#: per-connection local in the WS handler (tracking.activity_ws).
+_pending_uploads: dict[bytes, set[str]] = {}
+
+#: Remaining message/upload actions per session (token hash), granted by a
+#: solved PoW challenge (RAM only — single worker; the challenge itself is
+#: a per-connection local in tracking.activity_ws). Keyed by token hash
+#: rather than connection so the HTTP upload route (/_contact/files)
+#: shares the same budget, and a reconnect keeps it.
+_quota: dict[bytes, int] = {}
+
+#: easypow solving cost per challenge, the actions a solution grants
+#: (messages and uploads alike), and the pending-upload quota.
+POW_SECONDS = 5.0
+POW_QUOTA = 10
+MAX_PENDING_UPLOADS = 10
+
+
+# --- Strict rendering -----------------------------------------------------
+# A separate instance from markdown.py's page renderer: no raw HTML (the
+# trusted-author passthrough would be an XSS hole here), no plugins, no
+# typographer. markdown-it escapes all markup in the source and validates
+# link protocols (javascript: etc. never become hrefs).
+
+_md = MarkdownIt("default", {"html": False, "typographer": False, "breaks": True})
+
+#: Images may only reference the content-addressed store (uploads land
+#: there via /_contact/files); anything else collapses to its alt text.
+_IMG_SRC = re.compile(r"/_f/[0-9a-f]{12}(?:\.[a-z0-9]+)?")
+
+
+def _link_rule(self, tokens, idx, options, env):
+ """Open external links in a new tab without window.opener; store-file
+ links (uploaded attachments) render as download chips."""
+ token = tokens[idx]
+ href = token.attrs.get("href", "")
+ if href.startswith("/_f/"):
+ token.attrSet("class", "attachment")
+ token.attrSet("download", "")
+ else:
+ token.attrSet("rel", "nofollow ugc noopener")
+ token.attrSet("target", "_blank")
+ return self.renderToken(tokens, idx, options, env)
+
+
+def _image_rule(self, tokens, idx, options, env):
+ """Drop images that do not point at a /_f/ store file (escaped alt)."""
+ token = tokens[idx]
+ if not _IMG_SRC.fullmatch(token.attrs.get("src", "")):
+ return escapeHtml(self.renderInlineAsText(token.children, options, env))
+ token.attrSet("loading", "lazy")
+ return self.renderToken(tokens, idx, options, env)
+
+
+_md.add_render_rule("link_open", _link_rule)
+_md.add_render_rule("image", _image_rule)
+
+
+def render_message(text: str) -> str:
+ """Render message-source Markdown to thread HTML (the only HTML a
+ visitor's message ever becomes)."""
+ return _md.render(text)
+
+
+# --- Thread store ----------------------------------------------------------
+
+
+def thread_key(token: str) -> bytes:
+ """The Data.contact key for a session token (hash, never the token)."""
+ return blake3.blake3(token.encode()).digest(12)
+
+
+def _new_id(now: datetime) -> str:
+ """Sortable message id: timestamp plus a random tiebreak."""
+ return f"{now:%Y%m%d%H%M%S%f}-{secrets.token_hex(3)}"
+
+
+def _unread(thread: ContactThread) -> bool:
+ """Admin perspective: the last message is the visitor's and newer than
+ the admin's last read."""
+ for mid in reversed(thread.messages):
+ m = thread.messages[mid]
+ if m.admin:
+ return False
+ return thread.admin_read is None or m.t > thread.admin_read
+ return False
+
+
+def _payload(key: bytes, thread: ContactThread) -> dict:
+ """Full thread state for the wire (same shape for visitors and admins;
+ clients replace/upsert by id). Messages are capped to the last 200.
+ Admin messages are labeled with the site name as currently configured
+ (not the stored SSO name, which stays in the database for audit)."""
+ brand = data.brand or "admin"
+ return {
+ "type": "thread",
+ "id": key.hex(),
+ "created": thread.created.isoformat(),
+ "unread": _unread(thread),
+ "closed": thread.closed,
+ "name": thread.name,
+ "email": thread.email,
+ "messages": [
+ {
+ "id": mid,
+ "t": m.t.isoformat(),
+ "admin": brand if m.admin else "",
+ "html": m.html,
+ "page": m.page,
+ }
+ for mid, m in list(thread.messages.items())[-200:]
+ ],
+ }
+
+
+def _empty_payload(key: bytes) -> dict:
+ return {"type": "thread", "id": key.hex(), "created": "", "unread": False,
+ "closed": False, "name": "", "email": "", "messages": []}
+
+
+async def _push(sockets, payload: dict) -> None:
+ """Send a payload to a socket set, dropping the ones that fail."""
+ closed = set()
+ for ws in sockets:
+ try:
+ await ws.send_json(payload)
+ except Exception:
+ logger.exception("Message push failed; dropping client")
+ closed.add(ws)
+ for ws in closed:
+ sockets.discard(ws)
+
+
+async def _broadcast(key: bytes, thread: ContactThread) -> None:
+ """Push a thread update to its visitor's panels and every admin."""
+ payload = _payload(key, thread)
+ if key in _visitor_ws:
+ await _push(_visitor_ws[key], payload)
+ await _push(_admin_ws, payload)
+
+
+def _append(
+ key: bytes,
+ msg: ContactMessage,
+ action: str,
+ user: str | None,
+ name: str = "",
+ email: str = "",
+) -> None:
+ """Append a message to the thread (created on first use), in one
+ transaction so the database diff is just the new entry. A visitor's
+ name/email are recorded alongside their first message."""
+ with kanta.transaction(action, user=user, extra=key.hex()):
+ thread = data.contact.get(key)
+ if thread is None:
+ thread = data.contact[key] = ContactThread(created=msg.t)
+ if name and not thread.name:
+ thread.name = name
+ thread.email = email
+ thread.messages[_new_id(msg.t)] = msg
+
+
+# --- Visitor protocol (/_ws frames carrying "token") ------------------------
+
+
+def enabled() -> bool:
+ """Messaging on/off: the site contact label doubles as the switch —
+ empty disables the feature (docs/messages.md)."""
+ return bool(data.contact_label.strip())
+
+
+def quota_left(token: str) -> int:
+ """Remaining PoW-granted actions for a session token (0 for malformed
+ tokens — they fail validation later anyway)."""
+ if not TOKEN_RE.fullmatch(token):
+ return 0
+ return _quota.get(thread_key(token), 0)
+
+
+async def visitor_proven(ws: WebSocket, raw: dict) -> bytes | None:
+ """A solved PoW challenge submitted ahead of sending ({token, pow} —
+ the solution itself is already validated and burned by the WS
+ handler): grant the message/upload quota, acknowledge it, reveal the
+ site's contact email (never carried in the page's asset config, so
+ scrapers cannot lift it without solving) and return the thread key
+ (the handler records the visit's contact trail marker in analytics).
+ None for a malformed token."""
+ token = str(raw.get("token") or "")
+ if not TOKEN_RE.fullmatch(token):
+ await ws.send_json({"type": "contact-error", "detail": "bad session token"})
+ return None
+ key = thread_key(token)
+ _visitor_ws.setdefault(key, set()).add(ws)
+ _quota[key] = POW_QUOTA
+ await ws.send_json({"type": "pow", "quota": POW_QUOTA})
+ if data.contact_email:
+ # The mailto recipient is "Brand " (RFC-encoded here; the
+ # link text is the bare address, the subject is the client's
+ # localized contact_subject).
+ recipient = (
+ quote(f"{data.brand} <{data.contact_email}>", safe="@")
+ if data.brand.strip()
+ else quote(data.contact_email, safe="@")
+ )
+ await ws.send_json(
+ {"type": "contact-email", "email": data.contact_email, "to": recipient}
+ )
+ return key
+
+
+def _spend(key: bytes) -> None:
+ """Burn one message/upload of the session's PoW quota."""
+ if _quota.get(key, 0) > 0:
+ _quota[key] -= 1
+
+
+async def visitor_frame(ws: WebSocket, raw: dict) -> None:
+ """Handle one visitor frame: ``{token}`` registers/resyncs (the thread
+ state is pushed back), ``{token, msg, fr?}`` appends a message and
+ broadcasts the updated thread to both sides, ``{token, close}`` ends
+ the conversation (archived in the admin inbox; a fresh thread needs a
+ new token, which the client rotates itself). The PoW quota check
+ happens in the WS handler (tracking.activity_ws) — message frames
+ arrive here with quota remaining, spent here per accepted message."""
+ token = str(raw.get("token") or "")
+ if not TOKEN_RE.fullmatch(token):
+ await ws.send_json({"type": "contact-error", "detail": "bad session token"})
+ return
+ key = thread_key(token)
+ _visitor_ws.setdefault(key, set()).add(ws)
+ if raw.get("close"):
+ thread = data.contact.get(key)
+ if thread and not thread.closed:
+ with kanta.transaction("contact:close", extra=key.hex()):
+ thread.closed = True
+ await _push(_admin_ws, _payload(key, thread))
+ # No more pushes to this token: drop the socket from its registry
+ # and clean up its pending uploads like a disconnect would.
+ sockets = _visitor_ws.get(key)
+ if sockets is not None:
+ sockets.discard(ws)
+ if not sockets:
+ del _visitor_ws[key]
+ for digest in _pending_uploads.pop(key, ()):
+ file_store.delete(digest)
+ return
+ text = str(raw.get("msg") or "").strip()
+ if not text:
+ thread = data.contact.get(key)
+ # A closed thread resyncs as empty: the conversation is over, and a
+ # stale client (same token) starts from a clean slate.
+ if thread and not thread.closed:
+ await ws.send_json(_payload(key, thread))
+ else:
+ await ws.send_json(_empty_payload(key))
+ return
+ if len(text) > MAX_MESSAGE:
+ await ws.send_json({"type": "contact-error", "detail": "message too long"})
+ return
+ if not enabled():
+ # Messaging off (no contact label configured): sends are rejected
+ # (the UI is already hidden); existing threads and admin replies
+ # stay.
+ await ws.send_json({"type": "contact-error", "detail": "messaging is disabled"})
+ return
+ # The first message must introduce the visitor (name; email optional,
+ # unvalidated). The stored identity is fixed from then on.
+ thread = data.contact.get(key)
+ if thread and thread.closed:
+ # Defensive: the client rotates its token on close, so a message
+ # for a closed thread only comes from a stale client.
+ await ws.send_json({"type": "contact-error", "detail": "thread closed"})
+ return
+ name = email = ""
+ if thread is None or not thread.name:
+ name = str(raw.get("name") or "").strip()[:100]
+ email = str(raw.get("email") or "").strip()[:200]
+ if not name:
+ await ws.send_json({"type": "contact-error", "detail": "name required"})
+ return
+ now = datetime.now(UTC)
+ _append(
+ key,
+ ContactMessage(
+ t=now,
+ md=text,
+ html=render_message(text),
+ page=str(raw.get("fr") or "")[:200],
+ ),
+ "contact",
+ None,
+ name,
+ email,
+ )
+ _spend(key)
+ # Files the message references are no longer pending cleanup.
+ if pending := _pending_uploads.get(key):
+ for digest in re.findall(r"/_f/([0-9a-f]{12})", text):
+ pending.discard(digest)
+ await _broadcast(key, data.contact[key])
+
+
+def visitor_disconnect(ws: WebSocket) -> None:
+ """Drop a closed /_ws socket from every registry. When a session's
+ last socket closes, its uploaded files that no sent message ended up
+ referencing are deleted from the store."""
+ empty = [key for key, sockets in _visitor_ws.items() if not sockets - {ws}]
+ for sockets in _visitor_ws.values():
+ sockets.discard(ws)
+ for key in empty:
+ del _visitor_ws[key]
+ for digest in _pending_uploads.pop(key, ()):
+ file_store.delete(digest)
+
+
+# --- Admin protocol (/_api/ws/analytics frames) -----------------------------
+
+
+def unread_count() -> int:
+ """Threads with an unanswered visitor message — the admin inbox icon's
+ badge, seeded by /_api/settings (no polling; live while the inbox's
+ socket is open). Visitor-closed (archived) threads do not count."""
+ return sum(1 for t in data.contact.values() if _unread(t) and not t.closed)
+
+
+def _key_from_hex(value) -> bytes | None:
+ try:
+ key = bytes.fromhex(str(value or ""))
+ except ValueError:
+ return None
+ return key if len(key) == 12 else None
+
+
+def _last_t(thread: ContactThread) -> datetime:
+ """The thread's last-activity time (its newest message, else creation)."""
+ if thread.messages:
+ return list(thread.messages.values())[-1].t
+ return thread.created
+
+
+async def admin_connect(ws: WebSocket) -> None:
+ """Register an admin socket and send the inbox: all threads, most
+ recently active first, visitor-closed (archived) ones last (capped at
+ 100)."""
+ _admin_ws.add(ws)
+ threads = sorted(data.contact.items(), key=lambda kv: _last_t(kv[1]), reverse=True)
+ threads = [kv for kv in threads if not kv[1].closed] + [
+ kv for kv in threads if kv[1].closed
+ ]
+ await ws.send_json(
+ {"type": "threads", "threads": [_payload(k, t) for k, t in threads[:100]]}
+ )
+
+
+def admin_disconnect(ws: WebSocket) -> None:
+ _admin_ws.discard(ws)
+
+
+async def admin_frame(ws: WebSocket, raw: dict) -> None:
+ """Handle one admin frame: ``{type: reply, id, text}`` appends an admin
+ message, ``{type: read, id}`` marks a thread read."""
+ match raw.get("type"):
+ case "reply":
+ key = _key_from_hex(raw.get("id"))
+ text = str(raw.get("text") or "").strip()
+ thread = data.contact.get(key) if key else None
+ if thread is None:
+ await ws.send_json({"type": "contact-error", "detail": "no such thread"})
+ return
+ if thread.closed:
+ await ws.send_json(
+ {"type": "contact-error", "detail": "the visitor closed this conversation"}
+ )
+ return
+ if not text or len(text) > MAX_MESSAGE:
+ await ws.send_json({"type": "contact-error", "detail": "bad message"})
+ return
+ now = datetime.now(UTC)
+ user = ws.headers.get("remote-user")
+ _append(
+ key,
+ ContactMessage(
+ t=now,
+ md=text,
+ html=render_message(text),
+ admin=user or "admin",
+ ),
+ "contact:reply",
+ user,
+ )
+ with kanta.transaction("contact:read", user=user, extra=key.hex()):
+ thread.admin_read = now
+ await _broadcast(key, thread)
+ case "read":
+ key = _key_from_hex(raw.get("id"))
+ thread = data.contact.get(key) if key else None
+ if thread is None:
+ return
+ with kanta.transaction(
+ "contact:read", user=ws.headers.get("remote-user"), extra=key.hex()
+ ):
+ thread.admin_read = datetime.now(UTC)
+ await _push(_admin_ws, _payload(key, thread))
+
+
+# --- Uploads -----------------------------------------------------------------
+
+#: Contact-panel uploads: images (raster and SVG) go through the usual
+#: derivative pipeline and inline in the message — SVGs included: the
+#: extension-less link negotiates raster derivatives, and even an
+#: as-is-served .svg is inert inside an (and downloads when
+#: navigated to). Every other type is stored as an inert {hash}.file
+#: blob (store_attachment — no original extension on disk) and links as
+#: /_f/{hash}/{original-name}; the .file suffix never appears in URLs.
+IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".heic", ".heif"}
+UPLOAD_MAX_BYTES = 20 * 1024 * 1024
+
+
+@router.put("/_contact/files/{name}")
+async def contact_upload(name: str, request: Request) -> dict:
+ """Store a contact-panel upload in the content-addressed store.
+
+ Public like the messaging socket (only /_api is gated): the caller
+ must present a session-shaped token and size is capped. Uploads spend
+ the same PoW quota as messages (a solved challenge covers both): with
+ no quota left the answer is 429 "pow" (429, not 403 — no auth flows
+ here) and the client re-proves and retries. The file additionally
+ stays pending per session and is deleted when the session's last
+ socket closes without a sent message referencing it, and a separate
+ quota caps those pending files.
+ Images (raster and SVG) get the usual derivative treatment and are
+ referenced extension-less; any other file is stored as an inert
+ {hash}.file blob and referenced as /_f/{hash}/{original-name}, which
+ is always served as a forced download. Returns the /_f/ path and
+ whether the file is an inline-able image.
+ """
+ token = request.headers.get("x-contact-token", "")
+ if not TOKEN_RE.fullmatch(token):
+ raise HTTPException(403, "no session")
+ key = thread_key(token)
+ if _quota.get(key, 0) <= 0:
+ raise HTTPException(429, "pow")
+ pending = _pending_uploads.setdefault(key, set())
+ if len(pending) >= MAX_PENDING_UPLOADS:
+ raise HTTPException(429, "too many un-sent uploads — send your message first")
+ try:
+ length = int(request.headers.get("content-length") or 0)
+ except ValueError:
+ length = 0
+ if length > UPLOAD_MAX_BYTES:
+ raise HTTPException(413, "file too large")
+ body = await request.body()
+ if not body:
+ raise HTTPException(400, "empty file")
+ if len(body) > UPLOAD_MAX_BYTES:
+ raise HTTPException(413, "file too large")
+ _spend(key)
+ orig = Path(name).name[:100] or "file"
+ ext = _ext(orig)
+ if ext in IMAGE_EXTS:
+ stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif")
+ # Decodable images come back extension-less (GIFs and as-is SVGs
+ # keep their extension); an undecodable body attaches like any file.
+ if "." not in stored or ext in (".gif", ".svg"):
+ pending.add(stored.partition(".")[0])
+ return {"path": f"/_f/{stored}", "image": True, "name": orig}
+ stored = await asyncio.to_thread(store_attachment, body)
+ pending.add(stored.removesuffix(".file"))
+ return {
+ "path": f"/_f/{stored.removesuffix('.file')}/{quote(orig, safe='')}",
+ "image": False,
+ "name": orig,
+ }
+
+
+@router.get("/_contact/easypow.js")
+async def pow_solver() -> Response:
+ """The easypow browser solver as an ES module, served from the
+ installed package so the client solver and the server validator are
+ always the same version."""
+ return Response(
+ easypow.js,
+ media_type="text/javascript",
+ headers={"cache-control": "public, max-age=86400"},
+ )
diff --git a/pagerite/pages.py b/pagerite/pages.py
index 46e9b59..de08264 100644
--- a/pagerite/pages.py
+++ b/pagerite/pages.py
@@ -19,7 +19,9 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from pagerite import i18n, state
-from pagerite.data import Node, resolve, sorted_nodes
+from pagerite.chunks import chunk_key
+from pagerite.data import Node, node_markdown, resolve, sorted_nodes
+from pagerite.markdown import has_h1
from pagerite.state import (
SITE_URL,
_html_response,
@@ -47,6 +49,40 @@ def _is_trackable_path(path: str) -> bool:
return not _is_reserved(path)
+def _markdown_response(
+ request: Request, path: str, node: Node, lang: str, original: str
+) -> Response:
+ """The page as Markdown (text/markdown): the same source the HTML page
+ renders (per-language hybrid assembly), with the page title injected as
+ ``# title`` when the content has no h1 of its own — mirroring
+ markdown.render's title injection, so the .md reads like the page.
+
+ Served at the page's ``.md`` URL, and also at the plain URL when the
+ request's Accept header explicitly names text/markdown (Vary: accept).
+ Language selection is the page's own (?lang= or Accept-Language), so
+ ``/page.md?lang=fi`` is the Finnish markdown."""
+ content = node_markdown(data, node) or ""
+ title = node.title
+ if lang != original and lang in node.langs:
+ content = i18n.hybrid_markdown(data, node, path, lang)
+ if t := data.trans.get(chunk_key(node.title), {}).get(lang):
+ title = t
+ if title and not has_h1(content):
+ content = f"# {title}\n\n{content}"
+ etag = f'"{path}@{node.modified.timestamp()}g{state._render_gen}l{lang}md"'
+ headers = {
+ "etag": etag,
+ "last-modified": _http_date(node.modified),
+ "cache-control": "no-cache",
+ "vary": "accept",
+ }
+ if request.headers.get("if-none-match") == etag:
+ return Response(status_code=304, headers=headers)
+ if _is_trackable_path(path):
+ _record_get(request, lang=lang)
+ return Response(content, media_type="text/markdown", headers=headers)
+
+
@router.get("/")
async def front_page(request: Request) -> Response:
"""Render the front page (slug path "")."""
@@ -164,6 +200,14 @@ async def show_page(request: Request, path: str) -> Response:
"""
path = path.strip("/")
accept_language = request.headers.get("accept-language", "")
+ # A .md suffix (or an Accept header explicitly naming text/markdown)
+ # asks for the page's Markdown instead of HTML. Slugs never contain a
+ # dot, so "page.md" unambiguously means the markdown of "page"; the
+ # front page's markdown URL is "/.md".
+ markdown = False
+ if path.endswith(".md"):
+ path = path[:-3]
+ markdown = True
if path and _is_reserved(path):
# Invalid slug shape: not a content URL, let FastAPI return its
# built-in 404 instead of rendering an editable article page.
@@ -179,18 +223,20 @@ async def show_page(request: Request, path: str) -> Response:
# Accept-Language header regardless of the selection, and record
# the resolved language as the GET's rendered language.
query_lang = request.query_params.get("lang")
+ original = i18n.primary_lang(data.menu, path)
lang = i18n.select_language(
query_lang,
accept_language,
lambda tag: tag in node.langs,
- original=i18n.primary_lang(data.menu, path),
+ original=original,
)
# A ?lang= override is replicated onto the page's navigation links
# (link_lang), so clicks and prefetches stay in the chosen language.
# Query and header-selected renders of the same language differ in
# their links, so link_lang is part of the ETag and body cache key.
link_lang = i18n.base_tag(query_lang or "")
- # no-cache forbids serving a stored page without revalidation
+ if markdown or "text/markdown" in request.headers.get("accept", ""):
+ return _markdown_response(request, path, node, lang, original)
# (browsers would otherwise cache heuristically and serve stale
# pages, e.g. after a theme change). In-session speed instead comes
# from pagerite.js's in-memory page cache (preload everything, never
@@ -209,6 +255,9 @@ async def show_page(request: Request, path: str) -> Response:
"etag": etag,
"last-modified": _http_date(node.modified),
"cache-control": "no-cache",
+ # The same URL serves text/markdown when the Accept header
+ # explicitly asks for it (_markdown_response).
+ "vary": "accept",
},
lang=lang,
link_lang=link_lang,
diff --git a/pagerite/themes/corporate/theme.css b/pagerite/themes/corporate/theme.css
index fed8a93..a349867 100644
--- a/pagerite/themes/corporate/theme.css
+++ b/pagerite/themes/corporate/theme.css
@@ -16,6 +16,7 @@
--line: #12203f14;
--font-body: var(--font-inter);
--font-heading: var(--font-montserrat);
+ --nav-weight: 600;
--code-x-height: 0.546; /* Inter's x-height ratio */
}
@@ -54,7 +55,6 @@
#nav {
font-size: 1.05em;
- font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
text-shadow: none;
diff --git a/pagerite/themes/nitro/theme.css b/pagerite/themes/nitro/theme.css
index 4a1d34b..0a1506e 100644
--- a/pagerite/themes/nitro/theme.css
+++ b/pagerite/themes/nitro/theme.css
@@ -1,6 +1,7 @@
/* Nitro theme: racing/HUD style — saturated orange + cyan, console/HUD-style
monospace display type, an orange racing-tab nav with a bezier clip-path,
- and a dark bezier banner. Follows prefers-color-scheme: the light scheme
+ matching slanted-parallelogram buttons, and a dark bezier banner. Follows
+ prefers-color-scheme: the light scheme
is a warm light-grey page, the dark scheme a deep violet page; the banner
and other dark/bright elements carry over unchanged.
@@ -41,6 +42,8 @@
--font-body: var(--font-montserrat);
--font-heading: var(--font-literata);
+ --font-nav: var(--font-heading);
+ --nav-weight: 700;
--code-x-height: 0.517; /* Montserrat's x-height ratio */
/* Neutral grey selection instead of the accent tint: accent-colored
text (h2, links, markers) stays readable on it in both schemes. */
@@ -86,9 +89,7 @@
}
#nav {
- font-family: var(--font-heading);
font-size: 0.95em;
- font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
/* Racing tab: sitting at the bottom of the banner, with a bezier clip —
@@ -135,6 +136,70 @@
color: var(--tab-muted);
}
+/* Buttons in the article flow (the {contact:} trigger and any raw
+ in the markup): a racing parallelogram with / slanted sides,
+ clipped with the same shape() language as the nav tab. All buttons
+ are forced to one fixed 1.7em height (inline-flex centering, nowrap,
+ no border — it would be invisible under the clip), so every button
+ gets the identical shape and the geometry is exact: a ~62° slant from
+ the 0.9em run. The obtuse top-left and bottom-right corners replicate
+ the nav tab's top-left corner: a wide bezier sweep with the same
+ 0.8/0.6 control-handle split along the joining edges (over a 0.5em
+ tangent run), mirrored between the two corners. The sharp corners
+ keep small arcs, in balance with the wide sweeps (0.22em, arc
+ ≈ 0.45em — about half the obtuse sweep's, so the sharp corners stay
+ subordinate). border-radius is the fallback for browsers without
+ shape(). Asymmetric inline padding keeps the text clear of the
+ slants (wider on the right: the wide bottom-right sweep cuts deeper
+ into the label's end side). Editor chrome (.icon-btn pens, .copy)
+ keeps its own styling. The {contact:} envelope's line-height is
+ reduced in the base stylesheet so it doesn't outgrow this height. */
+.contact-trigger,
+article button:not(.icon-btn):not(.copy) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 1.7em;
+ padding: 0 1.3em 0 1em;
+ white-space: nowrap;
+ /* Same typography as the contact trigger (raw s otherwise get
+ the small UA font): nav family/weight, article size and leading. */
+ font-family: var(--font-nav);
+ font-weight: var(--nav-weight);
+ font-size: inherit;
+ line-height: inherit;
+ color: var(--ink);
+ background: var(--accent);
+ border: none;
+ border-radius: 0.2em;
+ cursor: pointer;
+ clip-path: shape(
+ /* bottom edge, left to right */
+ from 0.365em 100%,
+ hline to calc(100% - 1.4em),
+ /* bottom-right corner: nav-style bezier, mirrored from top-left */
+ curve to calc(100% - 0.666em) calc(100% - 0.442em)
+ with calc(100% - 1.1em) 100% / calc(100% - 0.853em) calc(100% - 0.088em),
+ /* right slant */
+ line to calc(100% - 0.171em) 0.323em,
+ /* top-right corner: sharp, small arc */
+ arc to calc(100% - 0.365em) 0% of 0.22em ccw,
+ /* top edge, right to left */
+ hline to 1.4em,
+ /* top-left corner: nav-style bezier */
+ curve to 0.666em 0.442em with 1.1em 0 / 0.853em 0.088em,
+ /* left slant */
+ line to 0.171em calc(100% - 0.323em),
+ /* bottom-left corner: sharp, small arc */
+ arc to 0.365em 100% of 0.22em ccw,
+ close);
+}
+
+article button:not(.icon-btn):not(.copy):hover {
+ color: var(--tab-hover);
+ text-shadow: 0 0 0.1em;
+}
+
/* Console-style headings: uppercase monospace. h1 in the page text color
with a hazard-stripe underline, h2 deep orange, h3 cyan. */
article h1 {
diff --git a/pagerite/tracking.py b/pagerite/tracking.py
index 418c92b..f2a7bea 100644
--- a/pagerite/tracking.py
+++ b/pagerite/tracking.py
@@ -23,13 +23,14 @@ from functools import lru_cache
from pathlib import Path
from urllib.parse import urlparse
+import easypow
import httpx
import msgspec
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from uarite import uaparse
-from pagerite import analytics, i18n
+from pagerite import analytics, i18n, messages
from pagerite.data import resolve
from pagerite.files import _hash_name, file_store
from pagerite.state import SITE_URL, _html_response, analytics_store, data
@@ -447,6 +448,18 @@ async def activity_ws(ws: WebSocket) -> None:
abusive IPs are filtered at display time, not here. The reverse-DNS and
DB-IP geoip lookups happen in background tasks so message handling is
never delayed by slow DNS or the first MMDB decompress.
+
+ Frames carrying a ``token`` field are visitor contact messages
+ (docs/messages.md), routed to pagerite.messages — the same socket
+ serves the contact panel, which also receives thread pushes on it.
+ Sends and uploads spend a per-session quota granted by solving an
+ easypow challenge: a single-use challenge (a per-connection local) is
+ issued on registration and whenever the quota runs out; the client
+ submits the solution as soon as it is ready ({token, pow}), validated
+ and burned here, and the quota itself lives in pagerite.messages
+ keyed by token hash (shared with the HTTP upload route, kept across
+ reconnects). A validated proof also reveals the site's contact email
+ and records the visit's contact trail marker in analytics.
"""
ip = _client_ip(ws)
ua = ws.headers.get("user-agent", "")
@@ -458,11 +471,70 @@ async def activity_ws(ws: WebSocket) -> None:
part for part in (uaparse(ua).pretty, lang) if part
)
await ws.accept()
+ challenge = "" # outstanding PoW challenge for contact sends (below)
try:
while True:
text = await ws.receive_text()
try:
- msg = msgspec.json.decode(text.encode(), type=analytics.Ping)
+ raw = msgspec.json.decode(text.encode())
+ except msgspec.DecodeError:
+ continue
+ if isinstance(raw, dict) and "token" in raw:
+ # Contact-messaging frames (docs/messages.md).
+ if raw.get("msg"):
+ # A send with no quota left gets a fresh challenge
+ # instead; the client solves it, re-proves and retries.
+ if messages.quota_left(str(raw.get("token") or "")) <= 0:
+ challenge = easypow.generate(messages.POW_SECONDS)
+ await ws.send_json(
+ {"type": "pow", "challenge": challenge, "required": True}
+ )
+ continue
+ await messages.visitor_frame(ws, raw)
+ if messages.quota_left(str(raw.get("token") or "")) <= 0:
+ # Quota just ran out: push the next challenge right
+ # away so the client can pre-solve it.
+ challenge = easypow.generate(messages.POW_SECONDS)
+ await ws.send_json(
+ {"type": "pow", "challenge": challenge, "required": False}
+ )
+ continue
+ if "pow" in raw:
+ # A solved challenge, submitted as soon as ready:
+ # validate and burn it here, grant the quota in
+ # pagerite.messages (shared with the upload route).
+ try:
+ if not challenge:
+ raise ValueError("no outstanding challenge")
+ easypow.validate(challenge, str(raw.pop("pow", "") or ""))
+ except ValueError:
+ challenge = easypow.generate(messages.POW_SECONDS)
+ await ws.send_json(
+ {"type": "pow", "challenge": challenge, "required": True}
+ )
+ continue
+ challenge = "" # burned; a fresh one comes when the quota ends
+ key = await messages.visitor_proven(ws, raw)
+ if key is not None:
+ # Panel opened AND PoW solved: mark the visit's
+ # trail at the page it first happened on.
+ new_client = analytics_store.record_contact(
+ str(raw.get("fr") or ""), ip, ua, accept_language
+ )
+ if new_client is not None:
+ _schedule_client_enrichment([new_client])
+ _schedule_analytics_broadcast()
+ continue
+ # Registration/resync: the thread state is pushed back, and
+ # a fresh challenge is issued for the client to pre-solve.
+ await messages.visitor_frame(ws, raw)
+ challenge = easypow.generate(messages.POW_SECONDS)
+ await ws.send_json(
+ {"type": "pow", "challenge": challenge, "required": False}
+ )
+ continue
+ try:
+ msg = msgspec.convert(raw, type=analytics.Ping)
except msgspec.DecodeError:
continue
new_client = analytics_store.record_msg(
@@ -480,6 +552,8 @@ async def activity_ws(ws: WebSocket) -> None:
_schedule_favicon_fetch()
except WebSocketDisconnect:
pass
+ finally:
+ messages.visitor_disconnect(ws)
@router.websocket("/_api/ws/analytics")
@@ -488,15 +562,31 @@ async def analytics_websocket(ws: WebSocket) -> None:
Admin-only via the /_api forward-auth gate, like every management
endpoint. Powers the analytics viewer rendered at /_a.
+
+ The same socket serves the admin contact inbox (the overlay opened
+ from the corner ✉️ icon, docs/messages.md):
+ on connect the thread list follows the snapshot, new visitor messages
+ are pushed as they arrive, and incoming frames (``reply``/``read``)
+ go to pagerite.messages. Analytics payloads carry no ``type`` field;
+ messaging payloads always do, so the client can tell them apart.
"""
await ws.accept()
await ws.send_text(_display_json())
_analytics_ws_clients.add(ws)
+ await messages.admin_connect(ws)
try:
- # Receive until the client goes away; we only push.
+ # Receive until the client goes away; messaging frames are handled,
+ # anything else is ignored (analytics only pushes).
while True:
- await ws.receive_text()
+ text = await ws.receive_text()
+ try:
+ raw = msgspec.json.decode(text.encode())
+ except msgspec.DecodeError:
+ continue
+ if isinstance(raw, dict):
+ await messages.admin_frame(ws, raw)
except WebSocketDisconnect:
logger.debug("Analytics WS client disconnected")
finally:
_analytics_ws_clients.discard(ws)
+ messages.admin_disconnect(ws)
diff --git a/pagerite/translate.py b/pagerite/translate.py
index 4e28883..1e25c1e 100644
--- a/pagerite/translate.py
+++ b/pagerite/translate.py
@@ -309,6 +309,15 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
walk(node.children, path, node_lang)
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
+ # Site-level messaging strings (Data.contact_label/contact_subject):
+ # translated like titles and looked up at render time by chunk key
+ # (i18n.site_string). Skipped when messaging is off (no label — the
+ # label doubles as the enable switch) and in the site default
+ # language, like titles in their article's primary language.
+ if data.contact_label.strip() and lang != i18n.primary_lang(data.menu, ""):
+ for text in (data.contact_label, data.contact_subject):
+ if text and "\n" not in text:
+ emit(chunk_key(text), text, "", "title")
return items
@@ -536,6 +545,19 @@ class Dispatcher:
walk(node.children, depth + 1, node_lang)
walk(self.data.menu, 0, i18n.ORIGINAL_LANGUAGE)
+ # The site-level messaging strings ride the same list (pending_items
+ # covers them for scoped jobs): one round trip names the menu and
+ # the contact chrome alike.
+ if self.data.contact_label.strip() and lang != i18n.primary_lang(self.data.menu, ""):
+ for text in (self.data.contact_label, self.data.contact_subject):
+ if text and "\n" not in text:
+ key = chunk_key(text)
+ if (
+ lang not in self.data.trans.get(key, {})
+ and (lang, key) not in inflight
+ and (lang, key, "nav") not in self.validation_failures
+ ):
+ titles.append((0, text, key))
if len(titles) < 2:
return None
md = "\n".join(f"{' ' * depth}- {title}" for depth, title, _ in titles)
diff --git a/pagerite/views.py b/pagerite/views.py
index ceaf4bd..a4f3b79 100644
--- a/pagerite/views.py
+++ b/pagerite/views.py
@@ -336,6 +336,8 @@ def _layout(
lang: str = i18n.ORIGINAL_LANGUAGE,
canonical: str = "",
alternates: list[tuple[str, str]] = (),
+ markdown_url: str = "",
+ contact: dict[str, str] | None = None,
) -> Template:
"""Page layout template with standard assets and ES-module scripts.
@@ -368,6 +370,9 @@ def _layout(
``alternates`` ((hreflang, href) pairs) are the page's language URLs
(see docs/localization.md), emitted right after the viewport and before
the social tags: canonical first, then the hreflang alternates.
+
+ ``contact`` carries the localized contact-messaging strings
+ (_contact_config; None = messaging disabled) into the asset config.
"""
doc = Document(
E.Title, lang=lang, dir="rtl" if lang in i18n.RTL_LANGUAGES else "ltr"
@@ -391,6 +396,9 @@ def _layout(
rel="alternate", type="application/rss+xml", title="RSS feed", href="/feed.xml"
)
doc.link(rel="llms-txt", type="text/markdown", title="llms.txt", href="/llms.txt")
+ # The page's own Markdown counterpart (pages.show_page's .md mode).
+ if markdown_url:
+ doc.link(rel="alternate", type="text/markdown", href=markdown_url)
for key, value in (social or {}).items():
if value:
if key.startswith(("og:", "article:")):
@@ -411,15 +419,27 @@ def _layout(
vite_url = env.vite_url
editor_scripts, editor_css = _editor_assets()
langselect_scripts, langselect_css = _langselect_assets()
+ contact_scripts, contact_css = _contact_assets()
config = {
"pagerite:editor-src": editor_scripts[-1],
"pagerite:analytics-src": _analytics_assets()[0][0],
"pagerite:langselect-src": langselect_scripts[-1],
+ "pagerite:contact-src": contact_scripts[-1],
}
if langselect_css:
config["pagerite:langselect-css"] = ",".join(langselect_css)
+ if contact_css:
+ config["pagerite:contact-css"] = ",".join(contact_css)
if editor_css:
config["pagerite:editor-css"] = editor_css
+ # Localized contact-messaging strings (label = the {contact:} buttons'
+ # default and the visitor dialog's title, mailto subject — the address
+ # itself is revealed over the socket only after the PoW is solved).
+ # Absent entirely when messaging is disabled: pagerite.js then shows
+ # the corner ✉️ icon to admins only.
+ if contact is not None:
+ config["pagerite:contact-label"] = contact["label"]
+ config["pagerite:contact-subject"] = contact["subject"]
if vite_url:
for key, value in config.items():
doc.meta(name=key, content=value)
@@ -896,13 +916,18 @@ def page_content(
# A {cards} tag places the card rows itself (possibly several);
# without one the children are appended after the content as before.
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
- directives = None
+ # Messaging disabled: {contact:} tags render nothing at all.
+ directives = {
+ "contact": (
+ (lambda args, _env: _contact_tag(args, _env, data, lang))
+ if data.contact_label.strip()
+ else (lambda _args, _env: "")
+ )
+ }
if has_cards_tag:
- directives = {
- "cards": lambda args, _env: _cards_tag(
- menu, data, node, path, args, translation, link_lang, lang
- )
- }
+ directives["cards"] = lambda args, _env: _cards_tag(
+ menu, data, node, path, args, translation, link_lang, lang
+ )
rendered = render(
content,
path,
@@ -980,6 +1005,43 @@ def _cards(
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.MULTILINE)
+def _contact_config(data: Data, lang: str) -> dict[str, str]:
+ """The localized contact-messaging strings for the page's asset config
+ (pagerite:contact-*): the {contact:} buttons' default label doubling as
+ the visitor dialog's title, and the mailto subject (the label when no
+ separate subject is configured). Translations come from the
+ chunk-keyed trans store (i18n.site_string). The email address itself
+ is NOT here — it is revealed over the messaging socket only after the
+ proof of work is solved, so scrapers cannot lift it from pages."""
+ label = i18n.site_string(data, lang, data.contact_label)
+ return {
+ "label": label,
+ "subject": i18n.site_string(data, lang, data.contact_subject) or label,
+ }
+
+
+def _contact_tag(args: str, _env: dict, data: Data | None = None, lang: str = "") -> str:
+ """Expand a ``{contact: Label}`` directive: the button that opens the
+ visitor contact panel (docs/messages.md). The args are a per-button
+ label overriding the configured site label (Data.contact_label,
+ localized here; "Contact us" is the last-resort fallback). Callers
+ expand the tag only when messaging is enabled — with a blank
+ contact_label the tag renders nothing at all. pagerite.js binds the
+ click and lazy-loads the panel bundle; in the editor preview the
+ button stays inert."""
+ label = args.strip()
+ if not label and data is not None:
+ label = i18n.site_string(data, lang, data.contact_label)
+ return str(
+ E.button(
+ E.span("✉️", class_="icon"),
+ label or "Contact us",
+ type="button",
+ class_="contact-trigger",
+ )
+ )
+
+
def _cards_tag(
menu: dict[str, Node],
data: Data,
@@ -1119,7 +1181,7 @@ def _card(
node.modified,
# Card heuristics only mine the prose: nested {cards} rows
# would just be noise in the description extraction.
- directives={"cards": lambda _args, _env: ""},
+ directives={"cards": lambda _args, _env: "", "contact": lambda _a, _e: ""},
).html
image, _ = _media(html)
if not image:
@@ -1139,7 +1201,7 @@ def _card(
path,
node.created,
node.modified,
- directives={"cards": lambda _args, _env: ""},
+ directives={"cards": lambda _args, _env: "", "contact": lambda _a, _e: ""},
).html
if not large and html:
description = _description(html, 150)
@@ -1397,6 +1459,9 @@ def render_page(
lang,
canonical,
alternates,
+ # The page's Markdown counterpart (pages.show_page's .md mode).
+ f"/{path}.md",
+ contact=_contact_config(data, lang) if data.contact_label.strip() else None,
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),
@@ -1460,6 +1525,7 @@ def render_category(
favicon,
lang=lang,
alternates=alternates,
+ contact=_contact_config(data, lang) if data.contact_label.strip() else None,
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),
@@ -1580,6 +1646,30 @@ def _langselect_assets() -> tuple[list[str], list[str]]:
return _asset_cache["langselect"]
+def _contact_assets() -> tuple[list[str], list[str]]:
+ """Script and stylesheet URLs for the on-demand contact panel."""
+ vite_url = env.vite_url
+ if vite_url:
+ return [f"{vite_url}/src/contact-main.js"], []
+ if "contact" not in _asset_cache:
+ manifest = _manifest()
+ # import() loads no CSS automatically: collect the stylesheets of
+ # the entry and its imported chunks (same walk as _langselect).
+ scripts, stylesheets, seen = [], [], set()
+ queue = ["src/contact-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["contact"] = scripts, stylesheets
+ return _asset_cache["contact"]
+
+
def render_analytics(
menu: dict[str, Node],
brand: str = SITE_NAME,
diff --git a/pyproject.toml b/pyproject.toml
index 2349be6..b413d46 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"blake3>=1.0.9",
+ "easypow>=0.1.0",
"fastapi-vue~=1.7.2",
"fastapi[standard]>=0.141.1",
"html5tagger>=2.0.0",