When I first added localization-file conversion, the job looked almost embarrassingly small: parse a file, copy the strings into a map, serialize that map in another format.
Then I tried it on real Flutter projects.
The converted files parsed correctly, which felt like success, but some of them had quietly lost translator context. Others kept the text and damaged a placeholder. ICU plurals were the most misleading case: a message could be perfectly valid and still be wrong for the target language.
These are the five fixtures I now reach for before I trust a converter.
1. @key is not another string
An ARB message can have a companion metadata entry:
{
"welcome": "Welcome, {name}!",
"@welcome": {
"description": "Greeting shown after sign-in",
"placeholders": {
"name": {
"type": "String"
}
}
}
}
welcome is the text. @welcome tells Flutter and the translator what that text means.
My first parser skipped every key beginning with @. That was convenient because metadata should not appear in the translation list. It also meant the conversion was not lossless. A round trip restored the greeting but forgot its description and the type of name.
The model I use internally now looks more like this:
id: "welcome"
value: "Welcome, {name}!"
description: "Greeting shown after sign-in"
placeholders: ["name"]
That does not magically solve export. CSV, for example, may have nowhere to put a placeholder type. The important part is knowing what was lost. Depending on the target format, I can keep metadata in comments, write a sidecar file, or show a warning.
I also test @missing without a matching missing message. Orphan metadata looks odd, but it turns up in repositories after keys are renamed or deleted.
2. A valid plural can still be wrong for the locale
This is enough for most English cardinal plurals:
{count, plural,
one {# file}
other {# files}
}
Using the same branches for Russian loses information:
{count, plural,
one {# файл}
few {# файла}
many {# файлов}
other {# файла}
}
English normally needs one and other. Russian uses one, few, many, plus the required other fallback. Arabic may use all six named categories: zero, one, two, few, many, and other.
So I treat validation as two separate questions:
- Does the ICU message parse?
- Does it cover the categories used by this locale?
Checking only the first question catches unbalanced braces and a missing other, but it will not tell you that the Russian few branch is absent.
Exact selectors need to survive as well:
{count, plural,
=0 {No files}
one {One file}
other {# files}
}
=0 is an exact-number override. It is not the same thing as the locale category zero, and a converter should not merge the two.
Then there is nesting:
{gender, select,
female {{count, plural, one {She has one task} other {She has # tasks}}}
male {{count, plural, one {He has one task} other {He has # tasks}}}
other {{count, plural, one {They have one task} other {They have # tasks}}}
}
This was the point where I stopped trying to count braces with regular expressions.
3. One value has to satisfy both JSON and ICU
ARB is JSON, but its string values may contain another language: ICU MessageFormat. The converter has to get through both layers without "helping" too much.
{
"receipt": "Line 1\nLine 2: \"{total}\"",
"path": "C:\\Exports\\{locale}\\messages.json",
"emoji": "Saved ✓ 🚀"
}
After JSON parsing, receipt contains a real newline. path contains backslashes and an ICU-style placeholder. The last value is a quick check that the pipeline is genuinely Unicode-safe.
The bugs here are small and annoying:
-
\nbecomes the visible characters\andn; - an escaped backslash is escaped a second time;
- UTF-8 is saved with an unexpected byte-order mark;
- ICU apostrophe quoting changes in one direction of the conversion;
- Unicode normalization changes a value that looks identical on screen.
I compare decoded values, not their JSON spelling. These two strings represent the same character:
"\u2713"
"✓"
Byte equality would fail that test for no useful reason. On the other hand, a changed non-breaking space can look fine in a diff and still break the UI.
4. Not every brace is a placeholder
Localization projects rarely agree on one placeholder dialect:
Hello, {name}
Downloaded %s of %d files
You have :count messages
Welcome, {{user}}
Price: %(amount)s
Unless the user explicitly asks for a syntax migration, I leave those tokens alone. I also compare them as a multiset rather than a set:
input: ["{name}", "{count}", "{count}"]
output: ["{name}", "{count}", "{count}"]
The repeated {count} matters.
The opposite problem is detecting placeholders where there are none:
CSS: .button { color: red; }
Discount: 20%
Object: {"id": 42}
A single broad regular expression will eventually eat ordinary text. Format-aware parsing is better. If that is not possible, I would rather ask which placeholder dialect a file uses than silently guess.
ARB gives us one extra consistency check. Tokens in the message should agree with the placeholders object in @key. If the text contains {name} but the metadata only declares count, that deserves a warning.
5. "It parses" is a very low bar for a round trip
Consider this ARB file:
{
"@@locale": "en",
"save": "Save",
"@save": {
"description": "Button label, not a noun"
}
}
A simple CSV export might be:
key,value
save,Save
The CSV is valid. Turning it back into this ARB is also valid:
{
"save": "Save"
}
But the locale and the translator's context have disappeared.
For a round-trip test, I compare message IDs, decoded values, placeholder counts, ICU selectors, metadata, and locale information. I do not require the same key order, whitespace, or Unicode escape style.
When the target format cannot represent something, I want the result described plainly:
lossless
lossy (with a reason)
unsupported
Returning a green checkmark merely because the output parses is how quiet data loss reaches a production translation file.
The small fixture I keep around
This one file covers most of the cases above:
{
"@@locale": "en",
"welcome": "Welcome, {name}!\nYou have {count, plural, =0 {no tasks} one {# task} other {# tasks}}.",
"@welcome": {
"description": "Dashboard greeting",
"placeholders": {
"name": {"type": "String"},
"count": {"type": "int"}
}
},
"exportPath": "C:\\Exports\\{name}\\✓"
}
For each target format, I verify what it can preserve and expect an explicit limitation for everything else. Then I convert it back and compare meaning rather than bytes.
I put two tools I use while chasing these cases online: an ARB-to-JSON converter and an ICU plural validator. They work without signup.
One honest caveat: the converter currently extracts translatable keys and values but does not carry ARB @key metadata into flat target formats. Treat that conversion as lossy.
Disclosure: both tools are part of Localization.One, a product I am building. If you have a file that breaks either tool, a minimal reproducible example would be genuinely useful.
Top comments (0)