From 13fecd21184384c7231141961edda3f3b53c561e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 4 Sep 2026 18:12:48 +0000 Subject: [PATCH] Hreflang alternates on category placeholder pages too --- docs/localization.md | 4 ++- pagerite/state.py | 1 + pagerite/views.py | 62 ++++++++++++++++++++++++++++++-------------- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index e691342..6caf221 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -106,7 +106,9 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`). language like content pages, but over the **subtree's** combined availability (`subtree_languages`) — they have no chunks of their own; the heading, navigation and card text localize from the title map and - the target articles' translations. + the target articles' translations. Their hreflang alternates are + computed exactly like a content page's (a translated title counts as + availability, so the language selector is offered there too). - Card descriptions and cover picks run on the target article's hybrid Markdown where that page is available in the served language, with per-card fallback to the original. diff --git a/pagerite/state.py b/pagerite/state.py index 2f4af2b..71ea613 100644 --- a/pagerite/state.py +++ b/pagerite/state.py @@ -132,6 +132,7 @@ def _render_html( data.theme, data.favicon, data.brand_html, + base_url, transition=data.transition, lang=lang, translation=translation, diff --git a/pagerite/views.py b/pagerite/views.py index e5ce61d..c74c983 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -1021,6 +1021,39 @@ def _social_meta( } +def _language_urls( + data: Data, + path: str, + node: Node, + lang: str, + original: str, + base_url: str, +) -> tuple[str, list[tuple[str, str]]]: + """(canonical, hreflang alternates) for a page (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 list the languages the page is + actually available in (``node.langs``; a category label's title counts + as its content): x-default first (the plain, autodetecting URL), then + every available language — the original again by its plain URL, + translations by ?lang=. The public language selector keys off these. + ("", []) without a base_url. + """ + if not base_url: + return "", [] + url = f"{base_url}/{path}" + canonical = url if lang == original else f"{url}?lang={lang}" + alternates = [] + if data.translate_langs: + alternates = [("x-default", url)] + [ + (tag, url if tag == original else f"{url}?lang={tag}") + for tag in sorted({original, *node.langs}) + ] + return canonical, alternates + + def render_page( menu: dict[str, Node], data: Data, @@ -1050,24 +1083,7 @@ def render_page( title = _title(path.rpartition("/")[2], node, translation, path) main = page_content(menu, data, path, translation, link_lang, 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 list the languages the page is actually - # available in: x-default first (the plain, autodetecting URL), then - # every available language — the original again by its plain URL, - # translations by ?lang=. The public language selector keys off these. - canonical = "" - alternates = [] - if base_url: - url = f"{base_url}/{path}" - canonical = url if lang == original else f"{url}?lang={lang}" - if data.translate_langs: - alternates = [("x-default", url)] + [ - (tag, url if tag == original else f"{url}?lang={tag}") - for tag in sorted({original, *node.langs}) - ] + canonical, alternates = _language_urls(data, path, node, lang, original, base_url) return str( _layout( *_page_assets(), @@ -1100,6 +1116,7 @@ def render_category( theme: str = "", favicon: str = "", brand_html: str = "", + base_url: str = "", transition: str = "cube", lang: str = i18n.ORIGINAL_LANGUAGE, translation: Translation | None = None, @@ -1115,12 +1132,16 @@ def render_category( With a translation (titles only — the category has no Markdown) the heading, navigation and card text localize per target article (docs/localization.md); ``link_lang`` replicates the ?lang= override - onto the navigation links as on content pages. + onto the navigation links as on content pages. The hreflang alternates + are computed as on content pages — a translated title makes the + language available here too. """ node = resolve(menu, path)[-1] + original = i18n.primary_lang(menu, path) if translation is None: - lang = i18n.primary_lang(menu, path) + lang = original title = _title(path.rpartition("/")[2], node, translation, path) + _, alternates = _language_urls(data, path, node, lang, original, base_url) doc = E.article with doc: doc.h1(title) @@ -1137,6 +1158,7 @@ def render_category( transition, favicon, lang=lang, + alternates=alternates, )( Title=f"{title} – {brand}" if brand else title, Brand=_brand_link(brand, brand_html, link_lang),