5.7 KiB
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 (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
defaultMessagevalues — no duplication between code and catalog. - The
@formatjs/clitool 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
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.
Extracting messages
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.
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:
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).
Locale detection
The locale is resolved at startup in the following order:
GOOSE_LOCALE— explicit override (set on thewindowobject or via env)navigator.language— the browser/OS locale"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:
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:
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
- Copy
src/i18n/messages/en.jsonto a new file, e.g.,src/i18n/messages/ja.json. - Translate the
defaultMessagevalues. Keep ICU syntax intact (e.g.,{count, plural, ...}). - Add the locale code to
SUPPORTED_LOCALESinsrc/i18n/index.ts. - Optionally run
pnpm i18n:compileto 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:
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:
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