Todos los ejemplos de abajo se probaron con Flutter 3.47.5 y flutter gen-l10n, con una plantilla app_en.arb y una traducción app_fr.arb (francés).
¿Tienes prisa? Genera el bloque de plural de cada idioma directamente en el navegador: gratis, sin registro, no se sube nada.
Lo que gen-l10n sí bloquea
La sintaxis ICU se verifica en todos los archivos, no solo en la plantilla. Un plural sin rama other detiene la generación, con el nombre del archivo y la posición del error:
[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
{count, plural, one{{count} article}}
^
Lo mismo ocurre con una llave sin cerrar. Esos errores aparecen enseguida; los de las secciones siguientes pasan sin aviso.
Un placeholder perdido pasa sin decir nada
La plantilla en inglés declara un parámetro:
"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }
La traducción al francés lo perdió: "welcomeUser": "Bon retour parmi nous !". La generación funciona, el analizador no dice nada y el método generado simplemente ignora su argumento:
@override
String welcomeUser(String name) {
return 'Bon retour parmi nous !';
}
Nada falla, pero los usuarios francófonos nunca ven su nombre. A veces es intencionado (la frase suena mejor sin él), pero normalmente es un error, y ninguna herramienta estándar distingue un caso del otro. Tienes que comparar los placeholders de cada traducción con los de la plantilla; más abajo hay un script para eso.
Un placeholder renombrado cambia la API de todas las configuraciones regionales
Aquí la traducción usa un nombre que la plantilla no tiene, por ejemplo {nombre} en lugar de {count}:
"reminder": "Il reste {nombre} articles dans {listName}."
gen-l10n sigue sin avisar. Añade nombre como parámetro adicional en la clase abstracta, es decir, en todas las configuraciones regionales:
String reminder(String listName, int count, Object nombre);
El error aparece más tarde, cuando compila cada llamada existente: Too few positional arguments: 3 required, 2 given. Apunta a tu código Dart, no al archivo ARB que lo causó. Si a un método de localización le aparece de repente un parámetro Object, busca ese nombre en tus traducciones.
En francés, one también cubre el cero
Las categorías one, few, many… siguen las reglas CLDR de cada idioma. En inglés (y en español), one solo significa 1. En francés significa 0 y 1. Con este mensaje:
"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
| count | Inglés | Francés |
|---|---|---|
| 0 | 0 items | 0 article |
| 1 | 1 item | 1 article |
| 2 | 2 items | 2 articles |
«0 article» es francés correcto, así que ahí no hay problema. La trampa está en otro sitio: gen-l10n convierte =0 y =1 en los argumentos zero y one de Intl.pluralLogic. Por eso una rama =1 se convierte en la categoría one, y en francés esa categoría también se aplica al cero:
"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"
Con count = 0, el inglés muestra «0 items left» y el francés muestra «1 article restant» (queda un artículo). El texto es incorrecto y nada lo señala. La solución: añade una rama =0, o escribe one{{count} article restant} para que el número mostrado sea siempre el correcto. Cualquier idioma en el que one cubra más que el 1 tiene el mismo problema, como el portugués de Brasil; en español, one solo cubre el 1.
Apóstrofos y use-escaping
Por defecto, un apóstrofo recto es solo un carácter: "Don't forget the milk" funciona. Si activas use-escaping: true en l10n.yaml (para escribir llaves literales), se convierte en el carácter de escape de ICU y un apóstrofo suelto detiene la generación:
[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
Don't forget the milk
^
Entonces tienes que duplicarlo (Don''t) o usar el apóstrofo tipográfico ’, que no se ve afectado. En un proyecto existente, activar esa opción obliga a revisar todos los idiomas; las contracciones del inglés y las elisiones del francés te darán trabajo.
Una verificación que vale la pena añadir a la CI
El caso silencioso es el placeholder perdido. Este script lee los placeholders declarados en la plantilla y comprueba que cada traducción los siga usando:
import 'dart:convert';
import 'dart:io';
void main() {
final template = _read('lib/l10n/app_en.arb');
var errors = 0;
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb') || file.path.endsWith('app_en.arb')) continue;
final translation = _read(file.path);
for (final key in template.keys.where((k) => !k.startsWith('@'))) {
final text = translation[key];
if (text is! String) continue; // missing key: see untranslated-messages-file
final meta = template['@$key'] as Map<String, dynamic>?;
final declared = (meta?['placeholders'] as Map<String, dynamic>?)?.keys ?? const <String>[];
for (final name in declared) {
if (!RegExp(r'\{\s*' + name + r'\s*[,}]').hasMatch(text)) {
stderr.writeln('${file.path} · $key: {$name} missing');
errors++;
}
}
}
}
if (errors > 0) exit(1);
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
Con el ejemplo de esta página, imprime lib/l10n/app_fr.arb · welcomeUser: {name} missing y termina con un error. Sus límites: solo mira los placeholders declarados en @key.placeholders y, en un plural, comprueba que la variable aparezca en algún lugar del mensaje, no en cada rama. Las dos trampas de plurales anteriores se detectan mejor con una prueba que llame a tus mensajes con 0, 1 y 2.