DEV Community

Cover image for From Frustration to Automation: My 3-Month Journey with i18n Translations
Kamil Buksakowski
Kamil Buksakowski

Posted on

From Frustration to Automation: My 3-Month Journey with i18n Translations

TL;DR

I optimized my translation writing process!

Two steps:

  1. I type the error intention or literal error,
  2. I run the script.

Boom! That’s it, I just added 7 translations to JSON files. Commit and done. Notice I typed “intentions”, not literal translations 😈

How was it before?

  1. Come up with a translation,
  2. Come up with a translation key,
  3. Translate to other languages,
  4. Add translation to JSON files,
  5. Replace the key in the code.

As you can see, tons of work. But this was the problem that got me started on automating translations. You can read about my entire journey and the final version below.

Frustration and Problem Definition

In the beginning, there was frustration. I was adding translations manually and getting annoyed that it was boring and a waste of time. I knew it could be faster, but how? I started by defining what I wanted to achieve and what I knew.

What do I want to achieve? I want translations to be added with minimal effort.

What do I know? I know the folder structure and that it’s JSON I want to fill with appropriate translations.

src/
└── i18n/
    ├── de/
       └── translation.json          # 🇩🇪 German
    ├── en/
       └── translation.json          # 🇬🇧 English (source)
    ├── es/
       └── translation.json          # 🇪🇸 Spanish
    ├── fr/
       └── translation.json          # 🇫🇷 French
    ├── it/
       └── translation.json          # 🇮🇹 Italian
    ├── nl/
       └── translation.json          # 🇳🇱 Dutch
    └── pl/
        └── translation.json          # 🇵🇱 Polish
Enter fullscreen mode Exit fullscreen mode

I know I want translations to work in two steps:

  1. I type the translation in code,
  2. AI replaces the key in code and adds appropriate translations to the right JSON file.

Nothing more. Knowing what I wanted to achieve, I was ready to take on the challenge.

First Encounter with AI, First Disappointment

I started getting into AI and the process of writing code faster. So I started using Claude Code, and I was impressed by how well it handled code. Then it was time to tackle my problem — I asked it to add translations for 7 languages.

It added the files, but the whole thing took over a minute and was very expensive (lots of tokens). If you didn’t clearly specify the rules for finding keys, it tried to read the entire translation JSON. Each JSON over 25k tokens and it didn’t even read the whole thing! Because there’s a 25k limit, then it chunks it. So after approving Claude Code’s operations and over a minute — it worked. I didn’t like the result: slow, expensive. I abandoned the topic for a few months.

The Problem That Wouldn’t Go Away. Custom Commands in Action

This topic really bothered me! How to do it right, it must be possible, right? And that’s how I stumbled upon Custom Commands. Why tell the LLM what to do every time and which translations to add where and how, when I can create a command run from the Claude Code terminal that will have instructions.

The result of my work was the command:

/i18n-extract "Translation here as argument"
Enter fullscreen mode Exit fullscreen mode

Goal? I type the command, and as a result it should do all the steps I was doing manually.

How did it go? Badly, on the plus side the rules were written in the i18n-extract.md file, but still: long, expensive.

EUREKA! What if I move some of the work to Bash?

At some point I had a developer epiphany. I’ll tell Claude that what’s mechanical should be executed in Bash instead of doing it itself.

I went back to the beginning: why do I need AI in translations? To do translations! So simple yet so easy to overcomplicate 😅

I took this thought further and came to what I thought was a brilliantly simple division: mechanical layer and creative layer.

Creative layer: AI handles generating translations.

Mechanical layer: Bash will handle all operations of finding translations to replace, saving to files, etc.

After the dust of joy settled, the first problem appeared. How is Bash supposed to find the fragment in code that it needs to send to Claude Code? That was a tough question.

Hmm, what would I actually like the full process to look like?

  1. I type throw new Error(‘Email is already taken’),
  2. I run the command and done.

Then I remembered why I’m doing this too — to make it convenient for me, and since I’m building it for myself, I can set the rules to make it work.

The '#go' marker.

A very simple concept that solved the problem of telling Bash where to find what it’s looking for in the codebase.

At the end we simply add #go, to tell Bash “I’m here”. We assume there’s only one #go in the project.

new Error('Email is already taken #go')
Enter fullscreen mode Exit fullscreen mode

'#go', despite being simple, was a breakthrough for me. While working on this solution, or rather typing the error “Email is already taken #go”, I thought you could make a significant upgrade and a serious convenience boost.

Intention mode vs literal

What’s wrong with “Email is already taken #go”?

We have to type the error message! The literal one it should throw.

What if we split this into two modes? Intention mode and literal. Literal would translate 1:1 what we typed, and intention mode would come up with the translation for us.

Another problem: How to tell Bash when to activate intention mode and when literal? Keywords! How powerful they are! I adopted a simple assumption: if the error contains keywords (“Need”, “Create”, “Generate”) it will activate intention mode and come up with a translation. Example of activating intention mode based on the word “need”:

throw new Error(
  'Need error that user reached project limit on free plan and must upgrade to premium to add more #go'
);
Enter fullscreen mode Exit fullscreen mode

Thus keywords became responsible for activating intention mode (my favorite) or literal mode.

Going through this long journey, after fine-tuning the Bash script to change, delete, replace what was needed — it was time for tests.

Flow:

1) I type the error intention in code.

throw new Error(
  'Need error that user reached project limit on free plan and must upgrade to premium to add more #go'
);
Enter fullscreen mode Exit fullscreen mode

2) I run the custom command from Claude Code terminal: /i18n-extract. As a result, it generated and populated translations to 7 files.

// de/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "Sie haben die maximale Anzahl an Projekten für Ihren aktuellen Free-Tarif erreicht. Um weitere Projekte hinzuzufügen und erweiterte Funktionen für Ihr Logistikmanagement freizuschalten, führen Sie bitte ein Upgrade auf unseren Premium-Tarif durch.\n"
}

// en/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "You have reached the maximum number of projects allowed on your current Free plan. To add more projects and unlock advanced logistics management features, please upgrade to our Premium plan.\n"
}

// es/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "Ha alcanzado el número máximo de proyectos permitidos en su plan Free actual. Para añadir más proyectos y desbloquear funciones avanzadas de gestión logística, actualice a nuestro plan Premium.\n"
}

// fr/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "Vous avez atteint le nombre maximum de projets autorisés dans votre offre Free actuelle. Pour ajouter davantage de projets et débloquer les fonctionnalités avancées de gestion logistique, veuillez passer à notre offre Premium.\n"
}

// it/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "Ha raggiunto il numero massimo di progetti consentiti nel Suo piano Free attuale. Per aggiungere ulteriori progetti e sbloccare le funzionalità avanzate di gestione logistica, effettui l'upgrade al nostro piano Premium.\n"
}

// nl/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "U heeft het maximale aantal projecten bereikt dat is toegestaan in uw huidige Free-abonnement. Om meer projecten toe te voegen en geavanceerde logistieke managementfuncties te ontgrendelen, kunt u upgraden naar ons Premium-abonnement.\n"
}

// pl/translation.json
{
  "PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED": "Osiągnięto maksymalną liczbę projektów dostępną w ramach aktualnego planu Free. Aby dodać więcej projektów i uzyskać dostęp do zaawansowanych funkcji zarządzania logistyką, prosimy o przejście na plan Premium.\n"
}
Enter fullscreen mode Exit fullscreen mode

Finally, AI replaced the error in code with the i18n key.

Before:

throw new Error('Need error that user reached project limit on free plan and must upgrade to premium to add more #go',);
Enter fullscreen mode Exit fullscreen mode

After:

throw new NotFoundException({key: 'translation.PROJECT_LIMIT_REACHED_UPGRADE_REQUIRED'});
Enter fullscreen mode Exit fullscreen mode

The quality of translations was good. The whole thing took about 20 seconds with a stopwatch in hand, in intention mode. I had mixed feelings, I felt it could be better and faster.

EUREKA x 2 — pure Bash!

Better, better. How to do it better, what’s wrong, why so many thoughts when the translation process itself only takes a few seconds?

The answer was interesting to me. It turned out that most of the time was spent on process orchestration through Claude Code. In other words, when I executed the command, Claude Code was deciding that Bash needed to run, then again, then again and again. But why? Why does Claude need to decide this when we know the full flow. Here it was worth going back to the earlier thinking:

Why do I need AI in translations? To do translations!

I took it literally. I refined the previously established architectural division:

  1. Claude Code should only come up with the key and translations. It shouldn’t direct the process. It should be responsible for the creative part.
  2. Bash should do everything else. It directs the entire process and at the right moment makes an API Call to Claude to generate translations and that’s it! It’s responsible for the mechanical layer.

What needed to be done? Remove the command run from Claude Code CLI level and instead run a script written in Bash, which works on our assumptions:

  1. finds the fragment in code we want to translate using the #go marker,
  2. determines whether to use intention or literal mode based on keywords contained in the error,
  3. API Call to Claude to translate,
  4. execution of the mechanical part by Bash (replacing translations, changing keys, cleanup, etc.).

Real intention mode flow:

1) I type the error intention in code that I want to throw (contains the keyword “Need”).

throw new Error(
  'Need error that user reached project limit on free plan and must upgrade to premium to add more #go'
);
Enter fullscreen mode Exit fullscreen mode

2) I run Bash using “time” to immediately measure time.

time bash .claude/scripts/i18n-extract-full.sh --yes
Enter fullscreen mode Exit fullscreen mode

3) The process executes.
Terminal output: 769 tokens used, 7 languages translated in 8.9 seconds

Terminal output: 769 tokens used, 7 languages translated in 8.9 seconds

On the screen we see details:

  • total of 769 tokens used (only key and translation generation),
  • translations added to 7 languages,
  • the whole thing took 8.9 seconds.

4) Done, 7 translations added to respective JSONs and key replaced, ready to commit.

Real literal mode flow:

1) I type the error in code 1:1 as it should be translated:

throw new Error('Email is already taken #go');
Enter fullscreen mode Exit fullscreen mode

2) I run Bash.

time bash .claude/scripts/i18n-extract-full.sh --yes
Enter fullscreen mode Exit fullscreen mode

3) The process executes.
Terminal output: 340 tokens used, 7 languages translated in 3.8 seconds

Terminal output: 340 tokens used, 7 languages translated in 3.8 seconds

On the screen we see details:

  • total of 340 tokens used,
  • translations added to 7 languages,
  • the whole thing took about 3.8 seconds for literal mode.

4) Done, ready to commit.

Since the literal flow is much simpler, we got down to 3.8 seconds and minimal token usage.

Comparison

As we’ve proven — custom commands don’t work well for this type of task. Below I’m attaching a comparison that clearly shows that for the type of problem discussed in the article, Bash wins.

Comparison table: Custom command 20s vs Plain Bash 3.8–8.9s, showing 2–5x speed improvement and 10x fewer LLM calls

Comparison table: Custom command 20s vs Plain Bash 3.8–8.9s, showing 2–5x speed improvement and 10x fewer LLM calls

Summary

This article was created to share the evolution of a solution to a specific problem. We often start with a complicated version, and through successive iterations we arrive at a simple solution. Simplicity is a form of art.

The AI hype sometimes makes you tend to overcomplicate. That’s how I was at the beginning. I started with the “everything on AI side” solution, then “custom commands”, then “custom commands + Bash” and only at the end “Full Bash”. I wonder if there hadn’t been an AI hype, would I have started digging into Bash right away 🤔

The solution shown in the article is part of my custom workflow, saves a significant amount of time and frustration, and was interesting to build. Below I’m recording the biggest EUREKA moments I experienced and conclusions:

  • you’re in the driver’s seat → the #go marker solved a lot of problems, let’s be creative,
  • just do it, even if the first solution will be overcomplicated, slow, you won’t use it. From idea to result can take a long time, it took me about 3 months for the thought to germinate and evolve during the process,
  • orchestration on the Bash side + LLM only for the creative layer. Clear division that simplifies a lot,
  • with custom workflow don’t be afraid to use keywords like “need” to trigger creative mode — ultimately it’s a solution for us, it’s important that it works and you want to use it, it should be convenient.

Thank you if you made it this far! What automations have you managed to create? Maybe you have practical use cases for custom commands?

Feel free to discuss in the comments 👇

Top comments (2)

Collapse
 
kamil_da1f77a8bc7214d5f50 profile image
Proxios

Thanks for this post! 🙌
You genuinely saved me a ton of time — I had no idea it could be this straightforward.

Based on your article, we built something similar on our side. In my case we’re handling translations in Laravel and went with Node instead of Bash, mainly because it made parsing strings and managing translation keys much easier for us.
And there’s an extra speed boost: batching API calls. After switching to batches, I managed to cut the runtime by roughly another 3x. 🚀

Really appreciate the write-up — super practical and spot on!

Collapse
 
kamilbuksakowski profile image
Kamil Buksakowski

I’m really glad I could help! Good luck with your future automations! 🚀