Skip to content

i18n Audit Developer Guide

Ed Mozley edited this page Aug 9, 2026 · 1 revision

i18n Audit β€” Developer Guide

scripts/i18n_audit.php compares any locale against English and reports what is wrong with it.

Related: Internationalisation for how translations work, and Adding a Language for the process of creating one.


1. πŸ”‘ Why this exists

A locale does not announce that it has fallen behind. A missing key falls back to English and renders perfectly, so a half-translated module looks finished until somebody who actually reads that language opens it. English keeps moving; twenty-three other locales do not move with it.

The only way to know is to compare, and comparing 9,290 strings across 23 locales by hand is not a thing anybody does twice.

This was written after a simple question β€” "the German files could be really out of date, can you check?" β€” turned out to need a real answer rather than a spot check. German was at 47.2%. So were fourteen other locales.


2. Running it

php scripts/i18n_audit.php de           # one locale, full detail
php scripts/i18n_audit.php de --keys    # …and list every missing key
php scripts/i18n_audit.php --all        # every locale, one line each

Read-only. It reads lang/ and writes nothing.

--all output, sorted worst first:

LOCALE    COVER   FILES     KEYS   PLACE   EXTRA SAME-AS-EN
--------------------------------------------------------------
gu        43.2%       8     5277       0       0 44
de        47.2%       7     4901       0       0 159
uk        78.8%       1     1967       0      30 104
pt-BR     97.3%       1      251       3      14 192
Column Meaning
COVER percentage of English strings present
FILES whole namespaces absent β€” the entire module renders in English
KEYS total English strings not present, including those inside missing files
PLACE placeholder mismatches
EXTRA keys English no longer has
SAME-AS-EN values byte-identical to English

3. What it checks, in order of how much the problem matters

Missing files

A whole namespace absent. The worst kind, and the easiest to miss, because the module works β€” it is simply in English.

Missing keys

Individual strings absent from a file that otherwise exists. These produce English words in the middle of a translated sentence.

πŸ”‘ Placeholder mismatches β€” the dangerous one

⚠️ A translation that drops {name}, or swaps %d and %s, is WORSE than a missing translation. A missing string falls back to English and looks obviously untranslated. A broken placeholder renders β€” so nobody reports it β€” and it either prints the wrong value or shows a raw token to the user.

Checked both ways: a placeholder the translator invented is as broken as one they lost.

Two styles are in use and a locale can be perfect in one and broken in the other:

  • {name} β€” the modern form. Compared as a sorted set, since order does not matter.
  • %s %d %1$s β€” the older form, still used across the tickets strings. Compared in sequence, because a swapped %d and %s passes any check that merely counts them.

Extra keys

Keys English no longer has. Dead weight, and a reliable sign the locale was written against an older version.

Identical to English

Reported as a number to look at, never as an error β€” plenty of strings are legitimately identical. A large number usually means a file was copied and never translated.


4. ⚠️ The false-positive that shaped the tool

The first version reported German as having two broken placeholders. Both were wrong:

'percent_uploaded'  => '{pct}% uploaded'      // en
'percent_uploaded'  => '{pct}% hochgeladen'   // de

The printf pattern included the space flag β€” % d is legal printf β€” so "% u" in "% uploaded" matched as a specifier, and "90% of" versus "90 % der" matched as another. Both were ordinary prose containing a percent sign.

The space flag is now excluded:

preg_match_all('/%(?:\d+\$)?[-+0#]*[\d.]*[bcdeEfFgGosuxX]|%%/', $s, $printf);
//                          ↑ no space in the flag set

πŸ”‘ The lesson is not about regex. A checker that cries wolf gets ignored, and then the real mismatch it eventually finds gets ignored too. Prefer missing an exotic case to reporting a common one wrongly. After the change, false positives went from several per locale to zero, and the findings that survived were all genuine.

A finding class that needs a human, not a fix

{{doubled braces}} are documentation examples of the workflow-variable syntax, not substitution tokens. The audit flags them when a translator translates the word inside. That is usually fine. Eyeball, do not auto-fix.


5. How it works

Step What happens
Flatten Each lang file is included and flattened to dot paths β€” ['a' => ['b' => 'x']] becomes a.b. Comparing nested arrays directly would report a whole branch as one difference
Compare Every English key is looked for in the locale; every locale key is looked for in English
Placeholders Extracted from both values and compared as described above
Score COVER counts strings inside missing FILES as missing too, or a locale that is missing eight whole modules would score well on the files it does have

looksDeliberatelyIdentical() suppresses the identical-to-English count for empty strings, one- and two-character values, pure punctuation, and a list of brand and protocol names nobody translates (FreeITSM, URL, API, SLA, Slack…). Extend that list rather than living with the noise.


6. Files

Colour key: πŸ› οΈ tool Β· 🌍 data Β· πŸ“„ docs

🎨 File What it does
πŸ› οΈ scripts/i18n_audit.php The whole thing. ~250 lines, no dependencies, read-only
🌍 lang/en/*.php The reference. 24 namespaces, 9,290 strings
🌍 lang/<locale>/*.php Compared against it
πŸ“„ This page

7. When to run it

  • Before adding a language β€” see what "complete" actually means today
  • Before a release β€” catch a locale that a new module left behind
  • After adding any new English strings β€” every locale just got slightly worse, and this says by how much
  • When somebody says a translation looks wrong β€” placeholder mismatches are the usual cause, and they are invisible by inspection

⚠️ It cannot tell you whether a translation is good, only whether it is there and structurally sound. A locale can score 100% and read like a machine wrote it.


8. The script

scripts/i18n_audit.php in full
<?php
/**
 * i18n audit β€” compare a locale against English and report what is wrong with it.
 *
 *   php scripts/i18n_audit.php de            one locale, full detail
 *   php scripts/i18n_audit.php --all         every locale, one summary line each
 *   php scripts/i18n_audit.php de --keys     also list every missing key
 */

$root    = dirname(__DIR__);
$langDir = $root . '/lang';

$args    = array_slice($argv, 1);
$flags   = array_values(array_filter($args, fn($a) => str_starts_with($a, '--')));
$locales = array_values(array_filter($args, fn($a) => !str_starts_with($a, '--')));
$showKeys = in_array('--keys', $flags, true);
$all      = in_array('--all', $flags, true);

$available = array_values(array_filter(scandir($langDir), function ($d) use ($langDir) {
    return $d !== '.' && $d !== '..' && $d !== 'en' && is_dir($langDir . '/' . $d);
}));
sort($available);

if ($all) {
    $locales = $available;
} elseif (!$locales) {
    fwrite(STDERR, "Usage: php scripts/i18n_audit.php <locale>|--all [--keys]\n");
    exit(1);
}

/** Flatten a nested lang array into dot paths. */
function flatten(array $a, string $prefix = ''): array
{
    $out = [];
    foreach ($a as $k => $v) {
        $key = $prefix === '' ? (string) $k : $prefix . '.' . $k;
        if (is_array($v)) { $out += flatten($v, $key); } else { $out[$key] = (string) $v; }
    }
    return $out;
}

/** Every placeholder in a string. Named tokens sorted; printf specs in sequence. */
function placeholders(string $s): array
{
    preg_match_all('/\{[a-zA-Z0-9_]+\}/', $s, $named);
    $named = $named[0];
    sort($named);
    // ⚠️ No space in the flag set β€” see section 4.
    preg_match_all('/%(?:\d+\$)?[-+0#]*[\d.]*[bcdeEfFgGosuxX]|%%/', $s, $printf);
    return ['named' => $named, 'printf' => $printf[0]];
}

/** Strings that are legitimately the same in most languages. */
function looksDeliberatelyIdentical(string $v): bool
{
    $t = trim($v);
    if ($t === '') return true;
    if (mb_strlen($t) <= 2) return true;
    if (preg_match('/^[\d\s\p{P}\p{S}]+$/u', $t)) return true;
    $brands = ['FreeITSM', 'Email', 'URL', 'API', 'SSO', 'LDAP', 'SLA', 'CMDB', 'PDF', 'CSV',
               'JSON', 'XML', 'Slack', 'Teams', 'Jira', 'Intune', 'Microsoft', 'Google'];
    return in_array($t, $brands, true);
}

$enNs = array_map(fn($f) => basename($f, '.php'), glob($langDir . '/en/*.php'));
sort($enNs);

$en = [];
foreach ($enNs as $ns) {
    $data = include $langDir . '/en/' . $ns . '.php';
    $en[$ns] = is_array($data) ? flatten($data) : [];
}
$enTotal = array_sum(array_map('count', $en));

foreach ($locales as $loc) {
    $dir = $langDir . '/' . $loc;
    $missingFiles = $missingKeys = $extraKeys = $phProblems = [];
    $untranslated = 0;

    foreach ($enNs as $ns) {
        $path = $dir . '/' . $ns . '.php';
        if (!is_file($path)) { $missingFiles[] = $ns; continue; }
        $data = @include $path;
        if (!is_array($data)) continue;
        $loc_ = flatten($data);

        foreach ($en[$ns] as $key => $enVal) {
            $full = $ns . '.' . $key;
            if (!array_key_exists($key, $loc_)) { $missingKeys[] = $full; continue; }
            $lv = $loc_[$key];
            if ($lv === $enVal && !looksDeliberatelyIdentical($enVal)) $untranslated++;

            $a = placeholders($enVal);
            $b = placeholders($lv);
            if ($a['named'] !== $b['named'] || $a['printf'] !== $b['printf']) {
                $phProblems[] = $full;
            }
        }
        foreach ($loc_ as $key => $v) {
            if (!array_key_exists($key, $en[$ns])) $extraKeys[] = $ns . '.' . $key;
        }
    }

    $missingFromFiles = 0;
    foreach ($missingFiles as $ns) $missingFromFiles += count($en[$ns]);
    $totalMissing = count($missingKeys) + $missingFromFiles;
    $pct = round(100 * ($enTotal - $totalMissing) / $enTotal, 1);

    // …reporting omitted here for brevity; see the file itself for the
    //   section-by-section output and the --all summary table.
}

The copy above is trimmed to the logic. The file in the repository is the authority β€” it also carries the per-section reporting, the --all summary table, and the comments explaining each decision.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally