

1/1/1970
كيف طبّقت i18n على 20 لغة خلال 3 أيام
مرحباً! انتهيت للتو من مهمة ضخمة ترجمت فيها Foony إلى 20 لغة مختلفة. كانت مهمة هائلة تطلّبت لمس تقريباً كل ملف في قاعدة الشيفرة، لكني مع ذلك أنجزت كل شيء خلال 3 أيام فقط.
فيما يلي أشرح كيف فعلت ذلك، والأرقام وراء هذا التغيير، ولماذا قررت أن أبني مكتبة الترجمة الخاصة بي (مرة أخرى) بدلاً من استخدام الحل القياسي في المجال.
لماذا ليس i18next؟
عندما بدأت أفكر في إضافة الترجمات، نظرت أولاً إلى المعيار المتعارف عليه في المجال: i18next و react-i18next.
لكنني قررت بدلاً من ذلك أن أُحسّن قابلية الصيانة بواسطة AI. i18next قوية، لكن تنوّع واجهتها البرمجية يمكن أن يجعل نماذج اللغة الكبيرة تهذي أو تكتب شيفرة غير متناسقة. من خلال تقييد المكتبة بدالتين بسيطتين t() و interpolate()، ضمنت أن أكثر من 10 وكلاء يعملون بالتوازي يمكنهم كتابة شيفرة مضمونة الأنواع بنسبة 100٪ مع تدخل بشري شبه معدوم.
كنت متوجسًا أيضًا من الانخراط في نظام بيئي ضخم قد يقدّم تغييرات مدمّرة للتوافق لاحقًا. بعد أن اكتويت من قبل بترقيات مؤلمة مثل React Router v5 و MUI v4 → v5، صرت أعرف أن كسر التوافقية مع الإصدارات السابقة بسرعة أمر شائع جدًا في عالم JavaScript. تكلفة إضافة ميزات الجمع لاحقًا أقل بكثير من تكلفة نقل 139 ألف سطر من الشيفرة يدويًا الآن.
كنت أريد شيئًا في غاية البساطة، وخفيفًا جدًا، ومصممًا تحديدًا لاحتياجات فريقي.
لذلك كتبت مكتبة خاصة بي.
أنشأت نواة محدودة الحجم لا تتجاوز 3 كيلوبايت، مصممة خصيصًا لتمكين إعادة هيكلة آلية عالية الدقة بواسطة AI. هذا سمح لي أن أتصرف كمهندس واحد ينجز عبء عمل فريق من 5 أشخاص في 3 أسابيع، خلال 3 أيام فقط.
التنفيذ المخصص
طوّرت مكتبة i18n بسيطة جدًا حجمها حوالي 3 كيلوبايت بعد الضغط بـ gzip. توفّر دالتين أساسيتين: getTranslation() للسياقات خارج React، و hook باسم 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;
// التحقق مما إذا كانت القيمة تحتوي على '/'، فهذا يعني وجود 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;
}
وهذا هو hook الخاص بـ React:
export function useTranslation() {
const [language] = useLanguage();
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 سطر شيفرة. وهي تتولى:
- تحميل ملفات الترجمة عند الحاجة فقط حتى لا نرسل اللغات العشرين كلها لكل مستخدم.
- تقسيم شيفرة الترجمات حسب "namespace" مثل
commonوmiscوgames/{gameId}. - لغة "debug" تعرض المفاتيح الخام حتى أستطيع التأكد من أن كل شيء موصول بشكل صحيح.
ولكي يبقى النظام سهل الصيانة، أضفت أيضًا توثيقًا شاملًا في shared/src/i18n/README.md يغطي كل شيء من بنية الملفات حتى أمثلة الاستخدام للعميل والخادم. بما أنني لا أستخدم مكتبة قياسية، فإن وجود هذا المرجع ضروري جدًا عند انضمام أعضاء جدد للفريق (أو حتى لتذكير نفسي في المستقبل أو تذكير نماذج اللغة بكيفية عمله).
بالأرقام
حتى تتكوّن لديك صورة عن حجم هذا التحديث، هذه هي التغييرات التي حدثت في قاعدة الشيفرة:
- 20 لغة مدعومة (بالإضافة إلى لغة debug خاصة بالتطوير).
- تم إنشاء 360 ملف لغة.
- 139,031 سطر من شيفرة الترجمات.
- إضافة 3,938 استدعاءً للدالة
t()في جانب العميل. - تعديل 728 ملف مصدر.
- 18 ملفًا باللغة الإنجليزية تُعد مصدر الحقيقة (16 لعبة + common + misc).
التنسيق باستخدام الوكلاء
لو قمت بكل هذا يدويًا لاحتجت إلى شهور من العمل الممل الميكانيكي. وبدلًا من ذلك، نسّقت عمل أكثر من عشرة وكلاء Cursor في الوقت نفسه ليتولوا الجزء الثقيل من المهمة.
بدأت بتقسيم قاعدة الشيفرة إلى "أقسام" حسب المجلدات. كل لعبة على Foony حصلت على مجلد خاص بها و namespace ترجمة خاص بها. هذا يبقي حجم التحميل الأولي صغيرًا، لأنك لا تحمّل إلا ترجمات اللعبة التي تلعبها.
شغّلت عدة وكلاء Cursor في وقت واحد. خصّصت لكل وكيل قسمًا محددًا، مثل "حوّل لعبة الشطرنج لاستخدام الترجمات"، ثم يمر الوكيل على الملفات واحدًا واحدًا، يبحث عن النصوص الظاهرة للمستخدم ويستبدلها باستدعاءات مثل t('games/chess/some.key').
بعد ذلك يضيف الوكيل هذا المفتاح إلى ملف اللغة الإنجليزية المناسب مع تعليق JSDoc يشرح "ما هو" النص و"أين" يُستخدم. هذا السياق مهم عند توليد الترجمات للغات الأخرى، لأنه يساعد نموذج اللغة على فهم ما إذا كانت كلمة "Save" تعني "حفظ إعدادات اللعبة" أو "حفظ رسمك في Draw & Guess".
ضبط الجودة
راجعت بسرعة كل الشيفرة التي وُلِّدت. كان أداء الوكلاء جيدًا بشكل مفاجئ، لكنهم ارتكبوا بعض الأخطاء أحيانًا، مثل وضع hook useTranslation بعد جملة return مبكرة.
استخدام ترجمات قوية النوعية ساعد بشكل هائل. هذا يضمن أن كل ترجمة في كل لغة تحتوي على جميع المفاتيح الصحيحة (ولا تحتوي على مفاتيح خاطئة). كما يضمن أن استدعاءات t() و interpolate() تستخدم نصوص ترجمة حقيقية موجودة فعلًا.
نظام الأنواع يستخرج كل مفاتيح الترجمة الممكنة من ملفات المصدر الإنجليزية:
/**
* يستخرج كل المسارات الممكنة من نوع كائن متداخل، وينشئ مفاتيح بصيغة dot-notation.
* مثال: {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 إنشاء سكربت يمرر كل واحد من هذه الملفات إلى نموذج لغوي كبير ليتولى التوليد، لكني أردت توفير بعض تكلفة استخدام النموذج. استخدام سكربت لتحديث الترجمات الناقصة فقط كان نهجًا أفضل، وعلى الأرجح سأستخدم حلاً مشابهًا في المستقبل. أود تتبّع أي النصوص تحتاج إلى تحديث أو ترجمة، لكنني أفضّل أن أبقي الأمور بسيطة. ربما أنقل عمل الترجمة إلى قاعدة بيانات أو ما يشبه ذلك.
أضفت أيضًا لغة "debug" لا تتوفر إلا في بيئة التطوير. هذا يسمح لي برؤية كل النصوص المستبدلة للتأكد من أن الأمور تعمل كما يجب (وفوق ذلك يبدو الأمر ممتعًا بالنسبة لي). عندما تستخدم لغة debug، تعيد 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 المناسب بطريقة lazy-loading. إذا كانت الترجمة مفقودة (لأنني لم أشغّل السكربت بعد)، يعود بسلاسة إلى النسخة الإنجليزية.
الخلاصة
في هذه المرحلة أصبح لدي موقع يعمل بالكامل ومترجم إلى كل اللغات العشرين!
كانت 3 أيام مجنونة، لكن النتيجة موقع مُوطَّن بالكامل يبدو (في الغالب) طبيعيًا للمستخدمين في كل أنحاء العالم. من خلال بناء مكتبة مخصّصة وخفيفة، والاعتماد على وكلاء AI لإنجاز أعمال إعادة الهيكلة المملة، استطعت تحقيق ما كان سيبدو مستحيلًا قبل عام واحد فقط: دعم i18n كامل خلال 3 أيام لموقع معقّد بواسطة مهندس واحد. مستقبل البرمجة لا يتعلق بكتابة الشيفرة بسرعة، بل بقدرتك على تنظيم عمل وكلاء الـ AI وامتلاك المعرفة العميقة بالمجال للتحقق من مخرجاتهم.