Stop Letting i18n Drift: Build a Checker Your AI Agent Can Actually Use
Working on a multilingual website with hundreds of translation keys is never simple. Keys get missed in some locales, and hardcoded strings inevitably remain in the codebase. This is the workflow I use—a small Python checker paired with an AI agent—to find those problems, fix the real issues, and verify the result automatically.
Internationalization does not have to become a recurring manual cleanup task. The trick is not asking AI to guess what is wrong. It is giving the agent a small, deterministic tool that finds the problems first.
I18n is definitely one of the worst developer experiences of all time. Building an i18n-compatible system is always a pain. You will forget keys. Static strings will slip through. Then you have to decide whether to put them in translation files or make the UI dynamic.
In startups, I have noticed something else: nobody is really translating those JSON files. They become forgotten artifacts. As a developer, I usually do a first pass with AI: “translate these JSON files into language X and Y.” After that, the review process somehow never happens. Most of the time, it is me—the poor developer in the startup with 20 roles—fixing the languages I happen to know.
The two i18n problems that keep coming back
The issue I want to talk about is not how to configure an i18n library. It is the slow drift after configuration:
- Missing translation keys: one locale gets a new key, but one or more of the others do not.
- Strings that are not keys at all: someone writes
<UButton>Save changes</UButton>oraria-label="Open menu"directly in the UI.
I have used VS Code and WebStorm tools that find some of this. They are useful. But there is a workflow problem: you forget to run them, their output lives in an editor panel, and then you still fix every result by hand. The work is tedious enough that it gets postponed.
More importantly, the output is not part of the agent’s workflow. You cannot reliably say, “Here is the exact list of issues; inspect them, ignore intentional cases, and fix the real ones.” The report becomes another thing for a human to remember.
The small idea that changed the workflow
In one of my projects, I wrote a Python tool that scans the project. It is not AI. It is just an algorithm: compare locale JSON keys, scan Vue templates for likely user-facing strings, and print a readable report.
Then I gave that tool to the AI agent.
This division of labour is the important part. Code is very good at finding repeatable structural problems. An agent is good at reading the component, deciding whether a finding is real, choosing a sensible key, updating the locale files, and changing the UI code. Neither side has to pretend it can do the other job perfectly.
The rule: use deterministic code to find candidates, use the agent to make contextual changes, and use a final scan to verify the result.
What the checker does
The checker performs two separate jobs:
- It finds keys that exist in some locale files but are missing from others in the same locale directory.
- It scans Vue template blocks for likely hardcoded UI text, including text between tags and static attributes such as
placeholder,label,title,aria-label,alt, andtooltip.
It supports nested JSON by flattening keys into dot notation for comparison. It also prints the source-language value as a hint when an English locale is available, so the agent or translator does not have to search through a large file before understanding the missing key.
The raw-text scan is intentionally heuristic. It will occasionally report a product name, a demo label, or a technical phrase that should not be translated. That is fine. Its job is to produce a useful candidate list, not to pretend that a regular expression fully understands human language.
Install it in your project
Create this file:
scripts/i18n_check.pyThen add the complete Python checker from the end of this article.
By default, it looks for locale files inside directories named:
i18n/localesA typical structure might look like this:
your-project/
├── scripts/
│ └── i18n_check.py
├── i18n/
│ └── locales/
│ ├── en.json
│ ├── de.json
│ └── fa.json
└── app/
└── components/The same checker also works when different parts of a project have their own i18n/locales directories. Each locale directory is compared independently.
Run both checks from the project root:
python3 scripts/i18n_check.py .Or run only the part you need:
# Only compare locale keys
python3 scripts/i18n_check.py --keys .
# Only scan Vue templates for likely hardcoded text
python3 scripts/i18n_check.py --text .
# Scan one component or directory
python3 scripts/i18n_check.py --text --path app/components .If your locale files live somewhere else, change the directory lookup in the script or ask your coding agent to adapt it to your project.
Give the agent a reusable skill
The checker becomes much more useful when the agent knows how to interpret its output. A deterministic report is only the first half of the workflow. The agent still needs rules for deciding what to fix, what to skip, how to name keys, and when to run the tool again.
I keep those instructions in:
.agents/i18n-sync/SKILL.mdThe exact location is not sacred. Different coding agents support different instruction formats. The important thing is that the instructions are stored in the project and are discoverable.
The skill tells the agent to:
- run the deterministic checker before finishing relevant UI work;
- inspect each finding instead of changing code blindly;
- preserve the existing translation structure and conventions;
- skip intentional names, demos, paths, and developer-only strings;
- run the checker again after making changes;
- report what it fixed and what it intentionally left alone.
You can also give the article directly to Claude Code or Codex
You do not have to copy every file manually. A repository-aware coding agent can inspect your existing structure and install the workflow for you.
Paste the link to this article into Claude Code, Codex, or another coding agent and use a prompt like this:
Read this article and add its i18n checker and reusable agent workflow to this project.
First inspect the existing locale directories, source language, Vue or Nuxt structure, translation conventions, and ignored paths. Then adapt the checker to the project instead of assuming the defaults are correct.
Add the checker, add an equivalent i18n-sync skill or project instruction file in the format you support, run the checker, inspect every finding, fix genuine i18n issues, skip intentional strings, and run the checker again to verify the result.This is exactly where agents are useful. They can read the article, inspect the repository, convert the instructions into their supported format, and make the small project-specific changes themselves.
But the agent should not be the thing searching blindly for every possible i18n problem. That is the deterministic tool’s job.
What the scanner can and cannot know
The checker is deliberately simple. It can detect structural inconsistencies and obvious text candidates, but it cannot judge translation quality. It also will not catch every possible source of user-facing copy.
For example, you may still need project-specific handling for:
- text created dynamically in JavaScript or TypeScript;
- custom component props that contain user-facing content;
- strings returned by an API;
- pluralization and grammatical correctness;
- translations that exist but are outdated or misleading;
- locale formats other than JSON.
That does not make the checker useless. It makes its boundary clear. It is a fast, repeatable way to make the most common problems visible.
Why this saves so much time
This setup removes the two things that make i18n maintenance miserable: remembering to do it and doing every tiny mechanical change yourself.
The checker makes the work observable. The agent turns the report into a coherent change. A final run gives you a concrete answer: are the locale files in sync, and which likely raw strings remain?
It is a small tool, but it changes i18n from a pile of forgotten translation files into a repeatable engineering loop.
And if you are the developer with 20 roles, this is exactly the kind of boring work you should hand off.
Complete i18n_check.py
Copy this into scripts/i18n_check.py. It requires Python 3.9 or newer and has no third-party dependencies.
#!/usr/bin/env python3
"""
i18n_check.py — Find missing locale keys and likely untranslated Vue UI text.
--keys : Find keys missing from one or more locale files within each package.
Each locale directory is checked independently.
--text : Scan Vue files for raw strings that look like UI text but aren't wrapped
in t() or $t() calls.
--path P : Limit --text scan to a specific directory or file.
Default (no flags): runs both checks from the project root.
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
# ── helpers ──────────────────────────────────────────────────────────────────
def flatten(obj: Any, prefix: str = "") -> dict[str, str]:
"""Flatten nested dict to {dot.notation.key: value}."""
out: dict[str, str] = {}
if isinstance(obj, dict):
for k, v in obj.items():
full = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
out.update(flatten(v, full))
else:
out[full] = str(v)
return out
def is_ignored_path(p: Path) -> bool:
parts = p.parts
# node_modules, build outputs
if any(s in parts for s in ("node_modules", ".nuxt", ".output", "dist")):
return True
# Story/demo files are often not production UI. Remove this rule if yours are.
if p.name.endswith((".story.vue", ".stories.vue")):
return True
return False
# ── key sync check ────────────────────────────────────────────────────────────
def check_keys(root: Path) -> int:
"""
For each package that has i18n/locales/*.json, diff the key sets across
all locales in that package. Report missing keys per locale.
Returns the total number of missing key occurrences found.
"""
violations = 0
for locale_dir in sorted(root.rglob("i18n/locales")):
if is_ignored_path(locale_dir):
continue
json_files = sorted(locale_dir.glob("*.json"))
if len(json_files) < 2:
continue
package_rel = locale_dir.parent.parent.relative_to(root)
locales: dict[str, dict[str, str]] = {}
for f in json_files:
try:
with open(f, encoding="utf-8") as fh:
locales[f.stem] = flatten(json.load(fh))
except json.JSONDecodeError as e:
print(f" [ERROR] {f.relative_to(root)}: invalid JSON — {e}")
all_keys: set[str] = set()
for keys in locales.values():
all_keys.update(keys.keys())
package_printed = False
for locale in sorted(locales):
present = set(locales[locale].keys())
missing = sorted(all_keys - present)
if not missing:
continue
if not package_printed:
print(f"\n{package_rel}/i18n/locales/")
package_printed = True
print(f" {locale}.json — {len(missing)} missing key(s):")
for key in missing[:30]:
# show what the English value is so translator has context
en_val = locales.get("en", {}).get(key, "")
hint = f' → en: "{en_val}"' if en_val else ""
print(f" ✗ {key}{hint}")
if len(missing) > 30:
print(f" … and {len(missing) - 30} more")
violations += len(missing)
if violations == 0:
print(" ✓ All locale files are in sync.")
else:
print(f"\n Total: {violations} missing key occurrence(s) across all packages.")
return violations
# ── untranslated text scan ────────────────────────────────────────────────────
# Attribute names that almost always carry user-visible text.
# Negative lookbehind for ':' excludes dynamic bindings (:label="...") and
# v-bind syntax — only static string attributes are flagged.
UI_ATTRS = re.compile(
r'(?<!:)(?<!v-bind:)\b(placeholder|label|title|aria-label|alt|tooltip|hint|'
r'description|help|empty|no-results-text|search-attributes)\s*=\s*"([^"${\n]{4,})"',
re.IGNORECASE,
)
# Text directly between tags (not inside {{ }} or component tags)
# Matches: > Some visible text < — skips whitespace-only and very short strings
BETWEEN_TAGS = re.compile(r">\s*([A-Za-z-ۿݐ-ݿ][^<>{}\n]{3,}?)\s*<")
# Things that look like UI text but aren't
SKIP_PATTERNS = [
re.compile(r"[{}$@#]"), # template expressions or directives
re.compile(r"^\s*<!--"), # HTML comments
re.compile(r"^[\d\s.,;:!?/\\-]+$"), # punctuation / numbers only
re.compile(r"^[A-Z_\d]+$"), # ALL_CAPS constants
re.compile(r"https?://|www\."), # URLs
re.compile(r"^\s*$"), # whitespace only
]
# Lines that already contain a translation call — skip them
TRANSLATED = re.compile(r"\$t\(|(?<![a-zA-Z])t\(|useI18n\(\)")
# Component/tag names that look like words but aren't UI text
COMPONENT_NAMES = re.compile(r"^[A-Z][a-zA-Z]+$")
def looks_like_ui_text(s: str) -> bool:
s = s.strip()
if len(s) < 4:
return False
if COMPONENT_NAMES.match(s):
return False
for pat in SKIP_PATTERNS:
if pat.search(s):
return False
# Must contain at least one real letter
if not re.search(r"[a-zA-Z-ۿ]", s):
return False
return True
def extract_template(content: str) -> tuple[str, int]:
"""Return (template_body, start_line_offset)."""
m = re.search(r"<template[^>]*>(.*?)</template>", content, re.DOTALL)
if not m:
return "", 0
offset = content[: m.start(1)].count("\n")
return m.group(1), offset
def line_of(content: str, char_pos: int) -> int:
return content[:char_pos].count("\n") + 1
def check_untranslated(root: Path, target: Path | None = None) -> int:
scan_root = target or root
violations = 0
if scan_root.is_file():
vue_files = [scan_root] if scan_root.suffix == ".vue" else []
else:
vue_files = sorted(
f for f in scan_root.rglob("*.vue") if not is_ignored_path(f)
)
for vue_file in vue_files:
content = vue_file.read_text(encoding="utf-8", errors="ignore")
template, _offset = extract_template(content)
if not template:
continue
findings: list[tuple[int, str]] = []
# 1. Raw text between tags
for m in BETWEEN_TAGS.finditer(template):
text = m.group(1).strip()
if not looks_like_ui_text(text):
continue
# Check the surrounding line — maybe it's already using t()
line_start = template.rfind("\n", 0, m.start()) + 1
line_end = template.find("\n", m.end())
line_content = template[line_start: line_end if line_end != -1 else len(template)]
if TRANSLATED.search(line_content):
continue
lineno = line_of(content, content.find(text, content.find(template)))
findings.append((lineno, f'raw text: "{text[:80]}"'))
# 2. Hardcoded UI attribute values (static bindings only — no colon prefix)
for m in UI_ATTRS.finditer(template):
attr_name = m.group(1)
text = m.group(2).strip()
if not looks_like_ui_text(text):
continue
# Skip if value itself is already a translation call (e.g. missing colon — separate bug)
if TRANSLATED.search(text):
continue
# Skip JS expressions used as static attrs (missing colon prefix) — different bug category
# Heuristic: value contains a ternary operator pattern
if re.search(r"\?\s*'", text):
continue
lineno = line_of(content, content.find(m.group(0), content.find(template)))
findings.append((lineno, f'{attr_name}="{text[:60]}"'))
if findings:
rel = vue_file.relative_to(root)
print(f"\n {rel} — {len(findings)} potential untranslated string(s):")
seen: set[str] = set()
shown = 0
for lineno, desc in sorted(set(findings)):
if desc in seen:
continue
seen.add(desc)
print(f" line ~{lineno}: {desc}")
shown += 1
if shown >= 20:
remaining = len(findings) - shown
if remaining > 0:
print(f" … and {remaining} more")
break
violations += len(findings)
if violations == 0:
print(" ✓ No obvious untranslated strings found.")
else:
print(f"\n Total: {violations} potential untranslated string(s) found.")
return violations
# ── main ──────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="i18n consistency checker for Vue and Nuxt projects"
)
parser.add_argument("--keys", action="store_true", help="Check locale key sync")
parser.add_argument("--text", action="store_true", help="Scan for untranslated raw text")
parser.add_argument("--path", type=str, help="Limit --text scan to this path")
parser.add_argument(
"root", nargs="?", default=".", help="Project root (default: current directory)"
)
args = parser.parse_args()
root = Path(args.root).resolve()
run_keys = args.keys or not args.text
run_text = args.text or not args.keys
exit_code = 0
if run_keys:
print("=== Key sync check ===")
if check_keys(root) > 0:
exit_code = 1
if run_text:
target = Path(args.path).resolve() if args.path else None
print("\n=== Untranslated text scan ===")
if check_untranslated(root, target) > 0:
exit_code = 1
sys.exit(exit_code)
if __name__ == "__main__":
main()Complete .agents/i18n-sync/SKILL.md
Copy this file as-is, or ask your coding agent to convert it into the instruction format it supports.
---
name: i18n-sync
description: Find and fix missing locale keys and likely untranslated Vue UI strings using the repository's deterministic i18n checker.
---
# i18n Sync
Use this skill when completing UI work, reviewing translations, or when the user asks to find or fix i18n problems.
## Run the checker
From the project root, run:
```bash
python3 scripts/i18n_check.py .
```
Use a narrower command when appropriate:
```bash
python3 scripts/i18n_check.py --keys .
python3 scripts/i18n_check.py --text .
python3 scripts/i18n_check.py --text --path path/to/component-or-directory .
```
If the project stores the script elsewhere, use its actual location.
## Handle missing keys
For every missing-key finding:
- inspect the English value and the surrounding locale structure;
- preserve the existing nested JSON organization and naming conventions;
- add the key to every locale file in that locale directory;
- do not overwrite an existing translation;
- keep placeholders, interpolation variables, HTML fragments, and pluralization syntax intact;
- prefer natural translations over literal word-for-word translations.
## Handle raw-text findings
For every raw-text finding:
- open the component and inspect the surrounding context before editing;
- translate real user-facing labels, headings, descriptions, placeholders, alt text, tooltips, and accessibility labels;
- use the project's existing `t()` or `$t()` convention;
- reuse an existing translation key when it already expresses the same meaning;
- otherwise create a clear, stable key that matches the project's naming style;
- skip product names, brand names, code examples, file paths, developer-only text, and other intentional strings;
- treat the report as a candidate list, not proof that every match is a bug.
## Adapt before changing code
Inspect the project before applying fixes. Determine:
- where locale files live;
- which locale is the source language;
- how translation functions are accessed;
- whether locale files use JSON, JSON5, YAML, or another format;
- which folders or files should be excluded.
Adapt the checker when the repository structure differs from its defaults.
## Verify
After making changes:
1. run the checker again;
2. confirm that missing keys are resolved;
3. review remaining raw-text findings;
4. report what was fixed and what was intentionally skipped.
Do not claim the i18n work is complete until the final checker run has been reviewed.
Hi, Im a seniour software engineer building web platforms. I write about Nuxt, Typescript, DevOps, AI and engineering decisions behind real products, based on my real experience.