# I18N Sync

> Keep translation/locale files in sync across languages. Use when locale files have drifted apart, when adding a new language, when a key exists in one locale but not others, or when the user mentions i18n, l10n, translation keys, missing translations, locale JSON/YAML files, i18next, vue-i18n, react-intl, Rails locales, gettext, or .po files.

- Skill: `khushbu-snapdevio/i18n-sync` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add khushbu-snapdevio/i18n-sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/khushbu-snapdevio/i18n-sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Khushbu-snapdevio (https://skillmd.com/u/khushbu-snapdevio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/khushbu-snapdevio/i18n-sync

---


# i18n Sync

Detect and fix drift between locale files so every language has the same key set.

## When to use

- "some translations are missing"
- "add a new language / locale"
- "check my locale files are in sync"
- after a PR adds strings to only the base locale

## Supported formats

| Format | Extensions | Notes |
|---|---|---|
| JSON | `.json` | nested or flat/dotted keys |
| YAML | `.yml` `.yaml` | incl. Rails-style files rooted at the locale code |
| gettext | `.po` `.pot` | `msgctxt`, plurals, and multi-line strings preserved |

Layouts handled automatically: `locales/en.json`, `locales/en/common.json`, and
`locales/fr/LC_MESSAGES/messages.po` (a `.pot` template is treated as the base).

YAML requires PyYAML (`pip install pyyaml`). JSON and PO need nothing beyond stdlib.

## Running the bundled script

`scripts/sync_keys.py` ships next to this SKILL.md. Your shell's working
directory is the **user's project**, not this skill's directory, so always
invoke it by absolute path — a bare `scripts/sync_keys.py` will not resolve:

```bash
SYNC="$(find ~/.claude ~/.config/claude -path '*i18n-sync*' -name sync_keys.py 2>/dev/null | head -1)"
python3 "$SYNC" <locale-dir> --base en
```

Set `SYNC` once, then reuse it for every invocation below.

### If `python3` is unavailable

The script is the reliable path, but it must not be a hard requirement. Check
first, and only fall back if it is genuinely absent:

```bash
command -v python3 || echo "no python3"
```

If there is no `python3`, do the comparison by reading the files directly:

1. Read the base locale file and each target locale file for the **same
   namespace** (`en/common.json` pairs with `fr/common.json`, never with
   `fr/errors.json`).
2. Flatten both to dotted key paths (`nav.home`), then diff the key **sets** —
   report `missing`, `extra`, and `untranslated` exactly as the script does.
3. Check placeholders per key: every `{{name}}` / `%{name}` / `%s` / `%(name)s`
   / `<0>` in the base string must appear in the translation.
4. Apply fixes with targeted edits — add only the absent keys, and never
   rewrite a line that already holds a translation.

Then tell the user plainly:

> Python 3 isn't installed, so I compared the files by reading them. Install
> Python 3 for a faster and more reliable check on large locale sets.

Say it because it is true: reading by hand does not scale. Past a few hundred
keys, or across many namespaces, quietly missing some is a real failure mode —
if the files are large, report that the comparison may be incomplete rather
than implying it was exhaustive.

## Workflow

### 1. Discover the setup

Find the locale files before assuming anything:

```bash
find . -type d \( -name node_modules -o -name .git -o -name dist \) -prune -o \
  \( -path '*locale*' -o -path '*i18n*' -o -path '*lang*' -o -path '*translation*' \) \
  -type f \( -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.po' \) -print \
  | head -50
```

Confirm with the user if ambiguous:
- **Locale directory** (e.g. `src/locales/`, `config/locales/`)
- **Base locale**: usually `en`. It is the source of truth.

### 2. Diff the key sets

Run the bundled script — do not eyeball the files (if `python3` is missing,
use the fallback above instead):

```bash
python3 "$SYNC" <locale-dir> --base en
```

Exit code is 1 when drift exists, so it works as a CI check. It reports per locale:

- **missing** — keys in base but not in this locale
- **extra** — keys here but not in base (usually stale)
- **untranslated** — empty, or identical to the base string
- **placeholder mismatch** — the translation lost or altered a `{{name}}` /
  `%{name}` / `%s` / `<0>` placeholder. **These are runtime bugs**, not style
  issues: a missing interpolation renders a literal placeholder or crashes.
- **plural needs review** — plural entries a human must complete
- **orphan file** — a locale file with no counterpart in the base locale.
  Reported, never auto-deleted: removing a whole file is the user's call.

### 3. Report before changing anything

Show the user a summary table (locale, missing, extra, untranslated). Never
bulk-edit locale files without showing this first — translations are
human-reviewed content, and overwriting a real translation is not recoverable
from the file itself.

### 4. Apply the fix the user chooses

```bash
python3 "$SYNC" <locale-dir> --base en --fix-missing [--prune-extra]
```

- `--fix-missing` inserts missing keys, preserving key order, indent width and
  trailing newline. Note: JSON is re-serialised one key per line, so a file
  written with compact inline objects (`"nav": { "a": 1, "b": 2 }`) comes back
  expanded. Content is unchanged; mention it if the user cares about the diff.
- `--prune-extra` removes stale keys. **Ask first** — the key may still be
  referenced by code that only that locale reaches.
- For a **new language**: copy the base file, keep all keys, and report which
  keys need human translation.

Per-format behaviour worth knowing:

- **JSON/YAML** — a missing key is filled with the **base-language text** as a
  visible placeholder, so the UI stays readable. Report these as untranslated.
- **gettext** — a missing entry is added with an **empty `msgstr`**. That is the
  correct idiom: gettext falls back to the `msgid` automatically, and an empty
  `msgstr` is what marks the string as untranslated to translator tooling.
  Never pre-fill a `msgstr` with English — it makes untranslated strings
  indistinguishable from finished ones.
- **YAML** — PyYAML cannot preserve comments. The script refuses to rewrite a
  commented YAML file unless `--allow-comment-loss` is passed. Prefer editing
  those by hand; translator comments carry context that is expensive to recreate.

### 5. Verify

Re-run the script; all locales should report 0 missing and 0 placeholder
mismatch. Then run the project's own i18n lint/test if one exists (check
`package.json` scripts, or `i18n-tasks health` on Rails). For gettext, validate
with `msgfmt --check-format -o /dev/null <file>.po` if gettext tools are present.

## Rules

- **Never machine-translate** unless the user explicitly asks. Filling a key is
  not the same as translating it — always report what still needs a human.
- **Preserve interpolation exactly**: `{{name}}`, `{count}`, `%{name}`, `%s`,
  `%(name)s`, `<0>`. Flag any mismatch as an error, not a warning.
- **Preserve what matters**: key ordering, indent width, trailing newline, and
  the locale root key in Rails YAML. Existing translations are never
  overwritten — only absent keys are added.
- **Plural forms are language-specific** — 1 form in Japanese, 2 in English, 3 in
  Russian, 6 in Arabic. The script reads `nplurals` from each PO file's own
  header. Never copy English's `_one`/`_other` shape into another language;
  flag those keys for human input instead.
- **RTL and length**: do not "fix" a translation that looks wrong for being
  longer, shorter, or right-to-left. Only key structure is machine-checkable.

