ICU 복수형과 플레이스홀더: gen-l10n이 통과시키는 것

업데이트 · LocalePilot

flutter gen-l10n은 잘못된 ICU 메시지는 거부하지만, 플레이스홀더를 잃어버린 번역이나 0일 때 "1개"라고 표시하는 복수형은 조용히 받아들입니다. 무엇을 검사하고 무엇을 검사하지 않는지, 그리고 그 틈을 메우는 스크립트를 소개합니다.

아래 예시는 모두 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영어프랑스어
00 items0 article
11 item1 article
22 items2 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로 호출하는 테스트로 잡는 것이 가장 좋습니다.

자주 묻는 질문

flutter gen-l10n은 번역도 검사하나요, 아니면 템플릿만 검사하나요?

모든 ARB 파일의 ICU 구문을 검사합니다. 하지만 번역의 플레이스홀더를 템플릿과 비교하지는 않으므로, 플레이스홀더가 빠져도 경고 없이 통과합니다.

현지화 메서드에 Object 매개변수가 하나 더 생긴 이유는 무엇인가요?

템플릿에 없는 플레이스홀더 이름을 번역이 쓰고 있기 때문입니다. 예를 들어 {count} 대신 {nombre}. gen-l10n은 이를 모든 로케일의 매개변수로 추가하고, 기존 호출 코드는 컴파일되지 않습니다.

복수형에서 =1과 one 중 무엇을 써야 하나요?

ICU에서 =1은 정확히 값 1만 가리키지만, gen-l10n은 이를 one 카테고리로 넘기고 프랑스어에서는 이 카테고리가 0도 포함합니다. =0 분기를 추가하거나 one 분기에 {count}를 표시하세요. 한국어는 other 하나뿐이라 이 문제가 생기지 않습니다.