Adding a Language Developer Guide - edmozley/freeitsm GitHub Wiki
How FreeITSM gets a language from nothing, or from half-done, to 100%. Companion
to Internationalisation for how t() and the fallback
chain work, and i18n Audit for the checks.
It described a workflow where each agent wrote a whole
lang/<code>/<module>.phpfile with theWritetool. That destroys a partial locale. A locale file holding 358 of 2,049 keys is partial, not absent, and writing it wholesale discards what is there β including anything a native speaker has reviewed. French had 4,443 existing strings when this was noticed.Everything below describes the pipeline that replaced it, in which no agent ever writes to
lang/.
One language is 24 files and roughly 12,550 keys. Too much for one agent, so it is fanned out β but the unit of work is a chunk (one screen), not a file, and the orchestrator does every destructive step.
i18n_chunk.php β what is missing, split into chunks an agent can do
β agents translate, writing TSV only, never touching lang/
i18n_repair_chunk.php β fix the one malformation that recurs
i18n_verify_chunk.php β the gate. Exit 0 = safe to merge
i18n_merge.php β the ONLY script that writes to lang/
i18n_gated_merge.php runs the last three together and cannot be invoked in a
way that merges an unverified chunk β see Β§6.
π The split of responsibility is the whole design. Translating agents read English and write a TSV. A bad agent therefore cannot damage anything. Every destructive step stays under one pair of hands.
# 1. What is missing, chunked, with per-locale byte budgets
php scripts/i18n_chunk.php fr --out C:/tmp/work
# 2. Fan out agents. Each reads scripts/i18n_BRIEF.md + a glossary + its chunks.
# See Β§4 for the prompt, Β§5 for sizing.
# 3. Verify and merge β one command, cannot be got wrong
php scripts/i18n_gated_merge.php fr C:/tmp/work/chunks
# 4. Prove it
php scripts/i18n_audit.php --all
php scripts/i18n_drift.php nn nb # only for close-language pairs
for f in lang/fr/*.php; do php -l "$f"; doneRe-running is safe at every step. i18n_merge.php never overwrites an existing
value, and i18n_chunk.php recomputes the gap from scratch β so an interrupted
run needs no bookkeeping. Just run it again.
Extract the terms the locale already uses and hand the same file to every agent.
This is what stops twelve agents inventing twelve words for "ticket", and it
removes the reason an agent goes wandering through lang/<code>/ β which is
what once made one read for twenty minutes and write nothing.
Take every short English value that already has a twin in the locale, keep only those used twice or more where one rendering holds at least 60% of uses. A 35% locale yields ~400 terms; a 75% locale yields ~650.
π΄ Settle contested terms BEFORE a wave, never during. Pinning a correction into the instructions mid-run left Malayalam calling one role two things, because ten bundles were already in flight. The glossary is extracted first, so every clash in it is knowable up front.
The hard rules live in scripts/i18n_BRIEF.md β the reusable artefact, which
works for a human translator or any AI tool. Put per-locale material in an
INSTRUCTIONS.<locale>.md that every agent for that locale reads, and keep each
agent's own prompt to its file list plus what is specific to those chunks.
Forty lines of rules retyped per agent is forty chances for one to drift out of step. Generate the instruction files rather than hand-copying them.
Model: Sonnet. The failure modes that matter are instruction-following, and Β§6 catches every one mechanically. Use one model for a whole locale.
Two things every prompt needs, learned the hard way:
- "Read the file for its key count β do not trust a number in your prompt." Three agents correctly flagged that stated counts did not match their files; the chunker was right and the orchestrator's numbers were stale. A count in a prompt is redundant with the file and can only mislead.
- "Write each output file as soon as that chunk is done." A rate limit killed eleven agents mid-run; every file already written was recovered, every unwritten one was lost.
The chunk size has been wrong four different ways. Every correction came from a real death, none from reasoning in advance.
A 597-key chunk of short labels completed in two minutes. A 358-key chunk of help prose killed its agent. Key count predicted neither.
The fix for those deaths was not a smaller chunk. It was "write the file once, do not echo it back" β both deaths were agents producing the same content twice.
Proven deliberately: seven pt-BR bundles at ~45 KB, larger than a chunk that had
died, with the rule. Six of six survived. That is why the rule lives in
i18n_BRIEF.md, and why the chunker's comment says the limit assumes the brief
is being followed. Translating without it? Halve the number.
--maxbytes counts English bytes, but the 64,000-token limit is on what the
agent writes. Measured output Γ· English bytes, over whole completed locales:
| Locale | Script | Expansion |
|---|---|---|
| nb | Latin | Γ1.04 |
| ms | Latin | Γ1.12 |
| fr | Latin | Γ1.18 |
| uk | Cyrillic | Γ1.64 |
| gu | Indic | Γ2.04 |
| ml | Indic | Γ2.42 |
A 45 KB English bundle is ~53 KB of French and ~109 KB of Malayalam. That is
exactly how two Malayalam agents died at a size five other locales had completed
comfortably. i18n_chunk.php now scales each locale's budget by its measured
expansion β an Indic locale gets ~19,700 English bytes rather than 40,000.
π Add a measured figure before running a new script family. An unknown locale gets the Latin default, which is only safe if it is Latin.
tickets.settings is one section of 39 KB. settings.modals, settings.sla and
settings.numbering are each a coherent screen. Cutting there keeps a dialogue
whole; cutting at an arbitrary key count gives you half a screen in one register
and half in another.
This is the single most important thing on the page. The translation work has been consistently good. It is specifically the self-checking that cannot be trusted, and that is why the gate is mechanical.
Five distinct classes of confident-but-wrong hand-back in one day:
| The claim | The reality |
|---|---|
"blank-English key written with its tab, verified with awk" |
No tab. Twice, each naming the exact key and quoting the command it ran. |
| "147 lines written" | 144. Three keys dropped outright. |
| "same keys, same order" | Four keys reordered. |
| "all HTML tags preserved in the same order" | Both <code> tags gone from one line. |
"only forms.php uses tΓΊ" |
Eleven of twenty-five modules did. It had read five files. |
π΄ i18n_gated_merge.php exists because verify; merge was typed instead of
verify && merge and a failing chunk was merged. The harm was nil that time, by
luck. A gate that depends on remembering a shell operator is not a gate.
Seven keys in the whole product have an empty English value. Eleven failures in one day were all on those seven.
The mechanism, found on the eleventh: the Write tool strips trailing
whitespace, so a line whose correct content is key + TAB + nothing cannot
survive being written. The agents were not careless β they verified content they
had genuinely produced, and the tool discarded it on the way out. No prompt
wording could ever have fixed it, which is what eleven attempts demonstrated.
So the pipeline stopped asking. i18n_chunk.php withholds those keys and
i18n_merge.php fills them, because a blank English value has exactly one correct
translation in every language. i18n_repair_chunk.php remains the net for chunks
generated before that change, and also strips a leaked </content> tool-call
fragment β the only tabless line it will ever delete, because a key never starts
with <.
ms/id, nb/nn, and in principle any Portuguese, Spanish or Serbo-Croatian variants. The file parses, every key is present, every placeholder survives β and it is quietly the wrong language.
Use php scripts/i18n_drift.php nn nb, in both directions. And finish one side
before starting the other: Malay completed with boleh 436 and bisa zero, so
Indonesian now has a settled sibling to be warned against, rather than two
half-done locales drifting toward each other.
π΄ Do not do this with grep.
grep -E '\bfrΓ₯\b'finds 9 occurrences inlang/nn; the real number is 608. The word ends in a non-ASCII letter and grep does not treatΓ₯as a word character, so the boundary never matches. A drift check that under-counts reports CLEAN on a drifting locale.i18n_drift.phpmatches with an explicit alphabet, reads values only so key names cannot produce false hits, and has a--self-test.
bruker against 88
brukar looks like drift and is not β those are the verb, which nynorsk permits
both ways. Listing it would flag correct Norwegian.
Read i18n Audit before quoting a figure. In
short: the audit compares a locale to lang/en, so a locale cannot be more
complete than its source. A screen never wired to t() is missing from
lang/en itself, and no locale comparison can ever reveal it.
Run php scripts/i18n_unwired.php as well. As of 2026-09-22 it reports 60 files,
including the whole Checklists module UI and problem-management, which has no
lang/en namespace at all.
-
includes/i18n.phpβSUPPORTED_LOCALES, the allow-list. Native name, not the English name: a Nynorsk reader offered only "Norwegian" cannot tell which of the two standards they are about to get. This is also what stops the locale parameter being used for path traversal. - Register the locale LAST on a live install. An absent file falls back cleanly; a registered locale with a half-written, unparseable file is a fatal on that module, because the per-key fallback only rescues a file that loads.
-
CHANGELOG.local.md, and the language count inREADME.mdβ a number in prose that nothing will ever tell you has gone stale. - Module names. FreeITSM keeps "Watchtower" and "War Room" in English in seven of eight Indian locales; Malayalam transliterates. Check the glossary before asserting a rule, and prefer what the locale already does.
100% is a snapshot, not a state. Every feature that adds lang/en keys un-does
parity for every locale at once. scripts/i18n_gate.php reports it, and
RELEASING.md step 3a asks before a tag is cut.
-
Internationalisation β
t(), the fallback chain - i18n Audit β every check, and what each cannot see