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:
- 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.
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:extractThis 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:checkThis 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:compileCompiled files go to src/i18n/compiled/ (gitignored).
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).
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.
- 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.
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.tssrc/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