"""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 ``.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 (````, ````, ````) 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