Internationalization (i18n)
Meratyn’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/mappview/src/i18n/.
The admin application has a separate catalog in
apps/admin/src/i18n/; it currently ships English and Spanish because
its copy and release cadence differ from the map workspace. A signed-in user’s
language is stored on their Meratyn profile and returned only by
GET /api/users/me, so public profiles do not expose the preference.
UserLanguageSync applies that setting to i18next and the document’s lang and
dir attributes. Signed-out visitors fall back to their browser language. Add
new Meratyn languages to its languages.ts, locale catalogs, and service-side
supported-language validation together.
The desktop ships 19 locale catalogs: the English baseline plus 18 selectable
non-English locales. Every catalog carries the complete current English key set.
Where a
native translation is still awaiting review, the catalog deliberately contains
the English source value rather than omitting the key. bun run --filter admin check:i18n rejects new hard-coded JSX copy, and the catalog test keeps all
catalogs and their interpolation placeholders aligned.
How it works
apps/mappview/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)
├── th.json # Thai
├── ka.json # Georgian
├── fa.json # Persian (right-to-left)
└── vi.json # Vietnamese
The non-English catalogs were seeded with machine-assisted translations and are open to native-speaker review and correction. Persian and Vietnamese are structurally complete, but retain English fallback values where the source translation did not cover a Meratyn-specific key; the catalog tests keep that coverage explicit rather than claiming a fully translated interface.
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 (bun 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. The 18 non-English catalog chunks 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. - Catalogs stay structurally complete. The test suite requires every
non-English catalog to contain the English key set (after normalizing plural
forms). Runtime
fallbackLng: "en"remains a defensive fallback for stale cached chunks and future catalog changes.
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.:
http://localhost:5173/?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"… in <tokenLink>Meratyn Share 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_ITEMSinfeatures/settings/dialog.tsx, the command registry infeatures/toolbar/commands.ts, and the menu catalogs underfeatures/toolbar/menus/.
Plugin and processing metadata
Plugin-owned plain-DOM panels use MappViewAppAPI.getLocale(),
onLocaleChange(listener), and translate(key, fallback, params) rather than
reaching into the host’s React tree. Keep the returned unsubscribe function and
call it during deactivate(). Toolbar menu labels additionally accept a
string | (() => string) getter; the host resolves it during each render and
subscribes the menu surface to locale changes. A malformed getter is isolated
and falls back to its stable id, so one plugin cannot break the toolbar.
Processing registries intentionally contain plain English metadata and have no
i18n dependency. Dialogs resolve names, descriptions, groups, parameter help,
and select options through features/processing/metadata/i18n.ts at render
time, always falling back to the registry value. Whitebox metadata is treated
the same way when a catalog entry exists, while control-selection heuristics
continue to inspect the raw manifest so translations cannot change execution.
The checked-in native-registry baseline is generated with
bun scripts/i18n/gen-processing-catalog.ts (use --check in CI). The
Whitebox menu snapshot remains owned by bun scripts/assets/whitebox-catalog.ts;
do not hand-edit either generated artifact. Whitebox tool metadata is kept on
its own owner boundary so its large catalog can evolve with the WASM manifests.
The Simplified Chinese catalog includes the complete generated metadata and
group-key surface for the native vector, network, statistics, and raster
registries; a newly added Meratyn descriptor stays an explicit English
fallback until an authoritative translation is available.
External Chinese source text is treated as authoritative only where its descriptor
semantics map to Meratyn; changed or Meratyn-only behavior keeps the English
fallback instead of presenting a stale translation.
Do not translate: developer logs (console.*), URLs, store IDs, file-format
names/extensions, or persisted data values.
Adding a new language
- Copy
apps/mappview/src/i18n/locales/en.jsontolocales/<code>.json(e.g.fr.json,pt-BR.json) and translate the values. Keep every English key; use the English value while a translation is pending. - 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. - Add a matching entry in
LANGUAGE_NAMES(languages.ts) so the selector shows a friendly native name. The catalog test requires registry and catalog codes to agree. - When English gains keys, run
bun scripts/i18n/sync-catalogs.ts en <code>for each affected locale. The script rebuilds the locale in English key order, preserves only placeholder-safe values, and fills new keys with an explicit English fallback; it never copies values by position. - 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) and Persian (fa) are right-to-left catalogs, 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. - Persian font.
index.cssloads the self-hosted Arabic subset of@fontsource-variable/vazirmatnonly whenhtml:lang(fa)is active. Latin product names and coordinates continue to use Geist, while Persian glyphs do not depend on an end-user system font. - Base UI direction.
App.tsxwraps the shell inDirectionProvider(re-exported from@mappview/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 MapLibre shapes Arabic and reorders bidirectional basemap labels itself, including offline builds.
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 Meratyn’s react-i18next pipeline, so
they stay English regardless of the selected language. Translating them requires
a change to the external library (or a maintained patch); it can’t be done from a locale
catalog here.