141 lines
5.5 KiB
Markdown
141 lines
5.5 KiB
Markdown
# Internationalization (i18next)
|
|
|
|
The v2 apps (starting with `apps/compliance-portal`) localize with [i18next](https://www.i18next.com/). Translations are **key-based**: code references a stable key, and each locale supplies the human-readable string in a JSON catalog. Catalogs live in `_locales/` folders colocated with routes.
|
|
|
|
> This differs from the legacy `@probo/i18n` translator used by `apps/console`, where the **English source string itself is the key** (`__("Save changes")`). New code uses i18next with explicit keys; do not copy the source-string-as-key pattern into v2 apps.
|
|
|
|
## Related guides
|
|
|
|
| Topic | Guide |
|
|
|-------|--------|
|
|
| Where `_locales/` folders live in the tree | [`contrib/claude/app-arborescence.md`](app-arborescence.md#_locales-folder) |
|
|
| Routes and resource folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) |
|
|
|
|
## Catalog files
|
|
|
|
- One JSON file **per locale**, named by its BCP 47 locale tag: `en-US.json`, `fr-FR.json`.
|
|
- The **filename is the locale** — there is no locale field inside the file; the file is the namespace's catalog for that locale.
|
|
- Keys are stable, descriptive identifiers (not the English text). Nest by feature/component to avoid collisions.
|
|
|
|
```jsonc
|
|
// pages/organizations/measures/_locales/en-US.json
|
|
{
|
|
"measures": {
|
|
"title": "Measures",
|
|
"empty": "No measures yet",
|
|
"actions": {
|
|
"create": "New measure"
|
|
},
|
|
"count_one": "{{count}} measure",
|
|
"count_other": "{{count}} measures"
|
|
}
|
|
}
|
|
```
|
|
|
|
```jsonc
|
|
// pages/organizations/measures/_locales/fr-FR.json
|
|
{
|
|
"measures": {
|
|
"title": "Mesures",
|
|
"empty": "Aucune mesure pour le moment",
|
|
"actions": {
|
|
"create": "Nouvelle mesure"
|
|
},
|
|
"count_one": "{{count}} mesure",
|
|
"count_other": "{{count}} mesures"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Where catalogs live
|
|
|
|
`_locales/` is colocated with a `routes.ts`, at the folder that **names a resource**. The constraint is exact:
|
|
|
|
- **No more `_locales/` folders than `routes.ts` files.** A folder without a `routes.ts` does not get its own `_locales/`; its strings belong to the nearest ancestor resource that has one.
|
|
- Resource boundaries own their translations: `organizations/routes.ts` → `organizations/_locales/`; `organizations/measures/routes.ts` → `organizations/measures/_locales/`.
|
|
|
|
See [app-arborescence.md](app-arborescence.md#_locales-folder) for the folder-tree examples.
|
|
|
|
## Using translations in components
|
|
|
|
Read translations with the i18next hook and a key. Keep keys close to where they are defined (the feature namespace), and pass interpolation values as the second argument.
|
|
|
|
```tsx
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
export function MeasuresPage() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<section>
|
|
<h1 className="text-6 text-sand-12">{t("measures.title")}</h1>
|
|
<p className="text-sand-11">{t("measures.count", { count })}</p>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Do / don't: keys, not source strings
|
|
|
|
```tsx
|
|
// Bad — English source string as the key (legacy @probo/i18n pattern)
|
|
__("No measures yet");
|
|
|
|
// Good — stable key resolved from the locale catalog
|
|
t("measures.empty");
|
|
```
|
|
|
|
### Do / don't: no string building
|
|
|
|
Never assemble translated sentences by concatenation — it breaks word order in other languages. Use interpolation and pluralization keys instead.
|
|
|
|
```tsx
|
|
// Bad — concatenation
|
|
`${count} ${t("measures.unit")}`;
|
|
|
|
// Good — pluralized key with interpolation
|
|
t("measures.count", { count });
|
|
```
|
|
|
|
## Formatting dates, numbers, and currency
|
|
|
|
Locale-aware formatting is **presentation** and must follow the active locale — never hand-format with string templates or hardcoded separators. Use i18next's [`Intl`-based formatting](https://www.i18next.com/translation-function/formatting) (which wraps `Intl.DateTimeFormat` / `Intl.NumberFormat` with the current language) or `Intl` directly when outside a translation string.
|
|
|
|
```tsx
|
|
// i18next interpolation formatters — locale comes from the active language
|
|
t("measures.updatedAt", { date: updatedAt, formatParams: { date: { dateStyle: "medium" } } });
|
|
t("invoice.total", { amount, val: amount, formatParams: { val: { style: "currency", currency: "EUR" } } });
|
|
|
|
// Outside a translation string — Intl directly with the active locale
|
|
const { i18n } = useTranslation();
|
|
new Intl.NumberFormat(i18n.language, { style: "percent" }).format(ratio);
|
|
```
|
|
|
|
```tsx
|
|
// Bad — hand-rolled, locale-blind formatting
|
|
`${(ratio * 100).toFixed(0)}%`;
|
|
`${day}/${month}/${year}`;
|
|
```
|
|
|
|
### Shared formatting helpers
|
|
|
|
Use the shared formatting helpers instead of duplicating their parsing and unit-selection logic:
|
|
|
|
- `formatDate(dateInput)` from `@probo/helpers` formats a date for display using the runtime locale.
|
|
- `formatDuration(duration, t)` from `@probo/i18n` formats a supported ISO 8601 duration. Pass the i18next `t` function so pluralization remains in the catalog; it uses `duration.min` and `duration.hour` with `count`.
|
|
- `fileSize(bytes, t)` from `@probo/i18n` formats a byte count using the translated `size.B`, `size.KB`, `size.MB`, `size.GB`, and `size.TB` units.
|
|
|
|
```tsx
|
|
import { fileSize, formatDuration } from "@probo/i18n";
|
|
import { formatDate } from "@probo/helpers";
|
|
|
|
const { t } = useTranslation();
|
|
|
|
formatDate(document.createdAt);
|
|
formatDuration(task.timeEstimate, t);
|
|
fileSize(attachment.size, t);
|
|
```
|
|
|
|
## Loading catalogs
|
|
|
|
Catalogs are loaded into i18next per locale at app startup (or lazily per route). Because each `_locales/` folder maps to a resource segment, catalogs can be code-split alongside the route bundle that needs them — keep a catalog scoped to the feature it serves rather than one global megafile.
|