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

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

Processing tool metadata

Tool names, descriptions, group labels, parameter labels/help text and select option labels live in @geolibre/processing, a package with no i18n access. The Processing dialogs (Vector / Network / Spatial statistics / Raster / Batch tools, and the Model Builder palette) render them through apps/geolibre-desktop/src/lib/processing-tool-i18n.ts, which resolves processing.toolMeta.<catalog>.<toolId>.… and falls back to the registry's own string. The catalog name (vector | network | statistics | raster) is part of every key because tool ids repeat across registries — reproject is both a vector tool and a raster tool. Grouping labels share one namespace, processing.toolGroup.<slug>, because the same label ("Analysis") appears in more than one registry.

Because the fallback is the registry string itself, English is correct with no catalog entries at all. en.json carries them anyway, as the baseline translators work from: tests/i18n-catalogs.test.ts rejects a key in another locale that has no English counterpart, so a string missing from en.json cannot be translated anywhere. That baseline is generated:

npm run i18n:tools          # regenerate the en.json baseline from the registries
npm run i18n:tools:check    # fail if it is out of date (runs in `npm run ci`)

Re-run it after adding or renaming a tool, a parameter or a select option, and commit the result. Only the processing.toolMeta and processing.toolGroup subtrees of en.json are rewritten; the other locales are never touched.

Whitebox tools are not covered: their ~800 names and descriptions come from the bundled WASM binary, so the host has no keys for them and renders them verbatim (modelProviderCatalog returns null for that provider).

The Model Builder palette is the one place that mixes the two, and its group headings need care: groupModelTools keys groups on the raw label, so a heading has no provider of its own, and the WASM catalog ships a terrain category (lowercase) that slugs onto the raster registry's Terrain. Translating both would render two identically-labelled headings. translateModelToolGroup gates on the group's tools instead — a heading is translated only when at least one of its tools comes from an owned catalog. Use it for palette headings rather than translateToolGroup directly.

Plugin-facing surfaces

Plugin display names resolve through toolbar.plugin.<pluginId>, and every surface that shows one (the Plugins menu, the command palette, Settings → Interface, Manage Plugins) goes through lib/plugin-display-name.ts, so a plugin cannot read translated in one menu and English in the next. A plugin with no catalog entry falls back to its registered name, which is right for the brand/proper-noun plugins (Mapillary, NASA Earthdata, …) and for externally installed plugins the host cannot know at build time.

A plugin's own UI is plain DOM and cannot use useTranslation. The app API gives it getLocale(), onLocaleChange(listener) and translate(key, defaultValue, params) instead, and toolbar menu labels and panel titles accept getter functions so they can call translate and be re-read after a language change. See docs/plugin-api.md ("Following the app language").

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.