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