

1/1/1970
كيف طبّقت دعم 20 لغة في 3 أيام
أهلاً! لقد أنهيت للتو مهمة ضخمة، حيث ترجمت Foony إلى 20 لغة مختلفة. كانت مهمة هائلة تطلّبت تعديل كل ملف تقريباً في قاعدة الكود، لكنني تمكنت من إنجازها بالكامل في 3 أيام فقط.
سأشرح فيما يلي كيف فعلت ذلك، والأرقام المحددة وراء هذا التغيير، ولماذا قررت كتابة مكتبة الترجمة الخاصة بي (مرة أخرى) بدلاً من استخدام المعيار الصناعي.
لماذا لم أستخدم i18next؟
عندما فكرت أول مرة في إضافة الترجمات، نظرت في المعيار الصناعي: i18next و react-i18next.
بدلاً من ذلك، قررت التحسين من أجل قابلية الصيانة عبر الذكاء الاصطناعي. مكتبة i18next قوية، لكن تنوع واجهتها البرمجية قد يجعل نماذج LLM تهلوس أو تكتب كوداً غير متسق. من خلال تقييد المكتبة بدالتَي t() و interpolate() بسيطتين، ضمنت أن أكثر من 10 وكلاء يعملون بالتوازي يستطيعون كتابة كود آمن النوع 100% بأقل تدخل بشري ممكن.
كنت حذراً أيضاً من الانخراط في نظام بيئي كبير قد يُدخل تغييرات كاسرة لاحقاً. بعد أن لُسعت من ترحيلات مؤلمة مثل React Router v5 و MUI v4 → v5، أعرف أن الكسر السريع للتوافق مع الإصدارات السابقة شائع جداً في عالم JavaScript. تكلفة إضافة ميزات الجمع لاحقاً أقل من تكلفة ترحيل 139 ألف سطر من الكود يدوياً الآن.
أردت شيئاً بسيطاً جداً، خفيفاً للغاية، ومصمماً تماماً لاحتياجات فريقي.
فكتبت مكتبتي الخاصة.
بنيت مجموعة فرعية مقيدة بحجم 3 كيلوبايت مصممة خصيصاً لتمكين إعادة هيكلة دقيقة وذاتية بواسطة الذكاء الاصطناعي. هذا سمح لي أن أعمل كمهندس واحد لأنجز عبء عمل فريق من 5 أشخاص يستغرق 3 أسابيع، في 3 أيام فقط.
التطبيق المخصص
ابتكرت مكتبة i18n بسيطة بحجم حوالي 3 كيلوبايت بعد الضغط بـ gzip. تكشف عن دالتين رئيسيتين: getTranslation() للسياقات غير المتعلقة بـ React وخطّاف useTranslation() للمكونات.
تُرجع هاتان الدالتان t() لاستبدال نصوص بسيط و interpolate() عندما أحتاج إلى حقن مكونات React في سلسلة ترجمة (مثل رابط أو أيقونة). كلتا الدالتين تدعمان استبدال المتغيرات، مثلاً "Hello {{thing}}", {thing: 'World'}.
تتبع المفاتيح صيغة "شرطة-نقطة" (الشرطات للمسار إلى ملف الترجمة، والنقاط للكائنات المتداخلة داخل الملف). لضمان التفرد، مفاتيح الترجمة داخل الملف لا يمكن أن تحتوي على شرطات مائلة للأمام.
إليك دالة t() الأساسية:
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
وخطّاف React:
export function useTranslation() {
const [language] = useLanguage();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
نواة المكتبة بأكملها لا تتعدى 580 سطراً من الكود. تتعامل مع:
- التحميل الكسول لملفات الترجمة بحيث لا نُرسل جميع اللغات الـ 20 لكل مستخدم.
- تقسيم الكود للترجمات حسب "namespace" (مثل
common،misc،games/{gameId}). - لغة "تصحيح" تُظهر المفاتيح الخام لأتحقق من أن كل شيء موصل بشكل صحيح.
لضمان بقاء النظام سهل الصيانة، أضفت أيضاً توثيقاً شاملاً في shared/src/i18n/README.md يغطي كل شيء من بنية الملفات إلى أمثلة الاستخدام للعميل والخادم. بما أنني لا أستخدم مكتبة قياسية، فإن وجود هذا المرجع أمر بالغ الأهمية لتعريف أعضاء الفريق الجدد (أو لتذكير نفسي المستقبلي أو نماذج LLM بكيفية عمله).
بالأرقام
لإعطائك فكرة عن حجم هذا التحديث، إليك ما تغير في قاعدة الكود:
- 20 لغة مدعومة (بالإضافة إلى لغة تصحيح للتطوير).
- 360 ملف لغة تم إنشاؤه.
- 139,031 سطراً من كود الترجمة.
- 3,938 استدعاء لـ
t()تمت إضافتها عبر العميل. - 728 ملف مصدر تم تعديله.
- 18 ملف مصدر إنجليزي تعمل كمصدر الحقيقة (16 لعبة + common + misc).
التنسيق مع الوكلاء
القيام بهذا يدوياً كان سيستغرق أشهراً من العمل الميكانيكي المُمل. بدلاً من ذلك، نسّقت أكثر من اثني عشر وكيل Cursor في الوقت نفسه للقيام بالعمل الشاق.
بدأت بتقسيم قاعدة الكود إلى "أقسام" بناءً على المجلدات. كل لعبة في Foony حصلت على مجلدها الخاص و namespace ترجمة خاص بها. هذا يحافظ على حجم التحميل الأولي صغيراً لأنك تُحمّل ترجمات اللعبة التي تلعبها فقط.
شغّلت عدة وكلاء Cursor في الوقت نفسه. أسندت لكل وكيل قسماً محدداً، مثل "حوّل لعبة الشطرنج لاستخدام الترجمات"، وكان يمر عبر الملفات واحداً تلو الآخر، يجد النصوص الموجهة للمستخدم ويستبدلها بـ t('games/chess/some.key').
ثم يضيف الوكيل ذلك المفتاح إلى ملف اللغة الإنجليزية المناسب مع تعليق JSDoc يشرح "ماهية" و "موضع" النص. هذا السياق مهم عند توليد الترجمات للغات الأخرى، لأنه يساعد LLM على فهم ما إذا كان "Save" يعني "حفظ إعدادات اللعبة" أو "حفظ رسمتك في Draw & Guess".
مراقبة الجودة
راجعت بسرعة كل الكود الذي تم توليده. كان الوكلاء جيدين بشكل مفاجئ، لكنهم ارتكبوا أخطاء عرضية أحياناً، مثل وضع خطّاف useTranslation بعد عبارة return مبكرة.
ساعدت الترجمات ذات النوع القوي بشكل هائل. هذا ضمن أن جميع الترجمات لكل لغة تحتوي على جميع المفاتيح الصحيحة (ولا تحتوي على أي مفتاح خاطئ). كما ضمن أن استدعاءات t() و interpolate() تستخدم سلاسل ترجمة حقيقية موجودة.
نظام الأنواع يستخرج جميع مفاتيح الترجمة الممكنة من ملفات المصدر الإنجليزية:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
هذا يوفر إكمالاً تلقائياً مثالياً في TypeScript، وأي خطأ إملائي في مفتاح الترجمة يُلتقط وقت التجميع. لا يستطيع الوكلاء ارتكاب أخطاء مثل t('games/ches/name') لأن TypeScript يضع علامة عليها فوراً.
الترجمة المحلية
بمجرد الانتهاء من التحويل الإنجليزي، قسّمت مهام اللغات المتبقية. جعلت كل وكيل مسؤولاً عن تحويل ملف لغة إنجليزي واحد إلى لغة محددة.
على سبيل المثال، أعطيت الوكلاء موجهاً مثل هذا:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
فكرت في جعل Cursor يُنشئ سكربتاً لتغذية كل من هذه الملفات إلى LLM ويولّد الأشياء، لكنني أردت توفير القليل في تكلفة LLM. كان استخدام سكربت لتحديث الترجمات المفقودة فقط هو النهج الأفضل، وعلى الأرجح سأستخدم حلاً مماثلاً في المستقبل. أود تتبع النصوص التي تحتاج إلى تحديث / ترجمة، لكنني أريد إبقاء الأمور بسيطة. قد أنقل عمل الترجمة إلى قاعدة بيانات أو ما شابه.
أضفت أيضاً لغة "تصحيح" متاحة في وضع التطوير فقط. هذا يتيح لي عرض جميع النصوص المستبدلة للتحقق من أن الأمور تعمل (بالإضافة إلى أنني أعتقد أن ذلك رائع). عندما تستخدم لغة التصحيح، تُرجع t() المفتاح ملفوفاً بأقواس:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
لذا بدلاً من رؤية "Welcome to Foony!"، سترى ⟦welcome⟧، مما يجعل من السهل اكتشاف أي ترجمات مفقودة.
أخيراً، نفّذ وكيل آخر التوجيه /{locale}/** بحيث تتجه أشياء مثل /ja/games/chess إلى اللغة الصحيحة (في هذه الحالة اليابانية).
ترجمة المدونة
ترجمة نصوص واجهة المستخدم شيء، لكن ماذا عن منشورات المدونة؟ لم أرغب في تشغيل وإدارة المزيد من الوكلاء لترجمة جميع منشورات مدونتي.
حللت هذا بجعل وكيل يُنشئ سكربتاً (scripts/src/generateBlogTranslations.ts) يُؤتمت العملية بالكامل.
إليك كيف يعمل:
- يفحص دليل
client/src/posts/enبحثاً عن ملفات MDX إنجليزية. - يتحقق من الترجمات المفقودة في مجلدات اللغات الأخرى (مثل
posts/ja،posts/es). - إذا كانت هناك ترجمة مفقودة، يقرأ المحتوى الإنجليزي ويغذيه إلى Gemini 3 Pro Preview بموجه محدد لترجمة المحتوى مع الحفاظ على تنسيق Markdown.
- يحفظ الملف الجديد في الموقع الصحيح.
في الواجهة الأمامية، أستخدم import.meta.glob لاستيراد جميع ملفات MDX هذه ديناميكياً. ثم يتحقق مكوّن PostPage ببساطة من اللغة الحالية للمستخدم ويُحمّل ملف MDX الصحيح بشكل كسول. إذا كانت الترجمة مفقودة (لأنني لم أُشغّل السكربت بعد)، فإنه يعود بسلاسة إلى الإنجليزية.
اليوم 4: توليد الترجمة الآلي
كنت أعرف أن الحل الأصلي لن يتوسع. لذا، الآن بعد أن أصبح i18n جاهزاً، حان الوقت لتقويته قليلاً بنهج مدفوع بقاعدة بيانات.
باختصار: عندما تتغير النصوص الإنجليزية أو تعليقات JSDoc، تحتاج الترجمات إلى إعادة توليد. التتبع اليدوي لما يحتاج إلى تحديث كان سيكون عرضة للخطأ ومضيعة لوقت المطور.
لذا بنيت الحل الذي خططت له أصلاً: نظام توليد ترجمة مدعوم بـ PostgreSQL.
مخطط قاعدة البيانات
أضفت جدول translations إلى قاعدة بيانات PostgreSQL لدينا بالبنية التالية:
key: مفتاح الترجمة بصيغة "شرطة-نقطة" (مثل"games/yacht/nested.name"،"config.timeLimit.label").en_value: قيمة المصدر الإنجليزيةtarget_locale: رمز اللغة الهدف (مثل"es"،"fr"،"zh")target_value: القيمة المترجمةcontext: حقل JSONB يحتوي على JSDoc لهذا المفتاح وجميع المفاتيح الأصليةcreated_atوupdated_at: طوابع زمنية للتتبع
الفهرس الفريد على (key, target_locale, en_value, context). هذا أمر حاسم: بتضمين context في القيد الفريد، يمكننا اكتشاف تغيرات تعليقات JSDoc تلقائياً وإعادة توليد الترجمات. الترجمات القديمة تُحفظ كمرجع تاريخي.
سكربت التوليد
أنشأت scripts/src/generateLocalizations.ts الذي يُؤتمت سير عمل الترجمة بالكامل:
- استخراج المفاتيح الإنجليزية: يستخدم تحليل AST (ts-morph) لاستخراج جميع مفاتيح الترجمة من ملفات
shared/src/i18n/locales/en/**، معالجاً التصديرات الافتراضية فقط - استخراج سياق JSDoc: يحلل تعليقات JSDoc لكل مفتاح وجميع المفاتيح الأصلية (الكائنات الأم) لتوفير سياق غني
- استعلام قاعدة البيانات: يفحص الترجمات الموجودة في PostgreSQL، مطابقاً على
keyوtarget_localeوen_valueوcontext. إذا تغير أي منها، تُعاد ترجمته. - تحديد المفاتيح المفقودة/المتغيرة: يجد المفاتيح التي تحتاج إلى ترجمة أو تغيرت قيم/تعليقات الإنجليزية فيها
- تجميع الترجمات في دفعات: يُجمّع حسب اللغة وبادئة namespace لإجراء استدعاءات LLM أكثر كفاءة (يجعل الترجمة أسرع أيضاً). إذا كانت الدفعة كبيرة جداً، فستتدهور جودة الترجمة.
- توليد الترجمات: يستخدم GPT 5.1 مع سياق شامل (JSDoc، اللغة + المنطقة، النبرة، المعجم، الأمثلة). قرأت أن 5.1 أفضل من 5.2 للكتابة (لا يبدو باهتاً)، لكنني لم أتأكد.
- فحوصات ضمان الجودة: يتحقق من الحفاظ على العناصر النائبة، مثل
{{name}}، وسلامة المفاتيح، وتنسيق JSON - التخزين في قاعدة البيانات: يحفظ الترجمات بالسياق الكامل (JSDoc + JSDoc الأصلي)
- توليد ملفات اللغة: يقرأ من قاعدة البيانات ويكتب ملفات لغة TypeScript منسقة بشكل صحيح بأنواع
RecursivePartial
الفوائد الرئيسية
يوفر هذا النهج العديد من تحسينات تجربة المطور:
- إعادة التوليد التلقائي: عندما يتغير النص الإنجليزي أو تعليقات JSDoc، تُعاد الترجمات تلقائياً. لذا إذا قال أحدهم إن ترجمة سيئة، فمن السهل جداً إعادة توليد الترجمات بتوفير المزيد من السياق كتعليق.
- سياق غني: تعليقات JSDoc توفر سياق الترجمة (مثل "رسالة خطأ تظهر للاعبين، بحد أقصى 15 حرفاً")، مما يساعد LLM على إنتاج ترجمات أكثر دقة
- سياق المفاتيح الأصلية: JSDoc الكائن الأم يوفر سياق namespace (مثل "إنجاز لكونك في لعبة حيث دُمّرت جميع البيض")، مما يعطي القليل من الوضوح الإضافي
- التتبع التاريخي: الترجمات القديمة تُحفظ في قاعدة البيانات. لا تأخذ مساحة كبيرة، لذا لا أرى سبباً كبيراً لحذفها الآن، ومن الرائع رؤية التاريخ.
التفاصيل التقنية
يستخدم التطبيق عدة تقنيات لضمان الموثوقية والكفاءة:
- استخراج قائم على AST لضمان الحصول على التعليقات الصحيحة
- معالجة متوازية باستخدام Semaphore للترجمة المُجمّعة المتزامنة
- منطق إعادة المحاولة بتراجع أُسّي لإخفاقات API. استدعاءات LLM متقلبة بشكل سيئ السمعة.
يمكن تشغيل السكربت بـ npm run generate-localizations من دليل scripts. يتصل بـ PostgreSQL ويعالج جميع الترجمات المفقودة أو المتغيرة لجميع اللغات المدعومة عند تشغيله.
الخاتمة
في هذه المرحلة، أصبح لديّ موقع كامل الوظائف مترجم إلى جميع اللغات الـ 20!
كانت 3 أيام مجنونة، لكن النتيجة موقع مترجم بالكامل يبدو (في معظمه) أصلياً للمستخدمين حول العالم. ببناء مكتبة مخصصة وخفيفة الوزن والاستفادة من وكلاء الذكاء الاصطناعي للعمل الممل لإعادة الهيكلة، تمكنت مما كان مستحيلاً قبل عام واحد فقط: i18n كامل في 3 أيام لموقع معقد بواسطة مهندس واحد. مستقبل البرمجة لا يتعلق بكتابة الكود بسرعة. بل يتعلق بتنسيق وكلاء الذكاء الاصطناعي وامتلاك خبرة عميقة في المجال للتحقق من مخرجاتهم.