# Internationalization (i18n) Guide

This document explains how the i18n system works, how to contribute translations, and how locale files stay in sync.

## Overview

All user-facing strings in the editor are stored in JSON locale files under `packages/i18n/`. English (`en.json`) is the source of truth — every other locale file must mirror its structure exactly.

```
packages/i18n/
  en.json     # source of truth (always complete)
  de.json     # community-contributed German
  fr.json     # community-contributed French
  ...
```

## How It Works

### For Consumers

Import a locale file and pass it to `<DocxEditor>`:

```tsx
import { DocxEditor } from '@eigenpal/docx-editor-react';
import { de } from '@eigenpal/docx-editor-i18n';

<DocxEditor i18n={de} />;
```

### For Developers (Internal)

Use the `useTranslation()` hook inside any component:

```tsx
import { useTranslation } from '../i18n';

function MyComponent() {
  const { t } = useTranslation();
  return <button title={t('toolbar.bold')}>{t('common.apply')}</button>;
}

// With interpolation:
t('dialogs.findReplace.matchCount', { current: 3, total: 15 });
// → "3 of 15 matches"
```

## Key States in Locale Files

Every key in a community locale file can be in one of three states:

| State                  | Value           | Behavior                               |
| ---------------------- | --------------- | -------------------------------------- |
| **Translated**         | `"Pogrubienie"` | Displayed to user                      |
| **Not yet translated** | `null`          | Falls back to English                  |
| **Missing**            | _(key absent)_  | **CI fails** — must be added as `null` |

### Why `null` Instead of Missing Keys?

When a new English string is added to `en.json`, every community locale file must have that key present — even if no translation exists yet. This is enforced by CI.

Setting a key to `null` means:

- "I know this key exists, but it hasn't been translated yet"
- The editor will display the English string as a fallback
- Translators can find untranslated strings by searching for `null`

**Example:**

```json
{
  "toolbar": {
    "bold": "Pogrubienie",
    "italic": null,
    "underline": null
  }
}
```

Here, "Bold" shows as "Pogrubienie", while "Italic" and "Underline" show in English until someone translates them.

## Contributing a New Locale

### 1. Scaffold a new locale

```bash
bun run i18n:new de        # German
bun run i18n:new pt-BR     # Brazilian Portuguese
bun run i18n:new zh-Hans   # Simplified Chinese
```

This creates `packages/i18n/<lang>.json` with all keys set to `null` AND wires the locale into the typed exports in `packages/i18n/src/index.ts` (extends the `LocaleCode` union, adds a typed `export const`, slots it into the `locales` record). You only edit the JSON. Use a [BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) language tag.

### 2. Translate strings

Open the generated file and replace `null` values with translations. You don't need to translate everything at once — partial translations are welcome. Untranslated keys (`null`) fall back to English.

**Tips:**

- Keep `{variable}` placeholders as-is — they get replaced at runtime
- Keyboard shortcuts (Ctrl+B, Ctrl+Z) should keep the key part unchanged
- Font names (Arial, Calibri) and typography terms (Sans Serif, Monospace) are typically not translated
- Page size identifiers (Letter, A4) keep the standard name

### 3. Check your progress

```bash
bun run i18n:status
```

```
Source: en.json (483 keys)

Locale      Translated  Untranslated  Coverage
----------------------------------------------
de          210         273           43% ████████░░░░░░░░░░░░
```

### 4. Validate

```bash
bun run i18n:validate    # check all locale files are in sync
bun run i18n:fix         # auto-repair if needed
```

### 5. Regenerate the public API snapshot

A new locale adds new `@public` exports (the named const, the `./<code>` subpath module, and an entry in the `LocaleCode` union). API Extractor snapshots under `docs/api/docx-editor-i18n/` capture that surface, and CI's `api:check` job fails if they drift — so regenerate and commit them:

```bash
bun run --filter '@eigenpal/docx-editor-i18n' build
bun run api:extract
git add docs/api/docx-editor-i18n/
```

You should see one modified `index.api.md` (with your locale slotted into the exports and the `LocaleCode` union) and one new `<code>.api.md` file. If you skip this step, the `build` job in CI fails with `API surface drift`.

### 6. Add a changeset

```bash
bun changeset
```

Select `@eigenpal/docx-editor-i18n` with a `patch` bump and write a one-line summary (e.g. `add <Language> (<code>) community-maintained locale`). The summary lands verbatim in the published `CHANGELOG.md`.

### 7. Open a PR

Include your coverage (e.g., "German: 100% translated" or "Japanese: toolbar + dialogs translated, errors section still null").

## Adding New English Strings

When adding a new feature with user-facing text:

### 1. Add to `en.json`

Add your key to the appropriate section. Nest by feature area:

```json
{
  "toolbar": { ... },
  "dialogs": {
    "myNewDialog": {
      "title": "My Dialog",
      "description": "Some description"
    }
  }
}
```

### 2. Use in component

```tsx
const { t } = useTranslation();
<h2>{t('dialogs.myNewDialog.title')}</h2>;
```

Types update automatically — `t()` will autocomplete your new key.

### 3. Sync locale files

```bash
bun run i18n:fix
```

This adds your new keys as `null` in all community locale files. CI will fail if you skip this step.

### Key Naming Conventions

- Nest by feature area: `toolbar.*`, `dialogs.findReplace.*`, `comments.*`
- Use camelCase for keys: `matchCount`, `insertRowAbove`
- Shared strings go in `common.*`: Cancel, Insert, Apply, Close, Delete
- Don't duplicate — if a string exists in `common.*`, use it

## CI Validation

The `i18n:validate` script runs in CI and ensures:

1. Every key in `en.json` exists in every locale file (as either a translated string or `null`)
2. No extra keys exist in locale files that aren't in `en.json`

If CI fails:

```bash
bun run i18n:fix   # auto-repair all locale files
git add packages/i18n/
git commit -m "fix: sync i18n locale files"
```

## CLI Reference

| Command                   | Description                                                                                                |
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `bun run i18n:new <lang>` | Scaffold a new locale file + auto-wire the typed exports in `packages/i18n/src/index.ts`                   |
| `bun run i18n:status`     | Show translation coverage for all locales                                                                  |
| `bun run i18n:validate`   | Check locale JSONs are in sync with `en.json` and typed exports are in sync with JSONs (errors if drifted) |
| `bun run i18n:fix`        | Auto-repair JSONs (missing → null, extras removed) + regenerate the typed exports                          |
| `bun run i18n:codegen`    | Regenerate the typed exports in `src/index.ts` from on-disk JSONs (run if you add/remove a JSON manually)  |

## Architecture

```
packages/i18n/en.json          → Source of truth (all strings)
packages/react/src/i18n/types.ts     → Auto-derived types from en.json
packages/react/src/i18n/LocaleContext.tsx → React Context + useTranslation hook
packages/react/src/i18n/index.ts     → Barrel exports
scripts/validate-i18n.mjs            → CI/pre-commit validation
```

- **Zero runtime dependencies** — uses React Context only
- **Type-safe** — `t()` accepts only valid dot-notation keys, autocomplete works
- **Tree-shakeable** — only the imported locale gets bundled
- **Null = untranslated** — deep merge skips nulls, falls back to English

## Interpolation and Pluralization

### Interpolation

Insert dynamic values into strings with `{variable}` placeholders:

```json
"greeting": "Hello {name}!"
```

```tsx
t('greeting', { name: 'Jane' }); // → "Hello Jane!"
```

### Cardinal pluralization

Format messages based on numerical values using ICU MessageFormat syntax (same as `next-intl`):

```json
"followers": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
```

- `=0`, `=1`, `=2` — exact matches (checked first)
- `one`, `few`, `many`, `other` — CLDR plural categories (language-dependent)
- `#` — replaced with the actual number

```tsx
t('followers', { count: 0 }); // → "You have no followers yet."
t('followers', { count: 1 }); // → "You have one follower."
t('followers', { count: 3580 }); // → "You have 3580 followers."
```

Translators use their language's plural forms in the **same key** — no structural changes:

```json
// pl.json — Polish has one/few/many/other
"followers": "{count, plural, =0 {brak obserwujących} one {# obserwujący} few {# obserwujących} many {# obserwujących} other {# obserwujących}}"
```

### Why `_lang` matters for plurals

The translation string defines the **branches** (what to show), but `locale` determines **which branch** to select for a given number. Different languages have different rules for what counts as "few" vs "many":

```
English:  count=3 → "other"  → "3 items"
Polish:   count=3 → "few"    → "3 przedmioty"
Polish:   count=5 → "many"   → "5 przedmiotów"
```

Each locale file includes a `_lang` field (set automatically by `bun run i18n:new`) that tells `Intl.PluralRules` which rules to apply. Just pass the file — no extra config needed:

```tsx
import { pl } from '@eigenpal/docx-editor-i18n';

<DocxEditor i18n={pl} />;
```

Since plurals are inline strings, **locale file keys stay identical across all languages** — the validator works unchanged.

## Limitations

- **No RTL layout** — String translation works, but the editor UI layout is not RTL-ready. RTL support is a separate effort.
