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
+25 -13
View File
@@ -36,15 +36,24 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
- Canonical URLs stay pretty (`/some-page`). Each language version is
addressable as `/some-page?lang=fi` so search engines can index them.
- `<link rel="canonical">` points to the page **itself including the query**
(each language version is its own canonical).
- `<link rel="alternate" hreflang="…">` entries point to every other language
version (with `?lang=`), plus `x-default` for the plain URL.
- On page load, pagerite.js removes the `?lang=` query via
`history.replaceState` (restoring the pretty URL) but remembers the language
in a JS variable. All fetch-navigation and preloads it performs afterwards
send that language in the `Accept-Language` header, so the chosen language
sticks for the session of clicks.
- `<link rel="canonical">` names the **actually served language**: the plain
URL when serving the original (for SEO the non-query URL means the
article's own language), `?lang=xx` when serving a translation — however
the language was arrived at (query or header).
- `<link rel="alternate" hreflang="…">` entries follow the canonical
directly (before the social meta tags) and are the same set on every
page — the site-wide configured languages (`translate_langs`, which the
translator works to fill in): `x-default` first, pointing at the plain
autodetecting URL, then every language explicitly with `?lang=`, the
default language included.
- The override sticks for the session of clicks: a page requested with
`?lang=` replicates the query onto the navigation links it renders (nav,
sidebar, cards, brand — in-article links are content and stay as
authored), so plain clicks and no-JS navigation keep the language.
pagerite.js additionally strips the query from the address bar via
`history.replaceState` (pretty, shareable URLs), remembers the language,
and adds it to every internal fetch that lacks one (preloads,
fetch-navigations, history traversals); history entries stay query-less.
- A full page refresh or a shared link resets to automatic selection (header
only). This gives a clean one-time override without cookies.
@@ -53,7 +62,10 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
- Content responses carry `Vary: accept-language` (added to the existing
`accept-encoding` vary).
- `_cached_body` and the page ETag include the **selected language** (not the
raw header, which would blow up the cache key space).
raw header, which would blow up the cache key space) and the **replicated
link language**: a `?lang=fi` render and a header-selected Finnish render
of the same page differ in their navigation links, so they are cached as
separate variants.
- `<html lang="…">` reflects the served language.
### Rendering
@@ -188,9 +200,9 @@ def get_translation(path, lang, data) -> Translation | None:
- Availability is an article-level index: `node.langs: dict[lang, True]`,
maintained by the translation writers (translator job, patch saves) in the
same transaction as their data writes — rendering, language selection and
hreflang never probe the `trans` store chunk by chunk. A stale key is
benign (the "translation" just renders as the original).
same transaction as their data writes — rendering and language selection
never probe the `trans` store chunk by chunk. A stale key is benign (the
"translation" just renders as the original).
- `titles` for nav/sidebar/cards: each node's translated title is
`trans.get(hash(node.title), {}).get(lang)` with per-node fallback — one
dict lookup per nav item at render time.
+3 -2
View File
@@ -119,8 +119,9 @@ translation data, in the same transaction:
falling back to `chunks[h]`; then apply `patches.get(f"{path}:{L}", [])`
in order (per-hunk, best effort); then `markdown.render` as today. All of
this assembles the `Translation` the phase-1 plumbing already consumes.
- **Availability:** `available_languages(path)` = `sorted(node.langs)`;
hreflang alternates and `?lang=` handling use exactly this set.
- **Availability:** `node.langs` is the availability index; `?lang=`
handling uses exactly this set. (hreflang alternates are site-wide from
`translate_langs` instead — see docs/localization.md.)
- **Save (primary language):** server re-chunks the submitted Markdown,
inserts new hashes into `Data.chunks`, replaces `node.chunks`. Unchanged
chunks keep their hashes — only genuinely new text lands in the diff.