read.markets/app/services/locales.py
Giorgio Gilestro ee8384f1ba 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>
2026-05-29 17:10:45 +02:00

176 lines
5.7 KiB
Python

"""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