<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: dg19</title>
    <description>The latest articles on DEV Community by dg19 (@dg19).</description>
    <link>https://dev.to/dg19</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035893%2Fde598f83-0e3c-40ac-9846-3c294ab87bed.png</url>
      <title>DEV Community: dg19</title>
      <link>https://dev.to/dg19</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dg19"/>
    <language>en</language>
    <item>
      <title>Why I built a GitHub Action that reads your code instead of your translation files</title>
      <dc:creator>dg19</dc:creator>
      <pubDate>Sun, 19 Jul 2026 00:38:00 +0000</pubDate>
      <link>https://dev.to/dg19/why-i-built-a-github-action-that-reads-your-code-instead-of-your-translation-files-3hjn</link>
      <guid>https://dev.to/dg19/why-i-built-a-github-action-that-reads-your-code-instead-of-your-translation-files-3hjn</guid>
      <description>&lt;p&gt;Every i18n tool I've used starts from the same assumption: you already know which keys need translating. Export your &lt;code&gt;en.json&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://github.com/dg19/i18n-auto-localizer" rel="noopener noreferrer"&gt;i18n-auto-localizer&lt;/a&gt;, a GitHub Action + CLI that scans your actual source for &lt;code&gt;t('key')&lt;/code&gt; calls, diffs that against your existing locale files, and only translates what's genuinely missing or changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with export-first tools
&lt;/h2&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;They translate whatever's in the export, not what's actually used.&lt;/strong&gt; Dead keys get translated right alongside live ones, forever, because nothing ever tells the tool, "Nobody calls &lt;code&gt;t()&lt;/code&gt; with this key anymore."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopting the tool is scary.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're locked into one vendor's LLM.&lt;/strong&gt; Pick Anthropic, pick OpenAI, and live with it.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h2&gt;
  
  
  Scanning code instead of exporting files
&lt;/h2&gt;

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

&lt;p&gt;That gives you two things for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unused keys stop mattering.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typos become detectable.&lt;/strong&gt; If your code calls &lt;code&gt;t('customerList.emial')&lt;/code&gt; and your &lt;code&gt;en.json&lt;/code&gt; only has &lt;code&gt;email&lt;/code&gt;, 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I actually worried about: not wrecking existing translations
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;Concretely, if your &lt;code&gt;ja.json&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring your own model
&lt;/h2&gt;

&lt;p&gt;Translation goes through &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt; rather than a hardcoded provider SDK, so the &lt;code&gt;model&lt;/code&gt; input is just a string—&lt;code&gt;anthropic/claude-sonnet-4.5&lt;/code&gt;, &lt;code&gt;openai/gpt-4o&lt;/code&gt;, &lt;code&gt;google/gemini-2.5-pro&lt;/code&gt;, whatever you want, swappable per run. Each translation batch gets checked for interpolation-placeholder integrity (&lt;code&gt;{{name}}&lt;/code&gt;, &lt;code&gt;{name}&lt;/code&gt;) 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real run looks like
&lt;/h2&gt;

&lt;p&gt;Here's the tool running against a small sample admin dashboard—a real command, real output, no editing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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 &amp;lt;span className={...}&amp;gt;{t(LABEL_KEYS[status])}&amp;lt;/span&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7bjj8jnpvk2ck21j2uj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7bjj8jnpvk2ck21j2uj.png" alt="Generated PR screenshot" width="800" height="712"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the dynamic-key warning—&lt;code&gt;t(LABEL_KEYS[status])&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it without touching CI
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENROUTER_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-or-...
npx i18n-auto-localizer run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-lang&lt;/span&gt; en &lt;span class="nt"&gt;--target-langs&lt;/span&gt; ja,fr &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--locales-dir&lt;/span&gt; ./public/locales

&lt;span class="c"&gt;# or preview the diff with zero API calls:&lt;/span&gt;
npx i18n-auto-localizer run &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-lang&lt;/span&gt; en &lt;span class="nt"&gt;--target-langs&lt;/span&gt; ja,fr &lt;span class="nt"&gt;--locales-dir&lt;/span&gt; ./public/locales
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you're happy with the output, the action is one &lt;code&gt;with:&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dg19/i18n-auto-localizer@v0.1.3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;source-lang&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en&lt;/span&gt;
    &lt;span class="na"&gt;target-langs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ja,fr,de&lt;/span&gt;
    &lt;span class="na"&gt;locales-dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./public/locales&lt;/span&gt;
    &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENROUTER_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;It's early—&lt;code&gt;v0.1.3&lt;/code&gt;, MIT-licensed, &lt;a href="https://github.com/dg19/i18n-auto-localizer" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; and &lt;a href="https://www.npmjs.com/package/i18n-auto-localizer" rel="noopener noreferrer"&gt;npm&lt;/a&gt;. It supports react-i18next, vue-i18n, next-intl, and raw i18next; nested-JSON locale files in either the per-language-directory (&lt;code&gt;locales/en/common.json&lt;/code&gt;) or flat single-file (&lt;code&gt;locales/en.json&lt;/code&gt;) convention; and OpenRouter for translation.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>github</category>
      <category>opensource</category>
      <category>react</category>
    </item>
  </channel>
</rss>
