Internationalization (i18n) Guide
August 9, 2026 · View on GitHub
This document explains how the i18n system works, how to contribute translations, and how locale files stay in sync.
Overview
All user-facing strings in the editor are stored in JSON locale files under packages/i18n/. English (en.json) is the source of truth — every other locale file must mirror its structure exactly.
packages/i18n/
en.json # source of truth (always complete)
de.json # community-contributed German
fr.json # community-contributed French
...
How It Works
For Consumers
Import a locale file and pass it to <DocxEditor>:
import { DocxEditor } from '@docx-editor.dev/react';
import { de } from '@docx-editor.dev/i18n';
<DocxEditor i18n={de} />;
For Developers (Internal)
Use the useTranslation() hook inside any component:
import { useTranslation } from '../i18n';
function MyComponent() {
const { t } = useTranslation();
return <button title={t('formattingBar.bold')}>{t('common.apply')}</button>;
}
// With interpolation:
t('navigation.find.counter', { current: 3, total: 15 });
// → "Result 3 of 15"
Key States in Locale Files
Every key in a community locale file can be in one of three states:
| State | Value | Behavior |
|---|---|---|
| Translated | "Pogrubienie" | Displayed to user |
| Not yet translated | null | Falls back to English |
| Missing | (key absent) | CI fails — must be added as null |
Why null Instead of Missing Keys?
When a new English string is added to en.json, every community locale file must have that key present — even if no translation exists yet. This is enforced by CI.
Setting a key to null means:
- "I know this key exists, but it hasn't been translated yet"
- The editor will display the English string as a fallback
- Translators can find untranslated strings by searching for
null
Example:
{
"formattingBar": {
"bold": "Pogrubienie",
"italic": null,
"underline": null
}
}
Here, "Bold" shows as "Pogrubienie", while "Italic" and "Underline" show in English until someone translates them.
Contributing a New Locale
1. Scaffold a new locale
bun run i18n:new de # German
bun run i18n:new pt-BR # Brazilian Portuguese
bun run i18n:new zh-Hans # Simplified Chinese
This creates packages/i18n/<lang>.json with all keys set to null AND wires the locale into the typed exports in packages/i18n/src/index.ts (extends the LocaleCode union, adds a typed export const, slots it into the locales record). You only edit the JSON. Use a BCP 47 language tag.
2. Translate strings
Open the generated file and replace null values with translations. You don't need to translate everything at once — partial translations are welcome. Untranslated keys (null) fall back to English.
Tips:
- Keep
{variable}placeholders as-is — they get replaced at runtime - Keyboard shortcuts (Ctrl+B, Ctrl+Z) should keep the key part unchanged
- Font names (Arial, Calibri) and typography terms (Sans Serif, Monospace) are typically not translated
- Page size identifiers (Letter, A4) keep the standard name
3. Check your progress
bun run i18n:status
Source: en.json (503 keys)
Locale Translated Untranslated Coverage
----------------------------------------------
de 210 293 42% ████████░░░░░░░░░░░░
4. Validate
bun run i18n:validate # check all locale files are in sync
bun run i18n:fix # auto-repair if needed
5. Regenerate the public API snapshot
A new locale adds new @public exports (the named const, the ./<code> subpath module, and an entry in the LocaleCode union). API Extractor snapshots under docs/api/docx-editor-i18n/ capture that surface, and CI's api:check job fails if they drift — so regenerate and commit them:
bun run --filter '@docx-editor.dev/i18n' build
bun run api:extract
git add docs/api/docx-editor-i18n/
You should see one modified index.api.md (with your locale slotted into the exports and the LocaleCode union) and one new <code>.api.md file. If you skip this step, the build job in CI fails with API surface drift.
6. Add a changeset
bun changeset
Select @docx-editor.dev/i18n with a patch bump and write a one-line summary (e.g. add <Language> (<code>) community-maintained locale). The summary lands verbatim in the published CHANGELOG.md.
7. Open a PR
Include your coverage (e.g., "German: 100% translated" or "Japanese: toolbar + dialogs translated, errors section still null").
Adding New English Strings
When adding a new feature with user-facing text:
1. Add to en.json
Add your key to the appropriate section. Nest by feature area:
{
"toolbar": { ... },
"dialogs": {
"myNewDialog": {
"title": "My Dialog",
"description": "Some description"
}
}
}
2. Use in component
const { t } = useTranslation();
<h2>{t('dialogs.myNewDialog.title')}</h2>;
Types update automatically — t() will autocomplete your new key.
3. Sync locale files
bun run i18n:fix
This adds your new keys as null in all community locale files. CI will fail if you skip this step.
Key Naming Conventions
- Nest by feature area:
toolbar.*,navigation.find.*,comments.* - Use camelCase for keys:
counter,insertRowAbove - Shared strings go in
common.*: Cancel, Insert, Apply, Close, Delete - Don't duplicate — if a string exists in
common.*, use it
CI Validation
The i18n:validate script runs in CI and ensures:
- Every key in
en.jsonexists in every locale file (as either a translated string ornull) - No extra keys exist in locale files that aren't in
en.json - Every key in
en.jsonis referenced by shipping code
If CI fails on 1 or 2:
bun run i18n:fix # auto-repair all locale files
git add packages/i18n/
git commit -m "fix: sync i18n locale files"
Rule 3 is why a key must be added and USED in the same change, not added ahead of the
component that renders it: an unused key is one every locale has to carry and every
translator is asked for. bun run i18n:unused lists what it is complaining about. It
reads only shipping source — tests, comments and docs do not count as a reference, and
neither do the Vue and Nuxt adapters while they are unshipped. A key built at runtime is
fine as long as the static part is one literal (t(`navigation.tabs.${id}`)); a key
assembled from fragments is invisible to it and will report as unused.
CLI Reference
| Command | Description |
|---|---|
bun run i18n:new <lang> | Scaffold a new locale file + auto-wire the typed exports in packages/i18n/src/index.ts |
bun run i18n:status | Show translation coverage for all locales |
bun run i18n:validate | Check locale JSONs are in sync with en.json, typed exports are in sync with JSONs, and no key is unused |
bun run i18n:unused | List keys in en.json that no shipping code references |
bun run i18n:fix | Auto-repair JSONs (missing → null, extras removed) + regenerate the typed exports |
bun run i18n:codegen | Regenerate the typed exports in src/index.ts from on-disk JSONs (run if you add/remove a JSON manually) |
Architecture
packages/i18n/en.json → Source of truth (all strings)
packages/react/src/i18n/types.ts → Auto-derived types from en.json
packages/react/src/i18n/LocaleContext.tsx → React Context + useTranslation hook
packages/react/src/i18n/index.ts → Barrel exports
scripts/validate-i18n.mjs → CI/pre-commit validation
- Zero runtime dependencies — uses React Context only
- Type-safe —
t()accepts only valid dot-notation keys, autocomplete works - Tree-shakeable — only the imported locale gets bundled
- Null = untranslated — deep merge skips nulls, falls back to English
Interpolation and Pluralization
Interpolation
Insert dynamic values into strings with {variable} placeholders:
"greeting": "Hello {name}!"
t('greeting', { name: 'Jane' }); // → "Hello Jane!"
Cardinal pluralization
Format messages based on numerical values using ICU MessageFormat syntax (same as next-intl):
"followers": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
=0,=1,=2— exact matches (checked first)one,few,many,other— CLDR plural categories (language-dependent)#— replaced with the actual number
t('followers', { count: 0 }); // → "You have no followers yet."
t('followers', { count: 1 }); // → "You have one follower."
t('followers', { count: 3580 }); // → "You have 3580 followers."
Translators use their language's plural forms in the same key — no structural changes:
// pl.json — Polish has one/few/many/other
"followers": "{count, plural, =0 {brak obserwujących} one {# obserwujący} few {# obserwujących} many {# obserwujących} other {# obserwujących}}"
Why _lang matters for plurals
The translation string defines the branches (what to show), but locale determines which branch to select for a given number. Different languages have different rules for what counts as "few" vs "many":
English: count=3 → "other" → "3 items"
Polish: count=3 → "few" → "3 przedmioty"
Polish: count=5 → "many" → "5 przedmiotów"
Each locale file includes a _lang field (set automatically by bun run i18n:new) that tells Intl.PluralRules which rules to apply. Just pass the file — no extra config needed:
import { pl } from '@docx-editor.dev/i18n';
<DocxEditor i18n={pl} />;
Since plurals are inline strings, locale file keys stay identical across all languages — the validator works unchanged.
Limitations
- No RTL layout — String translation works, but the editor UI layout is not RTL-ready. RTL support is a separate effort.