You add a new feature. You add the English string. You ship. Three days later, your French users see dashboard.analytics.export_button instead of a button label — and they file a support ticket.
This skill diffs every locale file against your base locale, tells you exactly what's missing in each language, and gives you the CI command to stop it from happening again.
Supports JSON, YAML, PO, and ARB. Works with any framework. Zero external API.
# Option 1: Audit the current directory (auto-detect locale files)
/i18n-audit
# Option 2: Specific locale directory
/i18n-audit locales/
/i18n-audit src/i18n/
/i18n-audit config/locales/ # Rails
# Option 3: Specify base locale
/i18n-audit --base en
/i18n-audit --base en-US
# Option 4: Check a specific target locale
/i18n-audit --locale zh-CN
/i18n-audit --locale fr
# Option 5: Set coverage threshold for CI
/i18n-audit --min-coverage 95
# Option 6: Show only missing keys (no orphans, no untranslated)
/i18n-audit --missing-only
# Option 7: Output a ready-to-fill translation file
/i18n-audit --scaffold zh-TW
# Generates zh-TW.json with all missing keys as empty strings
# Auto-detect locale file format and location
python3 -c "
import os, glob
# Common i18n directory patterns
patterns = [
'src/i18n/**/*.json',
'src/locales/**/*.json',
'locales/**/*.json',
'public/locales/**/*.json',
'i18n/**/*.json',
'assets/i18n/**/*.json',
'config/locales/**/*.yml', # Rails
'config/locales/**/*.yaml',
'lib/l10n/**/*.arb', # Flutter
'po/**/*.po', # gettext
'*.po',
]
found_files = []
for pattern in patterns:
found_files.extend(glob.glob(pattern, recursive=True))
# Exclude node_modules
found_files = [f for f in found_files if 'node_modules' not in f and 'dist' not in f]
if found_files:
# Group by format
by_ext = {}
for f in found_files:
ext = os.path.splitext(f)[1]
by_ext.setdefault(ext, []).append(f)
for ext, files in by_ext.items():
print(f'{ext}: {len(files)} files')
for f in sorted(files):
print(f' {f}')
else:
print('No locale files found in standard locations.')
print('Common locations to check:')
print(' src/locales/, src/i18n/, locales/, config/locales/, lib/l10n/')
"
python3 -c "
import json, os
try:
pkg = json.load(open('package.json'))
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
frameworks = []
if 'i18next' in deps or 'react-i18next' in deps:
frameworks.append('i18next / react-i18next')
if 'vue-i18n' in deps:
frameworks.append('vue-i18n')
if '@angular/localize' in deps:
frameworks.append('@angular/localize')
if 'react-intl' in deps:
frameworks.append('react-intl (FormatJS)')
if 'next-intl' in deps or 'next-i18next' in deps:
frameworks.append('next-i18next / next-intl')
if frameworks:
print('Detected:', ', '.join(frameworks))
else:
print('No i18n framework detected in package.json')
except FileNotFoundError:
pass
# Check for Rails
if os.path.exists('Gemfile'):
with open('Gemfile') as f:
content = f.read()
if 'rails-i18n' in content or \"gem 'i18n'\" in content:
print('Detected: Rails i18n')
# Check for Flutter
if os.path.exists('pubspec.yaml'):
print('Detected: Flutter (check lib/l10n/ for .arb files)')
"
import json, yaml, os, re
from pathlib import Path
from collections import defaultdict
def flatten_dict(d, prefix='', sep='.'):
"""Flatten nested dict to dot-notation keys."""
items = []
for k, v in d.items():
key = f"{prefix}{sep}{k}" if prefix else k
if isinstance(v, dict):
items.extend(flatten_dict(v, key, sep).items())
else:
items.append((key, v))
return dict(items)
def load_locale_file(fpath):
"""Load JSON, YAML, or ARB locale file into flat dict."""
ext = Path(fpath).suffix.lower()
content = Path(fpath).read_text(encoding='utf-8')
if ext in ['.json', '.arb']:
data = json.loads(content)
# ARB: skip keys starting with @ (metadata)
data = {k: v for k, v in data.items() if not k.startswith('@') and k != '@@locale'}
elif ext in ['.yml', '.yaml']:
data = yaml.safe_load(content)
# Rails: top level is the locale name → unwrap it
if len(data) == 1 and isinstance(list(data.values())[0], dict):
data = list(data.values())[0]
else:
return {}
return flatten_dict(data) if isinstance(data, dict) else {}
def load_po_file(fpath):
"""Simple PO file parser."""
content = Path(fpath).read_text(encoding='utf-8')
translations = {}
current_msgid = None
for line in content.splitlines():
if line.startswith('msgid '):
current_msgid = line[7:].strip().strip('"')
elif line.startswith('msgstr ') and current_msgid:
msgstr = line[8:].strip().strip('"')
if current_msgid: # skip empty msgid (file header)
translations[current_msgid] = msgstr
current_msgid = None
return translations
def audit_locales(locale_dir, base_locale='en'):
"""Compare all locales against the base locale."""
locale_files = {}
# Load all locale files
for fpath in Path(locale_dir).rglob('*'):
if fpath.suffix in ['.json', '.yml', '.yaml', '.arb']:
# Infer locale code from filename
stem = fpath.stem # e.g., "en", "zh-CN", "fr"
locale_files[stem] = load_locale_file(str(fpath))
elif fpath.suffix == '.po':
stem = fpath.stem
locale_files[stem] = load_po_file(str(fpath))
if base_locale not in locale_files:
print(f"Base locale '{base_locale}' not found. Available: {list(locale_files.keys())}")
return
base = locale_files[base_locale]
base_keys = set(base.keys())
results = {}
for locale, translations in locale_files.items():
if locale == base_locale:
continue
locale_keys = set(translations.keys())
missing = base_keys - locale_keys
orphaned = locale_keys - base_keys
# Untranslated: key exists but value is identical to base (or empty)
untranslated = set()
empty = set()
for key in (base_keys & locale_keys):
val = translations[key]
base_val = base[key]
if not val or val.strip() == '':
empty.add(key)
elif val == base_val and base_locale.startswith('en'):
untranslated.add(key)
translated_count = len(base_keys & locale_keys) - len(untranslated) - len(empty)
coverage = round(100 * translated_count / len(base_keys), 1) if base_keys else 100
results[locale] = {
'coverage': coverage,
'total': len(base_keys),
'translated': translated_count,
'missing': sorted(missing),
'untranslated': sorted(untranslated),
'empty': sorted(empty),
'orphaned': sorted(orphaned),
}
return results
## i18n Coverage Audit
Project: my-app | Framework: i18next | Base locale: en (247 keys)
Locales found: en, zh-CN, fr, de, ja, es, pt-BR
---
### Coverage Summary
| Locale | Coverage | Missing | Untranslated | Empty | Orphaned |
|--------|----------|---------|--------------|-------|---------|
| 🇩🇪 de | 97.2% ✅ | 7 | 0 | 0 | 2 |
| 🇫🇷 fr | 94.3% 🟡 | 14 | 0 | 0 | 0 |
| 🇨🇳 zh-CN | 89.1% 🟠 | 27 | 0 | 0 | 5 |
| 🇯🇵 ja | 78.5% 🔴 | 53 | 4 | 2 | 0 |
| 🇪🇸 es | 75.3% 🔴 | 61 | 8 | 0 | 3 |
| 🇧🇷 pt-BR | 61.1% 🔴 | 96 | 12 | 3 | 7 |
**Worst locale: pt-BR (61.1%) — 96 missing keys**
**Best locale: de (97.2%) — only 7 missing**
---
### 🔴 High-Impact Missing Keys (used in most templates)
These missing keys affect the most user-visible UI:
**zh-CN is missing these 27 keys:**
Common keys missing in zh-CN:
auth.login_button ← used in LoginPage, Header, Modal
auth.logout_confirm ← used in UserMenu
dashboard.export.csv_label ← NEW in last sprint
dashboard.export.pdf_label ← NEW in last sprint
settings.notifications.email_title ← used in 3 components
settings.notifications.push_title ← used in 3 components
onboarding.step1.title ← NEW - onboarding redesign
onboarding.step1.description
onboarding.step2.title
onboarding.step2.description
onboarding.step3.title
onboarding.step3.description
... (15 more keys)
**Pattern detected:** 8 of the 27 missing zh-CN keys are from the `onboarding.*` namespace — these are all new keys added in the last sprint that weren't added to zh-CN.json.
---
### 🟡 Untranslated Values (value = English source)
These keys exist in the locale file but the value was never translated (still the English text):
**ja — 4 untranslated keys:**
Key en (source) ja (current)
errors.network_timeout "Connection timeout" "Connection timeout" ← same!
errors.server_unavailable "Service unavailable" "Service unavailable" ← same!
pricing.per_month_label "/ month" "/ month" ← same!
pricing.per_year_label "/ year" "/ year" ← same!
These were likely added as placeholders and never handed off to translators.
---
### 🟡 Orphaned Keys (in secondary locale, not in base)
These keys exist in a locale file but not in the base English file — they may be from a deleted feature:
zh-CN has 5 orphaned keys (base 'en' doesn't have these):
features.old_dashboard.title ← probably from deleted old dashboard
features.old_dashboard.subtitle
features.beta_chart.label ← beta feature removed?
auth.legacy_sso_note ← legacy SSO removed?
marketing.promo_april_2025 ← expired promotion
Safe to delete from zh-CN.json after confirming the feature was removed.
---
### Auto-Generated Scaffold for Missing Keys
Run `/i18n-audit --scaffold zh-CN` to generate a file you can hand to a translator:
{
"_meta": {
"base_locale": "en",
"target_locale": "zh-CN",
"missing_count": 27,
"generated_at": "2026-03-18T10:42:00Z"
},
"auth": {
"login_button": "",
"logout_confirm": ""
},
"dashboard": {
"export": {
"csv_label": "",
"pdf_label": ""
}
},
"onboarding": {
"step1": {
"title": "",
"description": ""
}
}
}
Fill in the empty strings, then merge this file with the existing `zh-CN.json`.
---
### CI Fail-Gate Command
Add this to your CI pipeline to prevent missing translations from shipping:
npx --yes i18n-coverage-check --min 95 --base en --locales locales/
python3 -c "
import json, sys
from pathlib import Path
base = json.loads(Path('locales/en.json').read_text())
base_keys = set(base.keys())
threshold = 95
failed = False
for locale_file in Path('locales').glob('*.json'):
if locale_file.stem == 'en': continue
trans = json.loads(locale_file.read_text())
coverage = 100 * len(set(trans.keys()) & base_keys) / len(base_keys)
if coverage < threshold:
print(f'FAIL: {locale_file.stem} coverage {coverage:.1f}% < {threshold}%')
failed = True
else:
print(f'PASS: {locale_file.stem} {coverage:.1f}%')
sys.exit(1 if failed else 0)
"
---
### Sprint Workflow Integration
Add to your PR template:
en.json (base locale)/i18n-audit — no new regressions in top localesonboarding.* namespace: zh-CN and ja coverage still > 90%
---
### Namespace/File Pattern Support
**i18next namespace files** (separate JSON per namespace):
locales/
en/
common.json
auth.json
dashboard.json
zh-CN/
common.json ← has 3 missing keys vs en/common.json
auth.json ← has 0 missing keys
dashboard.json ← MISSING entirely (0 keys)
When namespace files are detected, the audit runs per-namespace and flags missing files entirely.
**Rails YAML** (nested locale key at top level):
fr:
activerecord:
errors:
messages:
blank: "ne peut pas être vide" ← present
# taken: ... ← MISSING vs en.yml
**Flutter ARB** (metadata keys skipped):
{
"@@locale": "zh",
"@title": {"description": "App title"},
"title": "", ← EMPTY
"loginButton": "登录",
"logoutButton": "" ← EMPTY
}
Fast one-line summary without full key listing:
i18n Audit: my-app (i18next, base: en, 247 keys)
🔴 pt-BR: 61.1% — 96 missing
🔴 es: 75.3% — 61 missing
🔴 ja: 78.5% — 53 missing (+ 4 untranslated)
🟠 zh-CN: 89.1% — 27 missing (+ 5 orphaned)
🟡 fr: 94.3% — 14 missing
✅ de: 97.2% — 7 missing
8 keys missing in zh-CN are from 'onboarding.*' namespace (last sprint)
Run /i18n-audit --locale zh-CN for full key list + scaffolded output file
Canlah AI — Run performance marketing without breaking your brand.
共 1 个版本