Flutter · gen-l10n
The ARB files your app compiles, reviewed on a branch before they ship.
Flutter's own localization tooling reads ARB, and so does Mergua — including the @key descriptions and placeholders that make an ARB file more than a list of strings. lib/l10n stays where it is, gen-l10n keeps generating the same AppLocalizations class, and every language gains a branch, a review step and a pipeline that fills it in.
Flutter + gen-l10n
/for/flutter
locale files
lib/l10n
file format
arb
The files you already have
Mergua reads what is in your repo. Nothing is renamed, nothing moves, and no package is added to your project — the sync script is fetched with curl when the pipeline runs.
lib/l10n/
├── en.arb ← source locale, the template
├── de.arb
└── fr.arb{
"@@locale": "en",
"cartTitle": "Your cart",
"@cartTitle": {
"description": "Heading of the cart page"
},
"cartItems": "{count, plural, =0{Your cart is empty} =1{1 item} other{{count} items}}",
"@cartItems": {
"description": "Item count under the heading",
"placeholders": {
"count": { "type": "int" }
}
}
}How gen-l10n reads them
This is gen-l10n's own setup, not Mergua's — if you already have it, skip the panel. It is here so the page is a working path from empty project to translated screen rather than a page about a product.
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
class CartSummary extends StatelessWidget {
const CartSummary({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Text(l10n.cartTitle),
Text(l10n.cartItems(count)),
],
);
}
}l10n.yaml
arb-dir: lib/l10n
template-arb-file: en.arb
output-localization-file: app_localizations.dartpubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
flutter:
generate: truelib/main.dart
import 'package:flutter/material.dart';
import 'cart/cart_summary.dart';
import 'l10n/app_localizations.dart';
void main() => runApp(const ShopApp());
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const CartSummary(count: 3),
);
}
}flutter pub add flutter_localizations --sdk=flutter
flutter pub add intl:any
flutter gen-l10nBack into the build
One job, in a stage before the build runs flutter gen-l10n. Both flags are needed: --format arb pulls every language back as its own .arb file beside the template, and --arb pushes the template itself, so a description written in @key reaches the translator. The files are committed to the branch the job is running on, and gen-l10n compiles them on the next build.
The job runs on the branch it is already on, so a feature branch syncs against the matching Mergua branch and main against main. That is the part no other translation tool does.
And you do not have to run it on a timer: a project can POST to a URL of yours when translations change or a branch is merged, so the job above starts on the change. The delivery is signed, it is filtered to the branch you name, and an import of four hundred keys arrives as one call.
# --format arb: the pull writes lib/l10n/de.arb beside en.arb.
# --arb: the push reads en.arb, @key descriptions included.
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --format arb --path lib/l10n --arb.gitlab-ci.yml
mergua-sync:
stage: .pre
image: alpine:latest
before_script:
- apk add --no-cache curl jq git
script:
- curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --format arb --path lib/l10n --arb# If you would rather own the commit yourself, pull and
# commit in your own step:
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- pull --format arb --path lib/l10nPlaceholders
gen-l10n writes a variable as {count}. Mergua treats it as part of the string: if a translation comes back without it, the validator says so on the way out, and the AI translator is told to carry it through.
"cartItems": "{count, plural, =0{Your cart is empty} =1{1 item} other{{count} items}}"l10n.cartItems(3)Between the push and the pull
The job above is both ends of one loop, and what happens in between is people. A translation gets written, somebody else approves it, and sometimes a whole language leaves for an agency and comes back — none of which touches a file in your repo until the next pull.
Reviewed before it is compiled in
gen-l10n turns every ARB file into Dart at build time, so a wrong string ships inside the binary and stays there until the next store release — which makes the review the last place anyone reads it. A reviewer approves or rejects one key at a time, with the description from its @key on the key itself, a review request mails everyone who could answer it, and a branch does not merge while anything on it is unreviewed.
How a key gets from draft to reviewedThe agency gets XLIFF, lib/l10n keeps ARB
A translation agency working in a CAT tool gets a language as one XLIFF 2.0 document and sends it back into the same keys. The document never reaches lib/l10n: gen-l10n compiles ARB, and the sync script does not carry XLIFF at all, so what the next build reads is still the ARB file Mergua wrote.
What travels in the document and what does notWorth knowing on Flutter
Keys are Dart method names
gen-l10n turns every key into a getter or a method, so cartTitle works and cart.title stops the build with "Invalid ARB resource name". Mergua accepts either, so a project that started on nested JSON with dotted keys has to rename them before Flutter can read them. Write camelCase from the first key.
The @key object makes the round trip
An @key object is metadata, and Mergua keeps it as metadata: description becomes the key's description in the editor, x-maxLength its character limit, and placeholders with their types are kept as they are. Every pulled language carries the same @key objects back, and gen-l10n reads what it knows and ignores the rest.
What an ARB file carriesPlurals are checked by their placeholders
{count, plural, …} is ICU, and the placeholder check reads its structure. A translation that drops {count} from every case is flagged on the way out; one that adds few and many for Polish is not, because the categories a language needs are its own. The AI translator is told to keep the placeholders too.
How the placeholder check reads ICUOne file per language
gen-l10n compiles one ARB file per language into one class, so keep the Mergua project to one file per language as well. Splitting a language means a folder of files per language, and gen-l10n reads one flat folder of ARB files.
A different stack?
Mergua writes plain locale files, so anything that loads one works. These stacks have a page of their own as well.
Run it on one branch.
Upload the lib/l10n/en.arb you already have and see the whole loop on the free tier.
Create your account