Files
goose/I18N.md

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 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

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:

  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:

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

  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:

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