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