# 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 └── 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-`) 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 ; } ``` - **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 `` component with named `components`, e.g. a catalog value of `"… at share.geolibre.app/settings."` rendered with ` }} />`. - **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/.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 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=`. ## 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.