AkurAI Build
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.