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
176
app/services/locales.py
Normal file
176
app/services/locales.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Locale loader for the public landing page (and, eventually, other
|
||||
public surfaces). Loads YAML translation files from ``app/locales/``
|
||||
at process startup into in-memory dicts; templates access them via the
|
||||
``t`` context variable.
|
||||
|
||||
Adding a new language: drop ``<code>.yaml`` into app/locales/ and add
|
||||
the code to ``ACTIVE_PUBLIC_LANGS`` here. No code changes elsewhere
|
||||
should be required.
|
||||
|
||||
Format: arbitrary nested dict. Values that contain inline HTML are
|
||||
rendered with the Jinja2 ``safe`` filter in the template — keep
|
||||
markup minimal (``<strong>``, ``<em>``, ``<a>``) and don't put user
|
||||
input through here. The dicts are wrapped in a small dotted-access
|
||||
helper so templates can write ``{{ t.hero.subhead }}`` instead of
|
||||
``{{ t['hero']['subhead'] }}``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.logging import get_logger
|
||||
|
||||
log = get_logger("locales")
|
||||
|
||||
LOCALES_DIR = Path(__file__).resolve().parent.parent / "locales"
|
||||
|
||||
# Public-surface languages. Mirrors ``services.i18n.ACTIVE_LANGUAGES``
|
||||
# but is kept separate because the public surface may roll a new
|
||||
# language out independently of the in-app translations.
|
||||
ACTIVE_PUBLIC_LANGS = ("en", "it")
|
||||
DEFAULT_LANG = "en"
|
||||
|
||||
|
||||
class _Dotted:
|
||||
"""Read-only dotted-access view over a YAML-loaded dict tree.
|
||||
|
||||
Templates write ``{{ t.hero.tagline }}`` instead of
|
||||
``{{ t['hero']['tagline'] }}``. The wrapper deliberately does NOT
|
||||
subclass ``dict`` — dict's built-in methods (``items``, ``keys``,
|
||||
``values``, ``copy``, ``update``, ``pop`` …) would shadow YAML
|
||||
keys with the same name. With this wrapper, a YAML key called
|
||||
``items`` (which the landing copy actually has: ``not_strip.items``)
|
||||
resolves through ``__getattr__`` like any other key.
|
||||
|
||||
Mutability is intentionally not supported: locale data is loaded
|
||||
once at startup and read-only afterwards.
|
||||
"""
|
||||
__slots__ = ("_data",)
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self._data = data
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
if key.startswith("_"):
|
||||
raise AttributeError(key)
|
||||
if key in self._data:
|
||||
return _wrap(self._data[key])
|
||||
raise AttributeError(key)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return _wrap(self._data[key])
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._data
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._data)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._data)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, _Dotted):
|
||||
return self._data == other._data
|
||||
return self._data == other
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"_Dotted({self._data!r})"
|
||||
|
||||
|
||||
def _wrap(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return _Dotted(value)
|
||||
if isinstance(value, list):
|
||||
return [_wrap(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
_LOADED: dict[str, _Dotted] = {}
|
||||
|
||||
|
||||
def _load_one(lang: str) -> _Dotted:
|
||||
path = LOCALES_DIR / f"{lang}.yaml"
|
||||
if not path.exists():
|
||||
log.warning("locale.missing_file", lang=lang, path=str(path))
|
||||
return _Dotted({})
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return _Dotted(raw)
|
||||
|
||||
|
||||
def load_locales() -> None:
|
||||
"""Populate the module-level cache. Called once at app startup;
|
||||
subsequent calls re-read from disk (useful for tests)."""
|
||||
_LOADED.clear()
|
||||
for lang in ACTIVE_PUBLIC_LANGS:
|
||||
_LOADED[lang] = _load_one(lang)
|
||||
log.info("locales.loaded", languages=list(_LOADED.keys()))
|
||||
|
||||
|
||||
def get_locale(lang: str) -> _Dotted:
|
||||
"""Return the loaded translations for ``lang``, falling back to
|
||||
``DEFAULT_LANG`` for an unknown code. Lazy-loads on first call
|
||||
so importers don't have to remember to call ``load_locales()``
|
||||
explicitly."""
|
||||
if not _LOADED:
|
||||
load_locales()
|
||||
if lang in _LOADED:
|
||||
return _LOADED[lang]
|
||||
return _LOADED.get(DEFAULT_LANG, _Dotted({}))
|
||||
|
||||
|
||||
# ----- request-time language detection ---------------------------------------
|
||||
|
||||
|
||||
# Country codes whose primary language is Italian. cf-ipcountry uses
|
||||
# ISO-3166 alpha-2.
|
||||
_GEO_TO_LANG = {
|
||||
"IT": "it", "SM": "it", "VA": "it",
|
||||
# Italian-speaking Swiss canton: we can't detect canton from country
|
||||
# so a Swiss visitor defaults to en here. The Accept-Language path
|
||||
# above catches the actual Italian-speakers in CH.
|
||||
}
|
||||
|
||||
|
||||
def detect_public_lang(
|
||||
cookie_lang: str | None,
|
||||
accept_language: str | None,
|
||||
cf_country: str | None,
|
||||
user_lang: str | None,
|
||||
) -> str:
|
||||
"""Resolve the language for a public-page request.
|
||||
|
||||
Priority (highest first):
|
||||
1. user_lang — a logged-in user's stored preference. Consistent
|
||||
with their dashboard, never overridden by detection.
|
||||
2. cookie_lang — sticky from a previous explicit toggle.
|
||||
3. accept_language — browser locale. First language tag only,
|
||||
stripped to its base subtag ("en-US" -> "en").
|
||||
4. cf_country — Cloudflare's IP-derived country code, mapped to
|
||||
a primary language via ``_GEO_TO_LANG``.
|
||||
5. ``DEFAULT_LANG`` ("en").
|
||||
|
||||
Returns a value guaranteed to be in ``ACTIVE_PUBLIC_LANGS``.
|
||||
"""
|
||||
if user_lang and user_lang in ACTIVE_PUBLIC_LANGS:
|
||||
return user_lang
|
||||
if cookie_lang and cookie_lang in ACTIVE_PUBLIC_LANGS:
|
||||
return cookie_lang
|
||||
if accept_language:
|
||||
first = accept_language.split(",", 1)[0].split(";", 1)[0]
|
||||
base = first.split("-", 1)[0].strip().lower()
|
||||
if base in ACTIVE_PUBLIC_LANGS:
|
||||
return base
|
||||
if cf_country:
|
||||
cc = cf_country.strip().upper()
|
||||
if cc in _GEO_TO_LANG:
|
||||
mapped = _GEO_TO_LANG[cc]
|
||||
if mapped in ACTIVE_PUBLIC_LANGS:
|
||||
return mapped
|
||||
return DEFAULT_LANG
|
||||
Loading…
Add table
Add a link
Reference in a new issue