Skip to content

i18n: missing primitives — register_locale_resolver, language picker context, set_request_language helper #1716

@davidpoblador

Description

@davidpoblador

Context

Wiring per-tenant locale resolution in radio (PR alltuner/radio#407, closes #398) surfaced three places where vibetuner's i18n surface forced workarounds. None individually warranted a separate issue, but together they make the per-tenant flow harder than per-tenant theming (which was clean — `register_tenant_theme_provider` + `TenantTheme`).

What hurt

1. No public way to inject a custom locale selector

`LocaleMiddleware` is built internally with `selectors=_build_locale_selectors()` driven by `settings.locale_detection` config. Projects that need a custom resolver (e.g. "this tenant's pages always render in the tenant's language regardless of `Accept-Language`") have no way to plug into that chain.

Workaround in radio: reach down to `starlette_babel.set_locale()` directly inside `resolve_podcast()` and update `scope["state"]["language"]` by hand. Works because:

  • It runs after `LocaleMiddleware` and before response-start.
  • `LocaleMiddleware`'s `send_wrapper` reads `scope["state"]["language"]` at response-start, so the `content-language` header still matches.

But this is two coupled mutations the caller has to know about. Brittle.

Proposed: `register_locale_resolver(getter, *, priority=...)` analogous to `register_tenant_theme_provider`. The getter receives the request, returns `str | None`. Vibetuner adds it to the selector chain at the configured priority (front by default). Per-tenant locale becomes a one-liner.

2. No `set_request_language()` helper

To force a language mid-request, you need to know that both `set_locale(Locale.parse(code))` (Babel contextvar — drives `{% trans %}`) and `request.state.language = code` (template/header consumer) must be updated. Miss either and you get inconsistent rendering: `` says one thing, translated strings say another.

Proposed: a single `vibetuner.i18n.set_request_language(request, code)` helper that does both, with type checks and a clear docstring. Even if (1) lands, this is still useful for the late-bound case ("after a session login, switch to the user's preferred language").

3. No language picker template helper

Every project that exposes a language switcher in the UI re-implements:

```python
[
{"code": code, "name": Locale.parse(code).english_name}
for code in sorted(settings.project.languages)
]
```

Or worse — hardcodes the list, drifts from `.copier-answers.yml`, and mishandles display names (radio had this exact issue: a hardcoded `SUPPORTED_LANGUAGES` list out of sync with config; merged in alltuner/radio#407).

Proposed: ship `vibetuner.i18n.language_picker(display_locale=None)` returning `list[{code, name}]` — codes from `settings.project.languages`, names from Babel rendered in the requested display locale (defaults to current request locale, so the dropdown shows itself in the user's current language: "English / Spanish / Catalan" if browsing in English; "inglés / español / catalán" if browsing in Spanish).

Bonus: a registered context provider that exposes `supported_languages` (the picker's output) to every template. The header partial in (#1712, if landed) becomes a generic dropdown driven by that.

Out of scope

  • HTTP Accept-Language parsing changes (the existing chain is fine).
  • RTL handling.
  • Per-tenant translation catalogues (radio's policy is one shared catalogue, the active language changes per request — that's the right call).

Pointers

  • `vibetuner/frontend/middleware.py` — `LocaleMiddleware` setup, `_build_locale_selectors`.
  • `vibetuner/config.py` — `ProjectConfiguration.languages` / `default_language`.
  • Reference: per-tenant theming landed via `register_tenant_theme_provider` (feat: per-tenant theming via runtime CSS-variable injection #1707) and that DX was great. The same shape applied to locale would close the gap.

Filed by Claude Code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions