i18n: bilingual landing page (EN / IT) with auto-detect routing
Public landing page is now served in English and Italian via path-
prefixed URLs (/en/ and /it/), with the bare / detecting the
visitor's language and 302-ing to the right one.
Routing
-------
* GET / : if authed → dashboard (unchanged). Otherwise the visitor's
language is resolved from (in priority order) the rtm.lang cookie,
the Accept-Language header, the cf-ipcountry geolocation header,
and finally DEFAULT_LANG, then a 302 redirects to /<lang>/.
* GET /en/ + /it/ : render the localised landing template, set the
rtm.lang cookie (1-year, SameSite=Lax) so the next /-visit goes
straight to the same translation. Logged-in users with user.lang
set bypass detection for / (same priority chain — user.lang wins
on every public surface they touch).
Storage
-------
* app/locales/<lang>.yaml — flat-ish nested copy files. YAML chosen
so a future translator can edit without touching Python. Strings
containing inline HTML (<strong>, <em>, <a>) are rendered with the
Jinja `safe` filter in the template.
* app/services/locales.py — loads at startup, exposes get_locale()
and detect_public_lang(). Wraps each YAML tree in a small _Dotted
view so templates can write {{ t.hero.subhead }} (deliberately NOT
a dict subclass — dict's built-in method names would shadow YAML
keys like `items`).
Template + chrome
-----------------
* landing.html ported in full to {{ t.<key> }} references.
* public_base.html gets <html lang="…"> + hreflang link tags (en/it/
x-default) when a route opts in via lang_switch=true. A tiny
EN | IT link group lands in the public header, only visible on
surfaces that opt in.
* public.css picks up the small lang-switch widget styles.
Scope (intentionally narrow)
----------------------------
* Only the landing page is localised. Pricing, terms, privacy,
disclaimer, login, verify all stay English-only for now; their
header chrome stays English too because localising labels there
while the linked content is still EN would be a worse mismatch.
* When other public pages get translated, lang_switch=true on those
routes will surface the same widget there with no template changes.
Tests
-----
* tests/test_locales.py covers YAML load parity (every active
language has a file), dotted access through the tree, the
detection precedence chain, and the unknown-locale fallback.
Deps
----
* pyyaml was already in requirements.lock as a transitive but not
declared. Added to pyproject so it stays pinned as an explicit
direct dependency.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d98a90f35e
commit
ee8384f1ba
10 changed files with 721 additions and 93 deletions
137
tests/test_locales.py
Normal file
137
tests/test_locales.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Tests for the public-landing locale loader and language detection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.locales import (
|
||||
ACTIVE_PUBLIC_LANGS,
|
||||
DEFAULT_LANG,
|
||||
detect_public_lang,
|
||||
get_locale,
|
||||
load_locales,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_locales_load_all_active_languages():
|
||||
"""Every code in ACTIVE_PUBLIC_LANGS must have a YAML file and
|
||||
return a usable dict — keeps deploys honest when a new language
|
||||
is added to the list but the YAML is missing."""
|
||||
load_locales()
|
||||
for lang in ACTIVE_PUBLIC_LANGS:
|
||||
t = get_locale(lang)
|
||||
assert t, f"no copy loaded for {lang}"
|
||||
|
||||
|
||||
def test_locale_dotted_access():
|
||||
"""Templates use {{ t.hero.subhead }} syntax — the wrapper must
|
||||
expose nested dotted access through the whole tree."""
|
||||
t = get_locale("en")
|
||||
assert isinstance(t.hero.tagline, str)
|
||||
assert isinstance(t.features.news.title, str)
|
||||
assert isinstance(t.not_strip.items, list)
|
||||
assert t.not_strip.items[0] # non-empty
|
||||
|
||||
|
||||
def test_unknown_locale_falls_back_to_default():
|
||||
t = get_locale("zz")
|
||||
# Same shape as the default — the lookup should hand back the
|
||||
# default locale, not raise.
|
||||
assert hasattr(t, "hero") or t == {}
|
||||
|
||||
|
||||
def test_en_and_it_have_matching_top_level_keys():
|
||||
"""Translation parity check — both languages must define the
|
||||
same top-level sections. Lets a missing IT section show up in
|
||||
CI rather than as a 500 on the live page."""
|
||||
en_keys = set(iter(get_locale("en")))
|
||||
it_keys = set(iter(get_locale("it")))
|
||||
assert en_keys == it_keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_public_lang precedence chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_user_lang_wins_over_everything():
|
||||
"""A logged-in user's stored preference is the highest-priority
|
||||
signal — never overridden by detection on a public surface."""
|
||||
lang = detect_public_lang(
|
||||
cookie_lang="it",
|
||||
accept_language="fr,fr-FR;q=0.9",
|
||||
cf_country="DE",
|
||||
user_lang="en",
|
||||
)
|
||||
assert lang == "en"
|
||||
|
||||
|
||||
def test_detect_cookie_wins_over_header_and_geo():
|
||||
lang = detect_public_lang(
|
||||
cookie_lang="it",
|
||||
accept_language="en-US",
|
||||
cf_country="DE",
|
||||
user_lang=None,
|
||||
)
|
||||
assert lang == "it"
|
||||
|
||||
|
||||
def test_detect_accept_language_first_subtag():
|
||||
"""Accept-Language is parsed to its base subtag — 'en-GB' resolves
|
||||
to 'en'. Falls through cookie (None) to header."""
|
||||
lang = detect_public_lang(
|
||||
cookie_lang=None,
|
||||
accept_language="en-GB,en;q=0.9,it;q=0.5",
|
||||
cf_country=None,
|
||||
user_lang=None,
|
||||
)
|
||||
assert lang == "en"
|
||||
|
||||
|
||||
def test_detect_geolocation_when_no_other_signal():
|
||||
lang = detect_public_lang(
|
||||
cookie_lang=None,
|
||||
accept_language=None,
|
||||
cf_country="IT",
|
||||
user_lang=None,
|
||||
)
|
||||
assert lang == "it"
|
||||
|
||||
|
||||
def test_detect_default_when_nothing_matches():
|
||||
"""Falls through to DEFAULT_LANG when no signal returns a code
|
||||
in ACTIVE_PUBLIC_LANGS. e.g. a French browser hitting from a
|
||||
German IP — neither lang is in our active set, default wins."""
|
||||
lang = detect_public_lang(
|
||||
cookie_lang=None,
|
||||
accept_language="fr-FR,fr;q=0.9",
|
||||
cf_country="DE",
|
||||
user_lang=None,
|
||||
)
|
||||
assert lang == DEFAULT_LANG
|
||||
|
||||
|
||||
def test_detect_invalid_cookie_ignored():
|
||||
"""A cookie pointing at a language we don't support shouldn't
|
||||
derail detection — fall through to the next signal."""
|
||||
lang = detect_public_lang(
|
||||
cookie_lang="zz",
|
||||
accept_language="it",
|
||||
cf_country=None,
|
||||
user_lang=None,
|
||||
)
|
||||
assert lang == "it"
|
||||
|
||||
|
||||
def test_detect_empty_inputs_default():
|
||||
lang = detect_public_lang(
|
||||
cookie_lang=None,
|
||||
accept_language=None,
|
||||
cf_country=None,
|
||||
user_lang=None,
|
||||
)
|
||||
assert lang == DEFAULT_LANG
|
||||
Loading…
Add table
Add a link
Reference in a new issue