Manual/Reference

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.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 (bun 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. 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 (see maximumFileSizeToCacheInBytes / HEAVY_PRECACHE_IGNORES in vite.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):

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

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_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 "… 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 with t(item.labelKey) at the render site. See SECTION_ITEMS in features/settings/dialog.tsx, the command registry in features/toolbar/commands.ts, and the menu catalogs under features/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

  1. Copy apps/mappview/src/i18n/locales/en.json to locales/<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.
  2. Use your language’s CLDR plural categories for plural keys: keep only the forms your language has (e.g. Indonesian/Japanese/Korean/Chinese 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. 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.
  4. 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.
  5. 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_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-SA → rtl, 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.
  • Persian font. index.css loads the self-hosted Arabic subset of @fontsource-variable/vazirmatn only when html: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.tsx wraps the shell in DirectionProvider (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 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 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.