<!-- demo:embed example="extensions/4-i18n-locale-pack" -->

The shipped builder is English-only out of the box. Multi-language SaaS builds layer locale packs on top — French / Vietnamese / Spanish / whichever audience you ship to. This doc shows the i18n API + the pattern for shipping a locale. **Time budget: 20 minutes.**

## The contract

Every UI string in the engine routes through `I18n.t('key')`. The base dictionary is `src/lang/en.json`. To translate, you call `I18n.init({...})` with a flat key/value object before `builder.load()` runs.

```javascript
import I18n from './includes/I18n.js';

I18n.init({
    'widgets.heading':    'Titre',
    'widgets.paragraph':  'Paragraphe',
    'elements.heading':   'Titre',
    /* …more keys… */
});
```

`I18n.init()` **replaces** the dictionary wholesale (W6.C.14 lesson — no merge). You ship the full pack you want active.

## Two key namespaces

There are two top-level namespaces (W6.C.14 lesson):

- **`widgets.*`** — palette item labels (`widgets.heading`, `widgets.paragraph`, `widgets.image`, …).
- **`elements.*`** — canvas + settings panel labels (`elements.heading`, `elements.paragraph`, `elements.image`, …).
- **`controls.*`** — control labels (`controls.text_color`, `controls.font_size`, …).

A locale pack must translate **all three** namespaces it cares about — translating only `widgets.*` and you get a half-translated UI (palette in French, panel still in English).

## Step 1 — Pre-flight

<!-- buyer:try -->
```bash
php -S localhost:8000 -t demo/
```

Open <http://localhost:8000/examples/extensions/4-i18n-locale-pack/>. The example ships three buttons (EN / FR / VI) — clicking flips the dictionary + repaints. Reproduce this pattern in your own host.

## Step 2 — Author your locale pack

Start from `src/lang/en.json` (the canonical key list). Translate the keys you need:

<!-- buyer:try -->
```javascript
const FR = {
    // Widgets palette
    'widgets.heading':    'Titre',
    'widgets.paragraph':  'Paragraphe',
    'widgets.image':      'Image',
    'widgets.button':     'Bouton',
    'widgets.divider':    'Séparateur',

    // Elements (canvas + settings panel)
    'elements.heading':   'Titre',
    'elements.paragraph': 'Paragraphe',
    'elements.image':     'Image',
    'elements.button':    'Bouton',

    // Controls
    'controls.text_color':   'Couleur du texte',
    'controls.font_size':    'Taille de police',
    'controls.padding_top':  'Marge supérieure',
    /* …more keys… */
};
```

Three rules to keep dictionary maintenance sane:

1. **Match key spelling exactly** to `en.json` — a typo means the engine falls back to the key itself ("widgets.heading" appearing in the UI).
2. **Translate every key in the namespaces you ship** — half-translation produces a half-translated UI.
3. **Keep the dict flat** — `'widgets.heading': 'Titre'`, not `widgets: { heading: 'Titre' }`. The engine looks up by literal dot-key.

## Step 3 — Wire the dictionary

Call `I18n.init()` BEFORE you call `builder.load()`. Otherwise the first paint uses English defaults, then flips to your locale on the next mutation:

<!-- buyer:try -->
```javascript
I18n.init(FR);

const builder = new Builder({
    mainContainer:    '#MyCanvas',
    widgetsContainer: '#MyWidgets',
    settingsContainer: '#MySettings',
});

builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
    // Stock widgets — palette label flows through I18n.t at render time.
    builder.widgetsBox.addWidget(new HeadingWidget(),   { group: 'Content' });
    builder.widgetsBox.addWidget(new ParagraphWidget(), { group: 'Content' });
    builder.widgetsBox.render();
});
```

The widgets palette renders with French labels. Click any heading → the settings panel labels are French too.

## Step 4 — Locale switcher (optional)

To let buyers flip locale at runtime, swap the dict + repaint:

<!-- buyer:try -->
```javascript
function setLocale(dict) {
    I18n.init(dict);

    // Reload re-evaluates every label.
    builder.load(builder.getData(), THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
        // Clear the existing palette wrapper before re-mounting (W6.C.14 lesson —
        // load() doesn't null widgetsContainer.innerHTML, so orphan widgets
        // accumulate across reloads).
        document.getElementById('MyWidgets').innerHTML = '';
        builder.widgetsBox.addWidget(new HeadingWidget(),   { group: 'Content' });
        builder.widgetsBox.addWidget(new ParagraphWidget(), { group: 'Content' });
        builder.widgetsBox.render();
    });
}

document.getElementById('btn-fr').addEventListener('click', () => setLocale(FR));
document.getElementById('btn-vi').addEventListener('click', () => setLocale(VI));
document.getElementById('btn-en').addEventListener('click', () => setLocale({}));
```

Empty dict (`{}`) routes through compile-time `en.json` defaults — that's the engine's fallback.

## Common patterns

### Locale from URL

For SaaS hosts where the active locale lives in the URL (`/fr/builder`, `?locale=fr`), pick the dict server-side and inline it:

```php
$locale = $_GET['locale'] ?? 'en';
$pack   = match ($locale) {
    'fr' => json_decode(file_get_contents(__DIR__ . '/locales/fr.json'), true),
    'vi' => json_decode(file_get_contents(__DIR__ . '/locales/vi.json'), true),
    default => [],
};
?>
<script>
    I18n.init(<?= json_encode($pack, JSON_UNESCAPED_UNICODE) ?>);
</script>
```

The dict ships in the same HTTP response as the page. No second fetch round-trip; first paint is correctly translated.

### Custom widget keys

When you author a custom Widget (per [Build a custom Widget](/docs/11-widget-dev/)), pick a key namespace under `widgets.<your-namespace>.*` so your translations don't collide with stock widget keys:

```javascript
class CalloutWidget extends BaseWidget {
    getName() { return I18n.t('widgets.acme.callout'); }
}
```

Then translate `widgets.acme.callout` in every locale pack you ship.

### Auditing for missing keys

If a label still renders in English after `I18n.init()`, the key is missing from your dict. The engine falls back to the literal key string when no value is found — useful as a debug heuristic but ugly in production. Run a CI check that walks `src/lang/en.json` and asserts every key exists in each shipped locale pack:

```bash
node scripts/i18n-audit.cjs --locale=fr.json
```

(The shipped audit script is illustrative — most projects ship their own.)

### Numbers, dates, currencies (pluralisation + formatting)

`I18n.t()` covers UI string lookup only. For number / date / currency formatting use the browser's `Intl` API — it's per-locale-correct without requiring any translation effort:

```javascript
// Per-locale number / currency / date formatting — no translation needed.
const fmt = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
fmt.format(1234.56);
// → "1 234,56 €"

const dateFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
dateFmt.format(new Date());
// → "10 mai 2026"
```

For pluralisation (`1 item` / `5 items`), use `Intl.PluralRules`:

```javascript
const pluralRules = new Intl.PluralRules('en-US');
pluralRules.select(5); // → "other"
const labels = { one: '1 item', other: '{n} items' };
const label = labels[pluralRules.select(count)].replace('{n}', count);
```

Custom Element templates can call these directly inside the EJS `<%- %>` blocks. The shipped engine doesn't ship its own number / date helpers — `Intl` is good enough.

### RTL languages (Arabic, Hebrew)

Set `dir="rtl"` on the canvas's `<html>` or container, and the shipped Page templates flip automatically (the engine uses logical CSS properties under the hood). Per-element overrides via `formats.text_align: "right"` work for cases where a particular element should buck the document direction.

## Did you make it?

You should now be able to:

- ✅ State the three namespaces (`widgets.*`, `elements.*`, `controls.*`) and what each one covers.
- ✅ Author a flat dict with translated keys matching `src/lang/en.json`.
- ✅ Call `I18n.init(dict)` before `builder.load()` to set the active locale.
- ✅ Switch locales at runtime by re-calling `init()` + `load()` + re-rendering the palette.

If a step didn't work:

- 🩹 *Some labels still in English* → that namespace isn't translated. Check `controls.*` if panel labels are English; check `widgets.*` if palette labels are.
- 🩹 *Switching locale leaves orphan widgets in palette* → clear `widgetsContainer.innerHTML = ''` before `widgetsBox.render()` (W6.C.14 lesson — engine doesn't auto-clear).
- 🩹 *Custom widget label not translated* → your custom widget's `getName()` must return `I18n.t('widgets.<key>')`, not a literal string.

What's next?

- → [FAQ](/docs/15-faq/) — common questions on version, email-client matrix, debug recipes.
- 🔗 [`examples/extensions/4-i18n-locale-pack/`](/examples/extensions/4-i18n-locale-pack/) — runnable EN / FR / VI switcher.
- 🔗 `docs/core/LANGUAGE.md` — the canonical i18n spec.
- 🔗 `src/lang/en.json` — the canonical key list to translate from.
