i18n

یک موتور بین‌المللی‌سازی مستقل از فریم‌ورک. طراحی‌اش آینه مسیریاب است: هسته‌ای کوچک (I18nCore) با یک singleton سراسری اختیاری (createI18n / useI18n) و بدون وابستگی به DOM، پس هرطور بخواهید آن را به رابط کاربری گره می‌زنید.

کجا به کار می‌آید: وقتی در یک برنامه ranui به تعویض زبان در زمان اجرا نیاز دارید. یک بار createI18n را صدا بزنید، سپس رشته‌ها را با useI18n().t(key, params) بخوانید و زبان را با setLocale عوض کنید. نه به فریم‌ورکی وابسته است و نه به DOM، پس در جاوااسکریپت ساده، در هر فریم‌ورکی و در SSR کار می‌کند.

این موتور با نقطه ورود مستقل خودش یعنی ranui/i18n عرضه می‌شود: وارد کردنش هیچ عنصر سفارشی‌ای ثبت نمی‌کند، پس صفحه‌ای که تنها به ترجمه نیاز دارد هرگز کتابخانه کامپوننت‌ها را با خود نمی‌کشد. همین خروجی‌ها از بسته سطح‌بالای ranui هم در دسترس‌اند.

شروع سریع

هنگام راه‌اندازی یک بار singleton را بسازید، بعد هرجا خواستید ترجمه کنید:

import { createI18n, useI18n } from 'ranui/i18n';

createI18n({
  // هر زبان یک واژه‌نامه تخت است — کلیدها عیناً جست‌وجو می‌شوند، نه تودرتو.
  messages: {
    en: { 'hero.title': 'Hi {name}', 'nav.home': 'Home' },
    zh: { 'hero.title': '你好 {name}', 'nav.home': '首页' },
  },
  fallbackLocale: 'en', // وقتی کلیدی در زبان فعال نباشد به کار می‌رود
  persist: true, // انتخاب را زیر کلید 'ran-locale' در localStorage به یاد می‌سپارد
  detectNavigator: true, // زبان آغازین را از ترجیح‌های زبانی مرورگر برمی‌دارد
});

const i18n = useI18n();

i18n.t('hero.title', { name: 'Ada' }); // → "Hi Ada"
i18n.setLocale('zh'); // ذخیره می‌کند و به مشترکان خبر می‌دهد
i18n.t('hero.title', { name: 'Ada' }); // → "你好 Ada"

t(key) نخست messages[activeLocale][key] را می‌جوید، سپس messages[fallbackLocale][key] را، و اگر هیچ‌کدام نبود خودِ key را برمی‌گرداند. جای‌های {param} درون رشته از آرگومان دوم پر می‌شوند. چون جست‌وجو یک دسترسی به نقشه تخت است، کلیدها رشته‌های عینی‌اند: 'hero.title' را یک کلید بنویسید، نه شیئی تودرتو مثل { hero: { title } }.

پارامترها (درج مقدار)

بله، پیام‌ها پارامتر زمان اجرا می‌گیرند. جای‌هایی به شکل {name} در رشته بگذارید و مقدارها را به‌عنوان آرگومان دوم به t() بدهید؛ هر {param} با مقدار متناظرش جایگزین می‌شود:

createI18n({
  messages: {
    en: {
      'cart.summary': '{count} items · ${total}',
      greeting: 'Welcome back, {user}!',
    },
    zh: {
      'cart.summary': '{count} 件商品 · ¥{total}',
      greeting: '欢迎回来,{user}!',
    },
  },
});

const i18n = useI18n();
i18n.t('cart.summary', { count: 3, total: 59.9 }); // → "3 items · $59.9"
i18n.t('greeting', { user: 'Ada' }); // → "Welcome back, Ada!"

جزئیات:

  • نحو جای‌نگهدار {word} است (حرف، رقم، _). مقدارها می‌توانند رشته یا عدد باشند؛ عددها به متن تبدیل می‌شوند.
  • جای‌نگهداری که کلید متناظر نداشته باشد دست‌نخورده می‌ماند ({oops} عیناً در خروجی می‌ماند)؛ این کار پارامتر جاافتاده را به چشم می‌آورد، به‌جای اینکه بی‌صدا خالی بماند.
  • درج مقدار پس از بازگشت به زبان جایگزین اجرا می‌شود، پس همان پارامترها فرق نمی‌کند کدام زبان در نهایت رشته را حل کرده باشد.
  • جمع‌بستن و قالب‌بندی عدد و تاریخ درون‌ساخت نیست؛ آن‌ها را با Intl.NumberFormat / Intl.PluralRules بسازید و رشته قالب‌بندی‌شده را به‌عنوان پارامتر بدهید.

نمایش عینی آکولاد

یک { یا } تنها، یا گروهی با فاصله مثل { color: red }، جای‌نگهدار نیست و دست‌نخورده رد می‌شود؛ پس CSS و JSON و تکه‌های کد درون یک پیام به‌طور پیش‌فرض در امان‌اند. تنها حالت مبهم یک {word} عینی است که می‌خواهید همان‌طور نشان داده شود. برای گریز از آن، آکولادها را دو برابر کنید (همان قراردادی که format! در Rust، str.format در پایتون و String.Format در دات‌نت دارند):

const i18n = useI18n(); // فرض بر این است که پیام‌های زیر ثبت شده‌اند

i18n.t('use {{ and }} for literal braces'); // → "use { and } for literal braces"
i18n.t('the {{count}} token'); // → "the {count} token"  (مقدار درج نمی‌شود)
i18n.t('{{{name}}}', { name: 'Ada' }); // → "{Ada}"  (مقدار درون آکولاد عینی)
در پیام خروجی
{{ {
}} }
{name} پارامتر name، یا {name} اگر نباشد
{ name } { name } (فاصله دارد → جای‌نگهدار نیست)
{ { (آکولاد تنها)

گریز در همان یک گذرِ چپ‌به‌راستِ درج مقدار انجام می‌شود و چه پارامتر بدهید و چه ندهید کار می‌کند، پس {{ و }} همیشه به‌معنای آکولاد عینی‌اند.

دو برابر کردن همان قرارداد format! در Rust، str.format در پایتون و String.Format در دات‌نت است، پس نیازی به کاراکتر گریز تازه نیست. اگر به دستور زبان واقعی جمع و جنس و عدد نیاز دارید، با Intl.* قالب‌بندی کنید و نتیجه را به‌عنوان پارامتر بدهید.

واکنش به تغییر زبان

onChange پس از هر setLocale فرستاده می‌شود؛ از آن برای بازکشیدن رشته‌هایی که پیش‌تر رسم کرده‌اید استفاده کنید:

const i18n = useI18n();

const unsubscribe = i18n.onChange((locale) => {
  document.documentElement.lang = locale;
  repaintStrings(); // فراخوانی‌های t() را دوباره اجرا کن
});

// بعدها، وقتی نما برچیده می‌شود
unsubscribe();

افزودن پیام‌ها به‌هنگام نیاز

واژه‌نامه یک زبان را هنگام نیاز بار کنید (مثلاً با جداسازی کد به تفکیک زبان) و آن را ادغام کنید:

const i18n = useI18n();

const { default: fr } = await import('./locales/fr.js');
i18n.addMessages('fr', fr); // با هر واژه‌نامه 'fr' موجود ادغام می‌شود
i18n.setLocale('fr');

بومی‌سازی متن کامپوننت‌ها

کامپوننت‌ها خودشان از این موتور نمی‌خوانند. این عمدی است: کامپوننتی که مستقیم از یک singleton سراسری بخواند، هر مصرف‌کننده‌ای را به یک نمونه و یک شیوه نام‌گذاری کلید گره می‌زند و کاری می‌کند صفحه‌ای که فقط یک دکمه وارد کرده، لایه ترجمه را هم با خود بیاورد. در عوض، هر رشته‌ای که کاربر می‌بیند یک ورودی است: یک اتریبیوت، یک ویژگی، یک گزینه، یا محتوای اسلات. پس بومی‌سازی ranui یعنی دادن خروجی t() به همان جایی که رشته پیش‌تر می‌رفت:

const i18n = useI18n(); // فرض بر این است که پیام‌های زیر ثبت شده‌اند

modal.setAttribute('title', i18n.t('dialog.deleteProject.title'));
themeSwitch.setAttribute('label-dark', i18n.t('theme.dark'));

بیشتر کامپوننت‌ها اصلاً متن از خود ندارند: متن از راه اسلات‌ها و اتریبیوت‌هایی می‌آید که خودتان می‌نویسید. تعداد انگشت‌شماری برای رشته‌ای که جای دیگری برای آمدن ندارد یک پیش‌فرض انگلیسی همراه دارند، که بیشترشان نام‌های دسترس‌پذیرند:

کامپوننت انگلیسیِ درون‌ساخت بازنویسی با
Modal.confirm / Modal.open عنوان Confirm، دکمه‌های OK / Cancel گزینه‌های title، okText، cancelText
Modal.info / .success / .warning / .error عنوان‌های Info / Success / Warning / Error گزینه title
<r-theme-switch> aria-labelهای Theme، System theme، Light theme، Dark theme label، label-system، label-light، label-dark
<r-voice-button> aria-labelهای Start voice input / Stop voice input؛ راهنماهای Release to keep · slide up to cancel، Release to cancel label، active-label، hold-hint، cancel-hint
<r-reasoning> برچسب سربرگ Reasoning label
<r-token-meter> برچسب Context label
<r-colorpicker> aria-labelهای Choose color، Hue، Alpha opacity label، hue-label، alpha-label

یک الگوی عملی این است که با هر تغییر زبان آن‌ها را از یک جا دوباره اعمال کنید، تا همان کد هم هنگام راه‌اندازی و هم پس از تعویض اجرا شود:

const i18n = useI18n();

const applyLabels = () => {
  document.querySelectorAll('r-voice-button').forEach((el) => {
    el.setAttribute('label', i18n.t('voice.start'));
    el.setAttribute('active-label', i18n.t('voice.stop'));
  });
};

applyLabels();
i18n.onChange(applyLabels);

یادتان باشد document.documentElement.lang را هم هم‌گام نگه دارید: مرورگر، صفحه‌خوان‌ها و گزینشگرهای :lang() به همان نگاه می‌کنند.

API

createI18n(config) singleton سراسری را می‌سازد و ثبت می‌کند (یک بار صدایش بزنید)؛ useI18n() آن را برمی‌گرداند، یا null اگر createI18n هنوز اجرا نشده باشد.

I18nConfig

میدان نوع پیش‌فرض توضیح
messages LocaleMessages {} locale → { key → string }. هر واژه‌نامه تخت است.
locale string زبان جایگزین زبان آغازین (اگر انتخابی ذخیره شده باشد، آن اولویت دارد).
fallbackLocale string 'en' زبانی که وقتی کلیدی در زبان فعال نباشد سراغش می‌روند.
persist boolean false زبان فعال را در localStorage نگه می‌دارد.
storageKey string 'ran-locale' کلید localStorage وقتی persist روشن است.
detectNavigator boolean false زبان آغازین را از ترجیح‌های زبانی مرورگر برمی‌دارد. کل فهرست مرتب navigator.languages را می‌خواند، پس خواننده‌ای که برای انتخاب اولش واژه‌نامه نیست، به‌جای زبان جایگزین انتخاب دومش را می‌گیرد.

متدهای I18nCore

متد بازگشت توضیح
t(key, params?) string ترجمه می‌کند؛ به زبان جایگزین و سپس به خود کلید برمی‌گردد.
setLocale(locale) void زبان را عوض می‌کند؛ (در صورت روشن بودن) ذخیره و به مشترکان خبر می‌دهد.
getLocale() string زبان فعال.
onChange(handler) () => void تغییر زبان را مشترک می‌شود؛ تابعی برای لغو اشتراک می‌دهد.
addMessages(locale, dict) void پیام‌های بیشتری را در یک زبان ادغام می‌کند.
getMessages(locale?) MessageDict واژه‌نامه یک زبان را می‌خواند (پیش‌فرض: زبان فعال).
availableLocales string[] زبان‌هایی که واژه‌نامه ثبت‌شده دارند.
destroy() void همه مشترکان را برمی‌دارد.

تایپ‌ها

type MessageDict = Record<string, string>; // تخت: 'hero.title' → 'Hi {name}'
type LocaleMessages = Record<string, MessageDict>; // locale → MessageDict
type TranslateParams = Record<string, string | number>;

SSR

هسته در SSR امن است: دسترسی به localStorage و navigator محافظت شده، پس createI18n و t هنگام رندر سمت سرور بدون پرتاب خطا اجرا می‌شوند. ذخیره‌سازی و تشخیص زبان مرورگر روی سرور صرفاً کاری نمی‌کنند و به‌محض اجرای کد در مرورگر اثر می‌گذارند.