# Internationalization (i18n)

GeoLibre's interface is translatable. UI strings live in JSON message catalogs
rather than being hard-coded in components, so the app can be shipped in any
language by dropping in a catalog. The layer is built on
[react-i18next](https://react.i18next.com/) and lives in
`apps/geolibre-desktop/src/i18n/`.

This is an **incremental** effort: high-traffic surfaces (the top toolbar and
menus, the Settings dialog) are translated first, and untranslated strings fall
back to English until they are migrated. Adding a new language never requires
touching component code.

## How it works

```text
apps/geolibre-desktop/src/i18n/
├── index.ts          # i18next init, catalog auto-discovery, language detection
├── languages.ts      # friendly language names + resolution helpers
├── i18next.d.ts      # types t() keys against the English catalog
└── locales/
    ├── en.json       # the baseline English catalog (source of truth)
    ├── zh.json       # Simplified Chinese
    ├── es.json       # Spanish
    ├── fr.json       # French
    ├── de.json       # German
    ├── pt.json       # Portuguese
    ├── it.json       # Italian
    ├── nl.json       # Dutch
    ├── ja.json       # Japanese
    ├── ko.json       # Korean
    ├── ru.json       # Russian
    ├── tr.json       # Turkish
    ├── id.json       # Indonesian
    ├── hi.json       # Hindi
    ├── th.json       # Thai
    └── ar.json       # Arabic (right-to-left)
```

The non-English catalogs were seeded with machine-assisted translations and are
open to native-speaker review and correction (PRs welcome).

- **`en.json` is the source of truth.** Every key used by `t()` must exist here;
  `i18next.d.ts` types the `t()` function against it, so a missing or misspelled
  key is a compile error (`npm run typecheck`).
- **Catalogs are auto-discovered.** `index.ts` picks up every `locales/*.json`
  via `import.meta.glob`, so adding `locales/fr.json` is all it takes to make
  French selectable — no code changes.
- **English is bundled; other locales are lazily loaded.** `en.json` ships in the
  app chunk (it is the fallback and the type source). Every other catalog is a
  separate chunk (`i18n-locale-<code>`) imported only when it becomes the active
  language — the initial locale is awaited before the first render (`i18nReady`
  in `index.ts`), and switching languages (`useLanguage.ts`) loads the target
  catalog before applying it. Fully translated, the non-English catalogs total
  several MB, so shipping them all at boot is avoided. The service worker keeps
  these chunks out of the app-shell precache and CacheFirst-caches each on first
  use (see `maximumFileSizeToCacheInBytes` / `HEAVY_PRECACHE_IGNORES` in
  `vite.config.ts`): the active language works offline after its first online
  load, and English works offline unconditionally.
- **Other locales may be partial.** Any key missing from a non-English catalog
  falls back to `en` at runtime (`fallbackLng: "en"`).

### Language detection

On startup the initial language is resolved in priority order
(`getInitialLanguage` in `index.ts`):

1. the `?locale=` or `?lang=` **query parameter** (for embeds — consistent with
   the existing `?theme=` / `?maponly` params),
2. the language **persisted in desktop settings** (`language` field),
3. the browser's preferred languages (`navigator.languages`),
4. the default, `en`.

Only languages that ship a catalog are honored; anything else falls through to
the next rule. The in-app selector (the Settings menu → Language, or the Layout
tab of the Settings dialog) changes the
language live and records the choice so it survives reloads.

The `?locale` parameter makes embeds language-aware, e.g.:

```text
https://geolibre.app/?maponly&locale=es
```

## Using translations in components

```tsx
import { useTranslation } from "react-i18next";

function Example() {
  const { t } = useTranslation();
  return <button aria-label={t("common.cancel")}>{t("common.save")}</button>;
}
```

- **Interpolation:** `t("settings.env.removeAria", { name })` against a catalog
  value like `"Remove {{name}}"`.
- **Pluralization:** define `key_one` / `key_other` and call
  `t("settings.env.variablesCount", { count })`.
- **Rich text (links, bold):** use the `<Trans>` component with named
  `components`, e.g. a catalog value of
  `"… at <tokenLink>share.geolibre.app/settings</tokenLink>."` rendered with
  `<Trans i18nKey="…" components={{ tokenLink: <a href="…" /> }} />`.
- **Module-scope constants** can't call `t()` (no hook in scope). Store a stable
  **catalog key** on the constant instead of the English string and resolve it
  with `t(item.labelKey)` at the render site. See `SECTION_ITEMS` in
  `SettingsDialog.tsx` and the command/menu arrays in `TopToolbar.tsx`.

Do **not** translate: developer logs (`console.*`), URLs, store IDs, file-format
names/extensions, or persisted data values.

## Adding a new language

1. Copy `apps/geolibre-desktop/src/i18n/locales/en.json` to
   `locales/<code>.json` (e.g. `fr.json`, `pt-BR.json`) and translate the
   values. You may delete keys you haven't translated yet — they fall back to
   English.
2. Use your language's CLDR plural categories for plural keys: keep only the
   forms your language has (e.g. Indonesian/Japanese/Korean/Chinese/Thai drop
   `_one` and keep `_other`; Russian adds `_few`/`_many`; Arabic carries all
   six forms). The catalog test compares plural-normalized keys, so a locale
   may carry more or fewer plural forms than `en` without failing.
3. If the code isn't already in `LANGUAGE_NAMES` (`languages.ts`), add an entry
   so the selector shows a friendly native name. (Optional — it falls back to
   the raw code.)
4. That's it. The language appears in **Settings → Language** (and the Layout
   tab of the Settings dialog) and works with `?locale=<code>`.

## Right-to-left languages

Arabic (`ar`) is the first right-to-left catalog, and the layout direction is
wired so further RTL locales are additive:

- **Direction registry.** `RTL_LANGUAGES` in `languages.ts` lists the
  right-to-left base subtags (`ar`, `he`, `fa`, `ur`);
  `languageDirection(code)` resolves any tag the same way `resolveLanguage`
  does (`ar-SA` → `rtl`, unknown → `ltr`).
- **Document mirroring.** `index.ts` subscribes to i18next's `languageChanged`
  event — registered before `init`, so it also fires for the initial
  language — and sets `document.documentElement.lang` and `.dir`. With
  `dir="rtl"` the flex-based shell mirrors automatically: Layers on the
  right, Style on the left.
- **Radix direction.** `App.tsx` wraps the shell in `DirectionProvider`
  (re-exported from `@geolibre/ui`), so menus and submenus open toward the
  reading direction and arrow keys, sliders, and tabs follow the mirrored
  order.
- **Logical utilities.** Components use Tailwind's logical
  spacing/position utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`,
  `text-start`/`text-end`, `border-s`/`border-e`, `start-*`/`end-*`) instead
  of the physical `ml-*`/`left-*` forms, so styles flip with the document
  direction. Use the logical forms in new code, and don't mix a physical and
  a logical utility for the same side on one element — `tailwind-merge`
  treats them as distinct groups, so both would apply. Symmetric centering
  (`left-1/2` + `-translate-x-1/2`), map-anchored overlays, and JS-computed
  pixel offsets intentionally stay physical.
- **The map chrome stays ltr.** MapLibre anchors its controls with physical
  CSS, so `index.css` pins `.maplibregl-control-container { direction: ltr }` —
  the map chrome is identical in every language. The rest of the
  `.maplibregl-map` subtree is deliberately left free to mirror, so app overlays
  portalled into the map container (e.g. the story-map presenter) still follow
  the RTL layout. Arabic text in popups still shapes correctly through the
  Unicode bidi algorithm, and basemap label shaping is handled by the
  `mapbox-gl-rtl-text` plugin (see `src/lib/rtl-text.ts`).

Adding another right-to-left language is the same recipe as any language,
plus its base subtag in `RTL_LANGUAGES` if it isn't already listed.

## Keeping formatting locale-aware

Number, date, and coordinate formatting should continue to use the runtime
locale via `Intl` APIs (e.g. `new Intl.NumberFormat(undefined, …)` /
`Intl.DateTimeFormat`) rather than hard-coded formatting, so values render in the
user's regional conventions.

## Known limitations

Some plugins wrap **third-party MapLibre controls** that render their own UI
(e.g. the Esri Wayback plugin's `maplibre-gl-esri-wayback` control, with labels
such as "Only versions with local changes"). Those strings live inside the
external library and never pass through GeoLibre's `react-i18next` pipeline, so
they stay English regardless of the selected language. Translating them requires
an upstream change to the library (or a fork); it can't be done from a locale
catalog here.
