Menu
Public repository / master
AkurAI-Framework / site/content/docs/i18n.md
Latest change 68c23789cce8e807c988190dcb1e6a3699cd5ed6 - OLI-156: path-prefix locales, hreflang alternates, sitemap xhtml:link by Ólafur Búi Ólafsson
---
title: "Internationalisation (i18n)"
description: "Locale message catalogs, the {{ t }} template helper, and Accept-Language wiring."
---
# Internationalisation (i18n)
AkurAI-Framework ships two cooperating pieces:
- **`akurai-i18n`** — loads per-locale JSON message catalogs from a directory, resolves translations with a fallback chain, and interpolates `{placeholder}` values.
- **`{{ t "key" }}`** — a template helper that reads the active locale's messages from the render context and emits the translated string.
The two pieces are decoupled: the template engine knows nothing about files or locales — it just does a cheap map lookup on reserved context keys. The framework bridges them by negotiating `Accept-Language` and injecting the right catalog into the context before rendering.
---
## Catalog format
One JSON file per locale, placed in `backend/locales/` next to `backend/page.json`:
```
backend/
locales/
en.json
is.json
```
Each file is a flat JSON object mapping message keys to translated strings:
```json
{
"footer.tagline": "A single-binary, pure-Rust web framework.",
"nav.docs": "Docs",
"hero.cta": "Get started"
}
```
**Rules:**
- Keys and values must be JSON strings (no nesting, no arrays, no numbers).
- Placeholder syntax is `{identifier}` — curly braces around a plain word.
- The file stem is the locale code (`en.json` → locale `"en"`).
- The default locale is `"en"` when `en.json` is present; otherwise the lexicographically smallest locale found.
---
## Wiring: how it works
When `akurai serve` / `akurai dev` starts up:
1. If `backend/locales/` exists, all `*.json` files are loaded via `akurai_i18n::Catalogs::load_dir`.
2. On every page request, the `Accept-Language` header is parsed and the best available locale is selected (`negotiate_locale`).
3. The active locale's messages are built into a `Value::Object` and injected into the render context as `__locale` (the locale code) and `__messages` (the full key → string map).
4. Template `{{ t "key" }}` expressions resolve against `__messages` — a cheap map lookup.
**No `backend/locales/` directory** → catalogs are `None`, the `__locale` / `__messages` keys are absent, and all `{{ t "…" }}` expressions fall back to the key text. Existing templates are unaffected.
---
## Accept-Language negotiation
The negotiation function (`negotiate_locale`) implements RFC 7231 quality factors:
- Comma-separated tags with optional `;q=<weight>` (default `1.0`).
- Tags sorted descending by quality weight.
- Each tag tried first as exact match, then by primary subtag (`is-IS` → `is`).
- Falls back to the default locale if nothing matches or the header is empty.
```
Accept-Language: is-IS, en;q=0.8
→ chooses "is" (primary subtag match, quality 1.0)
Accept-Language: en;q=0.5, is;q=0.9
→ chooses "is" (higher quality weight)
Accept-Language: fr
→ chooses "en" (default, fr not available)
(empty)
→ chooses "en" (default)
```
---
## Template helper: `{{ t "key" }}`
Inside any AkurAI template, use the `t` helper to emit a translated string:
```html
<p>{{ t "footer.tagline" }}</p>
<a href="/docs">{{ t "nav.docs" }}</a>
<a href="/start">{{ t "hero.cta" }}</a>
```
With placeholder substitution — supply named arguments as `name=context.path`:
```html
<p>{{ t "greeting" name=user.name count=inbox.unread }}</p>
```
Each `name=path` argument resolves `path` against the current template context, then replaces `{name}` in the translated string.
**Behavior summary:**
| Situation | Output |
|-----------|--------|
| Key found in `__messages` | Translated string (HTML-escaped) |
| Key missing from `__messages` | Key text itself (HTML-escaped) |
| `__messages` not in context | Key text itself |
| Placeholder arg resolves to nothing | `{placeholder}` left verbatim |
| `{{{ t "key" }}}` triple-brace form | Translated string, **not** HTML-escaped |
The helper never panics. Missing translations degrade gracefully to the key — visible, distinguishable, fixable.
---
## Demo element
The site footer (`site/frontend/footer.html`) uses:
```html
<p class="muted">{{ t "footer.tagline" }}</p>
```
With `backend/locales/` present, switching `Accept-Language` visibly changes the footer tagline:
```bash
# English (default)
curl -H 'Accept-Language: en' http://localhost:8090/
# → footer shows: A single-binary, pure-Rust web framework.
# Icelandic
curl -H 'Accept-Language: is' http://localhost:8090/
# → footer shows: Eitt-tvíandarforrit, hrein Rust vefumgjörð.
```
Without `backend/locales/`, `{{ t "footer.tagline" }}` renders as the literal key `footer.tagline` — backward-compatible and visibly flagged.
---
## Adding a locale
1. Create `backend/locales/<code>.json` with your translations:
```json
{
"footer.tagline": "Ein einzelnes Binärprogramm, reines Rust-Framework.",
"nav.docs": "Dokumentation",
"hero.cta": "Loslegen"
}
```
2. Restart `akurai serve` — catalogs are loaded at startup.
3. Browsers or API clients sending `Accept-Language: de` (or `de-DE`) will now receive German translations.
Keys absent from a locale catalog fall back to the default locale (`en`), then to the key itself — no missing-translation crashes.
---
## Context contract (`__locale` + `__messages`)
The template engine reads two reserved keys from the root context:
| Key | Type | Content |
|-----|------|---------|
| `__locale` | `Str` | Active locale code, e.g. `"is"` |
| `__messages` | `Object` | Flat map of key → translated string for the active locale |
Both keys are optional — omitting them is safe. The CLI injects them automatically when `backend/locales/` is present.
---
## SSG note
Static-generation (prerender / ISR) renders use the **default locale** — there is no live `Accept-Language` header available at warm time or during background revalidation. Dynamic page routes (no `prerender`/`revalidate`) negotiate per-request as normal.
---
## Path-prefix locales
Set `"locale_prefix": true` at the top level of `backend/routes.json` to make the URL choose the locale: the default locale is served at `/`, every other locale under `/<code>` (e.g. `/en`, `/en/docs`). The `akurai-lang` cookie and `Accept-Language` are ignored in this mode; `/<default>/…` is not a route. Route definitions are written once, unprefixed. `<html lang="{{ __locale }}">` follows the active locale, and SSG caches are keyed per locale.
The render context also gets `hreflang_links` — ready-made `<link rel="alternate" hreflang>` tags for every locale plus `x-default` (the default-locale URL), absolute when `site_url` is set in `page.json`. Emit it in the head with `{{{ hreflang_links }}}`. See [SEO](/docs/seo) for the sitemap side.