Localization #1
+25
-13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user