Liczba mnoga ICU i placeholdery: co przepuszcza gen-l10n

Zaktualizowano · LocalePilot

flutter gen-l10n odrzuca błędny komunikat ICU, ale po cichu akceptuje tłumaczenie, które zgubiło placeholder, albo liczbę mnogą, która dla zera powie „1 element”. Oto co sprawdza, czego nie sprawdza, i skrypt, który domyka tę lukę.

Każdy przykład poniżej został przetestowany na Flutter 3.47.5 i flutter gen-l10n, z szablonem app_en.arb i tłumaczeniem app_fr.arb.

Spieszysz się? Wygeneruj blok liczby mnogiej dla każdego języka od razu w przeglądarce: za darmo, bez rejestracji, nic nie jest wysyłane.

Co gen-l10n faktycznie blokuje

Składnia ICU jest sprawdzana w każdym pliku, nie tylko w szablonie. Liczba mnoga bez gałęzi other przerywa generowanie, z nazwą pliku i pozycją błędu:

[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
    {count, plural, one{{count} article}}
                    ^

Tak samo przy niezamkniętym nawiasie klamrowym. Takie błędy widać od razu, w przeciwieństwie do opisanych niżej.

Zgubiony placeholder przechodzi bez słowa

Angielski szablon deklaruje parametr:

"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }

Francuskie tłumaczenie go zgubiło: "welcomeUser": "Bon retour parmi nous !". Generowanie się udaje, analizator milczy, a wygenerowana metoda po prostu ignoruje swój argument:

@override
String welcomeUser(String name) {
  return 'Bon retour parmi nous !';
}

Aplikacja działa, tylko użytkownicy francuskojęzyczni nigdy nie zobaczą swojego imienia. Czasem to celowe (zdanie brzmi lepiej bez niego), zwykle to błąd, a żadne standardowe narzędzie nie odróżni jednego od drugiego. Trzeba porównać placeholdery każdego tłumaczenia z placeholderami szablonu; skrypt do tego znajdziesz niżej.

Przemianowany placeholder zmienia API wszystkich lokalizacji

Ten przypadek jest dziwniejszy. Tłumaczenie używa nazwy, której szablon nie ma, na przykład {nombre} zamiast {count}:

"reminder": "Il reste {nombre} articles dans {listName}."

gen-l10n nadal niczego nie zgłasza. Dodaje nombre jako dodatkowy parametr w klasie abstrakcyjnej, czyli we wszystkich lokalizacjach:

String reminder(String listName, int count, Object nombre);

Błąd pojawia się później, przy kompilacji każdego istniejącego wywołania: Too few positional arguments: 3 required, 2 given. Wskazuje na kod w Dart, a nie na plik ARB, który go spowodował. Jeśli metoda lokalizacji nagle dostaje parametr Object, poszukaj tej nazwy w tłumaczeniach.

Po francusku one obejmuje też zero

Kategorie one, few, many… wynikają z reguł CLDR każdego języka. Po angielsku one oznacza tylko 1. Po francusku oznacza 0 i 1. Po polsku one to tylko 1, ale potrzebujesz też few (2–4, 22–24…) i many (0, 5–21…). Z takim komunikatem:

"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
countAngielskiFrancuski
00 items0 article
11 item1 article
22 items2 articles

„0 article” to poprawny francuski, więc tu nie ma problemu. Pułapka jest gdzie indziej: gen-l10n zamienia =0 i =1 na argumenty zero i one funkcji Intl.pluralLogic. Gałąź =1 staje się więc kategorią one, a po francusku ta kategoria dotyczy też zera:

"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"

Przy count = 0 angielski pokazuje „0 items left”, a francuski „1 article restant” (został jeden element). Tekst jest błędny, a nic tego nie zgłasza. Rozwiązanie: dodaj gałąź =0 albo napisz one{{count} article restant}, żeby pokazywana liczba zawsze była prawidłowa. Ten sam problem ma każdy język, w którym one obejmuje więcej niż 1.

Apostrofy i use-escaping

Domyślnie prosty apostrof to zwykły znak: "Don't forget the milk" działa. Jeśli włączysz use-escaping: true w l10n.yaml (żeby pisać dosłowne nawiasy klamrowe), staje się on znakiem escape ICU, a samotny apostrof przerywa generowanie:

[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
    Don't forget the milk
       ^

Trzeba go wtedy podwoić (Don''t) albo użyć typograficznego apostrofu ’, którego to nie dotyczy. W istniejącym projekcie przełączenie tej opcji oznacza przejrzenie wszystkich języków; angielskie skróty i francuskie elizje przysporzą sporo pracy.

Kontrola, którą warto dodać do CI

Bez ostrzeżenia przechodzi zgubiony placeholder. Ten skrypt odczytuje placeholdery zadeklarowane w szablonie i sprawdza, czy każde tłumaczenie nadal ich używa:

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>;

Na przykładzie z tej strony wypisuje lib/l10n/app_fr.arb · welcomeUser: {name} missing i kończy się błędem. Jego ograniczenia: patrzy tylko na placeholdery zadeklarowane w @key.placeholders, a przy liczbie mnogiej sprawdza, czy zmienna występuje gdzieś w komunikacie, nie w każdej gałęzi. Obie pułapki liczby mnogiej opisane wyżej najlepiej wyłapać testem, który wywołuje komunikaty z wartościami 0, 1 i 2 (a dla polskiego także 5 i 22).

Najczęściej zadawane pytania

Czy flutter gen-l10n sprawdza tłumaczenia, czy tylko szablon?

Sprawdza składnię ICU każdego pliku ARB. Nie porównuje placeholderów tłumaczenia z placeholderami szablonu: zgubiony placeholder przechodzi bez ostrzeżenia.

Dlaczego moja metoda lokalizacji ma dodatkowy parametr Object?

Tłumaczenie używa nazwy placeholdera, której nie ma w szablonie, na przykład {liczba} zamiast {count}. gen-l10n dodaje ją jako parametr we wszystkich lokalizacjach, a istniejące wywołania przestają się kompilować.

=1 czy one w liczbie mnogiej?

W ICU =1 dotyczy dokładnie wartości 1, ale gen-l10n przekazuje je jako kategorię one, która po francusku obejmuje też 0. Dodaj gałąź =0 albo pokaż {count} w gałęzi one.