Localization #1

Merged
LeoVasanko merged 31 commits from localization into main 2026-09-03 03:54:28 +00:00
7 changed files with 160 additions and 101 deletions
Showing only changes of commit 14a37f5ab3 - Show all commits
+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.
+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).
+20 -9
View File
@@ -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
+2 -2
View File
@@ -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
-9
View File
@@ -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 []
+60 -43
View File
@@ -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 <html lang>; ``alternates`` holds
(hreflang, href) pairs for the other language versions of the page,
emitted as <link rel="alternate"> (see docs/localization.md).
``lang`` is the served language for <html lang>. ``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 <li> 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 <li>: 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 <li> 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,
),