mirror of
https://github.com/block/goose.git
synced 2026-06-02 06:19:33 +02:00
175 lines
5.7 KiB
Markdown
175 lines
5.7 KiB
Markdown
# Internationalization (i18n) — Goose Desktop UI
|
|
|
|
This document describes the i18n infrastructure for the Goose Desktop UI (`ui/desktop/`).
|
|
|
|
## Overview
|
|
|
|
The i18n system is built on [react-intl](https://formatjs.io/docs/react-intl/) (part of the FormatJS suite). It uses the **ICU MessageFormat** standard for translations, which provides full support for pluralization, gender/select, number/date formatting, and nested messages — all governed by CLDR rules.
|
|
|
|
**Key design decisions:**
|
|
|
|
- English strings live in source code as `defaultMessage` values — no duplication between code and catalog.
|
|
- The `@formatjs/cli` tool extracts messages automatically from source into translation catalogs.
|
|
- Date, time, and number formatting use the same locale as text translations (single source of truth via `IntlProvider`).
|
|
- No build pipeline changes required — react-intl is a pure runtime library.
|
|
|
|
## Marking strings for translation
|
|
|
|
### In React components
|
|
|
|
```tsx
|
|
import { defineMessages, useIntl } from 'react-intl';
|
|
|
|
const messages = defineMessages({
|
|
greeting: {
|
|
id: 'myComponent.greeting',
|
|
defaultMessage: 'Hello, {name}!',
|
|
},
|
|
itemCount: {
|
|
id: 'myComponent.itemCount',
|
|
defaultMessage: '{count, plural, one {# item} other {# items}}',
|
|
},
|
|
});
|
|
|
|
function MyComponent({ name, count }: { name: string; count: number }) {
|
|
const intl = useIntl();
|
|
return (
|
|
<div>
|
|
<h1>{intl.formatMessage(messages.greeting, { name })}</h1>
|
|
<p>{intl.formatMessage(messages.itemCount, { count })}</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Message ID conventions
|
|
|
|
Use dot-separated, hierarchical IDs that reflect the component location:
|
|
|
|
```
|
|
settings.appearance.title
|
|
sessions.delete.confirmMessage
|
|
launcher.placeholder
|
|
searchBar.caseSensitive
|
|
```
|
|
|
|
### ICU MessageFormat syntax
|
|
|
|
| Feature | Syntax | Example |
|
|
|---|---|---|
|
|
| Interpolation | `{variable}` | `Hello, {name}!` |
|
|
| Plural | `{var, plural, one {…} other {…}}` | `{count, plural, one {# file} other {# files}}` |
|
|
| Select | `{var, select, male {…} female {…} other {…}}` | `{gender, select, male {He} female {She} other {They}}` |
|
|
| Number | `{var, number}` | `{price, number, ::currency/USD}` |
|
|
| Date | `{var, date, medium}` | `{when, date, long}` |
|
|
|
|
The `#` symbol inside plural/selectordinal is replaced with the formatted number.
|
|
|
|
For full syntax details, see the [ICU MessageFormat specification](https://unicode-org.github.io/icu/userguide/format_parse/messages/).
|
|
|
|
## Extracting messages
|
|
|
|
After adding or modifying `defineMessages` calls, regenerate the English catalog:
|
|
|
|
```bash
|
|
cd ui/desktop
|
|
pnpm i18n:extract
|
|
```
|
|
|
|
This scans all `src/**/*.{ts,tsx}` files and writes the canonical English catalog to `src/i18n/messages/en.json`. Commit this file — it serves as the reference for translators.
|
|
|
|
### Keeping en.json in sync (automated check)
|
|
|
|
The `lint:check` script includes `i18n:check`, which re-runs extraction and verifies the output matches what's committed:
|
|
|
|
```bash
|
|
pnpm i18n:check
|
|
```
|
|
|
|
This runs as part of `pnpm lint:check` (and therefore CI). If a developer changes a `defaultMessage` in source but forgets to run `pnpm i18n:extract`, the check fails with a diff showing exactly what's out of date.
|
|
|
|
To compile messages into an optimized AST format (optional, for production performance):
|
|
|
|
```bash
|
|
pnpm i18n:compile
|
|
```
|
|
|
|
Compiled files go to `src/i18n/compiled/` (gitignored).
|
|
|
|
## Locale detection
|
|
|
|
The locale is resolved at startup in the following order:
|
|
|
|
1. **`GOOSE_LOCALE`** — explicit override (set on the `window` object or via env)
|
|
2. **`navigator.language`** — the browser/OS locale
|
|
3. **`"en"`** — fallback default
|
|
|
|
The resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times).
|
|
|
|
## Date and number formatting
|
|
|
|
### Inside React components
|
|
|
|
Use `intl.formatDate()`, `intl.formatNumber()`, `intl.formatRelativeTime()` from the `useIntl()` hook. These automatically use the same locale as text translations:
|
|
|
|
```tsx
|
|
const intl = useIntl();
|
|
intl.formatDate(new Date(), { month: 'long', day: 'numeric' });
|
|
intl.formatNumber(1234.5, { style: 'currency', currency: 'USD' });
|
|
```
|
|
|
|
### Outside React context
|
|
|
|
For utility functions that don't have access to the React tree (e.g., `timeUtils.ts`), import the resolved locale directly:
|
|
|
|
```ts
|
|
import { currentLocale } from '../i18n';
|
|
new Intl.DateTimeFormat(currentLocale, { ... }).format(date);
|
|
```
|
|
|
|
This ensures date/number formatting uses the same locale as the rest of the UI.
|
|
|
|
## Adding a new language
|
|
|
|
1. Copy `src/i18n/messages/en.json` to a new file, e.g., `src/i18n/messages/ja.json`.
|
|
2. Translate the `defaultMessage` values. Keep ICU syntax intact (e.g., `{count, plural, ...}`).
|
|
3. Add the locale code to `SUPPORTED_LOCALES` in `src/i18n/index.ts`.
|
|
4. Optionally run `pnpm i18n:compile` to pre-compile.
|
|
|
|
No other code changes are needed — `loadMessages()` dynamically imports the correct catalog at runtime.
|
|
|
|
## Testing
|
|
|
|
### Wrapping test renders with IntlProvider
|
|
|
|
Any component that uses `useIntl()` must be rendered inside an `IntlProvider`. Use the test helper:
|
|
|
|
```tsx
|
|
import { IntlTestWrapper } from '../i18n/test-utils';
|
|
|
|
render(<MyComponent />, { wrapper: IntlTestWrapper });
|
|
```
|
|
|
|
### i18n-specific tests
|
|
|
|
Unit tests for locale detection and message loading live in `src/i18n/i18n.test.ts`. Run them with:
|
|
|
|
```bash
|
|
cd ui/desktop
|
|
pnpm test:run -- src/i18n/i18n.test.ts
|
|
```
|
|
|
|
## Architecture summary
|
|
|
|
```
|
|
src/i18n/
|
|
├── index.ts # Locale detection, loadMessages(), re-exports
|
|
├── messages/
|
|
│ └── en.json # Extracted English catalog (committed)
|
|
├── compiled/ # Compiled catalogs (gitignored)
|
|
├── test-utils.tsx # IntlTestWrapper for tests
|
|
└── i18n.test.ts # Unit tests
|
|
|
|
src/renderer.tsx # IntlProvider wraps the entire app tree
|
|
```
|