Skip to content
Fondazione ITS INCOM

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:

  1. the model translates, and a JSON schema built from your keys makes it answer with exactly those keys;
  2. your code checks every string: the same placeholders, the same tags with the same attributes, and not the English text left as it was;
  3. 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

Good to know

Exercise

All examples