File formats

An ARB file is JSON that can explain itself

Keys and values, plus an @ entry per key for everything a value cannot carry: what the string is for, what its arguments are, how much room it has. What each attribute means, which ones anything actually reads, and why the round trip is where a file loses them.

Published Updated

Three kinds of entry

An ARB file — Application Resource Bundle, the format Flutter's locale files use — is a single flat JSON object, and every key in it is one of three things. A resource key holds the string itself. Its @-prefixed twin holds everything about that string which is not the string. Keys starting with @@ describe the document rather than any one entry.

lib/l10n/app_en.arb
{
  "@@locale": "en",
  "@@last_modified": "2026-08-18T09:24:00.000Z",

  "greeting": "Welcome back, {name}",
  "@greeting": {
    "description": "Shown once, above the dashboard.",
    "placeholders": {
      "name": { "type": "String", "example": "Mira" }
    }
  },

  "cart.empty": "Your cart is empty"
}

Nothing about that needs a parser: anything that can read JSON can read an ARB file, and the metadata simply looks like extra keys. Which is the format's real advantage and the source of most of its trouble — a tool that does not know what @greeting is will read it as a string called @greeting, or drop it, and either way nothing errors.

One structural consequence worth knowing before you convert anything: the twin is found by name, so resources have to sit at the top level. A key written as "checkout.pay.total" is fine — that is one key with dots in it — but the nested object form of the same file has no leaf an @ entry can address.

What an @ entry may say

The attributes below are the ones the format defines. They are worth reading as two groups: the two that something actually consumes, and the rest, which are carried more often than they are read.

AttributeWhat it holdsWho reads it
descriptionProse about the string: where it appears, which sense of a word is meant.Whoever translates it, and most tools that show a string to a person.
placeholdersOne entry per argument, with its type, an example and a number or date format.Flutter's code generation, which turns each into a typed parameter.
typeWhat kind of resource this is. In practice always text.Tooling that predates the format carrying only strings.
contextA dotted path naming where in the app the resource is used.Rarely anything. A convention waiting for a consumer.
source_textThe original this value was translated from.Anything checking whether a translation is still answering the current source.
screenshotA picture of the string in place — a path, or base64 image data.Translator-facing tools. The base64 form is why annotated files get large.
x-…Anything the format has no slot for, under a name that says so.Whoever wrote it. The convention for extending the format honestly.

placeholders is the one with teeth. Flutter's own code generation reads it and turns each argument into a typed parameter, so a num declared with a compact format arrives formatted rather than printed — and a locale whose translation dropped the argument fails at build time instead of at runtime.

one annotated plural
"cart.itemCount": "{count, plural, one {# item} other {# items}}",
"@cart.itemCount": {
  "description": "Line above the total.",
  "placeholders": {
    "count": { "type": "num", "format": "compact" }
  }
}

For anything the format has no slot for, the convention is a name prefixed x-. It is not in the specification's list and that is the point: a reader seeing x-maxLength knows immediately that it is somebody's extension rather than a field they have failed to implement. Inventing a meaning for context instead would look official and do nothing.

Where the annotations go

Writing the annotations is the easy half. They are lost on the way back, and almost always in one of three ways — worth knowing by shape, because two of the three leave a file that still looks fine.

  1. Everything is dropped. A tool that reads key→value pairs hands back key→value pairs. The diff is honest about it — every @ entry shows as a deleted line — and nobody reads a locale-file diff line by line, so it lands.
  2. Only the understood part survives. The file comes back with its descriptions and without its placeholders. This is worse than the first case: the file still looks annotated, so the loss is only noticed when a build that depended on the argument types breaks.
  3. The file is rewritten on every pull. A @@last_modified stamped at export time changes whether or not the strings did, so the locale files are permanently dirty and their diff stops carrying information. The one that says something — a value changed — is now indistinguishable from the noise.

Which turns into three things to require of anything in the loop, and they are cheap to check with one pull and one push on a throwaway branch:

  • Unknown attributes come back verbatim. Not dropped, not normalised — the same keys with the same values, so your own tooling can share the file.
  • Metadata merges per attribute rather than replacing the set. Otherwise a file that happens to omit one attribute deletes it, which is the first failure mode arriving through the back door.
  • Timestamps come from the data. Pull twice with nothing changed in between and the two files should be byte-identical.

And decide once which copy is authoritative for the annotations: the file in the repository or the tool. Both is not an option worth having — whichever one is not reviewed will quietly win an argument nobody knew was happening.

Ship it, or exchange it

Flutter does not load an ARB file at runtime. gen-l10n reads it at build time and emits Dart, so the descriptions and the placeholder declarations cost the shipped app nothing at all. That is the arrangement the format was designed for, and it is worth copying rather than assuming.

A web app that fetches its locale file pays for every byte it contains, and annotations are not small: a description is routinely longer than the string it describes, and the format permits a screenshot attribute to carry an embedded image. An annotated file can be several times the size of the one your app needs.

So the arrangement that holds up: the runtime file stays plain JSON, exactly where your app loads it from, and the ARB copy exists for the trip between the repository and whoever translates. Two files, one of them generated, and neither pretending to be the other.

Where Mergua fits

ARB is one of the three formats Mergua reads and writes, and the only one that carries more than keys and values. Two attributes have a column of their own; everything else in your @ entries is kept as it arrived.

In the fileOn the key
@key.descriptionThe key's context note, editable in the app and written back out on the next download.
@key.x-maxLengthThe room the string has. Advisory: the editor shows a translation that runs over as over, and still saves it.

A length has no standard slot, which is why it travels under an x- name — and why three spellings are accepted on the way in: x-maxLength, x-max-length and a bare maxLength. The exported file always uses the first. Both fields belong to the key rather than to a language, and what a translator does with them is a subject of its own.

Attributes nobody here understands are kept anyway

Your placeholders, a screenshot path, an x- name from a script of your own: stored against the key and written back into the next download, unchanged. The one limit is 4 KB of attributes per key, which clears any realistic placeholders object and excludes an embedded image — and a key over it is named in the import preview rather than quietly trimmed.

They are shown in the editor as they are stored, read-only, under the key's note. Read-only because the file is the way to correct one: an import merges per attribute and never deletes, so an attribute of the same name from your file wins, and one your file no longer mentions stays. Discarding the whole set in the editor is the only way attributes leave a key again.

What the export writes, and what it refuses

A downloaded ARB file carries @@locale, the two mapped attributes, your preserved ones, and two values it derives rather than stores. source_text is resolved per export from the project's source locale, so it cannot hand back an original that has since moved on — bring it back on an import and the preview names the keys whose source has changed since the translation was made. type: "text" is filled in for tooling that expects it, never written over a type that came from your file, and never stored: it is a value the export derives, so keeping it would leave a pull–edit–push cycle one push short of a fixed point.

@@last_modified is stamped from the data rather than from the clock, so two pulls with nothing changed in between produce the same bytes — the third failure mode above, closed. And metadata that is malformed is refused with the attribute named — "@cart.total.x-maxLength" must be a positive whole number — rather than being ignored into a value you would find out about later.

Keeping both files without maintaining both

Which leaves the arrangement the section above argues for, as two flags. A pull writes the annotated set beside the runtime JSON instead of replacing it; a push sends that same directory back.

.gitlab-ci.yml, the i18n job
# pull: runtime JSON where the app loads it,
#       plus an annotated copy beside it
curl -sf https://api.mergua.com/v1/mergua.sh \
  | sh -s -- pull --path src/assets/i18n \
                  --arb-path src/assets/i18n-arb

# push: the annotated copy is what goes back
curl -sf https://api.mergua.com/v1/mergua.sh \
  | sh -s -- push --arb \
                  --key-manifest src/assets/i18n-arb

The flag on the push is the whole opt-in: without --arb an ARB document is refused rather than read, which is what stops a stray file in your locale directory from being pushed as though its @ keys were strings. The CLI reference has both flags and the CI jobs around them, and the import and export manual covers the same round trip through the app's own dialogs.

Keep reading

Try it on one branch.

Upload the locale files you already have and see the whole loop — branch, translate, review, sync — on the free tier.

Create your account