Skip to content

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

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

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

Using translations in components

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 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-SArtl, 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.