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 processing registries that have no i18n access. The
Processing dialogs (Vector / Network / Spatial statistics / Raster / Batch
tools, the Whitebox toolbox, 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 | whitebox) 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. For the four small bundled registries, en.json
carries the keys translators work from: tests/i18n-catalogs.test.ts rejects a
key in another bundled locale that has no English counterpart. 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, parameter, or select option, and
commit the result. Only processing.toolMeta and processing.toolGroup in
en.json are rewritten; the other locales are never touched.
Whitebox tool names and parameter help text use
processing.toolMeta.whitebox.<toolId>…, but their hundreds of descriptions are
not bundled in any locale. Whitebox supplies the English fallback at runtime;
optional translations are versioned language packs hosted in
opengeos/geolibre-language-packs.
Users can download the active locale's pack from Settings → Language or import a
compatible JSON file. Packs are validated before being merged and persist in an
IndexedDB database separate from projects and desktop settings.
The v1 pack format accepts only these roots:
processing.toolMeta.whiteboxprocessing.whitebox.categoriesprocessing.whitebox.menuToolprocessing.whitebox.menuSubcategory
Official packs resolve from
https://languages.geolibre.app/v1/whitebox/<locale>.json. A self-hosted build
may set VITE_LANGUAGE_PACK_BASE_URL to an internal mirror. Local file import
continues to work without network access.
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 the vector registry. Use it for palette headings rather
than translateToolGroup directly. Note that modelProviderCatalog now returns
"whitebox" rather than null for Whitebox tools, so "has an owned catalog" is
no longer the same test: a Whitebox-only group's heading stays raw catalog text
even when an optional Whitebox language pack is installed.
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.