Fix language-switch tracking ping; serve first matching Accept-Language

The language-switch trail ping read <html lang> 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.
This commit is contained in:
2026-09-18 14:28:29 +00:00
parent 4522b8fa40
commit 867132f26b
4 changed files with 21 additions and 25 deletions
+4 -2
View File
@@ -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` frames matching the server's `Ping` msgspec struct with the fields `fr`
(source path), `to` (navigation target), `read` (active seconds on `fr` (source path), `to` (navigation target), `read` (active seconds on `fr`
since the last report), `lang` (the rendered language of the page the since the last report), `lang` (the rendered language of the page the
activity happened on — its `<html lang>`) and `hide`; falsy fields are activity happened on — its `<html lang>`, except the language-switch
omitted. One channel navigation ping, which passes the picked tag explicitly because the view
transition applies the new `<html lang>` 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 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 while the user is active the accumulated reading time is flushed every few
seconds: the times are incremental, so a disconnection simply leaves the seconds: the times are incremental, so a disconnection simply leaves the
+3 -8
View File
@@ -33,14 +33,9 @@ Deliberately simple — **q-values are ignored**:
- Selection rule (`select_language` in `pagerite/i18n.py`): - Selection rule (`select_language` in `pagerite/i18n.py`):
1. If `?lang=<tag>` is present, use it (if a translation exists; otherwise 1. If `?lang=<tag>` is present, use it (if a translation exists; otherwise
fall through to header logic). fall through to header logic).
2. If the article's original language appears anywhere in the header list, 2. Otherwise walk the header list in order and use the first language that
use the **original**. Rationale: an AI translation is strictly worse can be served — the original, or one with an available translation.
than the original for anyone who has that language configured at all 3. Fall back to the original.
(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.
Region tags normalize to their base subtag (`fi-FI``fi`). Region tags normalize to their base subtag (`fi-FI``fi`).
+9 -5
View File
@@ -628,7 +628,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
wsQueue.push(msg); 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 // Reading-time updates from the analytics page itself are not tracked
// (/_a is admin machinery; the server would reject the path anyway). // (/_a is admin machinery; the server would reject the path anyway).
if (!to && currentPath === "/_a") return; if (!to && currentPath === "/_a") return;
@@ -637,7 +637,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
if (to) msg.to = to; if (to) msg.to = to;
const secs = Math.round(read / 1000); const secs = Math.round(read / 1000);
if (secs > 0) msg.read = secs; if (secs > 0) msg.read = secs;
const lang = document.documentElement.lang; // The rendered language: normally the live <html lang>, but a language
// switch passes it explicitly — the swap that updates <html> runs inside
// the view-transition callback, after the switch ping goes out.
lang = lang || document.documentElement.lang;
if (lang) msg.lang = lang; if (lang) msg.lang = lang;
if (!msg.to && !msg.read) return; if (!msg.to && !msg.read) return;
report(msg); report(msg);
@@ -818,9 +821,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
await load(currentPath, false); await load(currentPath, false);
scrollTo(0, y); scrollTo(0, y);
// Log the switch as a trail event in the new language (load() updated // Log the switch as a trail event in the new language (load() updated
// <html lang>): the ping matches the switch's GET server-side, so it is // <html lang>, but possibly inside a still-pending view transition, so
// not misclassified as a crawler hit. // pass the tag explicitly): the ping matches the switch's GET
ping({ to: currentPath }); // server-side, so it is not misclassified as a crawler hit.
ping({ to: currentPath, lang: tag });
}); });
// --- Fetch navigation ------------------------------------------------ // --- Fetch navigation ------------------------------------------------
+5 -10
View File
@@ -87,21 +87,16 @@ def select_language(
1. ``?lang=`` wins when a translation exists for it (otherwise falls 1. ``?lang=`` wins when a translation exists for it (otherwise falls
through to the header logic). through to the header logic).
2. The original language anywhere in the header list wins — an AI 2. Otherwise the first header language that can be served — the
translation is strictly worse than the original for anyone who has original, or one with an available translation.
English configured at all. 3. Fall back to the original.
3. Otherwise the first header language with an available translation.
4. Fall back to the original.
""" """
if query_lang: if query_lang:
tag = base_tag(query_lang) tag = base_tag(query_lang)
if tag == original or (tag and is_available(tag)): if tag == original or (tag and is_available(tag)):
return tag return tag
langs = parse_accept_language(accept_language or "") for lang in parse_accept_language(accept_language or ""):
if original in langs: if lang == original or is_available(lang):
return original
for lang in langs:
if lang != original and is_available(lang):
return lang return lang
return original return original