رفتن به محتوای اصلی
← همه‌ی تصمیم‌ها

چندزبانگی را خودم نوشتم به‌جای اینکه نصبش کنم

accepted

زمینه

دو زبان، فارسی و انگلیسی، متن ایستا، بدون جمع، بدون صورت جنسیتی، بدون درج متن غنی. next-intl جواب پیش‌فرض است و کتابخانه‌ی خوبی هم هست.

اما لایه‌ی روتینگ را هم صاحب می‌شود — <Link> خودش، useRouter خودش، redirect خودش، کانفیگ پراکسی خودش. در نمونه‌کاری که کل حرفش این است که من زیرساخت در سطح فریم‌ورک می‌سازم، برون‌سپاری لایه‌ی روتینگ تنها وابستگی‌ای است که همان ادعایی را تضعیف می‌کند که قرار بوده پشتش دربیاید.

گزینه‌های بررسی‌شده

next-intl. با ICU MessageFormat، پخته، حدود ۲ کیلوبایت رانتایم سمت کلاینت. ارزش واقعی‌اش جمع و متن غنی است. سیستم جمعِ فارسی از نظر CLDR بسیار ساده است و انگلیسی هم فقط one/other دارد، پس استدلال ICU در این مقیاس ضعیف است. ضمناً روی تغییر نام middleware به proxy در Next 16 شکست خورد؛ که خودش استدلال دومی است علیه به ارث بردن کوپلینگ دیگران به یک API در حال تغییر.

دیکشنری JSON با یک تابع lookup. ایمنی تایپِ قابل‌اعتنایی ندارد — JSON یک تایپ ساختاری می‌دهد اما هیچ قید بین‌فایلی ندارد، پس کلیدی که در انگلیسی هست و در فارسی نیست، زمان اجرا undefined درمی‌آید.

ماژول دیکشنری تایپ‌اسکریپتی، با انگلیسی به‌عنوان اسکیما. انگلیسی اول نوشته می‌شود و خودش قرارداد است. فارسی یک پیاده‌سازی از آن است.

تصمیم

// en.ts — اسکیما
export const en = { nav: { work: 'Work' } } as const

// fa.ts — پیاده‌سازی
export const fa: Dictionary = { nav: { work: 'نمونه‌کار' } }

کلیدی که در فارسی جا افتاده باشد خطای کامپایل است، نه یک حفره‌ی زمان اجرا. کلید اضافه‌ی تایپی هم خطای کامپایل است، چون annotate کردنِ یک آبجکت لیترال مستقیم، excess-property check را فعال می‌کند. کل داستان ایمنی تایپ همین است: بدون کتابخانه، بدون کدجن، بدون ولیدیشن زمان اجرا.

زبان از طریق next/root-params — تازه در Next 16.3 — به سرور کامپوننت‌ها می‌رسد؛ که prop drilling را کامل حذف می‌کند و با 'use cache' هم می‌سازد: فقط آن root paramهایی که یک تابع کش‌شده واقعاً می‌خواند وارد کلید کش می‌شوند.

هزینه‌هایی که پذیرفتم

  • next/root-params فقط در سرور کامپوننت کار می‌کند. در کلاینت کامپوننت، سرور اکشن، روت هندلر و unstable_cache در دسترس نیست. هر تابعی در آن چهار جا locale را به‌عنوان آرگومان اول می‌گیرد، با کامنتی که دلیلش را می‌گوید. این اصطکاکی است که آگاهانه پذیرفتم.
  • کلاینت کامپوننت‌ها رشته‌ها را با prop می‌گیرند، نه با هوک. فقط برش‌های تایپ‌دار و باریک می‌گیرند (t: Dictionary['playground'])، نه کل دیکشنری را — هرچه از این مرز رد شود در پی‌لود RSC هر صفحه‌ای که رندرش می‌کند سریالایز می‌شود، و آن دیتا برای گیت حجم باندلِ من نامرئی است. رویکرد context آن کتابخانه اینجا واقعاً راحت‌تر است.
  • بدون ICU. روزی که به {count, plural, ...} نیاز پیدا کنم، یا باید بنویسمش یا نصبش کنم.

نتیجه

حدود سی خط. ترجمه‌ی جاافتاده به production نمی‌رسد چون اصلاً کامپایل نمی‌شود. هیچ‌کدام از دو دیکشنری هم به کلاینت نمی‌رسند — یک import() داینامیک زبان استفاده‌نشده را بیرون باندل نگه می‌دارد، و چون فقط روی سرور await می‌شود، تنها رشته‌های رندرشده فرستاده می‌شوند.

چه چیزی را عوض می‌کردم

تایپ بدیهی غلط است و من قبل از اینکه کامپایلر مچم را بگیرد نوشته بودمش. نوشته بودم:

export type Dictionary = typeof en

که درست به نظر می‌رسد و خراب است. as const هر رشته‌ی انگلیسی را به یک تایپ لیترال تبدیل می‌کند، پس این تایپ حکم می‌کند فایل فارسی عیناً همان رشته‌های انگلیسی را داشته باشد — هفده خطای تایپ، یکی به‌ازای هر کلید ترجمه‌شده. راه‌حل این است که شکل را map کنی و برگ‌ها را widen:

type Widen<T> = T extends string ? string : { [K in keyof T]: Widen<T[K]> }
export type Dictionary = Widen<typeof en>

ساختار کلیدها قرارداد می‌ماند؛ مقادیر از قرارداد بودن درمی‌آیند. دفعه‌ی بعد از همان اول سراغ همین الگو می‌روم، نه اینکه توی اولین بیلد کشفش کنم.

همچنین شفاف‌تر می‌گفتم کِی این تصمیم غلط است. برای دو زبان با متن ایستا، دست‌ساز درست است. با پنج زبان، با مترجم‌هایی که در یک TMS کار می‌کنند، با جمع و تاریخِ جاسازی‌شده داخل جمله، next-intl جواب درست است و بدون بحث نصبش می‌کنم. حرف این نیست که کتابخانه بد است — این است که هزینه‌ی این کتابخانه‌ی خاص، در این پروژه‌ی خاص، دقیقاً با همان واحدی پرداخت می‌شد که موضوع خودِ پروژه است.