# i18n

Bootgly ships a native, dependency-free i18n minimal contract at `Bootgly\ABI\Data\Language`:
`translate()` + message catalogs + locale negotiation. Keys are **natural-source strings** —
the message in the source language (English by default) *is* the catalog key, and any miss
returns it verbatim. With zero catalog files your application keeps working in English;
translations are purely additive.

Three framework surfaces consume it out of the box: **validation messages**, **templates**
(the `@translate` directive) and **error pages** (the production Catcher).

> [!NOTE]
> This is the frozen v0.24 contract: the public signatures already anticipate pluralization,
> domains and per-call locales. Bundled framework catalogs come later — projects supply their
> own translations.

## Quick start

A catalog is a plain PHP file at `{root}/{locale}/{domain}.php` returning
`source message → translation` pairs:

```php
<?php
// catalogs/pt-BR/app.php

return [
   'Welcome, {name}!' => 'Bem-vindo, {name}!',
];
```

Register the root, pick the locale, translate:

```php
use Bootgly\ABI\Data\Language;

Language::load(__DIR__ . '/catalogs');
Language::$locale = 'pt-BR';

echo Language::translate('Welcome, {name}!', ['name' => 'Bootgly']);
// Bem-vindo, Bootgly!
```

In a Bootgly **project**, the `catalogs/` directory is registered automatically at boot —
just create `{project}/catalogs/pt-BR/app.php` and skip the `load()` call.

Lookups fall back progressively: `pt-BR` → `pt` → the source message itself. A
`catalogs/pt/errors.php` file therefore serves `pt-BR`, `pt-PT` and plain `pt` requests
until a more specific catalog exists.

## Placeholders

`{token}` placeholders are replaced after the catalog lookup — the same tokens work in the
source message and in every translation:

```php
Language::translate('{field} must be at least {limit}.', [
   'field' => 'age',
   'limit' => 18,
]);
// age must be at least 18.
```

Tokens without a substitution pass through literally. `translate()` never escapes output —
escaping belongs to the output boundary (the `@translate` directive escapes, the validation
errors bag stays plain text).

## Plurals

Catalog values (and source messages) may carry `|`-separated forms. Passing `count:` selects
one — `count == 1` takes the first form, everything else takes the last — and auto-fills the
`{count}` placeholder:

```php
Language::translate('{count} result|{count} results', count: 7);
// 7 results

Language::translate('{count} result|{count} results', count: 1);
// 1 result
```

Without `count:`, pipes are inert regular text.

## Locale negotiation

`Language::negotiate()` matches ordered preferences against the locales your catalogs
actually provide (the `{locale}/` directory names), with subtag fallback and regional
expansion, falling back to the source language:

```php
Language::$locale = Language::negotiate(['pt-BR', 'en']); // catalog dirs: pt/ → 'pt'
```

### Automatic per-request negotiation (Web)

On the HTTP server there is nothing to wire. Once catalogs are registered, every request
negotiates `Accept-Language` automatically before routing — and the unconditional assignment
doubles as the per-request reset, so a `pt-BR` request can never leak its locale into the
next one. Apps without catalogs pay one static array read per request.

```text
GET /error HTTP/1.1
Accept-Language: pt-BR
→ <html lang="pt-BR"> ... <p>Erro interno do servidor</p>
```

## Localized validation messages

Every validator default message routes through the `validation` domain. The English defaults
are the keys — a complete pt-BR catalog:

```php
<?php
// catalogs/pt-BR/validation.php

return [
   '{field} is invalid.' => '{field} é inválido.',
   '{field} is required.' => '{field} é obrigatório.',
   '{field} must be a boolean.' => '{field} deve ser um booleano.',
   '{field} must be a valid date.' => '{field} deve ser uma data válida.',
   '{field} must be a valid date in the format {format}.' => '{field} deve ser uma data válida no formato {format}.',
   '{field} must be a valid email address.' => '{field} deve ser um endereço de e-mail válido.',
   '{field} must be a valid URL.' => '{field} deve ser uma URL válida.',
   '{field} must be an integer.' => '{field} deve ser um número inteiro.',
   '{field} must be at least {limit}.' => '{field} deve ser no mínimo {limit}.',
   '{field} must be at most {limit}.' => '{field} deve ser no máximo {limit}.',
   '{field} must be at most {limit} bytes.' => '{field} deve ter no máximo {limit} bytes.',
   '{field} must be one of the allowed values.' => '{field} deve ser um dos valores permitidos.',
   '{field} must have an allowed extension.' => '{field} deve ter uma extensão permitida.',
   '{field} must have an allowed MIME type.' => '{field} deve ter um tipo MIME permitido.',
   '{field} confirmation does not match.' => 'A confirmação de {field} não confere.',
   '{field} has an invalid format.' => '{field} tem um formato inválido.',
];
```

```php
use Bootgly\ADI\Validation;
use Bootgly\ADI\Validators\Required;

Language::$locale = 'pt-BR';

$Validation = new Validation(
   source: [],
   rules: ['email' => [new Required]]
);

$Validation->errors['email'][0]; // email é obrigatório.
```

Custom `message:` overrides also pass through the catalogs (so they are translatable keys
too) and gain `{field}` placeholder support; without a catalog entry they render verbatim,
exactly as before.

## Templates

The `@translate` directive is the canonical template form — output is **escaped by default**
(translations interpolate user data). Arguments pass verbatim to
`Language::translate()`, so named arguments work:

```text
@translate 'Welcome, {name}!', ['name' => $name];

@translate '{count} result|{count} results', count: $total;

@translate 'Sign in', domain: 'auth';
```

Escape the directive itself with `@@translate`. For trusted raw HTML translations, fall back
to the raw output directive: `@> \Bootgly\ABI\Data\Language::translate('key');`.

## Localized error pages

In Production/Staging the built-in clean error page and the JSON error body translate the
HTTP reason phrase through the `errors` domain — the reason phrase is the key:

```php
<?php
// catalogs/pt-BR/errors.php

return [
   'Not Found' => 'Não encontrado',
   'Internal Server Error' => 'Erro interno do servidor',
];
```

The HTML `lang` attribute follows the active locale. The **wire status line stays English**
(`HTTP/1.1 500 Internal Server Error`) — only body text is localized, per RFC semantics.

Fully custom pages keep working: a `views/errors/{code}.template.php` view is rendered with
the request locale already active, so `@translate` inside it just works.

## Console and scripts

Outside the Web platform, assign the locale explicitly. `Locales::normalize()` is
POSIX-aware, so the environment locale is one line away:

```php
use function getenv;
use Bootgly\ABI\Data\Language;

Language::load(__DIR__ . '/catalogs');
Language::$locale = Language::negotiate([(string) getenv('LANG')]); // pt_BR.UTF-8 → pt-BR
```

## Caveats

- **Catalogs cache per worker.** Files are `require`d once per (root, locale, domain) and
  held for the worker lifetime (opcache-friendly). Editing a catalog on a long-running
  server requires a reload/restart.
- **Deferred responses (Fibers).** The active locale is per-request worker state; a deferred
  continuation may resume after a later request re-negotiated it. Capture translated strings
  before deferring.
- **Source language edits orphan translations.** Keys are the source text (gettext trade-off):
  rewording the English message renames the key — update catalogs together with the source.

## Reference

### `Bootgly\ABI\Data\Language`

```php
public static string $source = 'en';
```

The language the source strings (keys) are written in. Lookups targeting it short-circuit
the catalogs.

```php
public static string $locale = 'en';
```

The active locale. Assign directly; on the HTTP server it is re-negotiated (and therefore
reset) on every request once catalogs are registered.

```php
public static array $roots = [];
```

Registered catalog roots, in registration order. Read it as the "i18n enabled" probe;
manage it via `load()`.

```php
public static function load (string $root): void
```

Registers a catalogs root directory (`{root}/{locale}/{domain}.php`). Roots registered
later take priority over earlier ones. Duplicates are ignored.

```php
public static function translate (string $message, array $substitutions = [], null|int|float $count = null, string $domain = 'app', null|string $locale = null): string
```

Translates a source message to the active (or given) locale. Never throws: on any miss the
message itself is the result. `$count` selects a `|`-separated plural form and auto-fills
`{count}`; `$domain` picks the catalog file (`app` by default); `$locale` overrides the
active locale for this call only. No output escaping is performed.

```php
public static function negotiate (array $preferred): string
```

Chooses the best available locale (the registered roots' locale directories) for the ordered
preferences — e.g. `$Request->languages`. Pure: returns the choice (falling back to
`Language::$source`) and never mutates state.

```php
public static function reset (): void
```

Restores the boot state: drops registered roots and caches; `$source`/`$locale` back to
`en`. Test-lifecycle helper.

### `Bootgly\ABI\Data\Language\Locales`

```php
public static function normalize (string $locale): string
```

Normalizes a BCP 47 or POSIX locale tag to canonical form (`pt_BR.UTF-8` → `pt-BR`,
`PT-br` → `pt-BR`). `C`, `POSIX`, empty or malformed input normalizes to `''`.

```php
public static function chain (string $locale): array
```

Expands a normalized tag into its lookup chain by progressive truncation:
`pt-BR` → `['pt-BR', 'pt']`.

```php
public static function choose (array $preferred, array $available): null|string
```

RFC 4647 lookup (lite): per preference — exact match, truncation (`pt-BR` matches `pt`),
regional expansion (`pt` matches the first `pt-*`), `*` wildcard. Client order wins. Returns
the matching available entry as given, or `null`.

### `@translate` directive

```text
@translate <arguments>;
```

Compiles to an HTML-escaped `Language::translate(<arguments>)` echo
(`htmlspecialchars` with `ENT_QUOTES | ENT_SUBSTITUTE`, UTF-8). The argument list is passed
verbatim — positional and named arguments (`count:`, `domain:`, `locale:`) both work.
`@@translate` emits the literal directive text.
