# Internationalisation (i18n) — HTML.PHP 5

Gettext-compatible translations with zero dependencies: PO **and** MO
binary catalogues, plus (new in v4) JSON and PHP-array catalogues; plural
rules for 60+ languages; message contexts; text domains; fallback chains;
`Accept-Language` negotiation; extraction and audit tooling; optional
WordPress delegation.

## Quick start

```php
$engine = Engine::create([
    'i18n' => [
        'locale'   => 'de_DE',
        'fallback' => ['de', 'en'],
        'domain'   => 'messages',
        'paths'    => [__DIR__ . '/languages'],   // autoloaded lazily
    ],
]);

echo $engine->t('Hello {name}', ['name' => 'Welt']);
echo $engine->n('{0} day', '{0} days', $count, [$count]);
echo $engine->x('File', 'menu');            // msgctxt
echo $engine->nx('{0} file', '{0} files', $n, 'menu', [$n]);
echo $engine->renderString('@{page.title}'); // in templates
```

## Catalogue discovery

For each configured path and locale, the first match wins (later paths
**merge**, first-path entries take precedence — theme-over-plugin style):

```
{path}/{domain}-{locale}.mo|.po|.json|.php
{path}/{locale}.mo|.po|.json|.php
{path}/{locale}/LC_MESSAGES/{domain}.mo|.po
```

Loading is lazy (first translation lookup) and idempotent per
locale/path/domain. `Engine::loadTranslations($locale, $domain)` forces it.

### Formats

**PO/MO** — standard gettext, including `msgctxt` (stored with the `\x04`
separator), `msgid_plural`/`msgstr[n]`, `#, fuzzy` (ignored at lookup) and
charset headers (non-UTF-8 payloads are converted via iconv/mbstring).

**JSON / PHP arrays** *(new in v4)*:

```json
{
  "headers": { "Plural-Forms": "nplurals=2; plural=n != 1;" },
  "hello": "hallo",
  "day": ["Tag", "Tage"],
  "menu\u0004file": "Datei"
}
```

```php
<?php return ['hello' => 'hallo', 'day' => ['Tag', 'Tage']];
```

**In memory** (databases, APIs, tests):

```php
$engine->translator()->register('de', 'messages',
    HtmlPhp\I18n\Catalog::fromArray(['hello' => 'hallo'], 'de'));
```

## Plural forms

`PluralForms` evaluates the catalogue's `Plural-Forms` header expression
with a purpose-built safe evaluator (C-subset grammar: ternary, `||`, `&&`,
comparisons, `%`, arithmetic on `n`) — never `eval()`. Unparseable or
over-long expressions degrade to form `0` (fail-safe). When a catalogue has
no header, built-in rules cover 60+ languages, including the tricky ones:

- Slavic 3-form (ru, uk, be, bs, hr, sr), Polish, Czech/Slovak, Baltic
  (lt, lv), Romanian, Slovenian 4-form, Celtic (ga 5-form, cy/gd 4-form),
  Arabic 6-form, Maltese, Icelandic/Macedonian `%10` rules
- `n > 1` family: **fr, pt, kab** *(fixed in v4 — v3 lacked French)*
- single-form: ja, zh, ko, th, vi, id, ms, fa, tr-family, …

## Interpolation

Both styles, in every `t/n/x/nx` call and `@Trans` block:

- named: `'Hi {name}'` + `['name' => 'Ada']`
- sprintf: `'%d%% of %s'` + `[42, 'x']` (list arguments only; a `ValueError`
  returns the untranslated text instead of crashing)

## Locale handling

- `Locale::normalize()`: `de-de` → `de_DE`, strips codesets/modifiers
  (`ru_RU.UTF-8@euro` → `ru_RU`), invalid input → `en`.
- `Locale::parseAcceptLanguage()` / `negotiate()`: q-value aware; exact
  locale first, then language match, then default. The engine never reads
  `$_SERVER` — pass `RequestContext::$acceptLanguage`:

```php
$best = Locale::negotiate($engine->translator()->availableLocales(),
                          $request->acceptLanguage, 'en');
$engine->setLocale($best);
```

- `i18n.detect: true` negotiates automatically when a request context is
  available at boot (WordPress bridge does this via `determine_locale()`).
- Per-render switch: `new RenderOptions(locale: 'fr')` — restored after.

## Resolution order

1. **WordPress delegation** (`i18n.use_wordpress`: `null` auto-detect,
   `true`/`false` force): `__()`, `_n()`, `_x()`, `_nx()` with your text
   domain; a gettext miss (source string returned) falls through.
2. **Own catalogues** along `[locale, ...fallback]`.
3. **Source fallback**: `count === 1 ? singular : plural`.

Results interpolate, then pass the `htmlphp.translate` filter/event (the WP
bridge mirrors it to `apply_filters('htmlphp_translate')`).
Misses fire `htmlphp.translate.miss` — hook it to log untranslated strings.

## Extraction & audit tooling *(new in v4)*

```bash
bin/htmlphp i18n:extract templates/ --out=lang/messages.pot
bin/htmlphp i18n:audit templates/ --locale=de        # exit 1 when incomplete
wp htmlphp i18n extract templates/ --out=messages.pot
wp htmlphp i18n audit templates/ --locale=de
```

- **Extractor** recognises `@{Key}` and `<!--@Trans-->` blocks (msgids with
  `{0}` placeholders exactly as the renderer builds them), records file:line
  references and flags dynamic `%{var|translate}` usages that static
  extraction cannot see.
- **Auditor** diffs extracted messages against a locale's catalogue:
  `missing` (will show English) and `unused` (dead translations), with a
  percentage summary — in CI, in WP-CLI, or on the WP admin screen.

## Locale-aware formatting

`I18n\Formats` (behind the `number`, `date`, `ago` filters):

```php
$engine->renderString('%{total|number:2}', ['total' => 1234.5]);
// locale de → "1.234,50" · en → "1,234.50" · fr → "1 234,50"
```

Separator tables ship for ~40 languages; `i18n.decimal_point`,
`thousands_separator`, `date_format`, `time_format`, `datetime_format` set
the fallback defaults.

## WordPress

The bridge enables delegation automatically (`__()`/`_n()` with your text
domain, `determine_locale()`), loads `.mo/.po` from `WP_LANG_DIR/htmlphp`
when present, and exposes `htmlphp_t()` for themes. Your plugin's own
catalogues keep working through `load_plugin_textdomain()` — the engine
asks WordPress first and only consults its own catalogues on a miss.
