

1/1/1970
چطور در ۳ روز i18n را برای ۲۰ زبان پیادهسازی کردم
سلام! تازه یه کار عظیم رو تموم کردم: Foony رو به ۲۰ زبان مختلف ترجمه کردم. کار خیلی بزرگی بود که تقریباً به همه فایلهای کدبیس دست زد، ولی تونستم همهاش رو تو فقط ۳ روز انجام بدم.
در ادامه توضیح میدم چطور این کار رو انجام دادم، اعداد دقیق پشت این تغییر چی بودن، و چرا (یه بار دیگه) تصمیم گرفتم به جای استاندارد صنعتی، کتابخانه ترجمه خودم رو بنویسم.
چرا i18next نه؟
وقتی اولین بار به اضافه کردن ترجمهها فکر کردم، استاندارد صنعتی یعنی i18next و react-i18next رو در نظر گرفتم.
اما تصمیم گرفتم برای نگهداری توسط هوش مصنوعی بهینهسازی کنم. i18next قدرتمنده، ولی تنوع API اش میتونه باعث بشه LLMها توهم بزنن یا کد ناسازگار بنویسن. با محدود کردن کتابخانه به یه t() و interpolate() ساده، مطمئن شدم که بیش از ۱۰ ایجنت موازی میتونن کد ۱۰۰٪ type-safe بنویسن، تقریباً بدون دخالت انسانی.
همچنین نسبت به وابسته شدن به یه اکوسیستم بزرگ که ممکنه بعداً تغییرات شکننده ایجاد کنه، محتاط بودم. بعد از تجربه مهاجرتهای دردناکی مثل React Router نسخه ۵ و MUI نسخه ۴ به ۵، میدونم که شکستن سریع سازگاری با نسخههای قبلی تو دنیای جاوااسکریپت خیلی رایجه. هزینه اضافه کردن قابلیتهای جمع و مفرد در آینده، کمتر از هزینه مهاجرت دستی ۱۳۹ هزار خط کد در حال حاضره.
یه چیز خیلی ساده، فوقالعاده سبک، و دقیقاً متناسب با نیازهای تیمم میخواستم.
پس خودم نوشتمش.
یه زیرمجموعه محدود ۳ کیلوبایتی ساختم که مخصوص فعالسازی refactoring خودکار و دقیق با هوش مصنوعی طراحی شده. این به من اجازه داد به عنوان یه مهندس تکی، بار کاری ۳ هفتهای یه تیم ۵ نفره رو تو فقط ۳ روز انجام بدم.
پیادهسازی سفارشی
یه کتابخانه i18n مینیمال درست کردم که حدود ۳ کیلوبایت gzipped حجم داره. دو تابع اصلی داره: getTranslation() برای کانتکستهای غیر React و یه هوک useTranslation() برای کامپوننتها.
اینها t() رو برای جایگزینی ساده رشته و interpolate() رو برای وقتی که لازمه کامپوننتهای React (مثل لینک یا آیکون) رو داخل یه رشته ترجمه تزریق کنم، برمیگردونن. هر دو تابع از جایگزینی متغیر پشتیبانی میکنن، مثلاً "Hello {{thing}}", {thing: 'World'}.
کلیدها از نمادگذاری "اسلش-نقطه" پیروی میکنن (اسلشها برای مسیر فایل به فایل localization، نقطهها برای آبجکتهای تو در تو داخل فایل). برای اطمینان از یکتایی، کلیدهای ترجمه داخل یه فایل نمیتونن اسلش رو به جلو داشته باشن.
این هم تابع اصلی 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]);
}
هسته کل کتابخانه فقط حدود ۵۸۰ خط کده. این موارد رو هندل میکنه:
- بارگذاری تنبل (lazy-loading) فایلهای ترجمه، تا همه ۲۰ زبان رو به هر کاربر ارسال نکنیم.
- تقسیم کد ترجمهها بر اساس "namespace" (مثلاً
common,misc,games/{gameId}). - یه locale «debug» که کلیدهای خام رو نشون میده تا بتونم درستی اتصالات همه چیز رو تأیید کنم.
برای اینکه مطمئن بشم سیستم به آسونی قابل نگهداری بمونه، یه مستندات جامع هم تو shared/src/i18n/README.md اضافه کردم که از ساختار فایلها تا مثالهای استفاده برای کلاینت و سرور رو پوشش میده. چون از یه کتابخانه استاندارد استفاده نمیکنم، داشتن این مرجع برای آشنا کردن اعضای جدید تیم (یا حتی یادآوری به خودم در آینده یا LLMها که چطور کار میکنه) حیاتیه.
با اعداد
برای اینکه حسی از مقیاس این بهروزرسانی به دست بیارید، این چیزیه که تو کدبیس تغییر کرد:
- ۲۰ زبان پشتیبانی شده (به علاوه یه locale debug برای توسعه).
- ۳۶۰ فایل locale ساخته شده.
- ۱۳۹,۰۳۱ خط کد ترجمه.
- ۳,۹۳۸ فراخوانی
t()در سراسر کلاینت اضافه شده. - ۷۲۸ فایل سورس تغییر کرده.
- ۱۸ فایل سورس انگلیسی که به عنوان منبع حقیقت عمل میکنن (۱۶ بازی + common + misc).
ارکستراسیون با ایجنتها
انجام این کار به صورت دستی، ماهها کار خستهکننده و مکانیکی میبرد. به جاش، بیش از یک دوجین ایجنت Cursor رو همزمان ارکستره کردم تا کار سنگین رو انجام بدن.
شروع کردم به تقسیم کدبیس به «بخشها» بر اساس پوشهها. هر بازی تو Foony پوشه و namespace ترجمه خودش رو گرفت. این باعث میشه حجم بارگذاری اولیه کوچیک بمونه چون فقط ترجمههای بازیای که داری انجام میدی بارگذاری میشن.
چندین ایجنت Cursor رو همزمان اجرا کردم. به هر ایجنت یه بخش خاص اختصاص دادم، مثل «بازی شطرنج رو به استفاده از ترجمهها تبدیل کن»، و اون فایل به فایل میرفت، رشتههای نمایان به کاربر رو پیدا میکرد و با t('games/chess/some.key') جایگزین میکرد.
ایجنت بعدش اون کلید رو به فایل locale انگلیسی مناسب با یه کامنت JSDoc اضافه میکرد که «چی» و «کجای» رشته رو توضیح میداد. این کانتکست هنگام تولید ترجمهها برای زبانهای دیگه مهمه، چون به LLM کمک میکنه بفهمه آیا "Save" به معنی «ذخیره تنظیمات بازی» است یا «ذخیره نقاشی Draw & Guess شما».
کنترل کیفیت
سریع همه کدی که تولید شده بود رو بررسی کردم. ایجنتها به طرز شگفتآوری خوب بودن، ولی گاهی اشتباهاتی میکردن، مثل گذاشتن هوک useTranslation بعد از یه return زودهنگام.
ترجمههای با تایپ قوی خیلی کمک کردن. این باعث شد همه ترجمههای هر locale تمام کلیدهای درست رو داشته باشن (و هیچ کلید اشتباهی نداشته باشن). همچنین مطمئن شد که فراخوانیهای 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
این autocomplete کامل TypeScript رو میده، و هر اشتباه تایپی تو کلید ترجمه در زمان کامپایل گرفته میشه. ایجنتها نمیتونن اشتباهاتی مثل t('games/ches/name') انجام بدن چون TypeScript بلافاصله علامتگذاریش میکنه.
Localization
بعد از اینکه تبدیل انگلیسی تموم شد، کارهای locale باقیمونده رو تقسیم کردم. هر ایجنت رو مسئول تبدیل یه فایل locale انگلیسی به یه زبان مشخص کردم.
مثلاً، به ایجنتها یه پرامپت مثل این میدادم:
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 صرفهجویی کنم. استفاده از یه اسکریپت برای فقط بهروزرسانی ترجمههای گمشده رویکرد بهتری بود، و احتمالاً تو آینده از یه راهحل مشابه استفاده میکنم. دوست دارم رهگیری کنم کدوم رشتهها به بهروزرسانی / ترجمه نیاز دارن، ولی میخوام چیزها رو ساده نگه دارم. ممکنه کار ترجمه رو به یه دیتابیس یا چیزی شبیه اون منتقل کنم.
همچنین یه locale «debug» اضافه کردم که فقط تو حالت توسعه در دسترسه. این به من اجازه میده همه رشتههای جایگزین شده رو ببینم تا تأیید کنم چیزها کار میکنن (به علاوه فکر میکنم باحاله). وقتی از locale debug استفاده میکنی، t() کلید رو با براکتها برمیگردونه:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
پس به جای دیدن "Welcome to Foony!"، ⟦welcome⟧ میبینی، که شناسایی هر ترجمه گمشده رو راحت میکنه.
در نهایت، یه ایجنت دیگه مسیریابی /{locale}/** رو پیادهسازی کرد تا چیزهایی مثل /ja/games/chess به زبان درست (تو این مورد ژاپنی) مسیریابی بشن.
ترجمه بلاگ
ترجمه رشتههای UI یه چیز بود، ولی پستهای بلاگ چی؟ نمیخواستم ایجنتهای بیشتری رو راهاندازی و مدیریت کنم تا همه پستهای بلاگم رو ترجمه کنن.
این رو با ساختن یه اسکریپت توسط یه ایجنت حل کردم (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 درست رو به صورت تنبل بارگذاری میکنه. اگه ترجمهای گم بود (چون هنوز اسکریپت رو اجرا نکردم)، به طور آبرومندانه به انگلیسی برمیگرده.
روز چهارم: تولید خودکار ترجمه
میدونستم راهحل اولیه مقیاسپذیر نخواهد بود. پس، حالا که i18n رو بیرون داده بودم، وقت این بود که با یه رویکرد دیتابیسمحور یه کم محکمترش کنم.
به طور خلاصه: وقتی متن انگلیسی یا کامنتهای JSDoc تغییر میکرد، ترجمهها باید دوباره تولید میشدن. رهگیری دستی اینکه چی نیاز به بهروزرسانی داره، خطاپذیر بود و وقت توسعهدهنده رو هدر میداد.
پس راهحلی که اولش برنامهریزی کرده بودم رو ساختم: یه سیستم تولید ترجمه با پشتوانه PostgreSQL.
اسکیمای دیتابیس
یه جدول translations به دیتابیس PostgreSQL ما با ساختار زیر اضافه کردم:
key: کلید ترجمه به نمادگذاری «اسلش-نقطه» (مثلاً"games/yacht/nested.name"،"config.timeLimit.label").en_value: مقدار سورس انگلیسیtarget_locale: کد 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/**استفاده میکنه و فقط export های پیشفرض رو پردازش میکنه - استخراج کانتکست JSDoc: کامنتهای JSDoc رو برای هر کلید و همه کلیدهای والد (آبجکتهای پدر) تجزیه میکنه تا کانتکست غنی فراهم کنه
- پرسوجوی دیتابیس: ترجمههای موجود تو PostgreSQL رو چک میکنه و با
key,target_locale,en_value, وcontextتطبیق میده. اگه هر کدوم از اینها تغییر کنه، ترجمه دوباره تولید میشه. - شناسایی کلیدهای گمشده/تغییریافته: کلیدهایی که نیاز به ترجمه دارن یا مقادیر/کامنتهای انگلیسیشون تغییر کرده رو پیدا میکنه
- دستهبندی ترجمهها: بر اساس locale و پیشوند namespace گروهبندی میکنه برای فراخوانیهای کارآمدتر LLM (همچنین ترجمهها رو سریعتر میکنه). البته اگه دسته خیلی بزرگ باشه، کیفیت ترجمه بدتر میشه.
- تولید ترجمهها: از GPT 5.1 با کانتکست جامع (JSDoc، زبان+منطقه، لحن، واژهنامه، مثالها) استفاده میکنه. خوندم که 5.1 برای نوشتن بهتر از 5.2 هست (بیمزه به نظر نمیرسه)، ولی تأیید نکردم.
- بررسیهای QA: حفظ placeholder ها رو اعتبارسنجی میکنه، مثلاً
{{name}}, یکپارچگی کلید، فرمت JSON - ذخیره تو دیتابیس: ترجمهها رو با کانتکست کامل (JSDoc + JSDoc والد) ذخیره میکنه
- تولید فایلهای locale: از دیتابیس میخونه و فایلهای locale تایپاسکریپت رو با فرمت مناسب و تایپ
RecursivePartialمینویسه
مزایای کلیدی
این رویکرد چند بهبود DevEx بهمون میده:
- بازتولید خودکار: وقتی متن انگلیسی یا کامنتهای JSDoc تغییر میکنن، ترجمهها به طور خودکار دوباره تولید میشن. پس اگه کسی بگه یه ترجمه بده، خیلی راحته که با ارائه کانتکست بیشتر به عنوان کامنت، ترجمهها رو دوباره تولید کنیم.
- کانتکست غنی: کامنتهای JSDoc کانتکست ترجمه رو فراهم میکنن (مثلاً «پیام خطا که به بازیکنان نمایش داده میشه، حداکثر ۱۵ کاراکتر»)، که به LLM کمک میکنه ترجمههای دقیقتری تولید کنه
- کانتکست والد: JSDoc آبجکت پدر کانتکست namespace رو فراهم میکنه (مثلاً «دستاورد برای بودن تو یه بازی که همه تخممرغها نابود میشن»)، که یه کم وضوح بیشتر میده
- رهگیری تاریخی: ترجمههای قدیمی تو دیتابیس ذخیره میشن. فضای زیادی نمیگیرن، پس فعلاً دلیلی نمیبینم پاکشون کنم، و دیدن تاریخچه باحاله.
جزئیات فنی
پیادهسازی از چند تکنیک برای اطمینان از قابلیت اطمینان و کارایی استفاده میکنه:
- استخراج مبتنی بر AST برای اطمینان از گرفتن کامنتهای درست
- پردازش موازی با استفاده از Semaphore برای ترجمه دستهای همزمان
- منطق retry با backoff نمایی برای شکستهای API. فراخوانیهای LLM به شکل بدنامی غیرقابل اعتمادن.
اسکریپت رو میشه با npm run generate-localizations از دایرکتوری scripts اجرا کرد. وقتی اجرا میشه به PostgreSQL متصل میشه و همه ترجمههای گمشده یا تغییریافته رو برای همه locale های پشتیبانی شده پردازش میکنه.
نتیجهگیری
تو این نقطه، یه سایت کاملاً عملکردی داشتم که به همه ۲۰ locale ترجمه شده بود!
این ۳ روز دیوانهواری بود، ولی نتیجه یه سایت کاملاً محلیسازی شدهست که (تقریباً) برای کاربران سراسر دنیا بومی به نظر میرسه. با ساختن یه کتابخانه سفارشی و سبک و استفاده از ایجنتهای هوش مصنوعی برای کار خستهکننده refactoring، چیزی رو مدیریت کردم که فقط یه سال پیش غیرممکن بود: i18n کامل تو ۳ روز برای یه وبسایت پیچیده توسط یک مهندس. آینده برنامهنویسی درباره نوشتن سریع کد نیست. درباره ارکستر کردن ایجنتهای هوش مصنوعی و داشتن تخصص عمیق دامنه برای تأیید خروجی اونهاست.