

1/1/1970
چطور i18n را در ۳ روز برای ۲۰ زبان پیادهسازی کردم
سلام! همین تازه یک کار خیلی بزرگ را تموم کردم: ترجمه کردن Foony به ۲۰ زبان مختلف. کاری بود که تقریبا به هر فایل توی کدبیس دست میزد، ولی تونستم همهاش را فقط توی ۳ روز جمع کنم.
پایینتر توضیح میدم چطور این کار را انجام دادم، عدد و رقمهای پشت این تغییر چی بودن، و چرا (باز هم) تصمیم گرفتم کتابخونه ترجمه خودم را بنویسم بهجای اینکه سراغ ابزار استاندارد صنعت برم.
چرا نه i18next؟
وقتی اول رفتم سراغ اضافه کردن ترجمه، گزینه استاندارد این حوزه را بررسی کردم: i18next و react-i18next.
ولی در عوض، تصمیم گرفتم روی قابلیت نگهداری توسط AI بهینه کنم. i18next قویه، اما تنوع APIهاش میتونه باعث بشه LLMها هذیان بگن یا کد نامنسجم بنویسن. با محدود کردن کتابخونه به یک t() و interpolate() ساده، مطمئن شدم بیشتر از ۱۰ عامل موازی میتونن ۱۰۰٪ type-safe کد تولید کنن، تقریبا بدون دخالت انسان.
از این هم میترسیدم که برم زیر بار یک اکوسیستم خیلی بزرگ که بعدا ممکنه تغییرات شکننده وارد کنه. قبلا با مهاجرتهای دردناک مثل React Router v5 و MUI v4 → v5 سوختم، و میدونم شکستن سریع سازگاری رو به عقب تو دنیای جاوااسکریپت خیلی هم رایجه. هزینه اینکه بعدا قابلیت جمع و مفرد و چیزهای مشابه را اضافه کنم، از اینکه الان بشینم ۱۳۹ هزار خط کد را دستی مهاجرت بدم کمتره.
چیزی میخواستم که خیلی ساده باشه، فوقالعاده سبک، و دقیقا مطابق نیازهای تیم خودم.
برای همین خودم نوشتمش.
یک زیرمجموعه محدود ۳ کیلوبایتی ساختم که مخصوص این طراحی شده بود که بازنویسی خودکار با دقت بالا توسط AI ممکن بشه. این اجازه را بهم داد که بهعنوان یک مهندس تنها، حجم کار ۳ هفتهای یک تیم ۵ نفره را فقط توی ۳ روز انجام بدم.
پیادهسازی اختصاصی
به یک کتابخونه i18n مینیمال رسیدم که حدود ۳ کیلوبایت (بهصورت gzipped) حجم داره. دو تابع اصلی در اختیار میذاره: 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;
// چک میکنیم آیا کلید شامل '/' هست یا نه؛ این یعنی 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();
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]);
}
هسته کل کتابخونه فقط حدود ۵۸۰ خط کد است. کارهای زیر را انجام میده:
- Lazy-loading کردن فایلهای ترجمه تا مجبور نباشیم هر ۲۰ زبان را برای هر کاربر بفرستیم.
- تکهتکه کردن ترجمهها بر اساس «namespace» (مثلا
common,misc,games/{gameId}). - یک locale مخصوص "debug" که کلیدهای خام را نشان میدهد تا مطمئن بشم همه چیز درست سیمکشی شده.
برای اینکه سیستم در طول زمان راحت قابل نگهداری بمونه، یک مستند کامل هم توی shared/src/i18n/README.md اضافه کردم که از ساختار فایلها تا مثالهای استفاده هم در سمت کلاینت و هم سرور را پوشش میده. چون از یک کتابخونه استاندارد استفاده نمیکنم، داشتن این مرجع برای آنبورد کردن اعضای جدید تیم خیلی مهم است (یا اینکه خودِ آیندهام یا LLMها را یادآوری کنم که داستان دقیقا چطور کار میکنه).
چند تا عدد و رقم
برای اینکه یک دید از مقیاس این آپدیت داشته باشی، اینها چیزهایی است که توی کدبیس تغییر کرد:
- ۲۰ تا زبان پشتیبانی میشه (بهاضافه یک locale مخصوص debug برای توسعه).
- ۳۶۰ تا فایل locale ساخته شد.
- ۱۳۹٬۰۳۱ خط کد ترجمه.
- ۳٬۹۳۸ بار صدا زدن
t()در سمت کلاینت اضافه شد. - ۷۲۸ فایل منبع ویرایش شد.
- ۱۸ فایل منبع انگلیسی که منبع اصلی حقیقت هستن (۱۶ بازی + common + misc).
هماهنگ کردن کار با Agentها
اگر میخواستم این کار را دستی انجام بدم، ماهها کار تکراری و خستهکننده طول میکشید. در عوض، بیش از دوازده Agent روی Cursor را همزمان هماهنگ کردم تا کار سنگین را انجام بدن.
اول کدبیس را بر اساس پوشهها به «بخش»های مختلف تقسیم کردم. هر بازی روی Foony پوشه خودش و namespace ترجمه خودش را گرفت. این کار باعث میشه حجم لود اولیه کوچک بمونه، چون فقط ترجمههای بازیای را لود میکنی که داری بازی میکنی.
چندتا Agent روی Cursor را همزمان اجرا کردم. به هر کدوم یک بخش مشخص سپردم، مثلا «بازی Chess را طوری تبدیل کن که از ترجمه استفاده کنه»، و Agent میرفت فایل به فایل، رشتههایی که کاربر میبینه را پیدا میکرد و جاشون را با t('games/chess/some.key') عوض میکرد.
بعد Agent آن کلید را به فایل locale انگلیسی مربوطه اضافه میکرد، همراه با یک کامنت JSDoc که توضیح میداد این رشته دقیقا چیست و کجاست. این کانتکست موقع تولید ترجمه برای زبانهای دیگه خیلی مهم است، چون کمک میکنه LLM بفهمه «Save» یعنی «Save Game Configuration» یا «Save Your Draw & Guess Drawing».
کنترل کیفیت
خیلی سریع همه کدی را که تولید شده بود مرور کردم. Agentها surprisingly خوب کار کرده بودن، ولی گاهی اشتباههای ریز هم میکردن، مثلا useTranslation را بعد از یک return زودهنگام میذاشتن.
ترجمههای strongly-typed خیلی کمک کرد. اینطوری مطمئن شدم برای هر locale همه کلیدهای درست وجود دارن (و هیچ کلید اشتباهی وارد نشده). همچنین تضمین میکرد که صدا زدنهای 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>}`
// ... و الی آخر برای همه بازیها
این کار autocomplete بینقص توی TypeScript میده و هر تایپ اشتباهی توی کلید ترجمه را همون موقع موقع کامپایل میگیره. Agentها نمیتونن اشتباههایی مثل t('games/ches/name') بکنن، چون TypeScript همون لحظه ارورش را نشون میده.
بومیسازی
وقتی کار تبدیل انگلیسی تموم شد، کارهای باقیمونده localeها را تکهتکه کردم. هر Agent مسئول تبدیل یک فایل locale انگلیسی به یک زبان مشخص شد.
مثلا به Agentها چنین پرامپتی دادم:
لطفا مطمئن شو که ar/games/dinomight.ts همه ترجمههای en/games/dinomight.ts را دارد.
از `export const account: DinomightTranslations = {` استفاده کن.
ادامه بده تا وقتی که دیگر هیچ خطای type برای فایل ترجمه خودت نبینی (اگر برای فایلهای دیگر خطا دیدی، نادیدهشان بگیر؛ تو به صورت موازی با Agentهای دیگری اجرا میشوی که مسئول آن فایلها هستند).
ترجمههایت باید عالی و مطابق با متن jsdocِ موجود در en باشند.
باید این کار را دستی و بدون نوشتن اسکریپتهای "helper" انجام بدهی و هیچ میانبری استفاده نکنی.
به این فکر کردم که به Cursor بگم یک اسکریپت بنویسه که هر کدام از این فایلها را به یک LLM بده تا ترجمه تولید کنه، ولی میخواستم کمی روی هزینه LLM صرفهجویی کنم. استفاده از اسکریپتی که فقط ترجمههای جاافتاده را بهروزرسانی کنه، رویکرد بهتری بود و احتمالا در آینده هم از راهحل مشابهی استفاده میکنم. دوست دارم رد اینکه کدام رشتهها نیاز به بهروزرسانی / ترجمه دارن را بگیرم، ولی میخوام همه چیز ساده بمونه. شاید بعدا کار ترجمه را به یک دیتابیس یا یک سیستم مشابه منتقل کنم.
یک locale "debug" هم اضافه کردم که فقط در حالت توسعه در دسترس است. این اجازه را میده همه رشتههای جایگزینشده را ببینم و مطمئن بشم همه چیز کار میکنه (بهعلاوه اینکه به نظرم باحال است). وقتی از locale debug استفاده کنی، t() خود کلید را داخل براکت برمیگردونه:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
پس بهجای اینکه «Welcome to Foony!» ببینی، ⟦welcome⟧ را میبینی، که پیدا کردن ترجمههای جاافتاده را خیلی راحت میکنه.
در نهایت، یک Agent دیگر هم روتینگ /{locale}/** را پیادهسازی کرد تا مثلا /ja/games/chess به زبان درست (اینجا ژاپنی) روت بشه.
ترجمه کردن وبلاگ
ترجمه کردن رشتههای UI یک موضوع بود، اما با پستهای وبلاگ چه کار باید میکردم؟ نمیخواستم باز هم Agentهای بیشتری برای ترجمه همه پستهای وبلاگ بالا بیارم و مدیریت کنم.
این مشکل را با این حل کردم که به یک Agent گفتم اسکریپتی (scripts/src/generateBlogTranslations.ts) بسازه که کل فرایند را اتوماتیک کنه.
کارش اینطوری است:
- فولدر
client/src/posts/enرا برای فایلهای MDX انگلیسی اسکن میکند. - توی پوشههای locale دیگر (مثلا
posts/ja,posts/es) دنبال ترجمههای جاافتاده میگردد. - اگر ترجمهای وجود نداشته باشد، محتوای انگلیسی را میخواند و آن را با یک پرامپت مشخص به Gemini 3 Pro Preview میدهد تا متن را ترجمه کند، در حالی که فرمت Markdown حفظ شود.
- فایل جدید را در مسیر درست ذخیره میکند.
در فرانتاند، از import.meta.glob برای import داینامیک همه این فایلهای MDX استفاده میکنم. کامپوننت PostPage بعد فقط locale فعلی کاربر را چک میکند و همون فایل MDX درست را lazy-load میکند. اگر ترجمهای وجود نداشت (چون هنوز اسکریپت را برای آن پست اجرا نکردم)، با ظرافت برمیگردد روی نسخه انگلیسی.
جمعبندی
اینجا بود که یک سایت کاملا کارا داشتم که به همه ۲۰ تا locale ترجمه شده بود!
این ۳ روز خیلی عجیب و شلوغ بود، اما نتیجهاش یک سایت کاملا بومیسازیشده است که برای کاربرهای سراسر دنیا (تقریبا) مثل یک سایت native حس میشه. با ساختن یک کتابخونه اختصاصی و سبک و استفاده از Agentهای AI برای ریفکتورهای خستهکننده، کاری را انجام دادم که شاید یک سال پیش عملا غیرممکن به نظر میرسید: i18n کامل در ۳ روز برای یک وبسایت پیچیده، فقط توسط یک مهندس.
آینده برنامهنویسی این نیست که فقط کد را سریعتر بنویسیم. درباره این است که بتوانیم Agentهای AI را خوب هماهنگ کنیم و آنقدر توی حوزه خودمان عمیق باشیم که خروجیشان را درست و دقیق بررسی کنیم.