AkurAI Build
Menu

Public repository / 2db2dd7dccca0704d27eae8a579adde62a95b820

AkurAI-Framework / site/content/docs/i18n.md

Latest change 9876ca1b84b75a78bec25332729ec70f36d4df3d - feat(i18n): add akurai-i18n crate and {{ t }} template helper 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 (CLI / router layer) 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 a single directory (conventionally `locales/`):

```
locales/
  en.json
  is.json
  fr.json
```

Each file is a flat JSON object mapping message keys to translated strings:

```json
{
  "app.title": "My App",
  "nav.home": "Home",
  "greeting": "Hello, {name}! You have {count} notifications."
}
```

**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"`).

---

## Loading catalogs (`akurai-i18n`)

```rust
use akurai_i18n::Catalogs;
use std::path::Path;

let catalogs = Catalogs::load_dir(Path::new("locales"))?;
```

`load_dir` scans every `*.json` file in the directory; non-JSON files are silently ignored.

**Default locale selection:**
1. `"en"` if an `en.json` file is present.
2. Otherwise the lexicographically smallest locale found.

### Translation lookup

```rust
// Simple lookup — returns &str
let text = catalogs.translate("is", "nav.home");   // "Heim" or falls back

// With placeholder interpolation — returns String
let text = catalogs.translate_args(
    "is",
    "greeting",
    &[("name", "Óli"), ("count", "3")],
);
```

**Fallback chain** (same for both methods):
1. Requested locale — key found → done.
2. Default locale — key found → done.
3. The key itself — never panics, never returns empty for a non-empty key.

---

## Template helper: `{{ t "key" }}`

Inside any AkurAI template, use the `t` helper to emit a translated string:

```html
<title>{{ t "app.title" }}</title>
<nav><a href="/">{{ t "nav.home" }}</a></nav>
```

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 (dotted paths, loop variables, all the usual rules), 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.

---

## 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 |

Example context (as `akurai_json::Value`):

```json
{
  "__locale": "is",
  "__messages": {
    "app.title": "Mitt forrit",
    "nav.home": "Heim",
    "greeting": "Halló, {name}!"
  },
  "user": { "name": "Óli" }
}
```

Both keys are optional — omitting them is safe (all `{{ t "…" }}` calls fall back to the key text).

---

## Framework wiring: `Accept-Language` negotiation

The CLI / router layer is responsible for:

1. Parsing the `Accept-Language` request header (quality-factor sorted).
2. Picking the best matching locale from the loaded `Catalogs`.
3. Calling `catalogs.translate_args(locale, key, &[])` for each key (or building a `Value::Object` from the whole catalog map) to produce `__messages`.
4. Inserting `__locale` and `__messages` into the render context before calling `engine.render(name, &ctx)`.

This wiring lives in the framework's HTTP handler layer and is **not** the template crate's concern.

### Minimal wiring sketch

```rust
// In a route handler:
let locale = negotiate_locale(&req, catalogs.default_locale());
let messages = build_messages_object(&catalogs, locale);

let ctx = Value::Object(vec![
    ("__locale".into(), Value::Str(locale.into())),
    ("__messages".into(), messages),
    // … your page data …
]);
engine.render("page", &ctx)?
```

---

## Backward compatibility

Templates that do not use `{{ t "…" }}` are completely unaffected — they render byte-for-byte identically to before i18n was added.  The `__locale` and `__messages` keys are ignored unless explicitly read by a `t` expression.