Skip to content

Adding a Language Developer Guide

Ed Mozley edited this page Aug 7, 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 β€” run the workflow

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: c:/wamp64/www/freeitsm-app

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.

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=/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.

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.
  • 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