From 867132f26b35a01527042cc74c2e7bdf14d04c5f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 18 Sep 2026 14:28:29 +0000 Subject: [PATCH] Fix language-switch tracking ping; serve first matching Accept-Language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language-switch trail ping read before the view transition applied the swap, so it reported the previous language (and read pings never update trail-item languages server-side): multi- language visits recorded as single-language trails and the analytics flag combining had nothing to show. The switch ping now passes the picked tag explicitly. Language negotiation no longer prefers the article's original language anywhere in the Accept-Language list — nearly every browser lists English as a fallback, so translations were almost never served. The first servable header language now wins, the original counting only in its natural position. --- docs/analytics.md | 6 ++++-- docs/localization.md | 11 +++-------- frontend/src/pagerite.js | 14 +++++++++----- pagerite/i18n.py | 15 +++++---------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index f792b5d..bc1bca9 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -110,8 +110,10 @@ whole browsing session and sends activity messages over it — JSON text frames matching the server's `Ping` msgspec struct with the fields `fr` (source path), `to` (navigation target), `read` (active seconds on `fr` since the last report), `lang` (the rendered language of the page the -activity happened on — its ``) and `hide`; falsy fields are -omitted. One channel +activity happened on — its ``, except the language-switch +navigation ping, which passes the picked tag explicitly because the view +transition applies the new `` only after the ping goes out) and +`hide`; falsy fields are omitted. One channel 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 diff --git a/docs/localization.md b/docs/localization.md index 20c5abe..b3613e1 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -33,14 +33,9 @@ Deliberately simple — **q-values are ignored**: - Selection rule (`select_language` in `pagerite/i18n.py`): 1. If `?lang=` is present, use it (if a translation exists; otherwise fall through to header logic). - 2. If the article's original language appears anywhere in the header list, - use the **original**. Rationale: an AI translation is strictly worse - than the original for anyone who has that language configured at all - (e.g. `fi-FI, fi, en-US, en` gets English, not machine-translated - Finnish). - 3. Otherwise walk the header list in order and use the first language for - which a translation exists. - 4. Fall back to the original. + 2. Otherwise walk the header list in order and use the first language that + can be served — the original, or one with an available translation. + 3. Fall back to the original. Region tags normalize to their base subtag (`fi-FI` → `fi`). diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index d9179c5..e640ec2 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -628,7 +628,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; wsQueue.push(msg); } - function ping({ to, fr = currentPath, read = 0 } = {}) { + 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; @@ -637,7 +637,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; if (to) msg.to = to; const secs = Math.round(read / 1000); if (secs > 0) msg.read = secs; - const lang = document.documentElement.lang; + // The rendered language: normally the live , but a language + // switch passes it explicitly — the swap that updates runs inside + // the view-transition callback, after the switch ping goes out. + lang = lang || document.documentElement.lang; if (lang) msg.lang = lang; if (!msg.to && !msg.read) return; report(msg); @@ -818,9 +821,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; await load(currentPath, false); scrollTo(0, y); // Log the switch as a trail event in the new language (load() updated - // ): the ping matches the switch's GET server-side, so it is - // not misclassified as a crawler hit. - ping({ to: currentPath }); + // , but possibly inside a still-pending view transition, so + // pass the tag explicitly): the ping matches the switch's GET + // server-side, so it is not misclassified as a crawler hit. + ping({ to: currentPath, lang: tag }); }); // --- Fetch navigation ------------------------------------------------ diff --git a/pagerite/i18n.py b/pagerite/i18n.py index 4badf5e..da5163d 100644 --- a/pagerite/i18n.py +++ b/pagerite/i18n.py @@ -87,21 +87,16 @@ def select_language( 1. ``?lang=`` wins when a translation exists for it (otherwise falls through to the header logic). - 2. The original language anywhere in the header list wins — an AI - translation is strictly worse than the original for anyone who has - English configured at all. - 3. Otherwise the first header language with an available translation. - 4. Fall back to the original. + 2. Otherwise the first header language that can be served — the + original, or one with an available translation. + 3. Fall back to the original. """ if query_lang: tag = base_tag(query_lang) if tag == original or (tag and is_available(tag)): return tag - langs = parse_accept_language(accept_language or "") - if original in langs: - return original - for lang in langs: - if lang != original and is_available(lang): + for lang in parse_accept_language(accept_language or ""): + if lang == original or is_available(lang): return lang return original