Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 79 additions & 13 deletions vibetuner-docs/docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<select name="language">
{% for code, name in locale_names.items() %}
<option value="{{ code }}"
{% if code == current_language %}selected{% endif %}>
{{ name }}
{% for entry in language_picker() %}
<option value="{{ entry.code }}"
{% if entry.code == language %}selected{% endif %}>
{{ entry.name }}
</option>
{% endfor %}
</select>
```

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

Expand All @@ -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 `<html lang>` 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

Expand Down
92 changes: 92 additions & 0 deletions vibetuner-docs/docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html lang>` 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
<select name="language">
{% for entry in language_picker() %}
<option value="{{ entry.code }}"
{% if entry.code == language %}selected{% endif %}>
{{ entry.name }}
</option>
{% endfor %}
</select>
```

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:
Expand Down
9 changes: 9 additions & 0 deletions vibetuner-docs/docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<style>:root { ... }</style>`
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
Expand Down
5 changes: 4 additions & 1 deletion vibetuner-py/src/vibetuner/frontend/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading