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.jsonis the source of truth. Every key used byt()must exist here;i18next.d.tstypes thet()function against it, so a missing or misspelled key is a compile error (npm run typecheck).- Catalogs are auto-discovered.
index.tspicks up everylocales/*.jsonviaimport.meta.glob, so addinglocales/fr.jsonis all it takes to make French selectable — no code changes. - English is bundled; other locales are lazily loaded.
en.jsonships 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 (i18nReadyinindex.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 (seemaximumFileSizeToCacheInBytes/HEAVY_PRECACHE_IGNORESinvite.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
enat runtime (fallbackLng: "en").
Language detection¶
On startup the initial language is resolved in priority order
(getInitialLanguage in index.ts):
- the
?locale=or?lang=query parameter (for embeds — consistent with the existing?theme=/?maponlyparams), - the language persisted in desktop settings (
languagefield), - the browser's preferred languages (
navigator.languages), - 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_otherand callt("settings.env.variablesCount", { count }). - Rich text (links, bold): use the
<Trans>component with namedcomponents, 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 witht(item.labelKey)at the render site. SeeSECTION_ITEMSinSettingsDialog.tsxand the command/menu arrays inTopToolbar.tsx.
Do not translate: developer logs (console.*), URLs, store IDs, file-format
names/extensions, or persisted data values.
Adding a new language¶
- Copy
apps/geolibre-desktop/src/i18n/locales/en.jsontolocales/<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. - 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
_oneand 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 thanenwithout failing. - 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.) - 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_LANGUAGESinlanguages.tslists the right-to-left base subtags (ar,he,fa,ur);languageDirection(code)resolves any tag the same wayresolveLanguagedoes (ar-SA→rtl, unknown →ltr). - Document mirroring.
index.tssubscribes to i18next'slanguageChangedevent — registered beforeinit, so it also fires for the initial language — and setsdocument.documentElement.langand.dir. Withdir="rtl"the flex-based shell mirrors automatically: Layers on the right, Style on the left. - Radix direction.
App.tsxwraps the shell inDirectionProvider(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 physicalml-*/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-mergetreats 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.csspins.maplibregl-control-container { direction: ltr }— the map chrome is identical in every language. The rest of the.maplibregl-mapsubtree 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 themapbox-gl-rtl-textplugin (seesrc/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.