From 14a37f5ab33a0b40a67d12c222bf7af78344a9f0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 2 Sep 2026 15:08:42 +0000 Subject: [PATCH] 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. --- docs/localization.md | 38 ++++++++++----- docs/migrate.md | 5 +- frontend/src/pagerite.js | 73 ++++++++++++++++++--------- pagerite/app.py | 29 +++++++---- pagerite/data.py | 4 +- pagerite/i18n.py | 9 ---- pagerite/views.py | 103 +++++++++++++++++++++++---------------- 7 files changed, 160 insertions(+), 101 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index 07a9c0e..0e20c3c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -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. -- `` points to the page **itself including the query** - (each language version is its own canonical). -- `` 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. +- `` 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). +- `` 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. - `` 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. diff --git a/docs/migrate.md b/docs/migrate.md index 721c121..78ebef5 100644 --- a/docs/migrate.md +++ b/docs/migrate.md @@ -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. diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 5b95d6f..71e1cee 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -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). diff --git a/pagerite/app.py b/pagerite/app.py index 2845791..065d61c 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -388,13 +388,13 @@ class FileStore: file_store = FileStore(FILES_DIR) -def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_LANGUAGE) -> str: +def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_LANGUAGE, link_lang: str = "") -> str: """Render one of the generated pages (see _html_response).""" if kind == "page": # A selected language without an actual translation renders the # original (translation is None = English; see docs/localization.md). translation = i18n.get_translation(path, lang, data) if lang != i18n.ORIGINAL_LANGUAGE else None - return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation) + return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang) if kind == "category": return views.render_category(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition) if kind == "not-found": @@ -418,14 +418,17 @@ def _invalidate_pages() -> None: @lru_cache(maxsize=128) -def _cached_body(kind: str, path: str, base_url: str, zstd: bool, lang: str = i18n.ORIGINAL_LANGUAGE) -> bytes: +def _cached_body(kind: str, path: str, base_url: str, zstd: bool, lang: str = i18n.ORIGINAL_LANGUAGE, link_lang: str = "") -> bytes: """Rendered page body; cleared by _invalidate_pages on any content/settings change. base_url feeds the social meta URLs, zstd selects the stored encoding (both variants are cached rather than re-compressed) and lang the selected language (not the raw Accept-Language header, which would blow up the cache key space). + link_lang is the ?lang= override replicated onto the navigation links: + a query render and a header-selected render of the same language differ + in their links, so they are cached separately. """ - body = _render_html(kind, path, base_url, lang).encode() + body = _render_html(kind, path, base_url, lang, link_lang).encode() return _zstd.compress(body) if zstd else body @@ -437,6 +440,7 @@ def _html_response( headers: dict | None = None, etag: bool = False, lang: str = i18n.ORIGINAL_LANGUAGE, + link_lang: str = "", ) -> Response: """Response for a generated page, zstd-compressed when the client accepts it (no gzip fallback). @@ -458,11 +462,11 @@ def _html_response( # localhost (varying ports) fall back to the request's own base URL. base_url = SITE_URL or str(request.base_url).rstrip("/") if DEVMODE: - identity = _render_html(kind, path, base_url, lang).encode() + identity = _render_html(kind, path, base_url, lang, link_lang).encode() body = _zstd.compress(identity) if zstd else identity else: - identity = _cached_body(kind, path, base_url, False, lang) - body = _cached_body(kind, path, base_url, True, lang) if zstd else identity + identity = _cached_body(kind, path, base_url, False, lang, link_lang) + body = _cached_body(kind, path, base_url, True, lang, link_lang) if zstd else identity h = dict(headers or {}) # Content varies by language (Accept-Language selects a translation) # and by encoding; keep caches from mixing either representation. @@ -1750,18 +1754,24 @@ async def show_page(request: Request, path: str) -> Response: # Language selection (docs/localization.md): ?lang= wins when a # translation exists, else header logic. Analytics keep the raw # Accept-Language header regardless of the selection. + query_lang = request.query_params.get("lang") lang = i18n.select_language( - request.query_params.get("lang"), + query_lang, accept_language, lambda l: l in node.langs, ) + # A ?lang= override is replicated onto the page's navigation links + # (link_lang), so clicks and prefetches stay in the chosen language. + # Query and header-selected renders of the same language differ in + # their links, so link_lang is part of the ETag and body cache key. + link_lang = i18n.base_tag(query_lang or "") # no-cache forbids serving a stored page without revalidation # (browsers would otherwise cache heuristically and serve stale # pages, e.g. after a theme change). In-session speed instead comes # from pagerite.js's in-memory page cache (preload everything, never # fetch on navigation); the ETag just makes those one-time preload # fetches and any revalidation cheap. - etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}l{lang}"' + etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}l{lang}q{link_lang}"' if request.headers.get("if-none-match") == etag: return Response(status_code=304) if _is_trackable_path(path): @@ -1777,6 +1787,7 @@ async def show_page(request: Request, path: str) -> Response: "cache-control": "no-cache", }, lang=lang, + link_lang=link_lang, ) if node is not None and node.published and node.chunks is None: # Category label without a landing page: placeholder with the pen diff --git a/pagerite/data.py b/pagerite/data.py index d7978b0..138cbc0 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -51,8 +51,8 @@ class Node(msgspec.Struct, omit_defaults=True): no_trans: dict[bytes, bool] = {} #: Languages this article is available in (besides its primary #: language). Presence-keys, value always True — the availability - #: index for rendering, language selection and hreflang alternates; - #: maintained by whoever writes translation data (docs/migrate.md). + #: index for rendering and language selection; maintained by whoever + #: writes translation data (docs/migrate.md). langs: dict[str, bool] = {} #: Raw HTML for the header banner (img, styled div, canvas+script...), #: rendered after the banner design's artwork so author code always diff --git a/pagerite/i18n.py b/pagerite/i18n.py index d239bb0..feb42a2 100644 --- a/pagerite/i18n.py +++ b/pagerite/i18n.py @@ -178,12 +178,3 @@ def get_translation(path: str, lang: str, data: Data) -> Translation | None: markdown=hybrid_markdown(data, node, path, lang), titles=title_map(data, lang), ) - - -def available_languages(path: str, data: Data) -> list[str]: - """Languages the page at ``path`` is available in (besides the - original): the ``node.langs`` index, maintained by the translation - writers. Drives language selection and hreflang alternate links.""" - chain = resolve(data.menu, path) - node = chain[-1] if chain else None - return sorted(node.langs) if node else [] diff --git a/pagerite/views.py b/pagerite/views.py index d4f4af1..e97dea9 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -312,6 +312,7 @@ def _layout( favicon: str = "", social: dict[str, str] | None = None, lang: str = i18n.ORIGINAL_LANGUAGE, + canonical: str = "", alternates: list[tuple[str, str]] = (), ) -> Template: """Page layout template with standard assets and ES-module scripts. @@ -338,24 +339,25 @@ def _layout( ``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as property attributes, everything else (description, twitter:*) as name. - ``lang`` is the served language for ; ``alternates`` holds - (hreflang, href) pairs for the other language versions of the page, - emitted as (see docs/localization.md). + ``lang`` is the served language for . ``canonical`` and + ``alternates`` ((hreflang, href) pairs) are the page's language URLs + (see docs/localization.md), emitted right after the viewport and before + the social tags: canonical first, then the hreflang alternates. """ doc = Document(E.Title, lang=lang) # Responsive layout (see the 48rem breakpoint in pagerite.css) needs # the real device width, not the default 980px layout viewport. doc.meta(name="viewport", content="width=device-width, initial-scale=1") + if canonical: + doc.link(rel="canonical", href=canonical) + for hreflang, href in alternates: + doc.link(rel="alternate", hreflang=hreflang, href=href) for key, value in (social or {}).items(): if value: if key.startswith(("og:", "article:")): doc.meta(property=key, content=value) - elif key == "canonical": - doc.link(rel="canonical", href=value) else: doc.meta(name=key, content=value) - for hreflang, href in alternates: - doc.link(rel="alternate", hreflang=hreflang, href=href) # A custom favicon (from the site editor) is linked explicitly; without # one, browsers fall back to the build's /favicon.ico by convention. if favicon: @@ -453,13 +455,13 @@ def _layout( return Template(body) -def _brand_link(brand: str, brand_html: str = "") -> HTML: +def _brand_link(brand: str, brand_html: str = "", link_lang: str = "") -> HTML: """Header brand: custom HTML (in a #brand wrapper, rendered instead of the link) when configured, else the plain brand link; omitted entirely when neither is set.""" if brand_html.strip(): return HTML(str(E.div(HTML(brand_html), id="brand"))) - return HTML(str(E.a(brand, href="/", id="brand"))) if brand else HTML("") + return HTML(str(E.a(brand, href=_href("", link_lang), id="brand"))) if brand else HTML("") def _title(slug: str, node: Node, translation: Translation | None = None, path: str = "") -> str: @@ -473,9 +475,18 @@ def _title(slug: str, node: Node, translation: Translation | None = None, path: return node.title or prettify(slug) or "Home" +def _href(path: str, link_lang: str = "") -> str: + """Site-chrome link to a page: when the page was requested with a + ?lang= override the query is replicated onto the navigation links it + renders, so clicks and prefetches (which take the href as-is) stay in + the chosen language — even without JS (docs/localization.md).""" + return f"/{path}?lang={link_lang}" if link_lang else f"/{path}" + + def _nav_link( doc, menu: dict[str, Node], node: Node, path: str, current: str, ancestors_current: bool = True, translation: Translation | None = None, + link_lang: str = "", ) -> None: """Render one
  • linking the node. Category labels (no content of their own — chunks None, or an empty page as left by the site editor's @@ -486,9 +497,9 @@ def _nav_link( is_current = current == path or ( ancestors_current and path and current.startswith(f"{path}/") ) - href = f"/{path}" + href = _href(path, link_lang) if not node.chunks and (leaf := first_leaf(menu, path)) is not None: - href = f"/{leaf}" + href = _href(leaf, link_lang) doc.li.a( _title(path.rpartition("/")[2], node, translation, path), href=href, @@ -496,7 +507,7 @@ def _nav_link( ) -def nav_html(menu: dict[str, Node], current: str, translation: Translation | None = None) -> HTML: +def nav_html(menu: dict[str, Node], current: str, translation: Translation | None = None, link_lang: str = "") -> HTML: """Render the contents of the #nav element for the current path. Top-level items in menu order; the front page (slug "", href "/") @@ -507,11 +518,11 @@ def nav_html(menu: dict[str, Node], current: str, translation: Translation | Non with nav: for slug, node in sorted_nodes(menu): if node.published: - _nav_link(nav, menu, node, slug, current, translation=translation) + _nav_link(nav, menu, node, slug, current, translation=translation, link_lang=link_lang) return HTML(str(nav)) -def sidebar_html(menu: dict[str, Node], current: str, translation: Translation | None = None) -> HTML: +def sidebar_html(menu: dict[str, Node], current: str, translation: Translation | None = None, link_lang: str = "") -> HTML: """Render the #sidebar element for the current path (empty when none). The sidebar is the current main level section's sub-navigation: the @@ -545,20 +556,20 @@ def sidebar_html(menu: dict[str, Node], current: str, translation: Translation | nav = E.ul with nav: for slug, child in items: - _sidebar_item(nav, menu, child, f"{section}/{slug}", current, translation) + _sidebar_item(nav, menu, child, f"{section}/{slug}", current, translation, link_lang) return HTML(str(E.aside(nav, id="sidebar"))) -def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: str, translation: Translation | None = None) -> None: +def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: str, translation: Translation | None = None, link_lang: str = "") -> None: """One sidebar
  • : the node link, with its published children as a nested list (third level and deeper, recursively).""" - _nav_link(doc, menu, node, path, current, ancestors_current=False, translation=translation) + _nav_link(doc, menu, node, path, current, ancestors_current=False, translation=translation, link_lang=link_lang) sub = [(s, c) for s, c in sorted_nodes(node.children) if c.published] if sub: # doc.li.a(...) above left the
  • open for nesting. with doc.ul: for slug, child in sub: - _sidebar_item(doc, menu, child, f"{path}/{slug}", current, translation) + _sidebar_item(doc, menu, child, f"{path}/{slug}", current, translation, link_lang) def first_leaf(menu: dict[str, Node], path: str) -> str | None: @@ -690,7 +701,7 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None: return None -def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None) -> HTML: +def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None, link_lang: str = "") -> HTML: """Render the contents of the #main element for a page. A page with published children (a category page) lists them as cards @@ -715,11 +726,11 @@ def page_content(menu: dict[str, Node], data: Data, path: str, translation: Tran doc = E.article(class_="multicol") if rendered.multicol else E.article with doc: doc(HTML(rendered.html)) - _cards(doc, menu, data, node, path, translation) + _cards(doc, menu, data, node, path, translation, link_lang) return HTML(str(doc)) -def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None) -> None: +def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "") -> None: """Card stacks of the node's published children (nothing when childless). One column per direct child, all in a single full-width row (the .wide @@ -745,7 +756,7 @@ def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, transl continue with doc.div(class_="stack"): for epath, enode in entries: - _card(doc, data, enode, epath, translation) + _card(doc, data, enode, epath, translation, link_lang) def _walk(node: Node, path: str): @@ -759,7 +770,7 @@ def _walk(node: Node, path: str): yield from _walk(child, f"{path}/{slug}") -def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None) -> None: +def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "") -> None: """One card in a stack: cover + title, plus the description when the page has no image (its card shows a gradient cover instead). @@ -772,7 +783,7 @@ def _card(doc, data: Data, node: Node, path: str, translation: Translation | Non image, _ = _media(html) if not image: description = _description(html, 150) - with doc.a(href=f"/{path}", class_="card"): + with doc.a(href=_href(path, link_lang), class_="card"): if image: doc.span(class_="cover", style=f'background-image: url("{image}")') else: @@ -861,7 +872,6 @@ def _share_media(html: str, base_url: str) -> tuple[str, str]: def _social_meta( node: Node, path: str, title: str, html: str, brand: str, base_url: str, - lang: str = i18n.ORIGINAL_LANGUAGE, ) -> dict[str, str]: """Open Graph/Twitter/SEO meta tags for a content page. @@ -871,9 +881,6 @@ def _social_meta( representative figure. Absolute URLs are built from the request's base (social scrapers cannot use relative ones). - When a translation is served, the canonical URL includes the ?lang= - query: each language version is its own canonical (docs/localization.md). - ``twitter:image`` pins extension-less store links to the ``.webp`` variant: X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. @@ -886,7 +893,6 @@ def _social_meta( ) return { "description": text, - "canonical": f"{url}?lang={lang}" if url and lang != i18n.ORIGINAL_LANGUAGE else url, "og:type": "article", "og:title": title, "og:description": text, @@ -914,37 +920,48 @@ def render_page( transition: str = "cube", lang: str = i18n.ORIGINAL_LANGUAGE, translation: Translation | None = None, + link_lang: str = "", ) -> str: """Render a full HTML page for the slug path. ``lang``/``translation`` serve a translated version (see docs/localization.md): None translation = the English original. + ``link_lang`` is the ?lang= override the page was requested with, + replicated onto the navigation links so the language sticks. """ node = resolve(menu, path)[-1] if translation is None: lang = i18n.ORIGINAL_LANGUAGE title = _title(path.rpartition("/")[2], node, translation, path) - main = page_content(menu, data, path, translation) - social = _social_meta(node, path, title, str(main), brand, base_url, lang) - # hreflang alternates: every other language version (with ?lang=) plus - # x-default for the plain URL. Emitted only when translations exist. + main = page_content(menu, data, path, translation, link_lang) + social = _social_meta(node, path, title, str(main), brand, base_url) + # Canonical/hreflang URLs (docs/localization.md): the canonical names + # the actually served language — the plain URL for the original (for + # SEO the non-query URL means the article's language), ?lang= for a + # translation — regardless of how the language was arrived at (query + # or header). The alternates are site-wide, the same set on every + # page: the configured translate_langs (the translator works to fill + # them all in), x-default first (the plain, autodetecting URL), then + # every language explicitly, the default language included. + canonical = "" alternates = [] if base_url: - available = i18n.available_languages(path, data) - alternates = [ - (l, f"{base_url}/{path}?lang={l}") for l in available if l != lang - ] - if available: - alternates.append(("x-default", f"{base_url}/{path}")) + url = f"{base_url}/{path}" + canonical = url if lang == i18n.ORIGINAL_LANGUAGE else f"{url}?lang={lang}" + if data.translate_langs: + alternates = [("x-default", url)] + [ + (l, f"{url}?lang={l}") + for l in [i18n.ORIGINAL_LANGUAGE, *sorted(data.translate_langs)] + ] return str( _layout( *_page_assets(), custom_css, theme, banner_design(menu, path, theme), - transition, favicon, social, lang, alternates, + transition, favicon, social, lang, canonical, alternates, )( Title=f"{title} – {brand}" if brand else title, - Brand=_brand_link(brand, brand_html), - Nav=nav_html(menu, path, translation), - Sidebar=sidebar_html(menu, path, translation), + Brand=_brand_link(brand, brand_html, link_lang), + Nav=nav_html(menu, path, translation, link_lang), + Sidebar=sidebar_html(menu, path, translation, link_lang), Banner=banner_html(menu, path, theme), Main=main, ),