diff --git a/vibetuner-docs/docs/development-guide.md b/vibetuner-docs/docs/development-guide.md index 74401357..afb090d5 100644 --- a/vibetuner-docs/docs/development-guide.md +++ b/vibetuner-docs/docs/development-guide.md @@ -965,23 +965,37 @@ The following language-related variables are available in templates: | `default_language` | `str` | Default language code (e.g., "en") | | `supported_languages` | `set[str]` | Set of supported language codes | | `locale_names` | `dict[str, str]` | Language codes to native display names | +| `language` | `str` | The active language for the current request | -#### Using `locale_names` for Language Selectors +A `language_picker()` Jinja global is also available — see below. -The `locale_names` dict maps language codes to their native display names, sorted alphabetically: +#### Using `language_picker()` for Locale-Aware Switchers + +`language_picker()` is a Jinja global (also importable as +`vibetuner.i18n.language_picker`) that returns a sorted list of +`{code, name}` entries with names rendered in the **current request's +locale**. Browsing in Spanish gives "inglés / español / catalán"; +browsing in Catalan gives "anglès / espanyol / català". ```html ``` -Example output: `{"ca": "Català", "en": "English", "es": "Español"}` +Pass an explicit `display_locale` to render names in a specific language +regardless of the request locale: `{% for e in language_picker("es") %}`. + +#### Using `locale_names` for native names + +`locale_names` is locale-independent — each language is shown in its own native name +(e.g. `{"ca": "Català", "en": "English", "es": "Español"}`). Use this when you want a +consistent display regardless of the user's current language. ### SEO-Friendly Language URLs @@ -1005,12 +1019,64 @@ The `LangPrefixMiddleware` handles path-prefix language routing: Languages are detected in this order (first match wins): -1. Query parameter (`?l=es`) -2. URL path prefix (`/ca/...`) -3. User preference (from session, for authenticated users) -4. Cookie (`language` cookie) -5. Accept-Language header (browser preference) -6. Default language +1. **Custom resolvers** registered via + [`register_locale_resolver`](#custom-locale-resolvers-register_locale_resolver) +2. Query parameter (`?l=es`) +3. URL path prefix (`/ca/...`) +4. User preference (from session, for authenticated users) +5. Cookie (`language` cookie) +6. Accept-Language header (browser preference) +7. Default language + +#### Custom Locale Resolvers (`register_locale_resolver`) + +For per-tenant or domain-specific locale rules, register a custom resolver at +startup. Resolvers run **before** all built-in selectors and the first one to +return a non-`None` value wins. Within the registered group, resolvers are +ordered by `priority` ascending (lower runs first). + +```python +from vibetuner.i18n import register_locale_resolver + +def tenant_locale(conn): + tenant = getattr(conn.scope.get("state", {}), "tenant", None) + return tenant.language if tenant else None + +register_locale_resolver(tenant_locale) +``` + +Resolvers must be synchronous (do any I/O upstream in middleware). If a +resolver raises, the exception is logged and the chain falls through to the +next resolver — a bad lookup never produces a 500. + +#### Forcing a Language Mid-Request (`set_request_language`) + +To change the active language partway through a request (e.g. right after a +session login), use `set_request_language`. It updates both the Babel context +(drives `{% trans %}`) and `request.state.language` (drives `` and +the `Content-Language` header) in one call so they stay in sync. + +```python +from vibetuner.i18n import set_request_language + +set_request_language(request, user.preferred_language) +``` + +The code is normalized to lowercase and validated; an invalid code raises +`ValueError`. + +#### Programmatic Language Picker (`language_picker`) + +When you need the picker output outside a template (e.g. JSON endpoint, email +rendering), call `language_picker` directly. By default the names are +rendered in the current request's locale. + +```python +from vibetuner.i18n import language_picker + +choices = language_picker() # display in current locale +es_choices = language_picker(display_locale="es") # always in Spanish +``` #### Redirect Behavior diff --git a/vibetuner-docs/docs/llms-full.txt b/vibetuner-docs/docs/llms-full.txt index 4c773831..9b4878bb 100644 --- a/vibetuner-docs/docs/llms-full.txt +++ b/vibetuner-docs/docs/llms-full.txt @@ -1389,6 +1389,98 @@ rules to load the binary before any `--font-display` variable can switch to it, which is a different mechanism. Tracked in [vibetuner#1705](https://github.com/alltuner/vibetuner/issues/1705). +### i18n Primitives + +`vibetuner.i18n` exposes three primitives for apps that need to override +locale detection or render language switchers without hardcoding language +lists. + +#### `register_locale_resolver(getter, *, priority=0)` + +Inject a custom selector at the **front** of `LocaleMiddleware`'s chain. +Resolvers receive the `HTTPConnection` and return either a locale code or +`None` to defer to the next resolver. All registered resolvers run before +the built-in chain (query param → URL prefix → user session → cookie → +`Accept-Language`). Within the registered group, lower `priority` runs +first; ties fall back to insertion order. + +```python +from vibetuner.i18n import register_locale_resolver + + +def tenant_locale(conn): + tenant = conn.scope.get("state", {}).get("tenant") + return tenant.language if tenant else None + + +register_locale_resolver(tenant_locale) +``` + +The combined selector is fail-soft: a resolver that raises is logged and +the chain falls through. Resolvers must be synchronous — do DB lookups in +upstream middleware that already attached the tenant to `request.state`. + +#### `set_request_language(request, code)` + +Force the active language for the rest of the request. Updates **both** +the Babel context (drives `{% trans %}`) and `request.state.language` +(drives `` and `Content-Language`) so all three stay in sync. +Use this for the late-bound case — e.g. switching to the user's preferred +language right after a session login. + +```python +from vibetuner.i18n import set_request_language + + +@router.post("/login") +async def login(request: Request, ...): + user = await authenticate(...) + if user.preferred_language: + set_request_language(request, user.preferred_language) + ... +``` + +The code is normalized to lowercase and validated with +`babel.Locale.parse`; an invalid code raises `ValueError`. + +#### `language_picker(display_locale=None, *, supported_languages=None)` + +Returns a sorted `[{"code", "name"}, ...]` list. Names are rendered in +`display_locale` so the dropdown shows itself in the user's current +language. Browsing in Spanish gives "inglés / español / catalán"; +browsing in Catalan gives "anglès / espanyol / català". When +`display_locale` is omitted, the current Babel context locale is used. + +```python +from vibetuner.i18n import language_picker + +picker = language_picker() # display in current request's locale +es = language_picker(display_locale="es") # always in Spanish +``` + +The function is also registered as a Jinja global so templates can call +it directly — no new context variable, no override of the existing +`supported_languages` template var (which stays a `set[str]` of codes): + +```jinja + +``` + +Pass an explicit `display_locale` to override the default of "current +request locale": `{% for e in language_picker("es") %}` always renders +in Spanish. + +The existing `locale_names` template variable (locale-independent native +names — `{"ca": "Català", "en": "English"}`) and the `supported_languages` +template variable (set of codes) are unchanged. + ### Service Dependency Injection Vibetuner provides FastAPI `Depends()` wrappers for built-in services: diff --git a/vibetuner-docs/docs/llms.txt b/vibetuner-docs/docs/llms.txt index 45400782..beab3cf2 100644 --- a/vibetuner-docs/docs/llms.txt +++ b/vibetuner-docs/docs/llms.txt @@ -80,6 +80,15 @@ Important notes: render, fail-soft. Vibetuner's `base/skeleton.html.jinja` already includes `base/theme.html.jinja`, which emits a CSP-noncified `` block after `bundle.css`. `bundle.css` stays tenant-agnostic and cached +- **i18n Primitives**: `vibetuner.i18n` ships + `register_locale_resolver(getter, *, priority=0)` to inject custom selectors + at the front of `LocaleMiddleware`'s chain (per-tenant locale becomes a + one-liner), `set_request_language(request, code)` to update both the Babel + context and `request.state.language` in one call, and `language_picker(display_locale=None)` + returning `[{code, name}]` with names rendered in the current request's locale + (browsing in Spanish gives "inglés / español / catalán"; never English-only). + `language_picker` is also registered as a Jinja global so templates can call + it directly without overriding any existing template variable - **Service DI**: `get_email_service()`, `get_blob_service()`, `get_runtime_config()` FastAPI dependency wrappers - **Email Providers**: Pluggable transactional email via Resend diff --git a/vibetuner-py/src/vibetuner/frontend/middleware.py b/vibetuner-py/src/vibetuner/frontend/middleware.py index 7b26e7a7..65237cb3 100644 --- a/vibetuner-py/src/vibetuner/frontend/middleware.py +++ b/vibetuner-py/src/vibetuner/frontend/middleware.py @@ -384,13 +384,16 @@ def _build_locale_selectors() -> list: Selectors are evaluated in order. The first one that returns a valid locale wins. Order is fixed by design: + 0. user-registered resolvers (vibetuner.i18n.register_locale_resolver) 1. query_param - ?l=ca query parameter 2. url_prefix - /ca/... path prefix 3. user_session - authenticated user's stored preference 4. cookie - language cookie 5. accept_language - browser Accept-Language header """ - selectors: list = [] + from vibetuner.i18n import combined_locale_selector + + selectors: list = [combined_locale_selector] config = settings.locale_detection if config.query_param: diff --git a/vibetuner-py/src/vibetuner/i18n.py b/vibetuner-py/src/vibetuner/i18n.py new file mode 100644 index 00000000..b7d25081 --- /dev/null +++ b/vibetuner-py/src/vibetuner/i18n.py @@ -0,0 +1,229 @@ +# ABOUTME: Public i18n primitives — locale resolver registry, request-language helper, language picker +# ABOUTME: Used by apps that need to override the locale chain or expose a language switcher +"""Public i18n primitives for vibetuner apps. + +Three pieces of glue that close the gap between vibetuner's +:class:`~starlette_babel.LocaleMiddleware` setup and per-tenant / +user-driven language flows: + +* :func:`register_locale_resolver` — inject a custom selector into the + locale-detection chain (e.g. "this tenant always renders in its own + language regardless of ``Accept-Language``"). +* :func:`set_request_language` — update both the Babel context and + ``request.state.language`` in one call so ``{% trans %}``, the + ```` attribute and the ``Content-Language`` header all + agree. +* :func:`language_picker` — produce ``[{code, name}]`` for a language + switcher, with names rendered in the *current* display locale (not + hard-coded to English). + +``language_picker`` is also exposed as a Jinja global so templates can +call it directly — no new context variable, no override of the existing +``supported_languages`` template var (which stays a ``set[str]`` of +codes). +""" + +from collections.abc import Callable + +from babel import Locale, UnknownLocaleError +from fastapi.requests import HTTPConnection +from starlette.requests import Request +from starlette_babel import get_locale, set_locale + +from vibetuner.context import ctx +from vibetuner.logging import logger + + +__all__ = [ + "LocaleResolver", + "combined_locale_selector", + "get_locale_resolvers", + "language_picker", + "register_locale_resolver", + "set_request_language", +] + + +LocaleResolver = Callable[[HTTPConnection], str | None] + + +_resolvers: list[tuple[int, int, LocaleResolver]] = [] +_insertion_counter = 0 + + +def register_locale_resolver( + resolver: LocaleResolver, + *, + priority: int = 0, +) -> LocaleResolver: + """Register a custom locale resolver at the front of the selector chain. + + Resolvers receive the current + :class:`~fastapi.requests.HTTPConnection` and return either a + locale code (e.g. ``"ca"``) or ``None`` to defer to the next + resolver. The first resolver to return a non-``None`` value wins. + + All registered resolvers run *before* vibetuner's built-in + selectors (query param, URL prefix, user session, cookie, + ``Accept-Language``). Within the registered group, resolvers are + ordered by ``priority`` ascending — lower numbers run first. Ties + fall back to insertion order. + + The combined selector is fail-soft: if a resolver raises, the + exception is logged and the next resolver runs. This avoids + turning a misbehaving per-tenant lookup into a 500. + + Args: + resolver: Callable that takes an ``HTTPConnection`` and returns + ``str | None``. Must be synchronous; do any I/O upstream. + priority: Sort key within the registered group. Lower runs + first. Defaults to ``0``. + + Returns: + The resolver, unchanged — handy for use as a decorator. + + Example:: + + from vibetuner.i18n import register_locale_resolver + + def tenant_locale(conn): + tenant = conn.scope.get("state", {}).get("tenant") + return tenant.language if tenant else None + + register_locale_resolver(tenant_locale) + """ + if not callable(resolver): + raise TypeError( + f"register_locale_resolver: expected callable, got {type(resolver).__name__}" + ) + global _insertion_counter + _resolvers.append((priority, _insertion_counter, resolver)) + _insertion_counter += 1 + _resolvers.sort(key=lambda item: (item[0], item[1])) + return resolver + + +def get_locale_resolvers() -> list[LocaleResolver]: + """Return registered locale resolvers in chain order.""" + return [resolver for _, _, resolver in _resolvers] + + +def combined_locale_selector(conn: HTTPConnection) -> str | None: + """Run registered resolvers in priority order; return the first hit. + + This is the single selector vibetuner inserts into + :class:`~starlette_babel.LocaleMiddleware` to delegate to + user-registered resolvers. Apps usually do not call it directly. + """ + for resolver in get_locale_resolvers(): + try: + result = resolver(conn) + except Exception as exc: + logger.error( + f"locale resolver {getattr(resolver, '__name__', resolver)!r} raised {exc!r}" + ) + continue + if result: + return result + return None + + +def _reset_locale_resolvers() -> None: + """Test hook: clear the resolver registry.""" + global _insertion_counter + _resolvers.clear() + _insertion_counter = 0 + + +def set_request_language(request: Request, code: str) -> None: + """Force the active language for the rest of the request. + + Updates both the Babel context (drives ``{% trans %}``) and + ``request.state.language`` (drives ```` and the + ``Content-Language`` response header) so all three stay in sync. + + The code is normalized to lowercase and validated with + :func:`babel.Locale.parse`; an invalid code raises + :class:`ValueError` rather than letting Babel surface a less + helpful error later in the request. + + Use this for the late-bound case — e.g. switching to the user's + preferred language right after a session login. For the + "every request for tenant X uses language Y" case, prefer + :func:`register_locale_resolver`. + + Args: + request: The active Starlette/FastAPI request. + code: Two-letter language code (e.g. ``"ca"``). + + Raises: + TypeError: If ``code`` is not a string. + ValueError: If ``code`` is not a parseable locale. + """ + if not isinstance(code, str): + raise TypeError( + f"set_request_language: code must be str, got {type(code).__name__}" + ) + normalized = code.lower() + try: + locale = Locale.parse(normalized) + except (UnknownLocaleError, ValueError) as exc: + raise ValueError( + f"set_request_language: {code!r} is not a valid locale code" + ) from exc + set_locale(locale) + request.state.language = normalized + + +def language_picker( + display_locale: str | Locale | None = None, + *, + supported_languages: set[str] | None = None, +) -> list[dict[str, str]]: + """Return a sorted ``[{code, name}, ...]`` list for a language switcher. + + Names are rendered in ``display_locale`` so the dropdown shows + itself in the user's current language. Browsing in Spanish gives + "inglés / español / catalán"; browsing in Catalan gives + "anglès / espanyol / català". When ``display_locale`` is omitted, + the current Babel context locale is used (falls back to the + project's default language). + + Args: + display_locale: Locale used to render display names. Accepts a + string code or a :class:`babel.Locale`. Defaults to the + current request's locale. + supported_languages: Override the set of language codes to + include. Defaults to ``settings.project.languages``. + + Returns: + A list of ``{"code": "", "name": ""}`` + dicts, sorted by ``name``. + """ + languages = ( + supported_languages + if supported_languages is not None + else ctx.supported_languages + ) + + if display_locale is None: + try: + display = get_locale() + except Exception: + display = Locale.parse(ctx.default_language) + elif isinstance(display_locale, Locale): + display = display_locale + else: + display = Locale.parse(display_locale) + + entries: list[dict[str, str]] = [] + for code in languages: + try: + locale = Locale.parse(code) + name = locale.get_display_name(display) or code + except Exception: + name = code + entries.append({"code": code, "name": name}) + + entries.sort(key=lambda entry: entry["name"].casefold()) + return entries diff --git a/vibetuner-py/src/vibetuner/rendering.py b/vibetuner-py/src/vibetuner/rendering.py index b150e375..cc95817c 100644 --- a/vibetuner-py/src/vibetuner/rendering.py +++ b/vibetuner-py/src/vibetuner/rendering.py @@ -755,6 +755,12 @@ def _csp_nonce_context(request: Request) -> dict[str, Any]: jinja_env.globals.update({"url_for_language": url_for_language}) jinja_env.globals.update({"hreflang_tags": hreflang_tags}) +# Language picker (lazy import to avoid circular dependency on vibetuner.i18n) +from vibetuner.i18n import language_picker as _language_picker # noqa: E402 + + +jinja_env.globals.update({"language_picker": _language_picker}) + # Date Filters jinja_env.filters["timeago"] = timeago jinja_env.filters["format_date"] = format_date diff --git a/vibetuner-py/tests/unit/test_i18n.py b/vibetuner-py/tests/unit/test_i18n.py new file mode 100644 index 00000000..590519a2 --- /dev/null +++ b/vibetuner-py/tests/unit/test_i18n.py @@ -0,0 +1,178 @@ +# ABOUTME: Unit tests for vibetuner.i18n public primitives +# ABOUTME: Covers register_locale_resolver, set_request_language, language_picker +# ruff: noqa: S101 + +"""Tests for the public i18n primitives. + +The module under test is :mod:`vibetuner.i18n`. These tests exercise the +public surface only — internal registries are reset between tests via the +``reset_locale_resolvers`` fixture so registrations from one test do not +bleed into the next. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from babel import Locale +from starlette_babel import set_locale +from vibetuner import i18n + + +@pytest.fixture(autouse=True) +def reset_locale_resolvers(): + """Clear the resolver registry between tests.""" + i18n._reset_locale_resolvers() + yield + i18n._reset_locale_resolvers() + + +def _fake_request(language: str | None = None) -> MagicMock: + """Build a minimal Request stand-in that supports request.state.language.""" + request = MagicMock() + request.state = SimpleNamespace() + if language is not None: + request.state.language = language + return request + + +def _fake_connection(state: dict | None = None) -> MagicMock: + """Build a minimal HTTPConnection stand-in for selector calls.""" + conn = MagicMock() + conn.scope = {"state": state or {}} + return conn + + +class TestRegisterLocaleResolver: + def test_resolver_appears_in_chain(self): + def resolver(conn): + return "ca" + + i18n.register_locale_resolver(resolver) + chain = i18n.get_locale_resolvers() + + assert resolver in chain + + def test_resolvers_ordered_by_priority_low_first(self): + first = MagicMock(return_value=None) + second = MagicMock(return_value=None) + third = MagicMock(return_value=None) + + i18n.register_locale_resolver(second, priority=10) + i18n.register_locale_resolver(third, priority=20) + i18n.register_locale_resolver(first, priority=0) + + chain = i18n.get_locale_resolvers() + assert chain == [first, second, third] + + def test_combined_selector_returns_first_non_none(self): + i18n.register_locale_resolver(lambda conn: None, priority=0) + i18n.register_locale_resolver(lambda conn: "es", priority=10) + i18n.register_locale_resolver(lambda conn: "ca", priority=20) + + combined = i18n.combined_locale_selector + assert combined(_fake_connection()) == "es" + + def test_combined_selector_returns_none_when_all_none(self): + i18n.register_locale_resolver(lambda conn: None) + i18n.register_locale_resolver(lambda conn: None) + + assert i18n.combined_locale_selector(_fake_connection()) is None + + def test_combined_selector_with_no_resolvers_returns_none(self): + assert i18n.combined_locale_selector(_fake_connection()) is None + + def test_combined_selector_swallows_resolver_exceptions(self, log_sink): + def boom(conn): + raise RuntimeError("kaboom") + + i18n.register_locale_resolver(boom, priority=0) + i18n.register_locale_resolver(lambda conn: "ca", priority=10) + + assert i18n.combined_locale_selector(_fake_connection()) == "ca" + assert any("kaboom" in m for m in log_sink) + + def test_register_rejects_non_callable(self): + with pytest.raises(TypeError): + i18n.register_locale_resolver("not a function") # type: ignore[arg-type] + + +class TestSetRequestLanguage: + def test_updates_request_state_and_babel_contextvar(self): + request = _fake_request(language="en") + # seed Babel context with a different language + set_locale(Locale.parse("en")) + + i18n.set_request_language(request, "ca") + + assert request.state.language == "ca" + from starlette_babel import get_locale + + assert str(get_locale()) == "ca" + + def test_rejects_non_string_code(self): + request = _fake_request() + with pytest.raises(TypeError): + i18n.set_request_language(request, 42) # type: ignore[arg-type] + + def test_rejects_invalid_locale_code(self): + request = _fake_request() + with pytest.raises(ValueError): + i18n.set_request_language(request, "not-a-locale") + + def test_normalizes_to_lowercase(self): + request = _fake_request() + i18n.set_request_language(request, "CA") + assert request.state.language == "ca" + + +class TestLanguagePicker: + def test_returns_code_and_name_for_each_supported_language(self): + result = i18n.language_picker( + display_locale="en", supported_languages={"en", "ca", "es"} + ) + + codes = {entry["code"] for entry in result} + assert codes == {"en", "ca", "es"} + for entry in result: + assert "code" in entry + assert "name" in entry + assert entry["name"] + + def test_names_render_in_requested_display_locale(self): + en = i18n.language_picker(display_locale="en", supported_languages={"en", "es"}) + es = i18n.language_picker(display_locale="es", supported_languages={"en", "es"}) + + en_map = {e["code"]: e["name"] for e in en} + es_map = {e["code"]: e["name"] for e in es} + + assert en_map["es"].lower().startswith("spanish") + assert es_map["es"].lower().startswith("español") + assert es_map["en"].lower().startswith("inglés") + + def test_default_display_locale_uses_babel_contextvar(self): + set_locale(Locale.parse("ca")) + + result = i18n.language_picker(supported_languages={"en", "ca", "es"}) + names = {e["code"]: e["name"] for e in result} + + assert names["es"].lower().startswith("espanyol") + assert names["en"].lower().startswith("anglès") + + def test_result_is_sorted_by_name(self): + result = i18n.language_picker( + display_locale="en", supported_languages={"en", "ca", "es"} + ) + names = [e["name"] for e in result] + assert names == sorted(names) + + def test_supported_languages_defaults_to_settings(self): + # No explicit list — should fall back to ctx.supported_languages. + result = i18n.language_picker(display_locale="en") + assert isinstance(result, list) + assert all("code" in e and "name" in e for e in result) + + def test_exposed_as_jinja_global(self): + from vibetuner.rendering import jinja_env + + assert jinja_env.globals.get("language_picker") is i18n.language_picker