Skip to content

Adding a Language Developer Guide

Ed Mozley edited this page Aug 8, 2026 · 2 revisions

Adding a language β€” developer guide

How to take FreeITSM from n locales to n+1, including the fan-out workflow that does the actual translating. Companion to Internationalisation, which covers how t() and the fallback chain work.

Why this page exists. The workflow script that translated all 23 modules into Brazilian Portuguese lived in a session temp directory and was deleted when that session ended. The next language had to start by rewriting it from memory. The script is reproduced in full below so that never happens again.

The shape of the job

One language is 23 files and roughly 9,000 keys. That is too much to do by hand and too much for a single agent, so it is fanned out one agent per module. It is not a workflow because it is clever β€” it is a workflow because the unit of work is a file and there are dozens of them.

🎨 File What it does
🧠 includes/i18n.php I18n::SUPPORTED_LOCALES β€” the allow-list. A locale that is not in here does not exist, which is also what stops the locale parameter being used for path traversal
πŸ“ lang/<code>/ One PHP file per module, each returning a nested array. Created by hand before the run
πŸ“„ lang/en/<module>.php The source of truth. Every other locale mirrors its structure exactly
πŸ“„ lang/pt-BR/<module>.php The reference translation. Handed to each agent as a structural template, never as a language template
πŸ–₯️ assets/js/i18n.js Client-side window.t(). Needs no change for a new locale

Step 1 β€” register the locale

includes/i18n.php, in SUPPORTED_LOCALES. Native name, not the English name β€” a Nynorsk reader offered only "Norwegian" cannot tell which of the two standards they are about to get.

'nb'    => 'Norsk bokmΓ₯l',
'nn'    => 'Norsk nynorsk',

Then create the folder: mkdir lang/nb. The app will now offer the language and fall back to English for every key, which is the correct intermediate state.

Codes follow BCP 47, the same form used in the HTML lang attribute.

Step 2 β€” translate

Two parts, and only one of them is tool-specific.

The prompt below β€” the hard rules, the placeholder discipline, the tone note β€” is the reusable artefact. It works whoever is doing the translating: a human translator, a different AI tool, an agency. If you take one thing from this page, take that and the verification in step 3.

The orchestration around it (Workflow, agent, pipeline) is Claude Code, which is how this repository's maintainer does it. If you are not using Claude Code, ignore the JavaScript and hand the prompt to whatever you are using, one module at a time.

Model: run the agents on Sonnet, not Opus. The failure modes that matter β€” dropped keys, mangled placeholders, invalid PHP β€” are instruction-following problems, and step 3 catches every one of them mechanically. Paying for the larger model across 9,000 mostly-short UI strings buys quality where it cannot show. Bahasa Melayu was done this way with zero structural faults across the whole locale.

Use one model for the whole locale. Mixing them between modules risks an inconsistent register within one language, which is worse than either model alone. If the long help prose reads poorly on review, re-run just those modules on a stronger model, deliberately.

Invoke with Workflow({ script: … }). The full script:

export const meta = {
  name: 'translate-locale',
  description: 'Translate all language modules into one or more new locales',
  phases: [{ title: 'Translate', detail: 'one agent per (module, locale)' }],
}

// Biggest first: a 1,500-key file wants a concurrency slot early, not last.
const MODULES = [
  'tickets', 'contracts', 'cmdb', 'system', 'forms', 'change-management',
  'asset-management', 'lms', 'knowledge', 'common', 'workflow', 'network-mapper',
  'process-mapper', 'tasks', 'reporting', 'calendar', 'software', 'service-status',
  'self-service', 'morning-checks', 'watchtower', 'system-wiki', 'setup',
]

const LOCALES = [
  { code: 'nb', name: 'Norwegian Bokmal', guidance: `...register and dialect notes...` },
]

const pairs = []
for (const m of MODULES) for (const l of LOCALES) pairs.push({ module: m, locale: l })

phase('Translate')

const results = await pipeline(
  pairs,
  (p) => agent(
`You are translating a PHP language file for FreeITSM, an IT service desk
application, into ${p.locale.name}.

APP ROOT: <absolute path to your FreeITSM checkout>

READ THESE TWO FILES FIRST:
  1. lang/en/${p.module}.php    - the English source. This is what you translate.
  2. lang/pt-BR/${p.module}.php - an already-completed locale. Use it ONLY as a
     structural template, to see how a finished file should look.

THEN WRITE: lang/${p.locale.code}/${p.module}.php

LANGUAGE GUIDANCE
${p.locale.guidance}

HARD RULES - a file that breaks any of these is useless:

1. STRUCTURE MUST BE IDENTICAL to lang/en/${p.module}.php. Same nested array shape,
   same keys, same order, same nesting depth. Translate only the VALUES. Never
   translate, rename, reorder, add or drop a KEY.

2. PLACEHOLDERS MUST SURVIVE EXACTLY. Tokens like {n}, {name}, {path}, {error} are
   substituted at runtime. A value with three placeholders in English must have the
   same three afterwards, spelled identically. You may move them within the sentence
   so it reads naturally - you may not rename, drop or invent one. This is the single
   most common way these files break.

3. LEAVE UNTRANSLATED: HTML tags and attributes, anything inside <code> or backticks,
   file names and paths, PHP identifiers, setting keys, URLs, product names, and
   protocol words the target audience uses in English anyway (IMAP, SMTP, OAuth, API).

4. VALID PHP. Starts with <?php and returns an array. Escape apostrophes inside
   single-quoted strings as \\'.

5. UTF-8, no BOM. Write accented characters directly, never as HTML entities.

6. KEEP THE COMMENT HEADER, translated, so the file explains itself as the English
   one does.

TONE: professional software an IT team uses all day. Buttons are one word where the
English is one word. Match the register exactly - where English says "Save", say the
one-word equivalent, not a polite sentence.

When you have written the file, reply with ONE line only:
  <module> <locale> <number of top-level keys written>`,
    { label: `${p.locale.code}:${p.module}`, phase: 'Translate', agentType: 'general-purpose' }
  )
)

return { requested: pairs.length, reported: results.filter(Boolean).length }

Two decisions in that script that are not arbitrary

No schema / no StructuredOutput. The pt-BR run used a schema and agents silently failed on the larger files (roughly 350 keys and up) β€” the structured call never came back and the file was simply never written. Having the agent use Write itself and return one line of plain text removes that failure mode entirely. Do not "improve" this by adding a schema.

pt-BR as the structural template, and only structural. Agents given a second language start blending it. The prompt says "ONLY as a structural template" for that reason.

⚠️ tickets is too big for one agent β€” split it

Learned on Bahasa Melayu, and it will happen every time. tickets is around 1,550 keys, roughly 1.6Γ— the next largest file. A single agent given it spent twenty minutes without ever attempting a write: it read the source thoroughly, then went grepping its sibling lang/<code>/*.php files to see how "Save" and "Delete" had already been translated, then began building a task list about terminology. A reasonable instinct β€” consistency does matter β€” that simply never converged.

Note the failure shape: not a crash, not a hang, and not a truncated file. The transcript kept growing. The tell was writes=0 after twenty minutes while its siblings were finishing in two.

Three changes fixed it, and all three matter:

  1. Split it by top-level section. Six chunks works: title…note_modal, split…time_entries, settings alone (~510 keys), rota…dashboard, help, help_sla. Each agent writes a fragment β€” no <?php, no return [, no ]; β€” and you assemble and lint them yourself.
  2. Hand it the glossary. Point the agent at lang/<code>/common.php, already finished, and say it is the only consistency reference needed. That removes the reason to go looking.
  3. Tell it not to research, naming the failure: "a previous agent spent twenty minutes researching and produced nothing."

Three of the six chunks completed within two minutes of launch.

The same shape applies to any module over roughly 700 keys β€” contracts and cmdb were slow but did finish whole.

Step 3 β€” verify, because nothing will tell you it went wrong

⚠️ The fallback is per-key and silent. A missing key renders the English string. No error, no console warning, nothing on screen. A locale can be 30% translated and look finished. So verification is not optional, and it cannot be done by eye.

Subagents cannot run php -l β€” Bash is denied in their sessions β€” so lint yourself afterwards.

PHP=php          # or the full path to it, e.g. /c/wamp64/bin/php/php8.4.0/php.exe

# 1. every file parses
for f in lang/nb/*.php; do $PHP -l "$f"; done

# 2. key COUNT matches English, per module
$PHP -r 'function c($a){$t=0;foreach($a as $v){$t+=is_array($v)?c($v):1;}return $t;}
foreach (glob("lang/en/*.php") as $f) { $m=basename($f);
  $en=c(require $f); $x=file_exists("lang/nb/$m") ? c(require "lang/nb/$m") : 0;
  if ($en!==$x) printf("%-24s en=%-5d nb=%-5d\n", $m, $en, $x); }'

# 3. key SET matches - a count can match while paths differ
$PHP -r 'function f($a,$p=""){$o=[];foreach($a as $k=>$v){$q=$p?"$p.$k":$k;
  if(is_array($v))$o+=f($v,$q); else $o[$q]=1;} return $o;}
foreach (glob("lang/en/*.php") as $g) { $m=basename($g);
  if(!file_exists("lang/nb/$m")) continue;
  $d=array_diff_key(f(require $g), f(require "lang/nb/$m"));
  foreach($d as $k=>$_) echo "MISSING $m: $k\n"; }'

# 4. placeholders match BOTH ways - the check people skip
$PHP -r 'function f($a,$p=""){$o=[];foreach($a as $k=>$v){$q=$p?"$p.$k":$k;
  if(is_array($v))$o+=f($v,$q); else $o[$q]=$v;} return $o;}
foreach (glob("lang/en/*.php") as $g) { $m=basename($g);
  if(!file_exists("lang/nb/$m")) continue;
  $en=f(require $g); $tr=f(require "lang/nb/$m");
  foreach ($en as $k=>$v) { if(!isset($tr[$k])) continue;
    preg_match_all("/\{[a-z_]+\}/",$v,$a); preg_match_all("/\{[a-z_]+\}/",$tr[$k],$b);
    sort($a[0]); sort($b[0]);
    if ($a[0]!==$b[0]) echo "PLACEHOLDER $m: $k  en=".implode(",",$a[0])."  nb=".implode(",",$b[0])."\n"; } }'

Check 4 is the one that earns its keep. A key can exist in both files, pass the count and the set check, and still be broken because the translator dropped {depth} β€” and the user then sees a sentence with a hole in it.

Check 5 β€” only when the new language is close to one you already have

⚠️ A translator drifts between closely related languages without noticing, and no structural check will ever catch it. The file parses, every key is present, every placeholder survives β€” and it is subtly the wrong language.

The pairs where this applies: Malay / Indonesian, Norwegian bokmΓ₯l / nynorsk, and in principle any Portuguese, Spanish or Serbo-Croatian variants added later.

The defence is a word list, applied twice β€” once in the prompt as an explicit "use this, NOT that" table, and once afterwards as a grep:

# Malay (ms) must contain none of these Indonesian forms
for w in kualitas aktivitas "perangkat lunak" unduh unggah berkas pengaturan surel bisa; do
  n=$(grep -roih "$w" lang/ms/*.php | wc -l)
  [ "$n" -gt 0 ] && echo "DRIFT: $w β€” $n occurrences"
done

Pick five to ten words that differ between the two and are common in UI text. For ms/id the decisive one is bisa vs boleh: Bahasa Melayu came out with boleh 432 times and bisa zero, which is a stronger signal than any amount of reading.

⚠️ Also list any false friends in the prompt as banned words. For ms/id it is kereta β€” car in Malay, train in Indonesian. That is the class of error that produces confident nonsense rather than something a reader spots as wrong.

Re-run the workflow for any module that comes back short, passing just that module and locale.

Step 4 β€” the bits that are not translation

  • Log it in CHANGELOG.local.md with the next sequential ID.
  • Update the language count in README.md β€” it is a number in prose, so nothing will ever tell you it has gone stale.
  • Register the locale LAST, not first, if the run is happening on a live install. Step 1 above registers early on the reasoning that an absent file falls back to English cleanly, which is true. But a file being written is briefly unparseable, and a registered locale with an unparseable file is a fatal on that module, not a fallback β€” the per-key fallback only rescues a file that successfully loads. On a machine nobody is using, register first; on anything live, register once the files verify.
  • Check for an open branch. If a feature branch is adding English keys at the same time, the new locale will be short by exactly those keys the moment it merges β€” silently. Note them and backfill after.

The drift problem, which never goes away

100% is a snapshot, not a state. Every feature that adds lang/en/<module>.php keys un-does parity for every locale at once, and nothing anywhere reports it.

The habit that keeps it under control: when a change adds English keys, write the pt-BR twin in the same commit, and run check 3 above before pushing. Full locale sweeps are then a periodic tidy rather than a rescue.

See also

  • Internationalisation β€” t(), the fallback chain, how a module gets wired for i18n in the first place

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally