I18N.md
This document describes the i18n infrastructure for the Goose Desktop UI (ui/desktop/).
The i18n system is built on 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:
defaultMessage values — no duplication between code and catalog.@formatjs/cli tool extracts messages automatically from source into translation catalogs.IntlProvider).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>
);
}
Use dot-separated, hierarchical IDs that reflect the component location:
settings.appearance.title
sessions.delete.confirmMessage
launcher.placeholder
searchBar.caseSensitive
| 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.
After adding or modifying defineMessages calls, regenerate the English catalog:
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.
The lint:check script includes i18n:check, which re-runs extraction and verifies the output matches what's committed:
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):
pnpm i18n:compile
Compiled files go to src/i18n/compiled/ (gitignored).
The locale is resolved at startup in the following order:
GOOSE_LOCALE — explicit override from env/app config when the desktop setting is
System Defaultnavigator.language — the browser/OS locale"en" — fallback defaultThe resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times). Changing the desktop language setting reloads the renderer so startup-only locale helpers pick up the new value.
Use intl.formatDate(), intl.formatNumber(), intl.formatRelativeTime() from the useIntl() hook. These automatically use the same locale as text translations:
const intl = useIntl();
intl.formatDate(new Date(), { month: 'long', day: 'numeric' });
intl.formatNumber(1234.5, { style: 'currency', currency: 'USD' });
For utility functions that don't have access to the React tree (e.g., timeUtils.ts), import the resolved locale directly:
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.
src/i18n/messages/en.json to a new file, e.g., src/i18n/messages/ja.json.defaultMessage values. Keep ICU syntax intact (e.g., {count, plural, ...}).SUPPORTED_LOCALES in src/i18n/index.ts.src/components/settings/app/AppSettingsSection.tsx.pnpm i18n:compile to pre-compile.No other code changes are needed — loadMessages() dynamically imports the correct catalog at runtime.
Any component that uses useIntl() must be rendered inside an IntlProvider. Use the test helper:
import { IntlTestWrapper } from '../i18n/test-utils';
render(<MyComponent />, { wrapper: IntlTestWrapper });
Unit tests for locale detection and message loading live in src/i18n/i18n.test.ts. Run them with:
cd ui/desktop
pnpm test:run -- src/i18n/i18n.test.ts
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