Skill: XLF Translation
SkillFiles & storageFill empty <target> elements in translations/messages.*.xlf files with machine-assisted translations prefixed with ~. Handles language selection, line-range filtering, encoding (inline PowerShell only), and produces a minimal diff touching only empty targets.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Skill: XLF Translation skill
What this skill tells your AI
The instructions your AI receives, as published by exelearning/exelearning in .agents/skills/xlf-translate/SKILL.md and read by ahel’s review.
When to Use
When asked to fill in empty translation targets (<target></target>) in the XLF files under translations/. This is the only legitimate reason to write to those files — and only with this skill's procedure. Never run make translations as part of this skill.
Step 0 — Gather parameters
Ask the user two questions before doing anything:
1. From which line?
Do you want to translate all empty targets in the file, or only from a specific line onwards?
Accept:
"all"→ process every empty target regardless of position- A line number (e.g.
15468) → only process empty targets at or after that line
2. Which languages?
Which language files should I fill in? (English is never modified.)
Read the list off disk, never from this document. Locales are added and removed, and a
list written here goes stale silently — the failure is a language quietly skipped on every
run. The set is every translations/messages.<code>.xlf except messages.en.xlf, which
is the source catalogue:
ls translations/messages.*.xlf | sed 's|.*messages\.||; s|\.xlf$||' | grep -v '^en$'
Offer that list to the user, and derive $langs from the same command everywhere below.
Accept any of:
"all"→ every locale the command above prints"all except es"(or any variant) → that list minus the excluded ones- An explicit list:
"es, ca, va"
messages.en.xlfis never touched under any circumstance. It holds the English source strings; there is nothing in it to translate.
Step 1 — Identify targets to translate
For each language file in scope, read the file and collect every <trans-unit> block that:
- Contains
<target></target>(exactly empty — no whitespace inside) - Starts at or after the specified line (if a line constraint was given)
Extract from each block:
- The
<source>text (the English original) - The line number of the
<target>element
Group all unique source texts — the same source may appear in multiple language files.
Step 1.5 — Anchor the terminology before translating
Do not translate from scratch. The catalogue already contains thousands of reviewed strings; a translation that ignores them introduces a synonym where the product had one settled term. Before writing anything, extract the following from each target file and translate against it:
-
Key nouns your strings reuse. Look up the existing
<target>for the terms that appear in your sources — e.g.Password,Icon,Image,File manager,Activity,Activities,Search,Warning,Style,Confirm,Cancel,Import.Run it over every locale in scope, including any that already has targets: a locale with existing translations is exactly the one a from-scratch translation drifts away from.
langs=$(ls translations/messages.*.xlf | sed 's|.*messages\.||; s|\.xlf$||' | grep -v '^en$') for l in $langs; do echo "--- $l ---" for s in "Password" "File manager" "Activities"; do grep -A1 "<source>$s</source>" translations/messages.$l.xlf | grep '<target>' done done -
Register (formal vs informal). Where the language marks it, match what the file already does. Read a full existing sentence with an imperative rather than guessing — e.g. look up
You didn't pass the test. Please try again. A file can be internally inconsistent; match the closest analogue (system errors with system errors, button labels with button labels). -
Regional variant. Check which one the file is written in before choosing vocabulary, by reading its existing targets rather than assuming the standard variant.
pt, for instance, is Brazilian here (Senha,arquivos,Gerenciador,você), not European Portuguese. -
Enumerated scales. When a string belongs to a graded series (text sizes, strength levels, difficulty), dump the whole series first and pick a term that is still free. The LaTeX size scale (
Tiny → Small → Normal → Large → Larger → Very large → Huge → Huger) is the trap: the obvious translation of the last step is usually already taken by an earlier one. Check the scale for pre-existing duplicates too, and report them —messages.gl.xlfhadVery large textandHuge textboth asTexto moi grande. -
Upstream vendored libraries. Some strings originate in a bundled library that ships its own translations, for a subset of the locales —
public/app/common/edicuatex/lang/*.jsis one; list what it actually covers rather than assuming. Prefer the upstream term so the editor and the surrounding UI agree, but verify it does not collide with a term the XLF already uses for a different source; forHuger textthe upstream term collides in several locales and must not be copied into them.
Step 2 — Produce translations
For every unique source text, produce a translation for each target language. Rules:
- Every translation must start with
~— no exceptions, including Spanish. Example: source"Save"→ Spanish"~Guardar", German"~Speichern". The prefix means machine-assisted, not yet reviewed by a human. The reviewer removes it to signal acceptance, so a~left behind on a single string is a deliberate flag that that one still needs a decision. Never remove a~yourself. - Translate faithfully. Keep the same tone, punctuation, and placeholders as the source.
- Ellipsis (
...) stays as-is; do not convert to…. - Do not translate proper nouns:
eXeLearning,iDevice,SCORM,Yjs. This holds in every locale, including the ones that already translate them somewhere — a name rendered as a common noun (iDevice→ "outil pédagogique") ends up next to the untranslated form in the same menu. Check what the majority of the file does before adding another. - Closely related locale pairs are distinct languages, not variants of one another. The
pair in this catalogue is Valencian (
va) and Catalan (ca); do not reuse one for the other. Real markers to apply:esta/estevsaquesta/aquest,acívsaquí,estigavsestigui,afigvsafegeix,coincidixenvscoincideixen, infinitive (Canviar) vs imperative (Canvia), and the typographic apostrophe’invaagainst the straight'that predominates inca. Strings where the two genuinely coincide may be identical — that is not the same as copying one into the other. - Resolve ambiguous UI strings from the source code, not from intuition.
Search iconis a search-field placeholder (blockNode.js:search.placeholder = _('Search icon')), not "icon for searching";Style iconsandGeneral iconsare section headings. Onegrepsettles what a label actually does.
Step 3 — Apply translations (encoding-critical)
Why never a .ps1 script
PowerShell 5.1 (Windows) reads .ps1 script files as ANSI (Windows-1252) by default. Writing a .ps1 file with the Write tool produces UTF-8 without BOM, which PS 5.1 misreads — corrupting every non-ASCII character silently. Never write translations to a .ps1 file and execute it.
The fix: run the substitution as an inline PowerShell command passed directly to the PowerShell tool. Inline commands are received as Unicode strings (UTF-16LE) and are unaffected by the file-encoding issue.
The rule is about scripts PowerShell parses, not about every file. Translation text held in a
JSON data file you decode yourself with an explicit UTF8Encoding is safe, and is the way
to get past the inline length limit — see Data-file route.
Either way, the encoding verification below is not optional.
Substitution template
For each language file, build one inline PowerShell command that:
- Reads the file as UTF-8 without BOM
- Detects the actual line ending (CRLF or LF)
- Replaces each
<source>SRC</source>LE<target></target>with<source>SRC</source>LE<target>~TRANSLATION</target> - Writes back as UTF-8 without BOM
$enc = New-Object System.Text.UTF8Encoding $false
$file = (Resolve-Path "translations/messages.LANG.xlf").Path
$content = [System.IO.File]::ReadAllText($file, $enc)
$le = if ($content.Contains("`r`n")) { "`r`n" } else { "`n" }
# One block per source text:
$old = " <source>SOURCE TEXT</source>$le <target></target>"
$new = " <source>SOURCE TEXT</source>$le <target>~TRANSLATION</target>"
if ($content.Contains($old)) { $content = $content.Replace($old, $new) }
# ... repeat for each source text ...
[System.IO.File]::WriteAllText($file, $content, $enc)
Write-Host "Done"
Key points:
- 8 spaces before
<source>and<target>— match exactly (do not guess indentation; verify from the file). - Use
.Contains()+.Replace()— not regex — for literal source texts. - Use
$levariable so the replacement works on both CRLF and LF files. Detect it, do not assume it.cat -Athrough git-bash strips the CR and will tell you a CRLF file is LF; settle it in PowerShell with$content.Contains("rn"), which is what the substitution actually depends on. - Anchor every replacement on the
<source>line. Never replace a bare<target>VALUE</target>: the same value legitimately appears under several sources, and a bare replace hits all of them. - Always report per-source hit/miss counts (
applied/MISS <source>), and check the total against the number of empty targets you found in Step 1.
Two mechanical traps
Never put a single pair in one command. PowerShell collapses a one-element array, so
@( @('src','tgt') ) becomes the flat two-element array @('src','tgt'), the loop iterates
over the two strings instead of the pair, and $p[0] yields the first character. The
symptom is NO MATCH: A / NO MATCH: ~. Nothing is written (the file is rewritten
unchanged), but the run silently does nothing. Use $pairs = ,@('src','tgt') or batch at
least two pairs.
Long inline commands are rejected. Past roughly 1,800 characters the tool aborts with a
misleading Remove-Item on system path '/' is blocked even though the command contains no
Remove-Item. It is the command length, not its content — the same pairs split in half both
succeed. Keep inline batches to about 4–5 short strings, or use the data-file route below.
Data-file route (for a full run across many languages)
A full run — every empty target across every locale in scope — does not fit in inline
batches. Write the translations to a JSON data file shaped
{ "<lang>": { "<source string>": "<target>" } }, then point $json at it and apply the
whole run with one command:
$enc = New-Object System.Text.UTF8Encoding $false
$json = (Resolve-Path "translations.json").Path
$data = [System.IO.File]::ReadAllText($json, $enc) | ConvertFrom-Json
$base = (Join-Path (Resolve-Path "translations").Path "messages.{0}.xlf")
foreach ($lang in $data.PSObject.Properties.Name) {
$file = $base -f $lang
$content = [System.IO.File]::ReadAllText($file, $enc)
$le = if ($content.Contains("`r`n")) { "`r`n" } else { "`n" }
$applied = 0
foreach ($prop in $data.$lang.PSObject.Properties) {
$old = " <source>" + $prop.Name + "</source>" + $le + " <target></target>"
$new = " <source>" + $prop.Name + "</source>" + $le + " <target>" + $prop.Value + "</target>"
if ($content.Contains($old)) { $content = $content.Replace($old, $new); $applied++ }
else { Write-Host (" MISS [$lang] " + $prop.Name) }
}
[System.IO.File]::WriteAllText($file, $content, $enc)
Write-Host ("{0}: applied={1}" -f $lang, $applied)
}
This does not reopen the .ps1 encoding hole. That ban exists because PowerShell 5.1
parses script files as ANSI; here the JSON is data whose decoding you control explicitly
with UTF8Encoding. Write the JSON with the Write tool (UTF-8, no BOM), keep it out of the
repository (use the scratchpad directory), and run the encoding verification below regardless.
Encoding verification
After writing, verify at least one file per run by reading back the actual bytes of a translated target containing non-ASCII characters:
$enc = New-Object System.Text.UTF8Encoding $false
$content = [System.IO.File]::ReadAllText($file, $enc)
$sample = ($content -split '\r?\n' | Where-Object { $_ -match '<target>~' } | Select-Object -Last 1).Trim()
Write-Host $sample
If the output shows garbled characters (e.g., Ä instead of Ä), the encoding is wrong — stop, revert using the revert pattern below, and diagnose before continuing.
Revert pattern (if encoding goes wrong)
$enc = New-Object System.Text.UTF8Encoding $false
$file = (Resolve-Path "translations/messages.LANG.xlf").Path
$content = [System.IO.File]::ReadAllText($file, $enc)
$le = if ($content.Contains("`r`n")) { "`r`n" } else { "`n" }
# For each source text, replace back to empty:
$escaped = [regex]::Escape("SOURCE TEXT")
$pattern = " <source>$escaped</source>\r?\n <target>~[^<]*</target>"
$replacement = " <source>SOURCE TEXT</source>" + $le + " <target></target>"
$content = [regex]::Replace($content, $pattern, $replacement)
[System.IO.File]::WriteAllText($file, $content, $enc)
Step 4 — Verify
After applying all translations:
# Count remaining empty targets in every locale file except the English source
Get-ChildItem translations\messages.*.xlf |
Where-Object { $_.Name -ne 'messages.en.xlf' } |
ForEach-Object {
$count = (Select-String -Path $_.FullName -Pattern '<target></target>' -SimpleMatch).Count
Write-Host ("{0}: {1} empty" -f $_.Name, $count)
}
Expected: 0 for every language in scope. If any remain, they were at a line before the specified start line (expected) or the pattern did not match (investigate).
Constraints (non-negotiable)
- Only empty
<target></target>are modified — never touch non-empty targets, source texts, IDs, resnames, indentation, line endings, or any other attribute. The single exception is a pre-existing defect you surfaced in Step 1.5 — two sources in one graded scale sharing a translation, say — and even then only after the user has explicitly asked for it. Report such a defect; do not fix it on your own initiative. When authorised, the corrected target gets a~like any other unreviewed string. ~prefix is mandatory on every translation, including Spanish.messages.en.xlfis never opened or written.make translationsis never run as part of this skill.- No
.ps1files — inline PowerShell only (see encoding section above). - The diff must show only
<target>~...</target>lines changing; everything else stays byte-for-byte identical.
Signals
- GitHub stars
- 142
- Forks
- 32
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
xlf-translate- Source
- github.com/exelearning/exelearning