| name | i18n-changes |
| description | Adding/renaming/removing translation keys in src/i18n/locales/en.ts and the 14 non-English locale JSONs, fixing the four i18n check failures ("i18n key mismatch detected", "Untranslated values detected", "interpolation mismatch detected", "Unused i18n keys detected"), the missing en.json TS2307 error, the values allowlist, plurals without ICU, and adding a new locale. |
i18n Changes
When to use
- Adding, renaming, or removing any user-facing string (nearly every feature PR).
- A pre-commit or CI quality-job step starting with
check:i18n failed.
pnpm run typecheck fails with TS2307: Cannot find module './locales/en.json'.
- UI shows a raw key (
feature.thing), a literal {count}, or an ICU block.
Mental model
src/i18n/locales/en.ts is the hand-edited source of truth (~2500 flat dotted keys). src/i18n/locales/en.json is a gitignored build artifact regenerated by pnpm run build:en-locale (also runs automatically inside dev and build). Never hand-edit en.json; on a fresh clone, generate it before typecheck.
- The translated locale JSONs (cs, de, es, fr, it, ja, ko, nb, nl, pl, pt-BR, sv, uk, zh-CN — 14 today) must each have exactly the same key set as en.ts. Never trust a memorized count: both check scripts
readdirSync the locales dir and skip en.json, so a new locale is picked up the moment its JSON lands. SUPPORTED_LOCALES in src/i18n/types.ts (15 incl. en) is the matching runtime list.
- All four check scripts parse en.ts with regex, never import it. Keep en.ts regex-parseable: single-quoted keys, plain quoted string values, no template literals, no concatenation, no escaped quotes (use double-quoted values for apostrophes). A value the regex can't match silently disappears from every check.
- Runtime interpolation (
interpolate() in src/i18n/context.tsx) is literal {name} replaceAll. ICU is never evaluated even though the interpolation check tolerates its syntax. Plurals = two keys + a ternary in code.
t() fallback is translations[key] || _loadedEn?.[key] || key — empty string counts as missing; you cannot intentionally blank a string per-locale.
- Gridfinity domain terms stay in English in all locales: bin, drawer, layer, staging/stash, grid unit, height unit, print bed, Gridfinity (see the en.ts header comment).
Recipe: add a string
- Add
'feature.context.element': 'Value', to src/i18n/locales/en.ts under the matching // section comment.
- Add the same key, translated, to every non-English locale JSON. If a locale's value is legitimately identical to English (brand name, format string, domain term), add the key to the
"keys" array in scripts/i18n-values-allowlist.json. Identical values ≤3 chars without spaces (OK, 3MF) or matching a valuePatterns regex (placeholder-only strings) auto-pass.
- In code:
const t = useTranslation(); then t('feature.context.element') or t('key', { count }) — the param name must equal the {count} token exactly. Outside React (workers, ErrorBoundary) use getStaticTranslation from @/i18n.
- Plurals: two keys, e.g.
'baseplate.bedLoads.one': 'Prints in 1 build-plate load' and 'baseplate.bedLoads.other': 'Prints in {count} build-plate loads', selected by a ternary at the call site (see src/features/baseplate/components/BaseplatePage/BaseplatePage.tsx ~line 326). Never {count, plural, ...}.
- Verify (see below).
Recipe: rename or remove a key
rg "old\.key" src/ — catch data-structure refs (labelKey: 'x.y') and ternary keys, not just t() calls.
- Change en.ts, every non-English JSON, and every code ref in the same commit. Touching only en.ts produces "Missing" (add) or "Extra ... (not in en.ts)" (remove) per locale.
- Merging duplicates:
pnpm exec tsx scripts/consolidate-i18n-key.ts --from old.key --to keep.key --dry-run — refuses unless both keys are identical in every locale; drop --dry-run to apply, then update the code refs it lists.
- Because you touched
src/i18n/, pre-commit WILL run the unused check and may surface unrelated pre-existing debt — fix it, don't bypass.
Recipe: add a locale
- Create
src/i18n/locales/<code>.json with every en.ts key translated (use en.json as template).
src/i18n/types.ts: extend the Locale union and append to SUPPORTED_LOCALES (renders in the language selector).
src/i18n/context.tsx: add entries to localeLoaders and OG_LOCALE_MAP (underscore OG format, e.g. sv_SE) — typecheck errors on both Record<Locale, ...> until you do.
src/i18n/detection.ts: add LANGUAGE_MAP entries for the base code and regional variants.
pnpm run typecheck && pnpm run check:i18n && pnpm run check:i18n:values.
Verification
pnpm run build:en-locale
pnpm run check:i18n && pnpm run check:i18n:values && \
pnpm run check:i18n:interpolation && pnpm run check:i18n:unused
Each failure prints a distinct ❌ line naming exact keys/files/lines. Pre-commit runs checks 1–3 always and the unused check only when src/i18n/ files are staged (.husky/pre-commit); the CI quality job runs each with continue-on-error and aggregates, so read the whole log — there may be several failures at once. After changing context.tsx/detection.ts: pnpm run test:run src/i18n.
For the other pre-commit gates (boundaries, design-system, exhaustiveness, missing-tests), see the quality-gates skill. For where strings live inside a feature slice, see the feature-slices skill.
Traps
| Symptom | Cause | Fix |
|---|
TS2307: Cannot find module './locales/en.json' at context.tsx | en.json is gitignored; fresh clone has none | pnpm run build:en-locale |
❌ i18n key mismatch detected | Key set differs between en.ts and a locale JSON (typos show as Missing + Extra) | Apply the exact per-file Missing/Extra list |
❌ Untranslated values detected | Locale value copied from English, misses allowlist heuristics | Translate it, or allowlist the key if identity is intentional |
❌ i18n interpolation mismatch detected | {name} token in en.ts ≠ param name at the t() call (output gives file:line) | Make token and param identical — fix whichever side is wrong |
❌ Unused i18n keys detected blocks a commit that only added one key | An earlier PR deleted the last ref; the gated check fires on the next i18n toucher | Delete the listed definitely-unused keys from en.ts + all JSONs; ⚠️ possibly-unused (dynamic-prefix) lines are warn-only |
UI renders literal {count} or a raw ICU block | Runtime is plain replaceAll; the interpolation check only sees literal t('key', ...) calls — ternary/dynamic keys are invisible to it | Pass the vars object; replace ICU with .one/.other keys + ternary. 'collab.participantCount' in en.ts is a live latent bug — do not copy it |
| UI shows raw dotted key, sometimes only briefly or only non-English | Key missing in active locale before the en chunk lazy-loads; pre-load resolves only the hardcoded fallback map in context.tsx (errorBoundary., seo., common.loading) | Run check:i18n for parity; keys needed outside React/before load must also go in that fallback map |
| Unrelated commit blocked by an i18n check | Checks scan whole files, not staged hunks | Fix or stash existing i18n debt first |
| Component test asserts translated text without a provider and passes | src/test/setup.ts globally mocks @/i18n with real en.ts strings | Expected; locale-switching/fallback behavior is only tested in |