Phase 1: content negotiation (?lang=, Accept-Language) + localization/migration design docs

- pagerite/i18n.py: parse_accept_language, select_language, Translation struct,
  get_translation/available_languages stubs
- app.py: language threaded through show_page/_render_html/_cached_body,
  ETag and Vary: accept-language
- views.py: <html lang>, translated nav titles with fallback, canonical
  with ?lang=, hreflang alternates
- pagerite.js: strip ?lang= via replaceState, send it as Accept-Language
  on fetch-navigation/preloads, lang-aware page cache
- docs/localization.md: negotiation + URL scheme + phase-2 fragment model
- docs/migrate.md: migrate_v3 content-addressed chunk storage plan
This commit is contained in:
2026-09-02 01:14:10 +00:00
parent b6e6e46cfb
commit 218313ba7a
6 changed files with 584 additions and 50 deletions
+26 -8
View File
@@ -34,6 +34,24 @@ 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.
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);
// Regions every page has. #sidebar is NOT among them: it is omitted
// entirely when the section has no sub-navigation, and handled below.
const REGIONS = ["page-banner", "nav", "main"];
@@ -352,9 +370,9 @@ 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(); // pathname -> HTML text
const pageCache = new Map(); // cacheKey(pathname) -> HTML text
addEventListener("pagerite:page-fetched", (ev) => {
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
pageCache.set(cacheKey(new URL(ev.detail.url, location.href).pathname), ev.detail.html);
});
// Editors mutate site-wide state (theme, structure, headings, banners),
@@ -380,14 +398,14 @@ import "overlayscrollbars/overlayscrollbars.css";
urls.add(a.pathname);
}
for (const url of urls) {
if (pageCache.has(url)) continue;
if (pageCache.has(cacheKey(url))) 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" } })
fetch(url, { headers: { "x-pagerite-preload": "1", ...pageHeaders } })
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : ""))
.then((html) => { if (html) pageCache.set(url, html); })
.then((html) => { if (html) pageCache.set(cacheKey(url), html); })
.catch(() => {});
}
}
@@ -697,12 +715,12 @@ import "overlayscrollbars/overlayscrollbars.css";
teardownAnalytics();
let doc;
let finalUrl = url;
const cached = !editing && pageCache.get(new URL(url, location.href).pathname);
const cached = !editing && pageCache.get(cacheKey(new URL(url, location.href).pathname));
if (cached) {
doc = new DOMParser().parseFromString(cached, "text/html");
} else {
try {
const res = await fetch(url);
const res = await fetch(url, { headers: pageHeaders });
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.
@@ -710,7 +728,7 @@ 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(new URL(finalUrl, location.href).pathname, html);
pageCache.set(cacheKey(new URL(finalUrl, location.href).pathname), html);
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
location.href = url; // fall back to a normal navigation