# Relaxjs i18n

Namespace-based translations with ICU message format, lazy loading, and locale change events. Built on top of the [modern Intl standard](intl-standard.md).

## Quick Start

```typescript
import { registerCatalogue, setLocale, loadNamespaces, t } from '@relax.js/core/i18n';

registerCatalogue(import.meta.glob('./locales/*/*.json', { eager: true }));

await setLocale('sv');
await loadNamespaces(['r-pipes', 'r-validation']);

t('greeting', { name: 'Anna' });         // "Hej, Anna!"
t('items', { count: 3 });                // "3 saker"
t('r-pipes:daysAgo', { count: 2 });      // "2 dagar sedan"
```

## Folder Structure

Translations live in your own project, as `locales/{locale}/{namespace}.json`:

```
src/i18n/locales/
├── en/
│   ├── shop.json
│   └── errors.json
└── sv/
    ├── shop.json
    └── errors.json
```

The folder name is up to you. What matters is that the last two parts of the path are
the locale and the namespace, and that you register the files as shown below.

## Registering Translations

A bundler resolves an import path relative to the file the path is written in. That file
is inside Relaxjs, so the library can only ever see its own translations, never yours.
Registration is how your files get in.

Do this once at startup, before `setLocale()`.

### With Vite

`import.meta.glob` runs in your code, where the pattern resolves against your folders:

```typescript
import { registerCatalogue } from '@relax.js/core/i18n';

// Every locale in the first download
registerCatalogue(import.meta.glob('./locales/*/*.json', { eager: true }));

// Or each locale downloaded the first time it is used
registerCatalogue(import.meta.glob('./locales/*/*.json'));
```

### Without Vite

`registerCatalogue` accepts any object keyed by path, so it also works with webpack's
`require.context` or with a plain object you write yourself:

```typescript
registerCatalogue({
    './locales/en/shop.json': { priceLabel: 'Price' },
    './locales/sv/shop.json': { priceLabel: 'Pris' },
});
```

### One Namespace at a Time

`registerNamespace` is the direct form. Pass the messages, or a function that loads them
when the namespace is first used:

```typescript
import { registerNamespace } from '@relax.js/core/i18n';
import shopEn from './locales/en/shop.json';

registerNamespace('en', 'shop', shopEn);
registerNamespace('sv', 'shop', () => import('./locales/sv/shop.json'));
```

Registering the same locale and namespace twice replaces the previous entry, which is how
you override a built-in namespace such as `r-validation`.

## Translation Keys

Use `t('namespace:key')` to translate. Omit the namespace to use `r-common`:

```typescript
t('greeting', { name: 'Anna' })           // r-common:greeting
t('r-common:greeting', { name: 'Anna' })  // explicit namespace
t('r-pipes:today')                        // r-pipes namespace
```

If a key is not found, `t()` returns the key itself.

## Translation Files

### Simple Interpolation

```json
{
    "greeting": "Hello, {name}!",
    "welcome": "Welcome to {appName}"
}
```

```typescript
t('greeting', { name: 'John' });  // "Hello, John!"
```

### Pluralization (ICU)

Uses `Intl.PluralRules` for locale-aware category selection. The `#` token inserts the count.

```json
{
    "items": "{count, plural, one {# item} other {# items}}",
    "daysAgo": "{count, plural, one {# day ago} other {# days ago}}"
}
```

```typescript
t('items', { count: 1 });  // "1 item"
t('items', { count: 5 });  // "5 items"
```

### Exact Matches (`=N`)

Exact values take priority over plural categories:

```json
{
    "pieces": "{count, plural, =0 {none} one {one} other {# pcs}}"
}
```

```typescript
t('pieces', { count: 0 });  // "none"   (exact =0 match)
t('pieces', { count: 1 });  // "one"    (plural category)
t('pieces', { count: 5 });  // "5 pcs"  (plural category)
```

### Select (ICU)

Chooses a branch based on an exact string match. Always include an `other` fallback.

```json
{
    "status": "{role, select, admin {Full access} editor {Can edit} other {Read only}}"
}
```

```typescript
t('status', { role: 'admin' });    // "Full access"
t('status', { role: 'editor' });   // "Can edit"
t('status', { role: 'viewer' });   // "Read only"  (other)
```

### Number and Date Formatting

ICU format supports locale-aware number and date formatting:

```json
{
    "price": "Price: {amount, number, currency}",
    "date": "Date: {date, date, medium}"
}
```

## Example Translation Files

**`src/i18n/locales/en/r-common.json`**:

```json
{
    "welcome": "Welcome to our site!",
    "greeting": "Hello, {name}!",
    "items": "{count, plural, =0 {No items} one {# item} other {# items}}",
    "goodbye": "Goodbye!"
}
```

**`src/i18n/locales/en/errors.json`**:

```json
{
    "notFound": "Page not found",
    "unauthorized": "You are not authorized to view this page",
    "serverError": "An unexpected error occurred"
}
```

**`src/i18n/locales/sv/r-common.json`**:

```json
{
    "welcome": "Välkommen till vår sida!",
    "greeting": "Hej, {name}!",
    "items": "{count, plural, =0 {Inga föremål} one {# föremål} other {# föremål}}",
    "goodbye": "Hejdå!"
}
```

## Built-in Namespaces

### r-common

General application strings. Loaded automatically with `setLocale()`.

| Key | Message (EN) |
|-----|-------------|
| `greeting` | `Hello, {name}!` |
| `items` | `{count, plural, one {# item} other {# items}}` |

### r-pipes

Translations for [locale-aware pipes](../Pipes.md). Load before using `daysAgo` or `pieces` pipes:

```typescript
await loadNamespace('r-pipes');
```

| Key | Message (EN) |
|-----|-------------|
| `today` | `today` |
| `yesterday` | `yesterday` |
| `daysAgo` | `{count, plural, one {# day ago} other {# days ago}}` |
| `pieces` | `{count, plural, =0 {none} one {one} other {# pcs}}` |

### r-validation

Translations for [form validation](../forms/validation.md) error messages:

```typescript
await loadNamespace('r-validation');
```

| Key | Message (EN) |
|-----|-------------|
| `required` | `This field is required.` |
| `range` | `Number must be between {min} and {max}, was {actual}.` |
| `digits` | `Please enter only digits.` |

## Locale Switching

```typescript
await setLocale('sv');  // Clears translations, loads sv/r-common.json, dispatches event
await setLocale('en');  // Clears translations, loads en/r-common.json, dispatches event
```

Locale codes are normalized: `en-US` becomes `en`, `sv-SE` becomes `sv`.

## LocaleChangeEvent

`setLocale()` dispatches a `LocaleChangeEvent` on `document` after loading translations. Components can listen for it to re-render:

```typescript
document.addEventListener('localechange', (e) => {
    console.log(`Switched to ${e.locale}`);
    this.render();
});
```

The event has a typed `locale` property (the normalized locale code) and is registered on `DocumentEventMap` for type-safe listeners.

## Missing Translation Handler

Register a callback for when `t()` encounters a missing key. Useful for development tooling, logging, or collecting untranslated strings:

```typescript
import { onMissingTranslation } from '@relax.js/core/i18n';

onMissingTranslation((key, namespace, locale) => {
    console.warn(`Missing: ${namespace}:${key} [${locale}]`);
});
```

Pass `null` to remove the handler:

```typescript
onMissingTranslation(null);
```

## Fallback Behavior

1. If a namespace is not registered for the current locale, the `en` version is used
2. If the key doesn't exist in the namespace, calls the missing handler (if set) and returns the key
3. If the namespace was never registered, `loadNamespace()` logs a warning and continues, and its keys stay untranslated

`loadNamespace()` never rejects. One forgotten translation file leaves some text
untranslated instead of stopping the application from starting.

### Text That Must Never Be Missing

Returning the key is a reasonable default for a button label, but not for wording you are
required to show. A visitor who does not read your key names sees `shell:aiDisclosure`
as if it were part of the design.

Pass a `fallback` for those strings. It is used only when the key is missing, and it goes
through the same formatter, so it can contain placeholders:

```typescript
t('shell:aiDisclosure', undefined, {
    fallback: 'You are interacting with an AI system.',
});
```

## Custom Message Formatter

The built-in formatter supports interpolation, pluralization, and select. For advanced ICU features (nested arguments, date/time formatting in messages), use an external library:

```typescript
import { setMessageFormatter } from '@relax.js/core/i18n';
import { IntlMessageFormat } from 'intl-messageformat';

setMessageFormatter((message, values, locale) => {
    const fmt = new IntlMessageFormat(message, locale);
    return fmt.format(values) as string;
});
```

## Integration with Pipes

Several [pipes](../Pipes.md) use the i18n system for localized output:

- `currency`: uses `getCurrentLocale()` for number formatting
- `date`: uses `getCurrentLocale()` for date formatting
- `daysAgo`: uses `t('r-pipes:...')` for translated text
- `pieces`: uses `t('r-pipes:...')` for translated text

```typescript
await setLocale('sv');
await loadNamespace('r-pipes');

// Now pipes output Swedish
// {{createdAt | daysAgo}} → "idag", "igår", "3 dagar sedan"
```

## Adding a New Locale

Relaxjs ships English and Swedish. To add another locale, create a folder for it in your
own project and translate the namespaces you use. Any namespace you do not translate falls
back to English.

1. Create `locales/{locale}/r-common.json`
2. Create `locales/{locale}/r-pipes.json` if you use the locale-aware pipes
3. Create `locales/{locale}/r-validation.json` if you use form validation
4. Add a file for each of your own namespaces
5. Register the folder, which the glob in the quick start already does for every locale

Example for German:

```json
// locales/de/r-pipes.json
{
    "today": "heute",
    "yesterday": "gestern",
    "daysAgo": "{count, plural, one {vor # Tag} other {vor # Tagen}}",
    "pieces": "{count, plural, =0 {keine} one {eins} other {# Stück}}"
}
```

Because registering the same locale and namespace replaces the previous entry, the same
steps let you reword a built-in namespace in a locale that already ships.

## API Reference

### Functions

| Function | Description |
|----------|-------------|
| `registerCatalogue(modules)` | Register a whole `locales/` folder from a path-keyed record |
| `registerNamespace(locale, ns, source)` | Register one namespace, eagerly or with a loader |
| `setLocale(locale)` | Set locale, clear translations, load `r-common`, dispatch event |
| `loadNamespace(ns)` | Load a single translation namespace |
| `loadNamespaces(ns[])` | Load multiple namespaces in parallel |
| `t(key, values?, options?)` | Translate a key, with optional interpolation and fallback |
| `getCurrentLocale()` | Get the current normalized locale code |
| `onMissingTranslation(handler)` | Register/remove missing key handler |
| `setMessageFormatter(fn)` | Replace the default ICU formatter |

### Types

```typescript
type MessageFormatter = (
    message: string,
    values?: Record<string, any>,
    locale?: string,
) => string;

type MissingTranslationHandler = (
    key: string,
    namespace: string,
    locale: string,
) => void;

type TranslationMap = Record<string, string>;

type NamespaceLoader = () => Promise<TranslationMap | { default: TranslationMap }>;

type NamespaceSource = TranslationMap | NamespaceLoader;

interface TranslateOptions {
    fallback?: string;
}
```

### Events

| Event | Target | Property | Description |
|-------|--------|----------|-------------|
| `localechange` | `document` | `locale: string` | Fired after `setLocale()` completes |

## Example: Full Setup

```typescript
import {
    registerCatalogue, setLocale, loadNamespaces, t,
    getCurrentLocale, onMissingTranslation,
} from '@relax.js/core/i18n';

async function initI18n() {
    // Hand our own translation files to the library
    registerCatalogue(import.meta.glob('./locales/*/*.json', { eager: true }));

    // Dev-time missing key logging
    onMissingTranslation((key, ns, locale) => {
        console.warn(`Missing: ${ns}:${key} [${locale}]`);
    });

    // Detect browser locale or use default
    const browserLocale = navigator.language || 'en';
    await setLocale(browserLocale);

    // Load namespaces needed by the app
    await loadNamespaces(['r-pipes', 'r-validation']);

    console.log(`Locale: ${getCurrentLocale()}`);
}

// React to locale changes
document.addEventListener('localechange', (e) => {
    console.log(`Locale switched to ${e.locale}`);
});
```
