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:
{
"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"whenen.jsonis present; otherwise the lexicographically smallest locale found.
Wiring: how it works
When akurai serve / akurai dev starts up:
- If
backend/locales/exists, all*.jsonfiles are loaded viaakurai_i18n::Catalogs::load_dir. - On every page request, the
Accept-Languageheader is parsed and the best available locale is selected (negotiate_locale). - The active locale's messages are built into a
Value::Objectand injected into the render context as__locale(the locale code) and__messages(the full key → string map). - 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>(default1.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:
<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:
<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:
<p class="muted">{{ t "footer.tagline" }}</p>
With backend/locales/ present, switching Accept-Language visibly changes the footer tagline:
# 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
- Create
backend/locales/<code>.jsonwith your translations:
{
"footer.tagline": "Ein einzelnes Binärprogramm, reines Rust-Framework.",
"nav.docs": "Dokumentation",
"hero.cta": "Loslegen"
}
- Restart
akurai serve— catalogs are loaded at startup. - Browsers or API clients sending
Accept-Language: de(orde-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.