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 #
-
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.
- Use nested objects for grouping (e.g.,
-
Run codegen to regenerate the merged message bundles:
mise run webapp-codegen-i18n -
Use in components with
useTranslations:const t = useTranslations('nav.user'); return <span>{t('account')}</span>; -
Translate to other locales (requires
ANTHROPIC_API_KEYvia Doppler):mise run i18n -
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 localesetLocale(newLocale)— persists to Clerk + Legend StateisPending— 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.jsonis 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:
- Loads each
en/*.jsonfrommainviagit showand compares against the current file - Finds keys present in both where the English value differs
- In check mode: compares each locale’s translation against
main’s — if unchanged, it’s stale - In fix mode: deletes the stale keys from all locale files, then
mise run i18ndetects 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:
-
Fix the glossary first — open
config/i18n/<locale>/glossary.jsonand 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. -
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 -
Re-translate to regenerate the deleted files:
mise run i18n -
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 🌍