Sticky ?lang= navigation + site-wide hreflang/canonical tags

- Server replicates a ?lang= override onto the navigation links it
  renders (nav, sidebar, cards, brand), so clicks and prefetches stay in
  the chosen language even without JS; link_lang is part of the ETag and
  body cache key (query and header renders of the same language differ
  in their links).
- pagerite.js drops the Accept-Language header hack: the remembered
  language rides internal fetches as ?lang= instead (added when a link
  lacks one), the page cache keys on path+query, and history/address bar
  keep the pretty query-less URL.
- Canonical names the actually served language (plain URL for the
  original, ?lang= for translations); hreflang alternates are site-wide
  from translate_langs, identical on every page: x-default (the plain
  autodetecting URL) first, then every language explicitly, default
  included, emitted right after canonical before the social tags.
This commit is contained in:
2026-09-02 15:08:42 +00:00
parent dd0a6cc04b
commit 14a37f5ab3
7 changed files with 160 additions and 101 deletions
+50 -23
View File
@@ -36,21 +36,43 @@ import "overlayscrollbars/overlayscrollbars.css";
// --- Language override (?lang=) ---------------------------------------
// /page?lang=fi serves a translated, indexable version (each language is
// its own canonical). Restore the pretty URL on load but remember the
// language: all fetch-navigation and preload requests below send it as
// Accept-Language, so the chosen language sticks for the session of
// clicks. A full refresh or shared link resets to automatic selection
// (the browser's own Accept-Language). See docs/localization.md.
// its own canonical). The chosen language sticks for the session of
// clicks: the server replicates ?lang= onto the navigation links it
// renders (nav, sidebar, cards — in-article links are content and stay
// as authored), and pageUrl adds it to internal fetches that lack one.
// The address bar keeps the pretty URL: the query is stripped on load
// and never pushed into history. A full refresh or a shared link resets
// to automatic selection (the browser's own Accept-Language — every
// plain fetch carries it by default). See docs/localization.md.
const langParam = new URL(location.href).searchParams.get("lang");
if (langParam) {
const url = new URL(location.href);
url.searchParams.delete("lang");
history.replaceState(history.state, "", url);
}
const pageHeaders = langParam ? { "Accept-Language": langParam } : {};
// The in-memory page cache is keyed per language: the same pathname holds
// different HTML for each selected language.
const cacheKey = (pathname) => (langParam ? `${langParam}|${pathname}` : pathname);
// An internal URL as fetched: carries the session's ?lang= unless the
// link already pins a language of its own. With no ?lang= on the initial
// load nothing is ever added.
const pageUrl = (url) => {
const u = new URL(url, location.href);
if (langParam && u.origin === location.origin && !u.searchParams.has("lang")) {
u.searchParams.set("lang", langParam);
}
return u;
};
// The in-memory page cache is keyed by path + query: the same pathname
// holds different HTML for each language version.
const rawKey = (url) => {
const u = new URL(url, location.href);
return u.pathname + u.search;
};
const cacheKey = (url) => rawKey(pageUrl(url));
// What goes into the address bar and history: the pretty URL, no ?lang=.
const prettyUrl = (url) => {
const u = pageUrl(url);
u.searchParams.delete("lang");
return u;
};
// Regions every page has. #sidebar is NOT among them: it is omitted
// entirely when the section has no sub-navigation, and handled below.
@@ -370,9 +392,13 @@ import "overlayscrollbars/overlayscrollbars.css";
// received it as the document (re-fetching would be redundant, and
// browser heuristics may send it without if-none-match, defeating the
// conditional request); it enters the cache when navigated to.
const pageCache = new Map(); // cacheKey(pathname) -> HTML text
const pageCache = new Map(); // rawKey/cacheKey(url) -> HTML text
addEventListener("pagerite:page-fetched", (ev) => {
pageCache.set(cacheKey(new URL(ev.detail.url, location.href).pathname), ev.detail.html);
// The editors' re-renders fetch the plain URL (no ?lang=); key by the
// URL as announced. Adding the session language would cache that
// header-language copy under the translated page's key and serve it
// back on navigation.
pageCache.set(rawKey(ev.detail.url), ev.detail.html);
});
// Editors mutate site-wide state (theme, structure, headings, banners),
@@ -391,21 +417,22 @@ import "overlayscrollbars/overlayscrollbars.css";
});
function preload() {
const urls = new Set();
const urls = new Map(); // cache key -> URL, deduped (hashes collapse)
for (const a of document.querySelectorAll(
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
)) {
urls.add(a.pathname);
const u = pageUrl(a.href);
urls.set(rawKey(u), u);
}
for (const url of urls) {
if (pageCache.has(cacheKey(url))) continue;
for (const [key, u] of urls) {
if (pageCache.has(key)) continue;
// x-pagerite-preload: idle cache warm-up, not a page view — the
// server excludes these GETs from analytics (the navigation message
// sent on actual navigation does the counting).
fetch(url, { headers: { "x-pagerite-preload": "1", ...pageHeaders } })
fetch(u, { headers: { "x-pagerite-preload": "1" } })
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : ""))
.then((html) => { if (html) pageCache.set(cacheKey(url), html); })
.then((html) => { if (html) pageCache.set(key, html); })
.catch(() => {});
}
}
@@ -715,12 +742,12 @@ import "overlayscrollbars/overlayscrollbars.css";
teardownAnalytics();
let doc;
let finalUrl = url;
const cached = !editing && pageCache.get(cacheKey(new URL(url, location.href).pathname));
const cached = !editing && pageCache.get(cacheKey(url));
if (cached) {
doc = new DOMParser().parseFromString(cached, "text/html");
} else {
try {
const res = await fetch(url, { headers: pageHeaders });
const res = await fetch(pageUrl(url));
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Reflect any redirect the server issued.
@@ -728,15 +755,15 @@ import "overlayscrollbars/overlayscrollbars.css";
const html = await res.text();
// Populate the cache too, so returning here (back/forward, or a
// self-link in the nav) is served from memory.
pageCache.set(cacheKey(new URL(finalUrl, location.href).pathname), html);
pageCache.set(cacheKey(finalUrl), html);
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
location.href = url; // fall back to a normal navigation
location.href = pageUrl(url); // fall back to a normal navigation
return false;
}
}
if (REGIONS.some((id) => !doc.getElementById(id))) {
location.href = url;
location.href = pageUrl(url);
return false;
}
const doit = () => {
@@ -816,7 +843,7 @@ import "overlayscrollbars/overlayscrollbars.css";
doit();
}
currentPath = new URL(finalUrl, location.href).pathname;
if (push) history.pushState({ idx: ++historyIdx }, "", finalUrl);
if (push) history.pushState({ idx: ++historyIdx }, "", prettyUrl(finalUrl));
// The open editor follows the URL: retarget the per-page tabs to the
// navigated-to page (unsaved text of the previous page is discarded —
// the article it previewed into is gone).