아래 예시는 모두 Flutter 3.47.5와 flutter gen-l10n으로, app_en.arb 템플릿과 app_fr.arb 프랑스어 번역을 사용해 검증했습니다.
브라우저에서 바로 언어별 복수형 블록을 생성해 보세요. 무료이고 가입이 필요 없으며, 아무것도 업로드되지 않습니다.
gen-l10n이 확실히 막는 것
ICU 구문은 템플릿뿐 아니라 모든 파일에서 검사됩니다. other 분기가 없는 복수형은 파일 이름과 오류 위치를 알려 주며 생성을 멈춥니다.
[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
{count, plural, one{{count} article}}
^
닫히지 않은 중괄호도 마찬가지로 바로 드러납니다. 까다로운 것은 다음 경우들입니다.
빠진 플레이스홀더는 아무 말 없이 통과한다
영어 템플릿은 매개변수를 선언합니다.
"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }
프랑스어 번역에서는 그것이 사라졌습니다: "welcomeUser": "Bon retour parmi nous !". 생성은 성공하고, 분석기도 조용하며, 생성된 메서드는 인수를 그냥 무시합니다.
@override
String welcomeUser(String name) {
return 'Bon retour parmi nous !';
}
크래시는 없습니다. 프랑스어 사용자에게 이름이 보이지 않을 뿐입니다. 의도한 경우(이름이 없는 편이 문장이 자연스러울 때)도 있지만 대개는 실수이며, 표준 도구로는 둘을 구분할 수 없습니다. 각 번역의 플레이스홀더를 템플릿과 비교해야 하며, 이를 위한 스크립트를 아래에서 소개합니다.
이름이 바뀐 플레이스홀더는 모든 로케일의 API를 바꾼다
이 경우는 더 헷갈립니다. 번역이 템플릿에 없는 이름, 예를 들어 {count} 대신 {nombre}를 쓴다고 해 봅시다.
"reminder": "Il reste {nombre} articles dans {listName}."
gen-l10n은 여전히 아무것도 보고하지 않습니다. nombre를 추가 매개변수로 추상 클래스에 넣으므로, 모든 로케일이 영향을 받습니다.
String reminder(String listName, int count, Object nombre);
오류는 나중에, 기존 호출 코드를 컴파일할 때 나타납니다: Too few positional arguments: 3 required, 2 given. 이 오류는 원인이 된 ARB 파일이 아니라 Dart 코드를 가리킵니다. 현지화 메서드에 갑자기 Object 매개변수가 생겼다면 번역에서 그 이름을 찾아보세요.
프랑스어에서는 one이 0도 포함한다
one, few, many 등의 카테고리는 언어별 CLDR 규칙을 따릅니다. 영어에서 one은 1만 뜻하지만, 프랑스어에서는 0과 1을 뜻합니다(한국어는 other 하나뿐입니다). 다음 메시지의 경우:
"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
| count | 영어 | 프랑스어 |
|---|---|---|
| 0 | 0 items | 0 article |
| 1 | 1 item | 1 article |
| 2 | 2 items | 2 articles |
"0 article"은 올바른 프랑스어이므로 여기에는 문제가 없습니다. 함정은 다른 곳에 있습니다. gen-l10n은 =0과 =1을 Intl.pluralLogic의 zero와 one 인수로 바꿉니다. 따라서 =1 분기는 one 카테고리가 되고, 프랑스어에서는 이 카테고리가 0에도 적용됩니다.
"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"
count = 0일 때 영어는 "0 items left"를 표시하지만 프랑스어는 "1 article restant"(1개 남음)를 표시합니다. 텍스트가 틀렸지만 어떤 경고도 나오지 않습니다. 해결책은 =0 분기를 추가하거나, one{{count} article restant}로 써서 표시되는 숫자가 항상 맞게 하는 것입니다. one이 1보다 많은 값을 포함하는 언어는 모두 같은 문제가 있습니다.
아포스트로피와 use-escaping
기본적으로 일반 아포스트로피는 그냥 문자입니다: "Don't forget the milk"는 잘 동작합니다. l10n.yaml에서 use-escaping: true를 켜면(중괄호를 문자 그대로 쓰기 위해) 아포스트로피가 ICU 이스케이프 문자가 되고, 홀로 있는 아포스트로피 때문에 생성이 멈춥니다.
[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
Don't forget the milk
^
그러면 두 번 쓰거나(Don''t) 영향을 받지 않는 인쇄용 아포스트로피 ’를 써야 합니다. 기존 프로젝트에서 이 옵션을 바꾸면 모든 언어를 다시 살펴봐야 합니다. 영어 축약형과 프랑스어 모음 생략 때문에 꽤 손이 갈 것입니다.
CI에 추가할 만한 검사
경고 없이 통과하는 것은 빠진 플레이스홀더입니다. 이 스크립트는 템플릿에 선언된 플레이스홀더를 읽고, 각 번역이 여전히 그것을 쓰는지 검사합니다.
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>;
이 페이지의 예시에서는 lib/l10n/app_fr.arb · welcomeUser: {name} missing을 출력하고 오류로 종료합니다. 다만 @key.placeholders에 선언된 플레이스홀더만 보며, 복수형에서는 변수가 메시지 어딘가에 나오는지만 검사하고 모든 분기에 있는지는 확인하지 않습니다. 위의 두 가지 복수형 함정은 메시지를 0, 1, 2로 호출하는 테스트로 잡는 것이 가장 좋습니다.