i18n Audit
Purpose
Find internationalization debt before the project ships into a new
locale. Catches the predictable bugs: hardcoded English strings,
translation keys defined in code but missing in the translation
files, RTL layout assumptions baked into CSS, locale-specific
formatting hardcoded as toLocaleString('en-US'), and untested
locales drifting because nobody runs the app in de-DE until a
customer reports it.
Scope
In:
- Hardcoded UI string literals in JSX/TSX/Vue/Svelte components.
- Translation key references that don't exist in the locale files.
- Locale files with missing keys vs the source-of-truth locale.
- CSS that uses
left / right instead of logical properties
(inline-start / inline-end) — likely RTL breakage.
Date, Number, Intl.DateTimeFormat, Intl.NumberFormat calls
with hardcoded locale arguments other than the user's locale.
- Currency literals (
$, €, £) outside of locale-aware
formatters.
- Locales declared in the i18n config but never tested in CI / e2e.
Out:
- Translation quality review — that's a translator's job, not the
audit. The audit confirms keys exist, not that translations are
accurate.
- Pluralization rule correctness across CLDR — too project-specific;
the i18n-specialist verifies on a flow-by-flow basis.
- Auto-fixing. Surface the drift; the i18n-specialist remediates.
When to use
- Before opening a new locale (e.g., adding
ja-JP).
- Before a release that touches user-facing copy substantially.
- Quarterly, on the main app code, to catch debt accumulation.
- When a customer reports broken layout in their locale — run the
audit scoped to the failing surface to find related issues.
When NOT to use
- For projects that explicitly ship single-locale (some internal
tools). The audit will produce noise.
- For backend-only repos where the only "string" surface is log
messages (logs typically aren't localized).
- For mobile native code (iOS strings catalogs, Android resources)
unless the project's i18n config covers them — the default scan
patterns are JS/TS/CSS oriented.
Automated pass
Resolve scope:
paths="${PATHS:-src app packages/*/src}"
locales="${LOCALES:-$(jq -r '.locales[]' i18n.config.json 2>/dev/null | tr '\n' ' ')}"
source_locale="${SOURCE_LOCALE:-en}"
Pattern 1: Hardcoded JSX strings.
# Strings between JSX tags that aren't wrapped in t() / Trans / FormattedMessage
rg -n -P '>\s*[A-Z][a-zA-Z ,.!?\047]{4,}\s*<' $paths \
| rg -v 'data-testid|aria-label|<style|<script' \
> /tmp/i18n-jsx-strings.txt
Heuristic — false positives on proper nouns ("Anthropic", "iPhone");
.claude/i18n-allow.txt lists allowed bare strings.
Pattern 2: Translation-key existence.
# Extract t('foo.bar') / i18n.t("foo.bar") / $t('foo.bar') keys
rg -nP "\b(t|\\\$t|i18n\\.t)\\(['\"]([^'\"]+)['\"]" $paths -o -r '$2' \
| sort -u > /tmp/i18n-keys-used.txt
# For each locale file, list keys
for f in locales/*.json; do
jq -r '
[paths(scalars)
| map(tostring)
| join(".")] | .[]' "$f" | sort -u > "/tmp/keys-${f##*/}.txt"
done
# used - defined-in-source-locale = missing
comm -23 /tmp/i18n-keys-used.txt "/tmp/keys-${source_locale}.json.txt" \
> /tmp/i18n-missing-keys.txt
Pattern 3: Locale-file drift.
For each locale, diff its key set against the source locale's set;
missing keys are translation gaps.
for loc in $locales; do
[ "$loc" = "$source_locale" ] && continue
comm -23 "/tmp/keys-${source_locale}.json.txt" "/tmp/keys-${loc}.json.txt" \
> "/tmp/i18n-gap-${loc}.txt"
done
Pattern 4: RTL-unsafe CSS.
rg -nP '\b(margin|padding|border)-(left|right)\b|\b(left|right):\s*\d' $paths \
--type css --type scss --type ts --type tsx > /tmp/i18n-rtl.txt
Findings should migrate to logical properties:
margin-inline-start, padding-inline-end, inset-inline-start.
Pattern 5: Hardcoded locale args.
rg -nP '\.toLocaleString\(\s*['"][a-z]{2}-[A-Z]{2}['"]' $paths \
> /tmp/i18n-hardcoded-locale.txt
rg -nP 'Intl\.(DateTimeFormat|NumberFormat)\(\s*['"][a-z]{2}-[A-Z]{2}['"]' $paths \
>> /tmp/i18n-hardcoded-locale.txt
Pattern 6: Bare currency symbols.
rg -nP '[\$€£¥]\s*\{?[a-zA-Z0-9_]+' $paths > /tmp/i18n-currency.txt
Currency formatting belongs in Intl.NumberFormat(locale, { style: 'currency', currency: code }).
Pattern 7: Untested locales.
- List locales declared in
i18n.config.json / next-i18next /
i18next / vue-i18n config.
- List locales referenced in e2e config (
playwright.config,
cypress.config, .github/workflows/).
- Diff: declared - tested = untested.
Compose the report:
# i18n audit
**Locales declared:** N (en, de, ja, fr-CA)
**Source locale:** en
**Findings:** N (hardcoded: x, missing keys: y, RTL: z, drift: w)
## Hardcoded UI strings
- src/components/Banner.tsx:12 — `>Welcome back<`
## Missing translation keys (used in code, not in en.json)
- checkout.confirm.title
- errors.network.retry
## Locale-file drift (vs en.json)
- de.json — 14 missing keys
- ja.json — 22 missing keys
## RTL-unsafe CSS
- src/styles/Card.module.css:8 — `padding-left: 16px` (use padding-inline-start)
## Hardcoded locales
- src/utils/format.ts:5 — `.toLocaleString('en-US')`
## Untested locales
- fr-CA — declared, no e2e coverage
Strict mode (--strict): exit 1 if hardcoded strings or missing
keys count > 0.
Manual pass
For a fast spot-check on one locale:
# Diff a locale against source
jq -r 'paths(scalars) | join(".")' locales/en.json | sort > /tmp/en.keys
jq -r 'paths(scalars) | join(".")' locales/de.json | sort > /tmp/de.keys
diff /tmp/en.keys /tmp/de.keys
…and walk a couple of pages in the locale to eyeball layout.
Known gotchas
- JSX-string heuristic over-triggers. Code-fence content,
inline-doc comments, and proper nouns trip the regex. The
allow-list at
.claude/i18n-allow.txt is meant to absorb these;
expect to maintain it.
- Plurals + interpolation. A key like
"items.count" may be
fine in English ({count} items) but break in Russian (3 plural
forms) or Arabic (6 forms). The audit checks key existence, not
plural-rule completeness — the i18n-specialist confirms with CLDR
rules per flow.
- Right-to-left ≠ Arabic only. Hebrew, Persian, Urdu also RTL.
Don't assume "Arabic = our only RTL test"; configure all RTL
locales the project ships into.
- String concatenation hides bugs.
t('greeting') + ', ' + user.name + '!' works in English, breaks in Japanese (no comma)
and German (different word order). The audit can't detect this
reliably — flag string-concat near t() for human review.
lang= attribute. The <html lang> should match the active
locale. Surfaces that hardcode lang="en" break screen readers in
every other locale. Worth a one-line check in the audit:
rg 'lang="en"' app/ src/.
- Locale fallback chains.
en-GB → en → default. A "missing"
key in en-GB may be served via fallback and look fine in QA but
fail when the fallback chain is broken in production. The audit
reports drift; the operator confirms whether fallback is
intentional.
References
1---2name: i18n-audit3description: Scan for hardcoded UI strings, missing translation keys, RTL layout breakages, untested locales, and date/number/currency hardcoding. Use when localizing a UI, adding a locale, or auditing internationalization readiness.4---56# i18n Audit78## Purpose910Find internationalization debt before the project ships into a new11locale. Catches the predictable bugs: hardcoded English strings,12translation keys defined in code but missing in the translation13files, RTL layout assumptions baked into CSS, locale-specific14formatting hardcoded as `toLocaleString('en-US')`, and untested15locales drifting because nobody runs the app in `de-DE` until a16customer reports it.1718## Scope1920In:21- Hardcoded UI string literals in JSX/TSX/Vue/Svelte components.22- Translation key references that don't exist in the locale files.23- Locale files with missing keys vs the source-of-truth locale.24- CSS that uses `left` / `right` instead of logical properties25 (`inline-start` / `inline-end`) — likely RTL breakage.26- `Date`, `Number`, `Intl.DateTimeFormat`, `Intl.NumberFormat` calls27 with hardcoded locale arguments other than the user's locale.28- Currency literals (`$`, `€`, `£`) outside of locale-aware29 formatters.30- Locales declared in the i18n config but never tested in CI / e2e.3132Out:33- Translation quality review — that's a translator's job, not the34 audit. The audit confirms keys exist, not that translations are35 accurate.36- Pluralization rule correctness across CLDR — too project-specific;37 the i18n-specialist verifies on a flow-by-flow basis.38- Auto-fixing. Surface the drift; the i18n-specialist remediates.3940## When to use4142- Before opening a new locale (e.g., adding `ja-JP`).43- Before a release that touches user-facing copy substantially.44- Quarterly, on the main app code, to catch debt accumulation.45- When a customer reports broken layout in their locale — run the46 audit scoped to the failing surface to find related issues.4748## When NOT to use4950- For projects that explicitly ship single-locale (some internal51 tools). The audit will produce noise.52- For backend-only repos where the only "string" surface is log53 messages (logs typically aren't localized).54- For mobile native code (iOS strings catalogs, Android resources)55 unless the project's i18n config covers them — the default scan56 patterns are JS/TS/CSS oriented.5758## Automated pass59601. Resolve scope:61 ```sh62 paths="${PATHS:-src app packages/*/src}"63 locales="${LOCALES:-$(jq -r '.locales[]' i18n.config.json 2>/dev/null | tr '\n' ' ')}"64 source_locale="${SOURCE_LOCALE:-en}"65 ```66672. **Pattern 1: Hardcoded JSX strings.**68 ```sh69 # Strings between JSX tags that aren't wrapped in t() / Trans / FormattedMessage70 rg -n -P '>\s*[A-Z][a-zA-Z ,.!?\047]{4,}\s*<' $paths \71 | rg -v 'data-testid|aria-label|<style|<script' \72 > /tmp/i18n-jsx-strings.txt73 ```7475 Heuristic — false positives on proper nouns ("Anthropic", "iPhone");76 `.claude/i18n-allow.txt` lists allowed bare strings.77783. **Pattern 2: Translation-key existence.**79 ```sh80 # Extract t('foo.bar') / i18n.t("foo.bar") / $t('foo.bar') keys81 rg -nP "\b(t|\\\$t|i18n\\.t)\\(['\"]([^'\"]+)['\"]" $paths -o -r '$2' \82 | sort -u > /tmp/i18n-keys-used.txt8384 # For each locale file, list keys85 for f in locales/*.json; do86 jq -r '87 [paths(scalars)88 | map(tostring)89 | join(".")] | .[]' "$f" | sort -u > "/tmp/keys-${f##*/}.txt"90 done9192 # used - defined-in-source-locale = missing93 comm -23 /tmp/i18n-keys-used.txt "/tmp/keys-${source_locale}.json.txt" \94 > /tmp/i18n-missing-keys.txt95 ```96974. **Pattern 3: Locale-file drift.**98 For each locale, diff its key set against the source locale's set;99 missing keys are translation gaps.100 ```sh101 for loc in $locales; do102 [ "$loc" = "$source_locale" ] && continue103 comm -23 "/tmp/keys-${source_locale}.json.txt" "/tmp/keys-${loc}.json.txt" \104 > "/tmp/i18n-gap-${loc}.txt"105 done106 ```1071085. **Pattern 4: RTL-unsafe CSS.**109 ```sh110 rg -nP '\b(margin|padding|border)-(left|right)\b|\b(left|right):\s*\d' $paths \111 --type css --type scss --type ts --type tsx > /tmp/i18n-rtl.txt112 ```113114 Findings should migrate to logical properties:115 `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.1161176. **Pattern 5: Hardcoded locale args.**118 ```sh119 rg -nP '\.toLocaleString\(\s*['"][a-z]{2}-[A-Z]{2}['"]' $paths \120 > /tmp/i18n-hardcoded-locale.txt121 rg -nP 'Intl\.(DateTimeFormat|NumberFormat)\(\s*['"][a-z]{2}-[A-Z]{2}['"]' $paths \122 >> /tmp/i18n-hardcoded-locale.txt123 ```1241257. **Pattern 6: Bare currency symbols.**126 ```sh127 rg -nP '[\$€£¥]\s*\{?[a-zA-Z0-9_]+' $paths > /tmp/i18n-currency.txt128 ```129130 Currency formatting belongs in `Intl.NumberFormat(locale, { style:131 'currency', currency: code })`.1321338. **Pattern 7: Untested locales.**134 - List locales declared in `i18n.config.json` / `next-i18next` /135 `i18next` / `vue-i18n` config.136 - List locales referenced in e2e config (`playwright.config`,137 `cypress.config`, `.github/workflows/`).138 - Diff: declared - tested = untested.1391409. Compose the report:141 ```markdown142 # i18n audit143144 **Locales declared:** N (en, de, ja, fr-CA)145 **Source locale:** en146 **Findings:** N (hardcoded: x, missing keys: y, RTL: z, drift: w)147148 ## Hardcoded UI strings149 - src/components/Banner.tsx:12 — `>Welcome back<`150151 ## Missing translation keys (used in code, not in en.json)152 - checkout.confirm.title153 - errors.network.retry154155 ## Locale-file drift (vs en.json)156 - de.json — 14 missing keys157 - ja.json — 22 missing keys158159 ## RTL-unsafe CSS160 - src/styles/Card.module.css:8 — `padding-left: 16px` (use padding-inline-start)161162 ## Hardcoded locales163 - src/utils/format.ts:5 — `.toLocaleString('en-US')`164165 ## Untested locales166 - fr-CA — declared, no e2e coverage167 ```16816910. Strict mode (`--strict`): exit 1 if hardcoded strings or missing170 keys count > 0.171172## Manual pass173174For a fast spot-check on one locale:175176```sh177# Diff a locale against source178jq -r 'paths(scalars) | join(".")' locales/en.json | sort > /tmp/en.keys179jq -r 'paths(scalars) | join(".")' locales/de.json | sort > /tmp/de.keys180diff /tmp/en.keys /tmp/de.keys181```182183…and walk a couple of pages in the locale to eyeball layout.184185## Known gotchas186187- **JSX-string heuristic over-triggers.** Code-fence content,188 inline-doc comments, and proper nouns trip the regex. The189 allow-list at `.claude/i18n-allow.txt` is meant to absorb these;190 expect to maintain it.191- **Plurals + interpolation.** A key like `"items.count"` may be192 fine in English (`{count} items`) but break in Russian (3 plural193 forms) or Arabic (6 forms). The audit checks key existence, not194 plural-rule completeness — the i18n-specialist confirms with CLDR195 rules per flow.196- **Right-to-left ≠ Arabic only.** Hebrew, Persian, Urdu also RTL.197 Don't assume "Arabic = our only RTL test"; configure all RTL198 locales the project ships into.199- **String concatenation hides bugs.** `t('greeting') + ', ' +200 user.name + '!'` works in English, breaks in Japanese (no comma)201 and German (different word order). The audit can't detect this202 reliably — flag string-concat near `t()` for human review.203- **`lang=` attribute.** The `<html lang>` should match the active204 locale. Surfaces that hardcode `lang="en"` break screen readers in205 every other locale. Worth a one-line check in the audit:206 `rg 'lang="en"' app/ src/`.207- **Locale fallback chains.** `en-GB` → `en` → default. A "missing"208 key in `en-GB` may be served via fallback and look fine in QA but209 fail when the fallback chain is broken in production. The audit210 reports drift; the operator confirms whether fallback is211 intentional.212213## References214215- `lib/agents/i18n-specialist.md` — primary consumer.216- `lib/skills/ux-writing-review/SKILL.md` — copy quality review,217 which this skill complements (existence vs quality).218- CLDR — https://cldr.unicode.org/219- W3C i18n techniques — https://www.w3.org/International/techniques/220- CSS logical properties — https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values221- `.claude/i18n-allow.txt` (project-supplied) — allow-list for bare222 strings (proper nouns, codes, symbols).