Skip to content

Latest commit

 

History

History
273 lines (189 loc) · 8.37 KB

File metadata and controls

273 lines (189 loc) · 8.37 KB

Internationalization (i18n) Guide

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/react/i18n/. English (en.json) is the source of truth — every other locale file must mirror its structure exactly.

packages/react/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 '@eigenpal/docx-js-editor';
import de from '@eigenpal/docx-js-editor/i18n/de.json';

<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('toolbar.bold')}>{t('common.apply')}</button>;
}

// With interpolation:
t('dialogs.findReplace.matchCount', { current: 3, total: 15 });
// → "3 of 15 matches"

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 easily find untranslated strings by searching for null

Example:

{
  "toolbar": {
    "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/react/i18n/<lang>.json with all keys set to null, mirroring the structure of en.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 (483 keys)

Locale      Translated  Untranslated  Coverage
----------------------------------------------
de          210         273           43% ████████░░░░░░░░░░░░

4. Validate

bun run i18n:validate    # check all locale files are in sync
bun run i18n:fix         # auto-repair if needed

5. 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.*, dialogs.findReplace.*, comments.*
  • Use camelCase for keys: matchCount, 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:

  1. Every key in en.json exists in every locale file (as either a translated string or null)
  2. No extra keys exist in locale files that aren't in en.json

If CI fails:

bun run i18n:fix   # auto-repair all locale files
git add packages/react/i18n/
git commit -m "fix: sync i18n locale files"

CLI Reference

Command Description
bun run i18n:new <lang> Scaffold a new locale file with all keys set to null
bun run i18n:status Show translation coverage for all locales
bun run i18n:validate Check all locale files are in sync with en.json
bun run i18n:fix Auto-repair: add missing keys as null, remove extras

Architecture

packages/react/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-safet() 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 '@eigenpal/docx-js-editor/i18n/pl.json';

<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.