تطوير الواجهات والجوال Frontend & Mobile الأدلة التقنية

التعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟

لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.

التعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟

لنفرض أن فريقك يملك منصة لحجز الدورات التدريبية، بنيت من أول يوم بالإنجليزية، ثم قررت الإدارة إطلاق الواجهة العربية خلال شهر لأن أغلب العملاء يكتبون بالعربية. ترجم الفريق النصوص في ملف واحد، وقلب اتجاه الصفحة إلى dir="rtl"، وظن أن العمل انتهى، ثم بدأت التذاكر تصل من أول أسبوع: صفحة التعليقات تعرض «3 - user99 تعليقات» بدلاً من «user99 - 3 تعليقات»، ورقم الدعم الفني يظهر وعلامة + في آخره، وصفحة الملفات تكتب «ملفات 2» و«ملفات 11»، والعميل الذي كتب رقم جواله بالأرقام العربية ٠٥٠ يرفضه النموذج لأن الرقم «غير صحيح»، وموعد الدورة يظهر بالتاريخ الميلادي بينما يسأل بعض العملاء عن التاريخ الهجري، والبحث عن «احمد» لا يجد «أحمد»، وأخيراً وصل مرفق اسمه يبدو invoiceexe.pdf وهو في الحقيقة ملف تنفيذي. ولا توجد مشكلة واحدة من هذه سببها الترجمة، وإنما سببها أن الكود نفسه لم يكتب ليتعامل مع لغة أخرى، وهذا بالضبط الفرق بين الترجمة والتدويل.

وهذا المقال من مرحلة هندسة الواجهات وتطبيقات الويب Frontend، ويتعامل مع النص والأرقام والتواريخ فقط، أما تخطيط الصفحة نفسها للعربية بالخصائص المنطقية في CSS فله مقال مستقل في نفس المرحلة، وأما البايتات وUTF-8 فشرحنا الترميز في مقال الترميز Encoding ولن نعيده هنا. وسوف نناقش في هذا المقال ما يلي:

  • الفرق بين التدويل i18n والتوطين l10n، وما هو الـ Locale، وكيف تقرأ وسماً مثل ar-SA-u-ca-islamic-umalqura-nu-latn.
  • خوارزمية النص ثنائي الاتجاه BiDi في Unicode: أنواع الحروف والمستويات، ولماذا يقفز الرقم إلى طرف الجملة، ولماذا تنقلب C++ ورقم الجوال.
  • العزل Isolation بالعنصر <bdi> والخاصية dir والحروف الخفية، والفرق بينه وبين التضمين القديم، ومتى تكفي العلامات LRM وRLM.
  • الحروف الخفية كأداة للخداع: أسماء الملفات المزيفة وهجوم Trojan Source.
  • تنسيق الأرقام والعملة بالواجهة Intl.NumberFormat، والتواريخ بالتقويم الميلادي وتقويم أم القرى، والتاريخ النسبي.
  • صيغ الجمع الست في العربية، وملفات الترجمة، ولماذا لا تبني الجملة بجمع النصوص.
  • ترتيب الأسماء العربية والبحث فيها، وقراءة الأرقام التي يكتبها المستخدم بالأرقام العربية.
  • تجربة عملية كاملة في Node.js وفي Chrome مع المخرجات الحقيقية، ثم الأخطاء الشائعة وقائمة تحقق.

ما الفرق بين التدويل i18n والتوطين l10n؟

التدويل Internationalization، واختصاراً i18n لأن بين الحرف الأول والأخير ثمانية عشر حرفاً، هو أن تكتب الكود بحيث لا يفترض لغة أو منطقة بعينها، فلا توجد جملة ثابتة داخل الكود، ولا يحفظ التاريخ كنص منسق، ولا يجمع الكود الجملة من قطع، ولا يفترض أن النص يكتب من اليسار. وهذا عمل تقوم به مرة واحدة في الكود، وغالباً يقوم به المطورون. أما التوطين Localization، واختصاراً l10n، فهو تجهيز المنتج لمنطقة ولغة محددة: ترجمة الجمل، واختيار صيغة الجمع المناسبة، والعملة، والتقويم، وشكل الأرقام، واتجاه الواجهة، ويتكرر هذا العمل لكل لغة تضيفها، وغالباً يشارك فيه المترجمون وفريق المنتج. والعلاقة بينهما بسيطة: التدويل يجعل التوطين ممكناً، فإذا لم يكن الكود مدولاً فكل لغة جديدة تعني تعديل الكود نفسه، وهذا ما حدث مع الفريق في القصة أعلاه، فقد قام بالتوطين (الترجمة) دون التدويل.

وبين الاثنين يوجد مفهوم ثالث يحمل كل القرارات، وهو الـ Locale، أي مجموعة التفضيلات التي تحدد كيف يرى المستخدم النص والأرقام والتواريخ. والـ Locale يكتب بوسم لغة Language Tag حسب المعيار BCP 47، وأبسط صورة له لغة فقط مثل ar، ثم لغة ومنطقة مثل ar-SA للعربية في السعودية وar-EG للعربية في مصر. ويمكن أن يحمل الوسم أيضاً امتداد Unicode، وهو الحرف u وبعده أزواج من المفاتيح والقيم كما في RFC 6067 والمعيار UTS #35، فالمفتاح ca يحدد التقويم Calendar، والمفتاح nu يحدد نظام الأرقام Numbering System، والقيم المسموحة لكل مفتاح موجودة في بيانات CLDR، مثل قائمة التقاويم التي فيها islamic-umalqura وقائمة أنظمة الأرقام التي فيها arab للأرقام العربية الهندية ٠١٢٣ وlatn للأرقام 0123. والصورة التالية تبين أجزاء الوسم الذي استخدمناه في التجربة وما يملكه كل من التدويل والتوطين:

الوسم ar-SA-u-ca-islamic-umalqura-nu-latn مقسماً إلى اللغة ar والمنطقة SA وامتداد Unicode والتقويم أم القرى ونظام الأرقام اللاتيني، وتحته عمودان: ما يقوم به التدويل مرة واحدة في الكود، وما يقوم به التوطين لكل Locale
الـ Locale ليس اللغة فقط، فالمنطقة والتقويم ونظام الأرقام جزء منه

والشيء الذي يفاجئ كثيراً من المطورين هو أن اختيار الوسم وحده يغير النتيجة، فقد طلبنا في التجربة من Intl.Locale ومن الدوال الأخرى أن تخبرنا بما تختاره لكل وسم، فكانت النتيجة كما يلي:

== 1) locale tags
ar                           numberingSystem=latn calendar=gregory max=ar-Arab-EG
ar-SA                        numberingSystem=arab calendar=gregory max=ar-Arab-SA
ar-EG                        numberingSystem=arab calendar=gregory max=ar-Arab-EG
ar-MA                        numberingSystem=latn calendar=gregory max=ar-Arab-MA
ar-SA-u-nu-latn              numberingSystem=latn calendar=gregory max=ar-Arab-SA-u-nu-latn
ar-SA-u-ca-islamic-umalqura  numberingSystem=arab calendar=islamic-umalqura max=ar-Arab-SA-u-ca-islamic-umalqura

في المخرج أعلاه لاحظ التالي:

  • الوسم ar وحده اختار الأرقام اللاتينية latn، والدالة maximize() أكملته إلى ar-Arab-EG، أي أن العربية بلا منطقة تعني في بيانات CLDR العربية المكتوبة بالحرف العربي في مصر، وهذا لا يناسب منتجاً يستهدف السعودية.
  • الوسم ar-SA اختار الأرقام العربية الهندية arab والتقويم الميلادي gregory، بينما اختار ar-MA الأرقام اللاتينية، وسوف نرى بعد قليل أن المغرب يستخدم أيضاً فواصل مختلفة.
  • عندما أضفنا -u-nu-latn تغير نظام الأرقام فقط وبقي كل شيء آخر سعودياً، وعندما أضفنا -u-ca-islamic-umalqura تغير التقويم فقط، وهذه هي الفائدة الحقيقية من الامتداد: تعدل تفضيلاً واحداً دون أن تختار منطقة أخرى.

ومن أين يأتي الـ Locale في تطبيق ويب؟ المصدر الأول هو اختيار المستخدم نفسه إذا كان مسجلاً، فتحفظه في ملفه الشخصي أو في Cookie كما شرحنا في مقال الجلسات، والمصدر الثاني هو ترويسة Accept-Language التي يرسلها المتصفح مع كل طلب كما رأينا في مقال HTTP، وnavigator.languages في JavaScript. ولا تقم باستنتاج اللغة من عنوان IP، والسبب أن الموقع الجغرافي لا يحدد اللغة، فالمهندس الهندي الذي يعمل داخل المملكة يريد غالباً الواجهة الإنجليزية، والطالب السعودي في الخارج يريد العربية، وأسوأ تجربة هي أن يتغير اتجاه الواجهة كلما سافر المستخدم أو استخدم VPN. وتذكر أيضاً أن تضع الخاصية lang على عنصر html، لأن قارئ الشاشة يختار النطق منها كما ذكرنا في مقال سهولة الوصول، والمتصفح يختار منها الخط وقواعد تقسيم الكلمات.

لماذا ينقلب النص المختلط؟ خوارزمية BiDi

نعود إلى أول تذكرة في القصة، فالقالب في الكود كان بسيطاً: اسم المستخدم ثم شرطة ثم عدد التعليقات ثم كلمة «تعليقات»، والنص المحفوظ في الذاكرة صحيح تماماً، ومع ذلك ظهر على الشاشة بترتيب آخر. والسبب أن الشاشة لا تعرض الحروف بترتيب حفظها، وإنما بترتيب تحسبه خوارزمية النص ثنائي الاتجاه Unicode Bidirectional Algorithm، وهي موصوفة في الملحق UAX #9، وكل متصفح وكل نظام تشغيل يطبقها. والفكرة الأساسية أن هناك ترتيبين: الترتيب المنطقي Logical Order، وهو ترتيب الحروف في الذاكرة كما كتبت وكما تقرؤها، والترتيب المرئي Visual Order، وهو مكان كل حرف على الشاشة من اليسار إلى اليمين. والخوارزمية تأخذ الأول وتنتج الثاني على أربع مراحل تقريباً.

المرحلة الأولى: لكل حرف نوع

كل حرف في Unicode له خاصية اسمها Bidi_Class، والأنواع تنقسم إلى ثلاث مجموعات:

  • الحروف القوية Strong: ولها اتجاه ثابت، فالحروف اللاتينية من النوع L، أي من اليسار إلى اليمين، والحروف العبرية من النوع R، والحروف العربية من النوع AL، وهو نوع يتصرف مثل R مع فرق في معاملة الأرقام كما سيأتي.
  • الحروف الضعيفة Weak: ولها اتجاه ولكنها تتأثر بما حولها، وأهمها الأرقام الأوروبية 0 إلى 9 من النوع EN، والأرقام العربية الهندية ٠ إلى ٩ من النوع AN، وعلامات مثل + و- من النوع ES، والنقطة والفاصلة والنقطتان من النوع CS.
  • الحروف المحايدة Neutral: وليس لها اتجاه أصلاً، مثل المسافة وعلامات الترقيم والأقواس وعلامة التعجب، وهي تأخذ اتجاهها من الحروف القوية على جانبيها.

المرحلة الثانية: اتجاه الفقرة Paragraph Direction

لكل فقرة اتجاه أساسي يسمى اتجاه الفقرة Paragraph Direction، وتحدده الخوارزمية في القاعدتين P2 وP3 من أول حرف قوي في الفقرة إذا لم يحدده أحد، أما في HTML فالخاصية dir على العنصر أو أحد آبائه هي التي تحدده، ولذلك كان سطر dir="rtl" الذي أضافه الفريق صحيحاً وضرورياً، ولكنه حل نصف المشكلة فقط. ومن اتجاه الفقرة تبدأ المستويات Embedding Levels، فالفقرة التي اتجاهها RTL تبدأ بالمستوى 1، والنص اللاتيني داخلها يرفع إلى المستوى 2، والقاعدة البسيطة أن المستوى الفردي يعرض من اليمين إلى اليسار والمستوى الزوجي من اليسار إلى اليمين.

المرحلة الثالثة: حل الحروف الضعيفة Weak والمحايدة Neutral

هنا يحدث كل ما يفاجئ المطورين. فالقاعدة W2 تقول إن الرقم الأوروبي EN الذي يسبقه حرف عربي AL يتحول إلى رقم عربي AN، والقاعدة W7 تقول إن الرقم الأوروبي الذي يكون أقرب حرف قوي قبله لاتينياً L يتحول إلى L، أي يصبح جزءاً من الكتلة اللاتينية. ثم تأتي القاعدتان N1 وN2 للحروف المحايدة، فالحرف المحايد الواقع بين حرفين قويين من نفس الاتجاه يأخذ اتجاههما، والواقع بين اتجاهين مختلفين يأخذ اتجاه الفقرة. والآن طبق هذا على النص user99 - 3 تعليقات في فقرة اتجاهها RTL:

  1. الحروف user من النوع L، فتأخذ المستوى 2.
  2. الرقمان 99 يسبقهما الحرف r وهو L، فيتحولان إلى L بالقاعدة W7.
  3. الرقم 3 أقرب حرف قوي قبله هو أيضاً r، لأن المسافة والشرطة ليست حروفاً قوية، فيتحول هو الآخر إلى L.
  4. المسافة والشرطة والمسافة تقع بين L وL، فتأخذ اتجاه L، وبهذا أصبحت user99 - 3 كلها كتلة واحدة من اليسار إلى اليمين في المستوى 2.
  5. المسافة الأخيرة تقع بين L وكلمة «تعليقات» العربية، فتأخذ اتجاه الفقرة RTL، والكلمة العربية في المستوى 1.

المرحلة الرابعة: إعادة الترتيب وعكس الأقواس Mirroring

في المرحلة الأخيرة تعكس الخوارزمية بالقاعدة L2 كل سلسلة متصلة من المستويات من الأعلى إلى الأدنى، فتعرض الكتلة user99 - 3 من اليسار إلى اليمين كما هي، ثم توضع كاملة في بداية السطر من جهة اليمين لأنها أول شيء في الفقرة، ثم تأتي كلمة «تعليقات» على يسارها. والنتيجة أن الرقم 3 أصبح في أقصى اليمين، فيقرأ المستخدم العربي الجملة من اليمين ويرى الرقم أولاً، ثم الاسم، ثم الكلمة. والصورة التالية تبين هذه الخطوات على نفس المثال، والترتيب الذي فيها مقاس من الشاشة في التجربة العملية وليس استنتاجاً:

حروف النص user99 - 3 تعليقات بترتيبها في الذاكرة، وتحت كل حرف نوعه ومستواه: الحروف اللاتينية L، والأرقام EN تتحول إلى L، والمسافات محايدة، والكلمة العربية R، ثم النتيجة على الشاشة حيث التصق الرقم 3 بالاسم وانتقل إلى أقصى اليمين، وبجانبها النتيجة الصحيحة بعد عزل الاسم بالعنصر bdi
الرقم 3 يأخذ اتجاه أقرب حرف قوي قبله، فيلتصق باسم المستخدم

وفي نفس المرحلة يحدث شيء آخر، وهو عكس الحروف المتقابلة Mirroring، فالحروف التي لها الخاصية Bidi_Mirrored، مثل الأقواس ( ) و[ ] وعلامتي < و>، ترسم بشكلها المعكوس إذا وقعت في مستوى فردي، والقائمة الكاملة في الملف BidiMirroring.txt. ولهذا تكتب في الكود العربي القوس المفتوح ( في بداية الجملة فيظهر على يمينها بالشكل الصحيح. ومنذ الإصدار 6.3 من Unicode أضيفت إلى الخوارزمية قاعدة أزواج الأقواس Paired Brackets، فأصبح القوسان المتقابلان يأخذان نفس الاتجاه، بعد أن كانت الأقواس حول نص لاتيني في جملة عربية تظهر أحياناً في أماكن غريبة. ولكن العكس نفسه يفاجئ في حالة واحدة، وهي العبارات الرياضية، فالنص 3 > 2 داخل جملة عربية يظهر على الشاشة كأنه 2 < 3 إذا قرأته من اليسار، وهو صحيح للقارئ العربي الذي يقرأ من اليمين، ولكنه يربك أي شخص يقرأ العبارة كمعادلة، ولذلك تعامل مع المعادلات والكود كنص لاتيني معزول كما سيأتي.

الحالات المشهورة: رقم الجوال واسم C++ والوقت والمسار

بنفس القواعد الأربع تستطيع أن تفسر كل التذاكر التي وصلت للفريق، وقد عرضنا كل حالة منها في Chrome وقسنا ترتيبها على الشاشة كما في التجربة العملية:

  • رقم الجوال الدولي +966 50 123 4567: كل مجموعة أرقام EN، والمسافات بينها محايدة، والجملة عربية، فتعرض كل مجموعة من اليسار إلى اليمين ولكن المجموعات نفسها ترتب من اليمين إلى اليسار، وعلامة + تتحول إلى محايد يأخذ اتجاه الجملة، فظهر الرقم على الشاشة بترتيب المجموعات المعكوس وعلامة + في آخره من جهة اليمين، أي 4567 123 50 966+ إذا قرأته من اليسار.
  • اسم اللغة C++: الحرف C من النوع L، وعلامتا + بعده محايدتان بين L وكلمة عربية، فتأخذان اتجاه الفقرة وتنتقلان إلى الجهة الأخرى من الحرف، فيظهر ++C، وهي من أشهر الأخطاء في إعلانات الوظائف العربية.
  • الوقت 05:30 PM: الرقم يسبقه حرف عربي فيبقى رقماً، وPM كتلة لاتينية منفصلة، والمسافة بينهما تأخذ اتجاه الجملة، فتظهر الكتلتان بترتيب معكوس PM 05:30.
  • السعر 1,499.50 SAR: نفس القصة، فالرقم والعملة كتلتان، وظهر على الشاشة SAR 1,499.50.
  • مسار الملف /home/sara/تقارير/2026.pdf: وهو أسوأها، لأن المسار فيه حروف عربية في وسطه، فتقطعت أجزاؤه وانتقلت pdf.2026 إلى أول السطر.

وقد يتساءل البعض: إذا كانت الخوارزمية تنتج هذه الأخطاء، فلماذا لا تصلحها Unicode؟ والإجابة أن الخوارزمية لا تعرف المعنى، فهي لا تعرف أن user99 اسم مستخدم وأن 3 عدد تعليقات، ولا تعرف أن +966 50 123 4567 رقم واحد وليس أربعة أرقام في جملة عربية، والمعلومة الناقصة هنا عند المطور فقط، ووظيفة المطور أن يقول للمتصفح أين يبدأ النص المستقل وأين ينتهي، وهذا هو العزل.

العزل Isolation: كيف تخبر المتصفح أين يبدأ النص المستقل؟

العزل يعني أن تضع النص الذي يأتي من مصدر آخر، مثل اسم المستخدم أو رقم الجوال أو اسم الملف، داخل صندوق، والصندوق له صفتان: اتجاه ما بداخله يحسب وحده دون أن يتأثر بما حوله، وما حوله يرى الصندوق كله كأنه حرف محايد واحد. وفي HTML يوجد العنصر bdi، واسمه اختصار Bi-Directional Isolate، وهو يحدد اتجاه ما بداخله من أول حرف قوي فيه، وبهذا يصلح القالب في القصة بتعديل واحد:

<!-- قبل: الرقم يلتصق بالاسم اللاتيني -->
<p>${user} - ${count} تعليقات</p>

<!-- بعد: الاسم معزول، فلا يؤثر على الرقم ولا يتأثر بالجملة -->
<p><bdi>${user}</bdi> - ${count} تعليقات</p>

وعند عرض الصفحة في Chrome ظهر الاسم في أقصى اليمين، ثم الشرطة، ثم الرقم 3، ثم كلمة «تعليقات»، وهو الترتيب الذي أراده الفريق. ولاحظ أن العنصر <bdi> لم يحدد اتجاهاً معيناً، وإنما ترك المتصفح يكتشفه، وهذا مهم في أسماء المستخدمين لأن بعضهم يكتب اسمه بالعربية وبعضهم بالإنجليزية، فالاسم العربي عمر_2 في صفحة إنجليزية يحتاج نفس الحماية، وقد عرضناه في التجربة في صفحة اتجاهها LTR، فظهر بدون عزل بالشكل التالي:

لقطة من Chrome لصفحة إنجليزية فيها سطران: الأول بدون عزل حيث انتقل الرقم 3 والنقطتان إلى مكان خاطئ حول الاسم العربي، والثاني مع العنصر bdi حيث ظهر الاسم ثم النقطتان ثم 3 reviews بالترتيب الصحيح
نفس المشكلة في الاتجاه المعاكس: اسم عربي في صفحة إنجليزية

وفي السطر الأول من الصورة تحول الرقم 3 إلى رقم عربي AN لأنه جاء بعد حرف عربي بالقاعدة W2، فدخل هو والنقطتان في الكتلة العربية وانعكس ترتيبها، أما في السطر الثاني فالاسم معزول، فظهرت الجملة الإنجليزية كما كتبت.

أدوات العزل: عناصر HTML وخصائص CSS والحروف الخفية

هناك أربع طرق للعزل، وكلها تؤدي نفس العمل في الخوارزمية، والفرق في المكان الذي تستخدم فيه كل طريقة:

  • العنصر <bdi>: للنص الذي لا تعرف اتجاهه مسبقاً، مثل الأسماء والتعليقات وعناوين المنتجات التي يكتبها المستخدمون.
  • الخاصية dir على أي عنصر: مثل <span dir="ltr"> لرقم الجوال أو المسار أو الكود، لأنك تعرف أن اتجاهها LTR دائماً. وقد تأكدنا في التجربة أن Chrome يعطي أي عنصر عليه dir القيمة unicode-bidi: isolate تلقائياً، ولذلك أصلح <span dir="ltr"> المشكلة بينما لم يصلحها <span> بدون الخاصية.
  • الخاصية unicode-bidi في CSS: بالقيمة isolate أو plaintext، وتستخدمها عندما لا تستطيع تعديل HTML، مثل محتوى يأتي من محرر نصوص.
  • حروف العزل الخفية في النص نفسه: وهي LRI بالرمز U+2066، وRLI بالرمز U+2067، وFSI بالرمز U+2068، وكلها تغلق بالحرف PDI بالرمز U+2069، وتستخدمها حيث لا يوجد HTML أصلاً: الإشعارات Push Notifications، والرسائل النصية SMS، وعنوان البريد Subject، والنص داخل ملف PDF أو صورة، وعنوان الصفحة <title> الذي لا يقبل عناصر.

والحرف FSI، أي First Strong Isolate، هو المقابل النصي للعنصر <bdi>، لأنه يحدد الاتجاه من أول حرف قوي داخل الصندوق، ولذلك إذا كانت عندك دالة واحدة تبني نصوص الإشعارات من بيانات المستخدمين فاجعلها تحيط كل قيمة متغيرة بالحرفين FSI وPDI، وهذا ما فعلناه في القسم الثامن من التجربة. وتنصح W3C في مقال الحروف الخفية أم HTML باستخدام العناصر والخصائص في HTML كلما أمكن، والسبب أن الحروف الخفية لا تراها في الكود ولا في قاعدة البيانات، فيصعب اكتشافها عند تتبع خطأ، وقد تنقص أو تزيد عند قص النص فتبقى بداية صندوق بلا نهاية.

لماذا لا تكفي حروف التضمين Embedding القديمة؟

قبل الإصدار 6.3 كانت الأداة المتاحة هي التضمين Embedding بالحروف LRE وRLE وإغلاقهما بالحرف PDF، وما زالت تجدها في مكتبات قديمة وفي إجابات كثيرة على الإنترنت. والتضمين يرفع مستوى النص بداخله، ولكنه لا يعزله، فالحروف التي بعده تحسب حدودها من المستوى الأعلى، وبالتالي يبقى الرقم 3 بعد الاسم المضمن متأثراً باتجاهه. وقد جربنا ذلك في Chrome، فوضعنا الاسم بين LRE وPDF مرة، وبين LRI وPDI مرة، فبقي الخطأ كما هو مع التضمين، واختفى مع العزل. والصورة التالية تلخص الفرق، ومعه جدول الحروف الخفية وما يقابلها في HTML:

مقارنة بين التضمين القديم LRE وPDF حيث بقي الرقم 3 ملتصقاً بالاسم، والعزل LRI وPDI أو العنصر bdi حيث ظهر الترتيب الصحيح، وتحتهما جدول الحروف الخفية: علامات LRM وRLM وALM، وحروف العزل U+2066 إلى U+2069، وحروف التضمين القديمة، وحروف الفرض LRO وRLO
التضمين يرفع المستوى فقط، أما العزل فيقطع تأثير النص على ما حوله

وتبقى العلامات LRM بالرمز U+200E وRLM بالرمز U+200F وALM بالرمز U+061C، وهي حروف قوية لا ترى، فهي لا تفتح صندوقاً وإنما تضع حرفاً قوياً في مكان معين، فتغير اتجاه الحروف المحايدة بجانبها. ومثالها المعروف علامة التعجب في آخر جملة عربية داخل صفحة إنجليزية، فإذا وضعت RLM بعدها أصبحت بين حرفين من نوع R فتأخذ اتجاههما. والعلامات مفيدة في الحالات الصغيرة، ولكن لا تقم ببناء حل كامل عليها، والسبب أن كل حالة جديدة تحتاج علامة في مكان جديد، بينما العزل يحل كل الحالات بقاعدة واحدة. وستجد هذه العلامات في مخرج Intl نفسه كما سيأتي في قسم الأرقام، فمكتبة ICU تضعها في السعر والنسبة المئوية والرقم السالب لتحمي ترتيبها.

مشكلة ظهرت في التجربة: المسار المعزول بقي مقلوباً

عندما عزلنا مسار الملف بالعنصر <bdi dir="ltr"> توقعنا أن ينتهي الأمر، ولكن القياس من الشاشة أظهر أن المسار ما زال يعرض بالشكل /home/sara/2026/تقارير.pdf، أي أن الرقم 2026 انتقل قبل اسم المجلد العربي. والسبب أن العزل حمى المسار من الجملة الخارجية، ولكن داخل الصندوق نفسه ما زالت الخوارزمية تعمل، فالرقم 2026 يسبقه اسم مجلد عربي، فتحول إلى AN بالقاعدة W2، وأصبح هو والمجلد العربي كتلة واحدة من اليمين إلى اليسار. والحل كان أن نعزل كل جزء من المسار وحده، فالمسار يتكون من أجزاء مستقلة تفصلها علامة /، تماماً كما أن الجملة تتكون من كلمات:

<bdi dir="ltr">/<bdi>home</bdi>/<bdi>sara</bdi>/<bdi>تقارير</bdi>/<bdi>2026.pdf</bdi></bdi>

وبعد هذا التعديل ظهر المسار على الشاشة بترتيبه الصحيح. ونفس الدرس ينطبق على الروابط URL التي فيها مسارات عربية، وعلى عناوين البريد، وعلى أي نص له بنية داخلية: العزل الخارجي يحمي الجملة، والعزل الداخلي يحمي البنية. وفي الكود يمكنك أن تبني هذا بدالة صغيرة تقسم المسار على / وتعزل كل جزء، بدلاً من كتابته بيدك في كل قالب.

التعليقات التي يكتبها المستخدم والقيمة dir="auto"

في صفحة التعليقات مشكلة من نوع آخر، فالتعليق نفسه قد يكون عربياً أو إنجليزياً، ولا تعرف اتجاه الفقرة مسبقاً. والقيمة dir="auto" تجعل المتصفح يحدد اتجاه الفقرة من أول حرف قوي فيها، وهي مناسبة للتعليقات والرسائل وحقول الإدخال، ولكن انتبه إلى حالة شائعة في المجتمع التقني العربي، وهي التعليق العربي الذي يبدأ بكلمة إنجليزية:

لقطة من Chrome لثلاثة تعليقات: الأول بدون dir داخل صفحة عربية يظهر صحيحاً، والثاني مع dir=auto ويبدأ بكلمة React فأصبحت الفقرة من اليسار إلى اليمين وانتقلت علامة التعجب إلى الطرف الخطأ، والثالث مع dir=auto ويبدأ بكلمة عربية فظهر صحيحاً
dir="auto" يحكم من أول حرف قوي، فالتعليق العربي الذي يبدأ بكلمة React يصبح فقرة LTR

التعليق الثاني في الصورة يبدأ بكلمة React، فحسب Chrome اتجاه الفقرة LTR، فوضع React في أقصى اليسار وعلامة التعجب في أقصى اليمين، والقارئ العربي يرى الجملة مقطعة. وهذا ليس خطأ في المتصفح، فهذا ما تقوله القاعدة P2، ولكنه يعني أن dir="auto" تخمين وليس معرفة. ولهذا إذا كان التطبيق عربياً في الأساس، فالأفضل أن تضع في نموذج التعليق زراً يختار به المستخدم الاتجاه وتحفظه مع التعليق، أو أن تجعل الاتجاه الافتراضي للتعليقات RTL وتستخدم auto فقط في الأماكن التي يغلب عليها الإنجليزية.

الحروف الخفية كأداة للخداع

الحرف RLO، أي Right-to-Left Override بالرمز U+202E، يفرض اتجاه اليمين إلى اليسار على كل حرف بعده مهما كان نوعه، وهذا يعني أنه يستطيع أن يجعل النص يظهر عكس ما هو مخزن تماماً. واستغلال هذا في أسماء الملفات تقنية موثقة في قاعدة MITRE ATT&CK باسم Right-to-Left Override، فالملف المخزن باسم invoice ثم RLO ثم fdp.exe يظهر على الشاشة كأنه invoiceexe.pdf، وهذا ما رأيناه حرفياً في Chrome في التجربة، بينما الدالة endsWith('.exe') في الكود أعادت true، أي أن الكود يعرف الحقيقة والمستخدم يرى غيرها. ولاحظ أن <bdi> لم يحم المستخدم هنا، لأن الحرف الخفي داخل الصندوق نفسه، والحل الوحيد أن تحذف هذه الحروف من أسماء الملفات وأسماء المستخدمين عند الإدخال، أو ترفضها.

💡
في نوفمبر 2021 نشر الباحثان Nicholas Boucher وRoss Anderson من جامعة Cambridge هجوماً أسمياه Trojan Source، وسجل برقم CVE-2021-42574. الفكرة أن حروف BiDi الخفية داخل تعليق أو نص في الكود تجعل محرر الكود يعرض الكود بترتيب يختلف عن الترتيب الذي يقرؤه المترجم Compiler، فيرى المراجع شرطاً داخل تعليق بينما هو كود يعمل فعلاً، أو العكس. وفي نفس الوقت أضافت GitHub تحذيراً يظهر على أي ملف فيه هذه الحروف، وأضافت لغات ومحررات كثيرة تحذيرات مشابهة. والدرس للمطور العربي أن هذه الحروف أدوات عرض فقط، ومكانها في النص الذي يراه المستخدم، وليس في الكود ولا في المعرفات.

الأرقام والعملة: لا تقم بتنسيقها بيدك

التذكرة التالية في القصة كانت عن الأسعار، فقد كتب الفريق السعر بقالب ثابت price + ' ر.س'، فظهر الرقم بأرقام لاتينية وبدون فاصل الآلاف، وانتقلت العملة إلى طرف آخر في بعض الصفحات. والواجهة Intl.NumberFormat تحل كل هذا من بيانات CLDR، وقد طبعنا بها نفس الرقم بعدة وسوم فكانت النتيجة كما يلي:

== 2) numbers
en-US                      "1,234,567.891"
ar-SA                      "١٬٢٣٤٬٥٦٧٫٨٩١"
ar-EG                      "١٬٢٣٤٬٥٦٧٫٨٩١"
ar-MA                      "1.234.567,891"
ar-SA-u-nu-latn            "1,234,567.891"
ar-AE                      "1,234,567.891"
currency ar-SA             "‏١٬٤٩٩٫٥٠ ر.س.‏"
currency ar-SA-u-nu-latn   "‏1,499.50 ر.س.‏"
currency en-SA             "SAR 1,499.50"
percent ar-SA              "١٥٪؜"
compact ar-SA              "٢٫٥ مليون"
compact ar-SA long         "٢٫٥ مليون"
negative ar-SA             "؜-٤٢"
codepoints of ar-SA 15%    "U+0661 U+0665 U+066A U+061C"

في المخرج أعلاه لاحظ التالي:

  • الوسم ar-SA كتب الرقم بالأرقام العربية الهندية، وفاصل الآلاف فيه ليس الفاصلة العادية وإنما الحرف ٬ بالرمز U+066C، والفاصلة العشرية هي الحرف ٫ بالرمز U+066B كما يظهر في آخر سطر من القسم السابع في التجربة، وهذا مهم لأن أي كود يحاول قراءة هذا النص كرقم سوف يفشل.
  • الوسم ar-MA استخدم النقطة لفاصل الآلاف والفاصلة للكسور، أي عكس السعودية تماماً، ولو كتبت الفواصل بيدك لكان الرقم خاطئاً في واحدة من الدولتين.
  • الوسم ar-AE كتب الرقم بالأرقام اللاتينية، فاختيار الأرقام في بيانات CLDR يختلف بين الدول العربية، ولذلك لا تفترض أن «العربية» تعني الأرقام العربية الهندية.
  • السعر بالعملة SAR ظهر بالرمز ر.س. بعد الرقم، وفي بدايته ونهايته حرف خفي هو RLM، وهو ما تحدثنا عنه في القسم السابق، فمكتبة ICU تحمي ترتيب السعر داخل الجمل. وفي الوسم en-SA ظهرت العملة SAR قبل الرقم، فمكان رمز العملة جزء من التوطين وليس من الترجمة.
  • النسبة المئوية استخدمت الحرف ٪ بالرمز U+066A ثم الحرف الخفي ALM بالرمز U+061C كما يبين سطر الرموز، والرقم السالب بدأ أيضاً بالحرف ALM قبل علامة السالب، حتى لا تنتقل العلامة إلى الطرف الآخر من الرقم.
  • الصيغة المختصرة compact كتبت «٢٫٥ مليون»، وهي مناسبة للوحات المؤشرات Dashboards والأرقام الكبيرة في الواجهة.

وهنا قرار منتج يجب أن يتخذه الفريق صراحة: الأرقام العربية الهندية أم اللاتينية؟ والتقنية تدعم الاثنين بنفس السهولة، فالامتداد -u-nu-latn أعطى نفس التنسيق السعودي بالأرقام اللاتينية. والمهم ألا يترك القرار للصدفة، فلا تختلط الأرقام في نفس الشاشة، ولا يظهر السعر بالأرقام العربية الهندية في صفحة المنتج وباللاتينية في الفاتورة. وأياً كان قرارك، فالرقم يحفظ في قاعدة البيانات رقماً، وليس نصاً منسقاً، والتنسيق يحدث عند العرض فقط، والسبب أن النص المنسق لا يمكن جمعه ولا ترتيبه ولا تحويله إلى Locale آخر، وأن الفاتورة التي ترسل بالبريد قد تحتاج أرقاماً غير التي في الواجهة.

التاريخ والتقويم الهجري: احفظ UTC ونسق عند العرض

مواعيد الدورات في المنصة كانت تحفظ كنص منسق باللغة الإنجليزية، فلما جاءت الواجهة العربية اضطر الفريق إلى تحويل النص إلى تاريخ ثم تنسيقه من جديد، وظهرت أخطاء في المواعيد التي تقع قرب منتصف الليل. والقاعدة التي تمنع هذا كله هي أن تحفظ اللحظة نفسها بتوقيت UTC، أي 2026-10-30T14:30:00Z، ثم تنسقها عند العرض بالـ Locale والمنطقة الزمنية Time Zone الخاصة بالمستخدم، وهذا ما تقوم به الواجهة Intl.DateTimeFormat. وقد نسقنا في التجربة لحظة واحدة بعدة وسوم وتقاويم، فكانت النتيجة كما يلي:

== 3) dates
iso (stored)               "2026-10-30T14:30:00.000Z"
ar-SA                      "الجمعة، ٣٠ أكتوبر ٢٠٢٦ في ٥:٣٠ م"
ar-SA-u-ca-gregory         "الجمعة، ٣٠ أكتوبر ٢٠٢٦ في ٥:٣٠ م"
ar-SA-u-ca-islamic-umalqura "الجمعة، ١٩ جمادى الأولى ١٤٤٨ هـ في ٥:٣٠ م"
ar-SA-u-ca-islamic-umalqura-nu-latn "الجمعة، 19 جمادى الأولى 1448 هـ في 5:30 م"
ar-EG                      "الجمعة، ٣٠ أكتوبر ٢٠٢٦ في ٥:٣٠ م"
en-US                      "Friday, October 30, 2026 at 5:30 PM"
islamic (civil?)           "٢٠ جمادى الأولى ١٤٤٨ هـ"
islamic-civil              "١٨ جمادى الأولى ١٤٤٨ هـ"
umalqura parts             "month=5 day=19 year=1448 era=AH"
same instant UTC           "٢:٣٠ م"
hour12 ar-SA               "٥:٣٠ م"

في المخرج أعلاه لاحظ التالي:

  • اللحظة المحفوظة هي الساعة 14:30 بتوقيت UTC، وقد ظهرت في كل الأسطر الساعة 5:30 مساءً لأننا حددنا timeZone: 'Asia/Riyadh'، أما السطر same instant UTC فطلب نفس اللحظة بتوقيت UTC فظهرت ٢:٣٠ م. ولو لم نحدد المنطقة الزمنية لأخذ التنسيق منطقة الجهاز الذي يعمل عليه الكود، وهي على السيرفر غالباً UTC، وفي متصفح المستخدم منطقة جهازه.
  • الوسم ar-SA وحده أعطى التاريخ الميلادي، والوسم مع -u-ca-islamic-umalqura أعطى «١٩ جمادى الأولى ١٤٤٨ هـ» لنفس اللحظة، وأسماء الأيام والأشهر وكلمة «في» و«م» كلها من بيانات CLDR.
  • ظهر في بيانات ICU ثلاثة تقاويم هجرية، وأعطت لنفس اللحظة ثلاثة أيام مختلفة: islamic-umalqura أعطى 19، وislamic أعطى 20، وislamic-civil أعطى 18.

وسبب الاختلاف في التقاويم الهجرية أنها ليست تقويماً واحداً، فوثائق IslamicCalendar في ICU تشرح أن التقويم المدني islamic-civil يستخدم دورة ثابتة من أشهر طولها 29 و30 يوماً، فيمكن حسابه مسبقاً، أما التقويم الديني وتقويم أم القرى الذي تستخدمه السعودية فيعتمدان على رؤية الهلال أو حسابه، ولذلك قد يختلفان عن التقويم المدني بعدة أيام. والنتيجة العملية أن المنتج الذي يستهدف المستخدم في السعودية يجب أن يحدد islamic-umalqura صراحة، وليس islamic، والسبب أن الفرق بينهما يوم كامل في مثالنا، وهو فرق كاف ليصل موعد الدورة في يوم غير الذي يتوقعه العميل. والصورة التالية تلخص الفكرة:

لحظة واحدة محفوظة في قاعدة البيانات بتوقيت UTC وهي 2026-10-30T14:30:00Z، ومنها خمسة أسطر منسقة: ar-SA بالتاريخ الميلادي، وتقويم أم القرى بالأرقام العربية ثم اللاتينية، والإنجليزية، ونفس اللحظة بتوقيت UTC، وتحتها ملاحظة بأن التقاويم الهجرية الثلاثة في ICU أعطت 19 و20 و18 جمادى الأولى
القيمة في قاعدة البيانات واحدة، وكل ما تراه على الشاشة تنسيق لها

وفي هذا القسم شيئان آخران. الأول أن التقويم الافتراضي لكل Locale جزء من بيانات CLDR، وهذه البيانات تتغير بين الإصدارات، ولذلك لا تعتمد على أن ar-SA سوف يعطي التقويم الميلادي أو الهجري، وإنما حدد التقويم دائماً بالامتداد ca أو بالخيار calendar في الدالة. والثاني أن التقويم يؤثر على حدود اليوم والشهر في التقارير، فإذا كانت الإدارة تريد تقرير مبيعات «شهر رمضان» فالمطلوب أن تحسب بداية الشهر ونهايته بتقويم أم القرى وبتوقيت السعودية، ثم تحولهما إلى UTC وتستعلم بهما قاعدة البيانات، وليس العكس. وفي المستقبل سوف يسهل الكائن Temporal في JavaScript هذا النوع من الحسابات، لأنه يدعم التقاويم والمناطق الزمنية في الحساب نفسه وليس في التنسيق فقط، فراجع دعم المتصفحات له قبل الاعتماد عليه.

التاريخ النسبي والقوائم

جزء كبير من أي واجهة هو التاريخ النسبي مثل «قبل 3 ساعات» و«أمس»، والقوائم مثل «أحمد وسارة وليلى»، وكلاهما يبنيه المطورون عادة بجمع النصوص فيخطئ في العربية. والواجهة Intl.RelativeTimeFormat تعرف أن اليوم السابق «أمس» واليوم الذي قبله «أول أمس»، وأن الأسبوعين «أسبوعين» وليس «2 أسابيع»، وIntl.ListFormat تعرف أن «و» في العربية تلتصق بالكلمة التالية بدون مسافة، كما ترى في القسم السادس من التجربة. ولاحظ في نفس القسم أن «قبل 3 ساعات» ظهرت بالأرقام اللاتينية، والسبب أننا استخدمنا الوسم ar وحده، وقد رأينا في القسم الأول أنه يختار latn، فلو استخدمنا ar-SA لظهرت بالأرقام العربية الهندية، وهذا يعني أن خلط الوسوم بين أجزاء التطبيق ينتج شاشة فيها نوعان من الأرقام. فاجعل الـ Locale قيمة واحدة تمر لكل دوال التنسيق، ولا تكتب الوسم بيدك في كل مكان.

صيغ الجمع: لماذا ظهرت «ملفات 2»؟

الكود الذي كتب «ملفات 2» كان يشبه هذا السطر الذي تجده في أغلب المشاريع التي بدأت بالإنجليزية:

const label = (n === 1 ? 'ملف' : 'ملفات') + ' ' + n;

وهذا السطر صحيح في الإنجليزية لأن فيها فئتين فقط للجمع، المفرد وكل ما عداه، ولكن العربية فيها ست فئات حسب جدول قواعد الجمع في CLDR: صفر zero، ومفرد one، ومثنى two، وقليل few للأعداد التي باقي قسمتها على 100 من 3 إلى 10، وكثير many للأعداد التي باقي قسمتها على 100 من 11 إلى 99، وفئة أخرى other لكل ما بقي مثل 100 و1000 والأعداد العشرية. والواجهة Intl.PluralRules لا تترجم شيئاً، وإنما تخبرك بالفئة التي ينتمي إليها العدد، ثم تختار أنت الجملة المناسبة لهذه الفئة من ملف الترجمة. وقد سألناها في التجربة عن مجموعة أعداد:

== 4) plural categories
ar categories              ["few","many","one","two","zero","other"]
en categories              ["one","other"]
0      zero
1      one
2      two
3      few
10     few
11     many
99     many
100    other
101    other
102    other
103    few
111    many
1000   other
1.5    other

لاحظ أن 103 عادت إلى الفئة few مثل 3، لأن القاعدة تنظر إلى باقي القسمة على 100، فالعربي يقول «103 ملفات» كما يقول «3 ملفات»، وأن 111 عادت إلى many مثل 11، وأن 100 و101 و102 في الفئة other، فنقول «100 ملف» و«101 ملف» و«102 ملف» عند كتابة الرقم بالأرقام، وأن العدد العشري 1.5 في other. والصورة التالية تجمع الفئات الست مع الجملة التي اخترناها لكل فئة:

ست بطاقات لفئات الجمع في العربية: zero للعدد 0 بجملة لا توجد ملفات، وone للعدد 1 بجملة ملف واحد، وtwo للعدد 2 بجملة ملفان، وfew لباقي القسمة من 3 إلى 10 بجملة 3 ملفات، وmany لباقي القسمة من 11 إلى 99 بجملة 11 ملفاً، وother لكل ما بقي بجملة 100 ملف، وتحتها مقارنة بالطريقة البسيطة التي تعطي ملفات 2
الفئة تحددها القاعدة، والجملة يكتبها المترجم في ملف الترجمة

ثم بنينا من هذه الفئات رسالة كاملة، والمهم في الكود أن الجملة كلها في ملف الترجمة، بما فيها مكان الرقم، وليس الكلمة فقط:

== 5) a message per category
0    لا توجد ملفات
1    ملف واحد
2    ملفان
3    ٣ ملفات
11   ١١ ملفاً
100  ١٠٠ ملف
102  ١٠٢ ملف
naive 11 -> ملفات 11 | naive 2 -> ملفات 2

والسطر الأخير يبين ما كانت تعرضه المنصة قبل الإصلاح. ولاحظ أن الفئة two لا تحتاج الرقم أصلاً، فالعربي يقول «ملفان» ولا يقول «2 ملفان»، وأن zero جملة مختلفة تماماً، وهذا ما لا يمكن الوصول إليه بإضافة لاحقة إلى كلمة.

ملفات الترجمة Message Catalogs والجمل المركبة

في التطبيقات الحقيقية لا تكتب الجمل في الكود كما فعلنا في التجربة، وإنما في ملفات ترجمة Message Catalogs، ملف لكل لغة، وكل جملة لها مفتاح ثابت. والصيغة الأكثر انتشاراً لوصف الجمل التي فيها جمع أو اختيار هي ICU MessageFormat، وتستخدمها مكتبات مثل FormatJS، وشكل الجملة السابقة فيها كما يلي:

{count, plural,
  zero {لا توجد ملفات}
  one {ملف واحد}
  two {ملفان}
  few {# ملفات}
  many {# ملفاً}
  other {# ملف}
}

والعلامة # هي مكان الرقم بعد تنسيقه حسب الـ Locale. أما مكتبة i18next فتستخدم طريقة مختلفة، فهي تضيف لاحقة الفئة إلى المفتاح، مثل files_zero وfiles_few، وتعتمد في اختيار الفئة على Intl.PluralRules نفسها كما تذكر وثائق الجمع فيها، ولذلك إذا كان التطبيق يعمل في بيئة لا تدعم Intl.PluralRules فالجمع العربي لن يعمل فيها. وهناك مقترح في TC39 لإضافة Intl.MessageFormat إلى JavaScript نفسها، وما زال في المرحلة الأولى Stage 1، فلا تعتمد عليه الآن.

وأياً كانت المكتبة، فهناك أخطاء في بناء الجمل يقع فيها الفريق مرة واحدة على الأقل:

  • جمع الجملة من قطع: مثل t('you_have') + n + t('files')، والسبب في أنه خطأ أن ترتيب الكلمات يختلف بين اللغات، والمترجم يرى قطعاً لا يعرف كيف تجتمع، فاكتب الجملة كاملة بمتغيراتها في مفتاح واحد.
  • إعادة استخدام كلمة في سياقين: كلمة Open في الإنجليزية قد تكون «افتح» على زر و«مفتوح» على حالة طلب، فاجعل لكل سياق مفتاحاً، واكتب للمترجم ملاحظة عن مكان ظهوره.
  • تجاهل التذكير والتأنيث: الرسالة «قام X بالتعليق» تصبح «قامت» للمستخدمة، والصيغة select في ICU MessageFormat تختار الجملة حسب قيمة متغير مثل الجنس، وإذا لم يكن الجنس معروفاً فاكتب الجملة بصيغة لا تحتاجه، مثل «تعليق جديد من X».
  • نسيان العزل داخل الجملة المترجمة: اسم المستخدم داخل جملة مترجمة يحتاج عزلاً مثل أي مكان آخر، فاجعل دالة التنسيق تعزل كل متغير نصي تلقائياً، بدلاً من الاعتماد على كل مطور في كل قالب.

ترتيب الأسماء العربية والبحث فيها

شكوى البحث في القصة كانت أن «احمد» لا يجد «أحمد»، وشكوى أخرى أن قائمة المدربين مرتبة بشكل غريب. والسبب في الثانية أن الدالة sort() بدون مقارن ترتب حسب رموز UTF-16، فتأتي الحروف اللاتينية الكبيرة قبل الصغيرة، ثم الحروف العربية حسب رموزها، فتأتي «آمنة» قبل «أحمد»، وتنفصل «أحمد» عن «احمد» فيقع بينهما «إبراهيم» و«ابراهيم». والواجهة Intl.Collator ترتب حسب خوارزمية الترتيب في UTS #10 وبيانات اللغة، ومخرجها في القسم السادس من التجربة يبين الفرق: الأسماء العربية أولاً لأن الـ Locale عربي، و«إبراهيم» بجانب «ابراهيم»، و«أحمد» بجانب «احمد»، ثم الأسماء الإنجليزية بدون تفريق بين الحرف الكبير والصغير.

أما البحث فيحتاج شيئاً إضافياً، وهو مستوى الحساسية Sensitivity، فالمقارن الافتراضي يعتبر «أحمد» و«احمد» مختلفين وإن رتبهما متجاورين، أما الخيار sensitivity: 'base' فيقارن الحروف الأساسية فقط، فأعاد true للمقارنة بينهما. وهذا يكفي للمقارنة داخل المتصفح في قائمة صغيرة، أما البحث في قاعدة البيانات فيحتاج توحيد النص Normalization عند الحفظ والبحث، مثل توحيد أشكال الهمزة وحذف التطويل، وهذا موضوع خاص بمحركات البحث يستحق مقالاً مستقلاً.

الأرقام التي يكتبها المستخدم

آخر تذكرة كانت عن العميل الذي كتب رقم جواله بالأرقام العربية الهندية، ولوحة المفاتيح العربية في بعض الأجهزة تكتب هذه الأرقام افتراضياً، فرفض النموذج الرقم. والسبب ظاهر في القسم السابع من التجربة:

== 7) parsing user digits
"٠٥٠١٢٣٤٥٦٧"     Number= NaN parseInt= NaN toAscii= "0501234567"
"۰۵۰۱۲۳۴۵۶۷"     Number= NaN parseInt= NaN toAscii= "0501234567"
"١٬٢٣٤٫٥"        Number= NaN parseInt= NaN toAscii= "1٬234٫5"
\d matches ٣ ?             false
\d with u flag ?           false
\p{Nd} matches ٣ ?         true
ar-SA separators           ["group=U+66C","decimal=U+66B"]

في المخرج أعلاه لاحظ التالي:

  • Number وparseInt أعادا NaN للأرقام العربية الهندية وللأرقام الفارسية ۰۵۰، وهي أرقام لها رموز أخرى من U+06F0 إلى U+06F9 يكتبها بعض المستخدمين إذا كانت لوحة مفاتيحهم فارسية أو أردية.
  • التعبير \d في JavaScript لا يطابق ٣ حتى مع العلامة u، لأنه يعني 0 إلى 9 فقط، ولذلك يفشل أي تحقق Validation بتعبير مثل /^05\d{8}$/، أما الفئة \p{Nd} في تعبيرات Unicode فتطابق كل الأرقام العشرية في كل الأنظمة.
  • الدالة toAscii حولت الرقم إلى 0501234567 فأصبح صالحاً، ولكنها في السطر الثالث حولت الأرقام فقط وتركت فاصل الآلاف ٬ والفاصلة العشرية ٫ كما هما، فالمبلغ «١٬٢٣٤٫٥» يحتاج أيضاً تحويل هذين الحرفين قبل قراءته، وقد طبعنا رمزيهما في السطر الأخير.

والقاعدة هنا أن تقبل الأرقام بأي نظام يكتبه المستخدم، ثم تحولها إلى الأرقام اللاتينية في أول نقطة تستقبلها، أي في المتصفح قبل التحقق، وفي السيرفر مرة أخرى قبل الحفظ، والسبب أن السيرفر لا يستطيع الاعتماد على تحقق المتصفح أصلاً، وأن الطلب قد يأتي من تطبيق جوال أو من API مباشرة. وفي حقول الأرقام استخدم inputmode="numeric" لتظهر لوحة الأرقام على الجوال، ولا تحاول منع المستخدم من كتابة الأرقام العربية، فهي ليست خطأ منه.

التجربة العملية Lab: في Node.js ثم في Chrome

التجربة من ثلاثة ملفات في المجلد lab بجانب المقال: الملف intl.mjs يطبع نتيجة كل دالة من دوال Intl ويعمل بدون تعديل في Node.js وفي المتصفح، والصفحة bidi.html فيها جدول بكل حالات النص المختلط قبل العزل وبعده، والملف visual.mjs يفتح الصفحة في Chrome ويقيس مكان كل حرف على الشاشة ليطبع الترتيب المرئي. ولا تحتاج التجربة إلى أي مكتبة، فكل ما فيها من الواجهات المدمجة في JavaScript.

الجزء الأول: Intl في Node.js

الملف intl.mjs كاملاً كما يلي:

// Intl lab for the i18n-l10n-arabic article. Runs in Node (node intl.mjs) and, pasted as a script, in Chrome.
// Every section prints real output from the ICU data the runtime ships with.
const out = [];
const log = (...a) => out.push(a.join(' '));
const show = (label, v) => log(label.padEnd(26), JSON.stringify(v));

if (typeof process !== 'undefined' && process.versions) {
  log('== 0) runtime');
  show('node', process.versions.node);
  show('icu', process.versions.icu);
  show('unicode', process.versions.unicode);
  show('cldr', process.versions.cldr);
  show('tz', process.versions.tz);
}

log('== 1) locale tags');
for (const tag of ['ar', 'ar-SA', 'ar-EG', 'ar-MA', 'ar-SA-u-nu-latn', 'ar-SA-u-ca-islamic-umalqura']) {
  const l = new Intl.Locale(tag);
  const r = new Intl.NumberFormat(tag).resolvedOptions();
  const d = new Intl.DateTimeFormat(tag).resolvedOptions();
  log(tag.padEnd(28), 'numberingSystem=' + r.numberingSystem, 'calendar=' + d.calendar, 'max=' + l.maximize().toString());
}

log('== 2) numbers');
const n = 1234567.891;
for (const tag of ['en-US', 'ar-SA', 'ar-EG', 'ar-MA', 'ar-SA-u-nu-latn', 'ar-AE']) show(tag, new Intl.NumberFormat(tag).format(n));
show('currency ar-SA', new Intl.NumberFormat('ar-SA', { style: 'currency', currency: 'SAR' }).format(1499.5));
show('currency ar-SA-u-nu-latn', new Intl.NumberFormat('ar-SA-u-nu-latn', { style: 'currency', currency: 'SAR' }).format(1499.5));
show('currency en-SA', new Intl.NumberFormat('en-SA', { style: 'currency', currency: 'SAR' }).format(1499.5));
show('percent ar-SA', new Intl.NumberFormat('ar-SA', { style: 'percent' }).format(0.15));
show('compact ar-SA', new Intl.NumberFormat('ar-SA', { notation: 'compact' }).format(2500000));
show('compact ar-SA long', new Intl.NumberFormat('ar-SA', { notation: 'compact', compactDisplay: 'long' }).format(2500000));
show('negative ar-SA', new Intl.NumberFormat('ar-SA').format(-42));
show('codepoints of ar-SA 15%', [...new Intl.NumberFormat('ar-SA', { style: 'percent' }).format(0.15)].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' '));

log('== 3) dates');
const t = new Date(Date.UTC(2026, 9, 30, 14, 30, 0)); // 2026-10-30T14:30:00Z
show('iso (stored)', t.toISOString());
const full = { dateStyle: 'full', timeStyle: 'short', timeZone: 'Asia/Riyadh' };
for (const tag of ['ar-SA', 'ar-SA-u-ca-gregory', 'ar-SA-u-ca-islamic-umalqura', 'ar-SA-u-ca-islamic-umalqura-nu-latn', 'ar-EG', 'en-US']) show(tag, new Intl.DateTimeFormat(tag, full).format(t));
show('islamic (civil?)', new Intl.DateTimeFormat('ar-SA-u-ca-islamic', { dateStyle: 'long', timeZone: 'Asia/Riyadh' }).format(t));
show('islamic-civil', new Intl.DateTimeFormat('ar-SA-u-ca-islamic-civil', { dateStyle: 'long', timeZone: 'Asia/Riyadh' }).format(t));
show('umalqura parts', new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', { year: 'numeric', month: 'numeric', day: 'numeric', timeZone: 'Asia/Riyadh' }).formatToParts(t).filter(p => p.type !== 'literal').map(p => p.type + '=' + p.value).join(' '));
show('same instant UTC', new Intl.DateTimeFormat('ar-SA', { timeStyle: 'short', timeZone: 'UTC' }).format(t));
show('hour12 ar-SA', new Intl.DateTimeFormat('ar-SA', { hour: 'numeric', minute: '2-digit', timeZone: 'Asia/Riyadh' }).format(t));

log('== 4) plural categories');
const pr = new Intl.PluralRules('ar');
show('ar categories', pr.resolvedOptions().pluralCategories);
show('en categories', new Intl.PluralRules('en').resolvedOptions().pluralCategories);
for (const k of [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 1000, 1.5]) log(String(k).padEnd(6), pr.select(k));

log('== 5) a message per category');
const files = { zero: 'لا توجد ملفات', one: 'ملف واحد', two: 'ملفان', few: '{n} ملفات', many: '{n} ملفاً', other: '{n} ملف' };
const nf = new Intl.NumberFormat('ar-SA');
const msg = (k) => files[pr.select(k)].replace('{n}', nf.format(k));
for (const k of [0, 1, 2, 3, 11, 100, 102]) log(String(k).padEnd(4), msg(k));
const naive = (k) => (k === 1 ? 'ملف' : 'ملفات') + ' ' + k;
log('naive 11 ->', naive(11), '| naive 2 ->', naive(2));

log('== 6) relative time, lists, sorting');
const rtf = new Intl.RelativeTimeFormat('ar', { numeric: 'auto' });
for (const [v, u] of [[-1, 'day'], [1, 'day'], [-2, 'day'], [-3, 'hour'], [-11, 'minute'], [2, 'week']]) show(`${v} ${u}`, rtf.format(v, u));
show('list and', new Intl.ListFormat('ar', { type: 'conjunction' }).format(['أحمد', 'Sara', 'ليلى']));
show('list or', new Intl.ListFormat('ar', { type: 'disjunction' }).format(['بطاقة', 'Apple Pay', 'تحويل']));
const names = ['يوسف', 'أحمد', 'إبراهيم', 'احمد', 'آمنة', 'ابراهيم', 'زياد', 'Zaid', 'adam', 'عمر'];
show('default sort()', [...names].sort());
show('Collator ar', [...names].sort(new Intl.Collator('ar').compare));
const base = new Intl.Collator('ar', { sensitivity: 'base' });
show('أحمد == احمد base', base.compare('أحمد', 'احمد') === 0);
show('أحمد == احمد default', new Intl.Collator('ar').compare('أحمد', 'احمد') === 0);

log('== 7) parsing user digits');
const toAscii = (s) => s.replace(/[٠-٩]/g, d => d.charCodeAt(0) - 0x0660).replace(/[۰-۹]/g, d => d.charCodeAt(0) - 0x06F0);
for (const s of ['٠٥٠١٢٣٤٥٦٧', '۰۵۰۱۲۳۴۵۶۷', '١٬٢٣٤٫٥']) log(JSON.stringify(s).padEnd(16), 'Number=', Number(s), 'parseInt=', parseInt(s, 10), 'toAscii=', JSON.stringify(toAscii(s)));
show('\\d matches ٣ ?', /\d/.test('٣'));
show('\\d with u flag ?', /\d/u.test('٣'));
show('\\p{Nd} matches ٣ ?', /\p{Nd}/u.test('٣'));
const parts = new Intl.NumberFormat('ar-SA').formatToParts(1234.5);
show('ar-SA separators', parts.filter(p => p.type === 'group' || p.type === 'decimal').map(p => p.type + '=U+' + p.value.codePointAt(0).toString(16).toUpperCase()));

log('== 8) bidi control characters in user input');
const BIDI_CONTROLS = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
const upload = 'invoice\u202Efdp.exe';
show('name (controls shown)', upload.replace(BIDI_CONTROLS, c => '<U+' + c.codePointAt(0).toString(16).toUpperCase() + '>'));
show('length', upload.length);
show('endsWith(".exe")', upload.endsWith('.exe'));
show('controls found', (upload.match(BIDI_CONTROLS) || []).map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase()));
show('cleaned', upload.replace(BIDI_CONTROLS, ''));
const isolate = (s) => '\u2068' + s.replace(BIDI_CONTROLS, '') + '\u2069';
show('isolated for display', [...isolate('user99')].map(c => c.codePointAt(0) > 127 ? 'U+' + c.codePointAt(0).toString(16).toUpperCase() : c).join(' '));

if (typeof module === 'undefined' && typeof window !== 'undefined') window.__out = out.join('\n');
else console.log(out.join('\n'));

ونشغله في Container من Image رسمي صغير لـ Node.js 22، ثم يحذف Docker الـ Container تلقائياً بعد انتهائه بالخيار --rm:

docker run --rm --name sfe-i18n-node -v "$PWD:/lab" -w /lab node:22-alpine node intl.mjs > node-output.txt

والمخرج يبدأ بإصدارات البيئة، وهي أهم سطور التجربة لأن كل ما بعدها يعتمد عليها:

== 0) runtime
node                       "22.23.4"
icu                        "78.3"
unicode                    "17.0"
cldr                       "48.0"
tz                         "2026e"

كل نتائج Intl تأتي من مكتبة ICU المدمجة في Node.js، وإصدارها هنا 78.3، ومعها بيانات CLDR 48.0 وUnicode 17.0 وقاعدة المناطق الزمنية 2026e، وإذا شغلت نفس الملف على إصدار آخر من Node.js فقد تختلف بعض النصوص، مثل التقويم الافتراضي أو شكل رمز العملة، لأن بيانات CLDR تتغير. ولهذا اطبع process.versions.icu في أي تقرير خطأ يتعلق بالتنسيق، ولاحظ أن وثائق Node.js تذكر أن بعض طرق بناء Node.js تأتي ببيانات ICU مختصرة لا تحتوي إلا الإنجليزية، فإذا رأيت العربية تخرج بتنسيق إنجليزي في بيئة معينة فهذا أول ما تتحقق منه. وقد مررنا على بقية الأقسام في أماكنها من المقال، وبقي القسم الثامن عن الحروف الخفية في مدخلات المستخدم:

== 8) bidi control characters in user input
name (controls shown)      "invoice<U+202E>fdp.exe"
length                     15
endsWith(".exe")           true
controls found             ["U+202E"]
cleaned                    "invoicefdp.exe"
isolated for display       "U+2068 u s e r 9 9 U+2069"

طول الاسم 15 حرفاً مع أن المرئي منه 14، والحرف الزائد هو RLO كما يبين السطر controls found، والدالة endsWith('.exe') أكدت أن الملف تنفيذي، وبعد حذف الحروف الخفية ظهر الاسم الحقيقي invoicefdp.exe. والسطر الأخير يبين شكل اسم المستخدم بعد الدالة isolate، فهو محاط بالحرفين U+2068 وU+2069، أي FSI وPDI، وهذا هو الشكل الذي ترسله في نص إشعار أو رسالة SMS.

الجزء الثاني: نفس الكود في Chrome، ثم العرض على الشاشة

الملف chrome.mjs يشغل Chrome بدون واجهة Headless عبر بروتوكول DevTools، ويفتح الصفحة bidi.html، ثم ينفذ نفس الملف intl.mjs داخل الصفحة ويحفظ مخرجه، ثم يلتقط صور الجدول. وأول ما فعلناه هو مقارنة مخرج المتصفح بمخرج Node.js بعد حذف سطور الإصدارات:

node lab/chrome.mjs
tail -n +7 node-output.txt > node.txt
tail -n +2 chrome-output.txt > chrome.txt
diff node.txt chrome.txt

والمخرج سوف يكون كما يلي:

37c37
< ar categories              ["few","many","one","two","zero","other"]
---
> ar categories              ["zero","one","two","few","many","other"]

كل النصوص المنسقة متطابقة بين Node.js 22 وChrome 156، فالأرقام والتواريخ والتقاويم الهجرية الثلاثة والترتيب والجمع أعطت نفس الحروف، والفرق الوحيد هو ترتيب أسماء الفئات في resolvedOptions().pluralCategories، فقد أعادها Node.js بترتيب أبجدي تقريباً، وأعادها Chrome بالترتيب الطبيعي من zero إلى other. والسبب تعديل في المعيار ECMA-402 جعل هذا الترتيب محدداً في التعديل رقم 918، وقد طبقه محرك V8 الأحدث في Chrome، أما V8 في Node.js 22 فأقدم منه. والدرس العملي أن هذه القائمة تصلح لمعرفة الفئات المتاحة، ولكن لا تقم ببناء واجهة المترجم أو أعمدة ملف الترجمة على ترتيبها، والسبب أنه اختلف أمامك بين بيئتين في نفس اليوم.

بعد ذلك صورنا جدول الحالات في الصفحة، وفي كل سطر النص بدون عزل في العمود الأوسط، ونفس النص بعد العزل في العمود الأيسر، والخلفية الحمراء للخطأ والخضراء للإصلاح:

لقطة من Chrome لجدول من أحد عشر سطراً: اسم مستخدم لاتيني ثم رقم، ونفس الحالة مع LRE وPDF، وspan بدون dir، ورقم جوال دولي، واسم لغة C++، والسعر بالعملة، ومسار ملف فيه اسم عربي، والمسار مع عزل كل جزء، والوقت بصيغة إنجليزية، والأقواس وعلامة المقارنة، واسم ملف فيه الحرف RLO، وفي كل سطر النص بدون عزل بخلفية حمراء ومع العزل بخلفية خضراء
كل حالة بدون عزل ومع العزل، كما رسمها Chrome 156

ولأن الصورة وحدها لا تكفي للتأكد من الترتيب، خاصة مع الأرقام والعلامات، كتبنا visual.mjs ليقيس موقع كل حرف على الشاشة بالدالة getBoundingClientRect على Range لكل حرف، ثم يرتب الحروف من اليسار إلى اليمين. ولأن الكلمات العربية لو طبعت كما هي داخل سطر المخرج لأعادت شاشتك ترتيبها من جديد، يطبع الملف كل كلمة عربية برمز مثل A1 ويكتب معناها في سطر الحالة. والأمر والمخرج كما يلي:

node lab/visual.mjs
case 1  A1=تعليقات
  bug: A1 user99 - 3
  fix: A1 3 - user99
case 2  A1=تعليقات
  bug: A1 <U+202A>user99<U+202C> - 3
  fix: A1 3 - <U+2069>user99<U+2066>
case 3  A1=تعليقات
  bug: A1 user99 - 3
  fix: A1 3 - user99
case 4  A1=اتصل A2=على A3=للدعم
  bug: A3 4567 123 50 966+ A2 A1
  fix: A3 +966 50 123 4567 A2 A1
case 5  A1=نبحث A2=عن A3=مطور A4=بخبرة A5=سنتين
  bug: A5 A4 ++C A3 A2 A1
  fix: A5 A4 C++ A3 A2 A1
case 6  A1=المجموع A2=شامل A3=الضريبة
  bug: A3 A2 SAR 1,499.50 :A1
  fix: A3 A2 1,499.50 SAR :A1
case 7  A1=حفظنا A2=الملف A3=في A4=تقارير A5=أمس
  bug: A5 pdf.2026/A4/home/sara/ A3 A2 A1
  fix: A5 /home/sara/2026/A4.pdf A3 A2 A1
case 8  A1=حفظنا A2=الملف A3=في A4=تقارير A5=أمس
  bug: A5 /home/sara/2026/A4.pdf A3 A2 A1
  fix: A5 /home/sara/A4/2026.pdf A3 A2 A1
case 9  A1=يبدأ A2=البث A3=بتوقيت A4=السعودية
  bug: A4 A3 PM 05:30 A2 A1
  fix: A4 A3 05:30 PM A2 A1
case 10  A1=الإصدار A2=أسرع، A3=لأن
  bug: 2 > 3 A3 A2 )v2.1( A1
  fix: 3 > 2 A3 A2 )v2.1( A1
case 11  A1=مرفق
  bug: invoiceexe.pdf<U+202E> :A1
  fix: invoicefdp.exe :A1
box 1 (rtl): !A3 A2 A1 React   A1=ممتازة A2=للمشاريع A3=الكبيرة
box 2 (ltr): React A3 A2 A1!   A1=ممتازة A2=للمشاريع A3=الكبيرة
box 3 (rtl): A5 A4 React A3 A2 A1   A1=هل A2=تنصحون A3=بـ A4=للمشاريع A5=الكبيرة؟
box 4 (ltr): 3 :2_A1 reviews   A1=عمر
box 5 (ltr): 2_A1: 3 reviews   A1=عمر

اقرأ كل سطر من اليسار إلى اليمين كما يظهر على الشاشة، والقارئ العربي يقرؤه من اليمين، ثم لاحظ التالي:

  • الحالة 1: بدون عزل ظهر user99 - 3 كتلة واحدة، فالرقم 3 هو أقرب شيء إلى يمين الشاشة، ومع العزل أصبح الاسم في أقصى اليمين وبعده الرقم ثم كلمة «تعليقات» A1.
  • الحالة 2: التضمين بالحروف U+202A وU+202C ترك الخطأ كما هو، والعزل بالحرفين U+2066 وU+2069 أصلحه. ولاحظ أن الحرفين ظهرا في المخرج بترتيب معكوس <U+2069>user99<U+2066>، لأن الحروف الخفية نفسها تأخذ مكاناً في الترتيب المرئي.
  • الحالة 3: <span> بدون dir لا يعزل شيئاً، و<span dir="ltr"> يعزل لأن Chrome يعطيه unicode-bidi: isolate، وقد طبع chrome.mjs هذه القيمة من getComputedStyle للعناصر الثلاثة bdi وspan[dir] وdiv[dir=auto].
  • الحالات 4 إلى 6 و9: الجوال ظهر بمجموعات معكوسة و+ في آخره، وC++ أصبحت ++C، والسعر والوقت انقلبت كتلتاهما، وكلها صلحت بالعزل.
  • الحالتان 7 و8: هذه المشكلة التي شرحناها في قسم المسار، فالعزل الخارجي في الحالة 7 لم يكف، وعزل كل جزء في الحالة 8 أعطى الترتيب الصحيح /home/sara/A4/2026.pdf.
  • الحالة 10: القوسان ظهرا في المخرج بترتيب )v2.1( لأن المخرج يطبع الحرف المخزن في كل موقع، والمتصفح رسم كل قوس بشكله المعكوس، فرأى المستخدم (v2.1) صحيحة، وهذا هو العكس Mirroring بعينه. وعلامة > كذلك رسمت معكوسة، فظهرت العبارة على الشاشة 2 < 3، ومع العزل بالاتجاه LTR ظهرت 3 > 2.
  • الحالة 11: الحرف RLO جعل fdp.exe تظهر exe.pdf، والعزل لم يغير ذلك، والإصلاح الوحيد كان حذف الحرف.
  • الصناديق 1 إلى 5: التعليق الذي يبدأ بكلمة React مع dir="auto" أخذ الاتجاه ltr وانتقلت علامة التعجب إلى اليمين، والاسم العربي في الصفحة الإنجليزية تغير مكان الرقم 2 والنقطتين حوله بدون عزل، ورجع صحيحاً مع <bdi>.

ماذا يحدث إذا غيرنا الوسم Language Tag أو اتجاه الصندوق؟

بعد أن عملت التجربة قمنا بتغيير شيء واحد في كل مرة لنرى أثره، وكل النتائج التالية موجودة في المخرجات أعلاه:

  • ماذا يحدث إذا استخدمنا ar بدلاً من ar-SA؟ تتغير الأرقام إلى اللاتينية كما في القسم الأول، ولهذا ظهر التاريخ النسبي في القسم السادس بالأرقام اللاتينية بينما بقية الشاشة بالأرقام العربية الهندية.
  • ماذا يحدث إذا استخدمنا islamic بدلاً من islamic-umalqura؟ يتقدم التاريخ يوماً كاملاً في مثالنا، من 19 إلى 20 جمادى الأولى، دون أي رسالة خطأ.
  • ماذا يحدث إذا وضعنا الاسم بين LRE وPDF بدلاً من LRI وPDI؟ يبقى الخطأ كما في الحالة 2، فالتضمين ليس عزلاً.
  • ماذا يحدث إذا وضعنا dir="auto" على تعليق عربي يبدأ بكلمة إنجليزية؟ تصبح الفقرة LTR كما في الصندوق 2.
  • ماذا يحدث إذا عزلنا المسار كله باتجاه LTR؟ تبقى أجزاؤه العربية مع الأرقام بعدها مقلوبة كما في الحالة 7، حتى نعزل كل جزء.

وإذا أردت أن تثبت هذه الحالات في مشروعك فاكتبها كاختبارات تلتقط صورة الصفحة بالـ Locale العربي، فأداة Playwright تسمح بتحديد locale وtimezoneId لكل اختبار كما في وثائق المحاكاة، وقد شرحنا بناء هذه الاختبارات في مقال اختبارات E2E مع Playwright، وأضف إليها اختباراً بتاريخ قريب من منتصف الليل بتوقيت السعودية واختباراً لكل فئة من فئات الجمع.

أخطاء شائعة

  1. اعتبار التعريب ترجمة فقط: الترجمة آخر خطوة، وقبلها يجب أن يكون الكود خالياً من الجمل الثابتة ومن التنسيق اليدوي ومن الجمل المركبة.
  2. إدخال اسم المستخدم في الجملة دون عزل: كل نص لا تتحكم فيه، من اسم أو عنوان أو رقم أو مسار، يدخل الصفحة داخل <bdi> أو عنصر عليه dir.
  3. استخدام <span> بدون dir للعزل: العنصر بدون الخاصية لا يعزل شيئاً، كما رأينا في الحالة 3.
  4. استخدام حروف التضمين القديمة LRE وRLE: استبدلها بحروف العزل LRI وRLI وFSI مع PDI.
  5. الثقة في dir="auto" للنص العربي المختلط: أول حرف قوي يحكم الفقرة كلها، فاحفظ الاتجاه الذي اختاره المستخدم إن أمكن.
  6. ترك الحروف الخفية في أسماء الملفات والمستخدمين: احذفها عند الإدخال، فهي أداة خداع وليست تنسيقاً.
  7. حفظ التاريخ أو الرقم كنص منسق: احفظ UTC والرقم الخام، ونسق عند العرض فقط.
  8. الاعتماد على التقويم الافتراضي أو استخدام islamic بدلاً من islamic-umalqura: حدد التقويم صراحة، وحدد المنطقة الزمنية صراحة.
  9. شرط المفرد والجمع: العربية فيها ست فئات، فاستخدم Intl.PluralRules وملف ترجمة فيه الفئات الست.
  10. رفض الأرقام العربية في النماذج: حولها إلى اللاتينية قبل التحقق، ولا تنس الفاصلين U+066B وU+066C.
  11. الترتيب بالدالة sort() بدون مقارن: استخدم Intl.Collator بالـ Locale الصحيح.
  12. اختلاف الوسم بين أجزاء التطبيق: اجعل الـ Locale قيمة واحدة تمر إلى كل دوال التنسيق.

قائمة تحقق Checklist قبل إطلاق الواجهة العربية

الجدول التالي يجمع المشكلات التي رأيناها مع الأداة المناسبة لكل منها، واستخدمه كقائمة مراجعة قبل الإطلاق:

المشكلةالأداةأين تطبقها
اتجاه الصفحة واللغة<html lang="ar" dir="rtl">القالب الرئيسي
اسم أو عنوان يكتبه المستخدم داخل جملة<bdi>كل قالب يعرض بيانات مستخدمين
رقم جوال، مسار، رابط، كود، معادلةdir="ltr" على عنصر، وعزل كل جزء في المساراتالقوالب
فقرة اتجاهها غير معروفdir="auto"، أو حفظ الاتجاه مع النصالتعليقات والرسائل وحقول الإدخال
نص بدون HTMLFSI ثم النص ثم PDIالإشعارات والبريد وSMS
الحروف الخفية في المدخلاتحذف U+202A إلى U+202E وU+2066 إلى U+2069السيرفر عند الحفظ
الأرقام والعملة والنسبIntl.NumberFormat مع قرار صريح nuعند العرض
التاريخ والتقويم الهجريUTC في القاعدة، وIntl.DateTimeFormat مع calendar وtimeZoneعند العرض
الجمعIntl.PluralRules وملف ترجمة بالفئات الستملفات الترجمة
الترتيب والبحثIntl.Collator مع sensitivity المناسبةالقوائم والفلاتر
الأرقام التي يكتبها المستخدمتحويل إلى اللاتينية ثم التحققالمتصفح والسيرفر
اختلاف البيئاتطباعة process.versions.icu واختبارات بلقطات شاشةCI

وإذا كنت تستعد لمقابلة عمل في الواجهات، فأسئلة الاتجاه والتعريب تظهر كثيراً، وقد جمعنا أمثلة منها في أسئلة مقابلة مطور Frontend.

تدريب عملي

  • عدل الملف intl.mjs ليطبع السعر 1499.5 بالوسوم ar-SA وar-EG وar-AE مع العملة المحلية لكل دولة، وقارن مكان رمز العملة والأرقام في كل منها.
  • أضف إلى الصفحة bidi.html سطراً لعنوان بريد إلكتروني اسمه عربي، مثل سارة@example.com، وجرب العزل الخارجي وحده ثم عزل كل جزء، كما فعلنا مع المسار.
  • اكتب دالة formatCount(n, key) تقرأ الجمل الست من ملف JSON وتختار منها بـ Intl.PluralRules، واختبرها على 0 و1 و2 و5 و11 و100 و103.
  • احسب بداية شهر رمضان 1448 ونهايته بتقويم أم القرى وبتوقيت السعودية، ثم حولهما إلى UTC، واكتب استعلام SQL يجلب الطلبات بينهما.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • التدويل i18n عمل في الكود يتم مرة واحدة، والتوطين l10n عمل لكل لغة ومنطقة، والترجمة وحدها لا تجعل التطبيق عربياً.
  • الـ Locale وسم BCP 47 يحمل اللغة والمنطقة والتقويم ونظام الأرقام، وar وحده ليس ar-SA.
  • الشاشة تعرض النص بترتيب تحسبه خوارزمية BiDi من أنواع الحروف، فالرقم يلتصق بأقرب حرف قوي قبله، والمحايد يأخذ اتجاه جيرانه، والأقواس تعكس.
  • الحل هو العزل: <bdi> للنص المجهول، وdir="ltr" للجوال والمسار والكود، وحروف FSI وPDI حيث لا يوجد HTML، والتضمين القديم لا يكفي، والنص الذي له بنية داخلية يحتاج عزل كل جزء.
  • الحروف الخفية تستخدم للخداع في أسماء الملفات والكود، فاحذفها من المدخلات.
  • الأرقام والتواريخ تحفظ خاماً وتنسق عند العرض بالواجهة Intl، مع تحديد التقويم islamic-umalqura والمنطقة الزمنية صراحة.
  • العربية فيها ست فئات للجمع، فاكتب الجملة كاملة لكل فئة في ملف الترجمة، ولا تجمع الجمل من قطع.
  • Node.js وChrome أعطيا نفس النصوص في تجربتنا، ولكن بيانات ICU وCLDR تتغير بين الإصدارات، فاطبع إصدارها واختبر الشاشات بالـ Locale العربي.

وفي مرحلة هندسة الواجهات وتطبيقات الويب تجد المقالات التي يكملها هذا المقال، ومنها تخطيط الصفحة للعربية بالخصائص المنطقية:

دليلCSS للعربية واتجاه RTL: الخصائص المنطقية Logical Propertiesكيف تكتب ملف CSS واحداً يعمل بالعربية والإنجليزية، ولماذا تخطئ margin-left في الصفحة العربية، مع قياسات حقيقية من Chrome.دليلكيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلاتماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.دليلالترميز Encoding للمطورين: Base64 وأخواتهلماذا نحول البايتات إلى حروف حتى تمر في البريد والروابط، وكيف تفك أي سلسلة منها، ولماذا لا يحمي Base64 أي سر.دليلسهولة الوصول Accessibility: موقع وتطبيق يستخدمه الجميعكيف تبني صفحات وتطبيقات يستخدمها الجميع بلوحة المفاتيح وقارئ الشاشة، من عناصر HTML الأصلية إلى النوافذ والنماذج العربية.دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة في Node.js 22.23.4 مع ICU 78.3 وCLDR 48.0 داخل Container من node:22-alpine، وفي Chrome 156 بدون واجهة.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.