-
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.
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 }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.
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.
-
Log it in
CHANGELOG.local.mdwith 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.
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)