Internationalization (i18n)

Internationalization (i18n) #

BonsAI uses next-intl for client-side internationalization with locale persistence via Clerk user metadata.

Architecture Overview #

config/i18n/
├── locales.json                    # Locale registry (code → display name)
├── en/                             # English source of truth
│   ├── webapp.json                 # Main webapp strings
│   ├── error.json                  # API error messages
│   ├── features.json               # Feature flag labels
│   ├── extraction-fields.json      # Extraction field labels
│   └── glossary.json               # AI translation glossary (not bundled)
├── ja/                             # Per-locale translations (mirror en/ structure)
├── zh-CN/
└── ...

tools/local/scripts/i18n-gen/       # Codegen & translation tooling
├── src/codegen.ts                  # Merges namespace JSONs → webapp bundle
├── src/check.ts                    # Validates locale completeness
├── src/diff.ts                     # Detects/fixes stale translations vs main
├── src/translate.ts                # AI-powered translation
└── src/error-codegen.ts            # Generates Rust error constants

apps/webapp/src/shared/lib/i18n/    # Webapp runtime
├── config.ts                       # Auto-generated: Locale type, localeNames
├── provider.tsx                    # NextIntlClientProvider wrapper
├── get-messages.ts                 # Dynamic message loading
├── use-locale.ts                   # Hook: read/write user locale
└── messages/                       # Auto-generated merged JSON per locale

Adding New Strings #

  1. Add keys to the English source file in config/i18n/en/webapp.json (or appropriate namespace).

    • Use nested objects for grouping (e.g., nav.user.account).
    • Top-level keys must be unique across namespace files.
  2. Run codegen to regenerate the merged message bundles:

    mise run webapp-codegen-i18n
    
  3. Use in components with useTranslations:

    const t = useTranslations('nav.user');
    return <span>{t('account')}</span>;
    
  4. Translate to other locales (requires ANTHROPIC_API_KEY via Doppler):

    mise run i18n
    
  5. Check completeness across all locales:

    mise run i18n-check
    

Locale Persistence #

User locale is stored in Clerk unsafeMetadata.locale and synced to Legend State (ui$.persistent.locale) for synchronous access by ClerkProvider localization.

The useLocale() hook from @/shared/lib/i18n/use-locale provides:

  • locale — current user locale
  • setLocale(newLocale) — persists to Clerk + Legend State
  • isPending — mutation loading state

Namespace Files #

Each .json in config/i18n/<locale>/ is a namespace file. All files (except glossary.json) are merged into a single messages/<locale>.json at codegen time.

Rules:

  • Top-level keys must not collide across namespace files.
  • glossary.json is excluded from the webapp bundle — it provides context for the AI translator.
  • Leaf values are strings; ICU-style placeholders like {reason} are supported.

Feature Flags & Extraction Fields #

Two namespace files are auto-generated by codegen pipelines. Do not edit them by hand.

features.json — Feature Flag Labels #

Generated from config/features.yaml by the feature-codegen pipeline (mise run webapp-codegen).

Each feature flag entry gets a title, description, and optional warning. The webapp consumes them via useFeatureLabel():

import { useFeatureLabel } from '@/shared/components/feature-flags/use-feature-label';

const label = useFeatureLabel();
label('dropbox_integration', 'title');       // "Dropbox Integration"
label('dropbox_integration', 'description'); // "Import documents directly from Dropbox folders"

To add or change a feature label, edit config/features.yaml and run mise run webapp-codegen. The codegen writes config/i18n/en/features.json automatically.

extraction-fields.json — Extraction Field Labels #

Generated from field definition YAMLs by the field-codegen pipeline (mise run webapp-codegen).

Fields are keyed by extraction type (apBill, arInvoice, bankStatement, directExpense) and section (data for header fields, line for line items, meta for grouping labels). The webapp provides typed hooks in features/review/hooks/use-field-label.ts:

import { useFieldDataLabel, useFieldLineLabel } from '@/features/review/hooks/use-field-label';

const dataLabel = useFieldDataLabel(ExtractionType.AP_BILL);
dataLabel('invoiceNumber'); // "Bill Number"

const lineLabel = useFieldLineLabel(ExtractionType.AP_BILL);
lineLabel('unitPrice');     // "Unit Price"

To add or rename a field label, edit the field definition YAML and run mise run webapp-codegen. The codegen writes config/i18n/en/extraction-fields.json automatically.

Translation #

Both files follow the same translation workflow as webapp.json — after codegen, run mise run i18n to translate them to other locales and mise run i18n-check to verify completeness.

Updating Existing English Strings #

When you change the English value of an existing key, locale translations become stale — they still reflect the old text. The i18n translate tool only detects missing or extra keys, not changed values.

Check for stale translations:

mise run i18n-diff-check

Compares config/i18n/en/*.json against main, finds keys whose English value changed, and checks if locale translations are still untouched. Exits non-zero if stale translations are found.

Fix stale translations:

mise run i18n-diff

Deletes stale locale keys and re-translates them via mise run i18n. Supports --base <ref> to compare against a different branch (defaults to main).

How it works:

  1. Loads each en/*.json from main via git show and compares against the current file
  2. Finds keys present in both where the English value differs
  3. In check mode: compares each locale’s translation against main’s — if unchanged, it’s stale
  4. In fix mode: deletes the stale keys from all locale files, then mise run i18n detects them as missing and re-translates

The tooling lives in tools/local/scripts/i18n-gen/src/diff.ts.

Fixing Translation Quality #

When translations are incorrect or awkward, follow this order:

  1. Fix the glossary first — open config/i18n/<locale>/glossary.json and correct any core vocabulary translations (e.g., key business terms, product names, accounting terms). The glossary provides context for the AI translator, so fixing it here improves all future translations for that locale.

  2. Delete affected namespace files — remove any locale namespace files that contain strings using the corrected glossary terms. This forces regeneration with the updated glossary context.

    # Example: fix German translations after updating glossary
    rm config/i18n/de/webapp.json config/i18n/de/error.json
    
  3. Re-translate to regenerate the deleted files:

    mise run i18n
    
  4. Fix minor issues directly — review the regenerated output and hand-edit any remaining minor issues (phrasing, punctuation, context-specific wording) directly in the locale files.

Why this order? The glossary is the AI translator’s reference for domain-specific terms. Fixing individual strings without fixing the glossary means the same mistakes will reappear next time translations are regenerated.

Lint Enforcement #

The i18next/no-literal-string oxlint rule enforces that all user-visible strings use useTranslations. Excluded patterns (CSS classes, camelCase identifiers, test IDs, etc.) are configured in .oxlintrc.json.

Mise Commands #

Command Description
mise run i18n Translate all locales from English source
mise run i18n-diff Delete stale translations for changed English keys and re-translate
mise run i18n-diff-check Check if changed English keys need re-translation (vs main)
mise run webapp-codegen-i18n Regenerate config.ts and merged message files
mise run rust-codegen-i18n Generate Rust error key constants
mise run i18n-check Validate all locales have complete keys
mise run webapp-lint-check Run oxlint (includes literal string check)
mise run webapp-check Full check: format + lint + typecheck

If you have read through the documentation, please react to Koki’s slack thread with Globe icon 🌍