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>
176 lines
5.7 KiB
Python
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
|