-
Notifications
You must be signed in to change notification settings - Fork 15
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.
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 |
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.
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 }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.
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:
-
Split it by top-level section. Six chunks works:
titleβ¦note_modal,splitβ¦time_entries,settingsalone (~510 keys),rotaβ¦dashboard,help,help_sla. Each agent writes a fragment β no<?php, noreturn [, no];β and you assemble and lint them yourself. -
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. - 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.
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.
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"
donePick 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.
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.
-
Log it in
CHANGELOG.local.mdwith 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.
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.
-
Internationalisation β
t(), the fallback chain, how a module gets wired for i18n in the first place
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)