DEV Community

dg19
dg19

Posted on

Why I built a GitHub Action that reads your code instead of your translation files

Every i18n tool I've used starts from the same assumption: you already know which keys need translating. Export your en.json, upload it somewhere, get translations back, and import them. That workflow makes sense if a human translator is doing the work — they need a clean list of strings, not your codebase.

But if an LLM is doing the translating, that assumption is backwards. The LLM doesn't need a curated export. It can read the code directly. So I built i18n-auto-localizer, a GitHub Action + CLI that scans your actual source for t('key') calls, diffs that against your existing locale files, and only translates what's genuinely missing or changed.

The problem with export-first tools

I kept hitting the same three issues with Crowdin/Lokalise-style tools and even with newer LLM-based i18n actions:

  1. They translate whatever's in the export, not what's actually used. Dead keys get translated right alongside live ones, forever, because nothing ever tells the tool, "Nobody calls t() with this key anymore."
  2. Adopting the tool is scary. The first run against a real project either blasts every existing translation with fresh output or requires some manual "mark these as already translated" step before you can trust it.
  3. You're locked into one vendor's LLM. Pick Anthropic, pick OpenAI, and live with it.

None of these are fundamental—they're just what you get when key detection lives outside the codebase instead of inside it.

Scanning code instead of exporting files

i18n-auto-localizer parses your source with Babel and walks the AST looking for t('key'), t('namespace:key'), useTranslation('namespace') + t('key'), and i18n.t('key')—across react-i18next, vue-i18n, next-intl, and raw i18next, including .vue script blocks. Whatever comes out of that scan is the actual, current, ground-truth set of keys your app uses. Nothing else gets touched.

That gives you two things for free:

  • Unused keys stop mattering. If a key's still in your locale JSON but nothing calls it anymore, the tool reports it as an orphan in the PR—visible, never silently deleted, and never silently re-translated.
  • Typos become detectable. If your code calls t('customerList.emial') and your en.json only has email, that's a real bug, and now it shows up as an "undefined key" warning instead of quietly rendering the raw key string in production.

The part I actually worried about: not wrecking existing translations

This was the one thing I wasn't willing to ship without getting right. If a tool's first action against your repo is "overwrite everything with AI output," nobody sane adopts it.

So the diff logic works off a lockfile (.i18n-localizer-lock.json, committed to your repo) that records a hash of each key's source-language value once it's been translated by the tool. The rule is simple: a key with an existing value in the target locale but no lockfile record is never touched—its hash just gets stamped into the lockfile so future source edits can be detected, but the value itself is left alone. That holds on the very first run, not just after some manual bootstrapping step.

Concretely, if your ja.json already has hand-translated Japanese strings before you ever run this tool, running it doesn't overwrite a single one. It only fills in what's missing.

Bring your own model

Translation goes through OpenRouter rather than a hardcoded provider SDK, so the model input is just a string—anthropic/claude-sonnet-4.5, openai/gpt-4o, google/gemini-2.5-pro, whatever you want, swappable per run. Each translation batch gets checked for interpolation-placeholder integrity ({{name}}, {name}) before being accepted—if a placeholder gets dropped or mangled, that key gets retried once, then reported as failed rather than written with a silently broken interpolation.

What a real run looks like

Here's the tool running against a small sample admin dashboard—a real command, real output, no editing:

$ i18n-auto-localizer run --source-lang ja --target-langs en --locales-dir ./public/locales --dry-run

Used keys detected: 104
  [en] translated=104 stamped=0 failed=0
Dynamic keys (could not be statically analyzed):
  src/components/Badge.tsx:23 return <span className={...}>{t(LABEL_KEYS[status])}</span>;
Enter fullscreen mode Exit fullscreen mode

And the PR it opens (title and body are the tool's own literal output, not paraphrased for this post):

Generated PR screenshot

Note the dynamic-key warning—t(LABEL_KEYS[status]) can't be resolved statically, so instead of guessing or silently skipping it, the tool surfaces it explicitly. That's the same philosophy as the orphan/undefined-key reporting: anything the tool can't be certain about becomes visible, not invisible.

Try it without touching CI

The GitHub Action and the CLI run the exact same core pipeline—the CLI isn't a stripped-down demo mode; it's the whole thing minus the Git/PR steps. So you can try real translations against your own repo before wiring up any workflow:

export OPENROUTER_API_KEY=sk-or-...
npx i18n-auto-localizer run \
  --source-lang en --target-langs ja,fr \
  --locales-dir ./public/locales

# or preview the diff with zero API calls:
npx i18n-auto-localizer run --dry-run \
  --source-lang en --target-langs ja,fr --locales-dir ./public/locales
Enter fullscreen mode Exit fullscreen mode

Once you're happy with the output, the action is one with: block:

- uses: dg19/i18n-auto-localizer@v0.1.3
  with:
    source-lang: en
    target-langs: ja,fr,de
    locales-dir: ./public/locales
    api-key: ${{ secrets.OPENROUTER_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

Where it stands

It's early—v0.1.3, MIT-licensed, on GitHub and npm. It supports react-i18next, vue-i18n, next-intl, and raw i18next; nested-JSON locale files in either the per-language-directory (locales/en/common.json) or flat single-file (locales/en.json) convention; and OpenRouter for translation.

If you're maintaining translations by hand across a few languages or paying for a TMS mostly to sync a handful of language files, I'd genuinely like to know if this holds up against your actual codebase—orphan keys, dynamic keys, and namespace edge cases are exactly the kind of thing that only shows up against a real, messy project. Issues and PRs welcome.

Top comments (0)