Examples · Web Development
Translate your i18n files
The en.json of a web shop goes in, and it.json, de.json and fr.json come out. A schema built from your keys fixes the shape; your code checks that every placeholder and every HTML tag is still there, exactly, and that nothing was left in English. Only the files that pass are written.
Intermediate · 35 min
The idea
A web app keeps the text of its interface in one file per language: en.json, it.json, de.json. This is called i18n, short for “internationalization”: an i, 18 letters, an n. The code calls t("auth.welcome", { name }), and the library picks the string of the user's language and puts the name in {name}.
A model translates such a file in seconds, and it usually does it well. One mistake is enough to break the page, though: a {nome} instead of {name}, a tag that is missing, a link that changed. These are not language mistakes: they are bugs, and a program can find them. So:
- the model translates, and a JSON schema built from your keys makes it answer with exactly those keys;
- your code checks every string: the same placeholders, the same tags with the same attributes, and not the English text left as it was;
- only a file that passes every check is written.
What you need
Node.js 22 or newer, a key and the openai package, as in Your first API call in JavaScript.
The strings
The interface of a small web shop (invented). Save it as en.json:
{
"app.name": "Bottega Olona",
"nav.orders": "My orders",
"nav.cart": "Cart",
"nav.logout": "Log out",
"auth.welcome": "Welcome back, {name}!",
"cart.items": "You have {count} items in your cart.",
"cart.empty": "Your cart is empty.",
"cart.total": "Total: <strong>{total}</strong>",
"cart.checkout": "Go to checkout",
"product.add": "Add to cart",
"product.variant": "Colour: {colour} · Size: {size}",
"product.outOfStock": "<strong>Out of stock.</strong> We will email you when it is back.",
"product.reviews": "{count} reviews",
"product.left": "Only {count} left: order by {time} and it ships today.",
"order.confirmed": "Thank you, {name}! Your order <strong>#{orderId}</strong> is confirmed.",
"order.delivery": "Delivery to {city} expected on {date}.",
"form.required": "This field is required.",
"form.email": "Please enter a valid email address.",
"help.returns": "Read our <a href=\"/en/help/returns\">returns policy</a> before you send anything back.",
"privacy.notice": "By continuing you accept our <a href=\"/privacy\">privacy policy</a>.",
"footer.copyright": "© {year} Bottega Olona. All rights reserved.",
"common.ok": "OK",
"common.cancel": "Cancel"
}
There are placeholders ({name}, {count}, {colour}), tags (<strong>, <a href="...">), a brand name, and two strings that are the same in every language: the name of the shop and “OK”.
The program
Save it as translate.mjs, next to en.json:
import { readFileSync, writeFileSync } from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.ai.itsincom.org/v1",
apiKey: process.env.API_KEY,
});
const model = process.argv[2] ?? "qwen3.8-27b";
const languages = { it: "Italian", de: "German", fr: "French" };
const english = JSON.parse(readFileSync("en.json", "utf8"));
const keys = Object.keys(english);
const SAME_IS_OK = new Set(["app.name", "common.ok"]); // these may stay as they are
// The keys of en.json, and only those: the model cannot drop a string, rename a key or add one.
const schema = {
type: "object",
properties: Object.fromEntries(keys.map((key) => [key, { type: "string" }])),
required: keys,
additionalProperties: false,
};
// What must not change: every {placeholder} and every HTML tag, with its attributes.
const code = (text) => (text.match(/\{\w+\}|<[^>]+>/g) ?? []).sort().join(" ");
for (const [lang, language] of Object.entries(languages)) {
const answer = await client.chat.completions.create({
model,
temperature: 0,
messages: [
{
role: "system",
content: `You translate the strings of a web shop from English into ${language}. ` +
"Translate only the text people read. Copy exactly, as they are: every placeholder in curly " +
"braces, like {name}; every HTML tag with its attributes, like <a href=\"/privacy\">; the " +
"name Bottega Olona. Use the informal you.",
},
{ role: "user", content: JSON.stringify(english, null, 2) },
],
response_format: { type: "json_schema", json_schema: { name: "strings", schema } },
});
if (answer.choices[0].finish_reason !== "stop") throw new Error(`${language}: the answer was cut off.`);
const translated = JSON.parse(answer.choices[0].message.content);
const problems = [];
for (const key of Object.keys(translated)) if (!(key in english)) problems.push(`${key}: not in en.json`);
for (const key of keys) {
if (typeof translated[key] !== "string") problems.push(`${key}: missing`);
else if (code(translated[key]) !== code(english[key])) problems.push(`${key}: «${translated[key]}»`);
else if (translated[key] === english[key] && !SAME_IS_OK.has(key)) problems.push(`${key}: still in English`);
}
if (problems.length === 0) {
writeFileSync(`${lang}.json`, JSON.stringify(translated, null, 2) + "\n");
console.log(`${lang}.json: ${keys.length} strings, all checks passed`);
} else {
console.log(`${lang}: NOT written, ${problems.length} problem(s)`);
for (const problem of problems) console.log(` ${problem}`);
}
}
Run it with node translate.mjs, or node translate.mjs gemma-4-26b. With qwen3.8-27b the three languages take about 45 seconds; with gemma-4-26b, about 12.
What you should see
it.json: 23 strings, all checks passed
de.json: 23 strings, all checks passed
fr.json: 23 strings, all checks passed
And it.json, for example:
{
"app.name": "Bottega Olona",
"nav.orders": "I miei ordini",
"nav.cart": "Carrello",
"nav.logout": "Esci",
"auth.welcome": "Bentornato, {name}!",
…
"order.confirmed": "Grazie, {name}! Il tuo ordine <strong>#{orderId}</strong> è confermato.",
…
"help.returns": "Leggi la nostra <a href=\"/en/help/returns\">politica di reso</a> prima di inviare qualcosa indietro.",
"privacy.notice": "Continuando accetti la nostra <a href=\"/privacy\">informativa sulla privacy</a>.",
"footer.copyright": "© {year} Bottega Olona. Tutti i diritti riservati.",
"common.ok": "OK",
…
}
{name}, #{orderId} and both links are as they were. “Bottega Olona” and “OK” are the same as in English, and they are allowed to be.
What fails, and how the request fixes it
The program above is our third try. Here are the first two.
First try: one line, no schema. We sent only “Translate this JSON file of a web shop into Italian:” and the file, once per language, with each model. Every answer, six of six, put the JSON inside a Markdown code block, with a sentence before it or notes after it: JSON.parse fails on the first line. And in four of the six, the link had changed. Gemma, in Italian:
Here is the Italian translation for your JSON file. I have ensured that the placeholders (e.g., `{name}`, `{count}`) and HTML tags remain intact.
…
"help.returns": "Leggi la nostra <a href=\"/it/help/returns\">politica di reso</a> prima di effettuare un reso.",
…
* **Help/Returns Link:** I updated the URL in the `help.returns` key from `/en/` to `/it/` assuming you will have an Italian version of that page.
It says the tags are intact, and a few lines below it says it changed one.
Second try: the schema, and one line. With response_format the answers are plain JSON with the right keys, every time. We kept a one-line system message: `Translate these strings of a web shop into ${language}.`. In four runs, Gemma kept every link. Qwen changed the link in German in four runs out of four, and in Italian in three out of four:
it.json: 23 strings, all checks passed
de: NOT written, 1 problem(s)
help.returns: «Lesen Sie unsere <a href="/de/help/returns">Rückgabebedingungen</a>, bevor Sie etwas zurückschicken.»
fr.json: 23 strings, all checks passed
Third try: the rules in the system message. “Copy exactly, as they are: every placeholder…, every HTML tag with its attributes…”. In three runs per model, 18 files, every check passed.
No model renamed a placeholder or dropped a tag in any of our runs: the link was the only thing that changed. The checks for placeholders are there for the day it happens, and the link shows why they compare whole tags, attributes included.
Is /it/help/returns wrong? Maybe your site has that page, maybe it does not. The model does not know: it guessed. The link is code, and code does not change in a translation. If your site has a page per language, the language belongs in a placeholder, filled in by your code (see the exercise).
The request made the mistakes rare. The checks are still there, because a request is advice to a model, and a check is a rule.
Why the tokens are sorted
code() takes every {placeholder} and every tag of a string, and sorts them. Word order changes from one language to another: in German, a placeholder can move to the end of the sentence. What must not change is the set: the same placeholders and the same tags, each with the same attributes, as many times as in English.
What the checks do not see
- The language. In all three runs, Qwen wrote «ordina entro {time} e spedisce oggi»: it passes every check, and an Italian reader stops at it. Gemma wrote «per la spedizione di oggi». Someone who speaks the language reads the file before it goes online.
- Half a translation. Gemma kept “privacy policy” in English inside the Italian sentence, in all three runs. Many Italian sites write it that way; but the check compares whole strings, so it would let any half-translated string through. The “still in English” check never fired in our runs. It costs one line, and a string left in English is the kind of mistake nobody notices until a customer does.
Good to know
- Plurals. “You have {count} items” is wrong when
countis 1. Many i18n libraries use the ICU format for that:{count, plural, one {# item} other {# items}}. The words inside must be translated, the structure must not: the simple regular expression ofcode()does not understand that structure at all. For ICU strings, use a parser such as@formatjs/icu-messageformat-parser. - Formal or informal. Without “Use the informal you”, Qwen wrote “Sie” in German, and Gemma “du”. Decide it once, for each language, and write it in the request: many French shops say “vous”.
- Send only what changed. 23 strings take seconds; 2,000 do not. Translate only the keys that are new or changed in
en.json, and keep the rest. temperature: 0makes answers alike, not identical. In three runs, Qwen translated “before you send anything back” in two different ways.
Exercise
- Change the link in
en.jsonto/{lang}/help/returns, and fill in{lang}in your code. Does the model leave it alone? Doescode()still pass? - Add a check for length: a button label that is more than 1.5 times as long as the English one may not fit in the layout. Which German strings does it flag?
- Make the program translate only the keys that are missing from
it.json, and keep the others as they are. - Add
rm: "Romansh"and try it withapertus-70b-instruct, the Swiss model. Do the checks still pass? Who can tell you whether the Romansh is right?
