Skip to content

Localization

Tomoda ships in four locales, wired up through i18next + react-i18next with system-locale detection from expo-localization.

Supported locales

BCP 47 tag Language File
en-US English (US) — source of truth frontend/i18n/locales/en-US.json
ja-JP Japanese frontend/i18n/locales/ja-JP.json
zh-CN Simplified Chinese frontend/i18n/locales/zh-CN.json
zh-TW Traditional Chinese frontend/i18n/locales/zh-TW.json

en-US is the fallback — any missing key in another locale resolves against the English file.

Bootstrap

frontend/i18n/index.ts is imported once from app/_layout.tsx:

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';

i18n.use(initReactI18next).init({
  resources: {
    'en-US': { translation: enUS },
    'ja-JP': { translation: jaJP },
    'zh-CN': { translation: zhCN },
    'zh-TW': { translation: zhTW },
  },
  lng: getSystemLanguage(),
  fallbackLng: 'en-US',
  interpolation: { escapeValue: false },
  compatibilityJSON: 'v4',
});

Fallback chain

getSystemLanguage() walks the device locale list returned by Localization.getLocales():

  1. If the primary tag is one of the four supported keys, use it.
  2. Otherwise, fall back by language code:
  3. zh + region CNzh-CN
  4. zh + any other region → zh-TW
  5. jaja-JP
  6. enen-US
  7. Anything else → en-US.

The AuthContext syncs i18n.changeLanguage() whenever the persisted server profile's user.language changes — so the server is the source of truth once the user has signed in, and the device locale is only used pre-login.

Usage

Component-side, just call the hook:

import { useTranslation } from 'react-i18next';

export function LoginScreen() {
  const { t } = useTranslation();
  return (
    <Button title={t('auth.login.submit')} />
  );
}

Imperative reads (outside components) go through the singleton:

import i18n from '@/i18n';
i18n.t('errors.network');
i18n.changeLanguage('ja-JP');

Interpolation uses the i18next default {{var}} syntax:

{ "discover.nearbyCount": "{{count}} events nearby" }
t('discover.nearbyCount', { count: 12 })

Validation script

frontend/scripts/compare_i18n.js diffs each locale against en-US.json and prints missing and extra keys:

node frontend/scripts/compare_i18n.js

The script resolves localesDir relative to its own location, so it works on any checkout without configuration.

Tip

For substantive i18n maintenance (extracting hardcoded strings, removing orphan keys, autofilling missing translations), use the /locale-reconcile skill rather than this script.

/locale-reconcile skill

The user-defined /locale-reconcile skill is the recommended way to keep locales in sync. It:

  • Audits the four locale files against en-US.
  • Finds keys referenced in code but missing from one or more locale files.
  • Detects unused locale keys (defined but not referenced).
  • Surfaces hardcoded user-facing strings that should be wired through t().
  • Optionally fills in missing translations.

Invoke it whenever you add or remove i18n keys.

Conventions

  • Key style: dot-separated lowercase with subdomains (auth.login.submit, discover.nearbyCount).
  • No raw strings in JSX: every user-facing string goes through t(). The reconcile skill flags violations.
  • Plurals: use i18next's _one / _other suffixes when needed.
  • Dates: format with date-fns (already a dependency) using the active i18n.language — do not put dates in locale files.