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:
+50
-23
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user