صيغ الجمع والمتغيرات في ICU: ما يتركه gen-l10n يمر

آخر تحديث: · LocalePilot

يرفض flutter gen-l10n رسالة ICU سيئة الصياغة، لكنه يقبل بصمت ترجمة فقدت متغيرها، أو صيغة جمع ستعرض «عنصر واحد» عندما يكون العدد صفرًا. إليك ما يفحصه، وما لا يفحصه، وسكربتًا لسد هذه الثغرة.

كل الأمثلة أدناه اختُبرت مع 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 !". ينجح التوليد، ولا يقول المحلل (analyzer) شيئًا، وتتجاهل الدالة المولَّدة معاملها ببساطة:

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

لا شيء يتعطل. كل ما في الأمر أن المستخدمين الناطقين بالفرنسية لن يروا أسماءهم أبدًا. أحيانًا يكون ذلك مقصودًا (الجملة أوضح من دونه)، لكنه غالبًا خطأ، ولا توجد أداة قياسية تميّز بين الحالتين. عليك أن تقارن متغيرات كل ترجمة بمتغيرات القالب؛ وستجد سكربتًا لذلك في الأسفل.

متغير بعد إعادة تسميته يغيّر واجهة كل اللغات

هذه الحالة أغرب. تستخدم الترجمة اسمًا غير موجود في القالب، مثل {nombre} بدل {count}:

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

هنا أيضًا لا يبلّغ gen-l10n عن شيء، ويضيف nombre معاملًا إضافيًا في الصنف المجرد (abstract class)، أي في كل اللغات:

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

يظهر الخطأ لاحقًا، عند تصريف كل موضع استدعاء: Too few positional arguments: 3 required, 2 given. وهو يشير إلى كود Dart الخاص بك، لا إلى ملف ARB الذي سبّبه. إذا ظهر فجأة معامل من نوع Object في إحدى دوال التوطين، فابحث عن هذا الاسم في ترجماتك.

في الفرنسية، تشمل one الصفر أيضًا

تتبع الفئات one وfew وmany… قواعد CLDR الخاصة بكل لغة. في الإنجليزية، تعني one العدد 1 فقط. وفي الفرنسية تعني 0 و1. مع هذه الرسالة:

"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 إلى المعاملين zero وone في Intl.pluralLogic. فيصبح فرع =1 هو الفئة one، وهذه الفئة في الفرنسية تنطبق على الصفر أيضًا:

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

عندما يكون count = 0، تعرض الإنجليزية «0 items left» وتعرض الفرنسية «1 article restant» (بقي عنصر واحد). النص خاطئ ولا شيء ينبّه إلى ذلك. الحل: أضف فرع =0، أو اكتب one{{count} article restant} ليكون العدد المعروض صحيحًا دائمًا. وكل لغة تغطي فيها one أكثر من العدد 1 تعاني المشكلة نفسها.

العربية مثال آخر على أن الفئات تختلف من لغة إلى أخرى: تستخدم ست فئات، هي zero (0) وone (1) وtwo (2) وfew (من 3 إلى 10) وmany (من 11 إلى 99) وother (100 مثلًا). رسالة جمع عربية لا تحتوي إلا على one وother تمر من gen-l10n، لكنها ستعرض صيغة other لعدد مثل 3 أو 11. اكتب الفروع الستة كلها.

علامات الاقتباس الأحادية وuse-escaping

افتراضيًا، علامة الاقتباس الأحادية المستقيمة مجرد حرف: "Don't forget the milk" تعمل. وإذا فعّلت use-escaping: true في l10n.yaml (لكتابة أقواس معقوفة حرفية)، تصبح هذه العلامة حرف الهروب في 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 (وفي العربية أيضًا 3 و11 و100).

الأسئلة الشائعة

هل يفحص flutter gen-l10n الترجمات أم القالب فقط؟

يفحص صياغة ICU في كل ملف ARB. لكنه لا يقارن متغيرات الترجمة بمتغيرات القالب: المتغير المحذوف يمر دون أي تحذير.

لماذا ظهر في دالة التوطين معامل Object إضافي؟

إحدى الترجمات تستخدم اسم متغير غير موجود في القالب، مثل {nombre} بدل {count}. يضيفه gen-l10n معاملًا في كل اللغات، فيفشل تصريف مواضع الاستدعاء الحالية.

=1 أم one في صيغة الجمع؟

في ICU يستهدف =1 القيمة 1 تحديدًا، لكن gen-l10n يمرره على أنه الفئة one، وهي تشمل 0 أيضًا في الفرنسية. أضف فرع =0 أو اعرض {count} داخل فرع one.