@ditsmod/i18n
i18n - це скорочення від слова internationalization. Модуль @ditsmod/i18n
забезпечує базову функціональність для перекладу системних повідомлень (що видає Ditsmod-застосунок під час роботи), і надає можливість для легкого розширення словників. Фактично ви використовуєте звичайні сервіси у якості словників для перекладу, тому текст для перекладу можна брати як з TypeScript-файлів, так і з баз даних. Робота @ditsmod/i18n
спроектована таким чином, щоб кожен поточний модуль міг мати власний переклад, і щоб можна було змінити чи доповнити переклад будь-якого імпортованого модуля.
Проглянути код з прикладами використання @ditsmod/i18n
можна у репозиторію Ditsmod, хоча зручніше його проглядати локально, тому краще спочатку клонувати його:
git clone https://github.com/ditsmod/ditsmod.git
cd ditsmod
npm i
cd examples/15-i18n
Приклад можна запускати командою:
npm start
Встановлення
Вище показано як клонувати готовий приклад з @ditsmod/i18n
, а коли вам потрібно встановити даний модуль у ваш застосунок, це можна зробити так:
npm i @ditsmod/i18n
Структура каталогів
Рекомендована структура директорій словників з перекладом є такою:
└── modulename
├── ...
├── locales
│ ├── current
│ │ ├── _base-en
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ ├── uk
│ │ └── index.ts
│ └── imported
│ ├── one
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ ├── two
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ └── index.ts
Як бачите, кожен модуль має переклад у теці locales
, яка містить дві теки:
current
- переклад для поточного модуля;imported
- змінений чи доповнений переклад для імпортованих модулів.
Причому лише тека current
містить теку _base-en
, де розміщено базові словники (в даному разі - англійською мовою), від яких відгалуджуються словники з перекладами на інші мови. У назві використано символ нижнього підкреслення щоб _base-en
була постійно вгорі над іншими теками.
Базові та дочірні класи з перекладами
Як вже було сказано, словники представляють собою звичайні сервіси:
import { Dictionary, ISO639 } from '@ditsmod/i18n';
import { injectable } from '@ditsmod/core';
@injectable()
export class CommonDict implements Dictionary {
getLng(): ISO639 {
return 'en';
}
/**
* Hi, there!
*/
hi = `Hi, there!`;
/**
* Hello, ${name}!
*/
hello(name: string) {
return `Hello, ${name}!`;
}
}
Це базовий словник з англійською локалізацією. В даному разі він має назву CommonDict
, але не обов'язково весь переклад вміщати в один клас, можна використовувати інші класи, наприклад, ErrorDict
, EmailDict
і т.д.
Кожен базовий словник повинен імплементувати інтерфейс Dictionary
, який має єдину вимогу - щоб у словнику був метод getLng()
, що повертає скорочення назви мови по стандарту ISO 639 (наприклад, скорочення для англійської та української мов - en, uk).
Чому саме метод, а не властивість повертає скорочення назв мов?
Справа в тому, що у JavaScript властивість класу не можна проглядати до того моменту, поки не зробити інстанс цього класу. А от метод getLng()
можна легко проглянути через YourClass.prototype.getLng()
. Це дозволяє ще до використа ння словників отримувати статистику наявних перекладів.
Кожен клас сервісу рекомендується називати із закінченням *Dict
, а файл - із закінченням *.dict.ts
. Окрім цього, назва класу базового словника не повинна містити локалі, і саме тому в даному разі клас названо не CommonEnDict
, а CommonDict
. Це рекомендовано робити через те, що клас базового словника буде використовуватись для перекладу на будь-яку іншу доступну мову. Наприклад, у коді ось такий вираз може насправді повертати переклад на будь-яку мову, не зважаючи на те, що у якості токена використовується клас базового словника:
const dict = this.dictService.getDictionary(CommonDict);
dict.hello('World');
Кожен клас словника, що містить переклад, повинен розширювати клас базового словника:
import { ISO639 } from '@ditsmod/i18n';
import { injectable } from '@ditsmod/core';
import { CommonDict } from '#dict/second/common.dict';
@injectable()
export class CommonUkDict extends CommonDict {
override getLng(): ISO639 {
return 'uk';
}
override hello(name: string) {
return `Привіт, ${name}!`;
}
}
Як мінімум, кожен словник з перекладом повинен переписувати метод getLng()
. Для більш строгого контролю словників з перекладами, рекомендується використовувати TypeScript 4.3+, а також наступне налаштування у tsconfig.json
:
{
"compilerOptions": {
"noImplicitOverride": true,
// ...
}
}
В такому разі, у дочірньому класі TypeScript буде вимагати дописувати ключове слово override
перед кожним методом чи властивістю з батьківського класу. Це дозволяє покращувати читабельність дочірнього класу і запобігає помилці у назві методу чи властивості. Якщо ви створили метод з помилкою у назві, наприклад helo
замість hello
, і позн ачили його як override
, TypeScript видасть попередження, що у батьківському класі такого метода не існує. Той же самий сценарій спрацює, якщо з батьківського класу видалили раніше написаний метод.
Як бачите, назви класів словників з перекладами вже містять локаль CommonUkDict
- це словник з українською локалізацією. І оскільки цей словник розширює клас базового словника, то усі відсутні переклади будуть видаватись мовою базового словника. В даному разі, у базовому словнику CommonDict
є ось ця властивість:
/**
* Hi, there!
*/
hi = `Hi, there!`;
А у словнику CommonUkDict
відсутній переклад цієї фрази, тому при запиті локалізації на українську мову буде використовуватись англійський варіант з базового класу.
Зверніть увагу, що над кожною властивістю чи методом, які безпосередньо використовуються для перекладу, написано коментар з шаблоном тієї фрази, яку вони будуть повертати. Це додає зручності використання словника в коді програми, бо ваша IDE буде показувати ці коментарі:
const dict = this.dictService.getDictionary(CommonDict);
dict.hi;
В даному разі при наведенні на dict.hi
IDE покаже Hi, there!
.
Збір словників у групи у теці current
Нагадаю, що тека current
містить словники з перекладом для поточного модуля. Ці словники повинні бути зібрані в одному масиві у файлі index.ts
:
import { DictGroup, getDictGroup } from '@ditsmod/i18n';
import { CommonDict } from '#dict/second/common.dict';
import { CommonUkDict } from './uk/common-uk.dict.js';
import { ErrorDict } from '#dict/second/error.dict';
import { ErrorsUkDict } from './uk/errors-uk.dict.js';
// ...
export const current: DictGroup[] = [
[CommonDict, CommonUkDict, CommonPlDict, CommonFrDict, CommonDeDict],
[ErrorDict, ErrorsUkDict, ErrorsPlDict, ErrorsFrDict, ErrorsDeDict],
// ...
];
Як бачите, в масиві передаються групи словників, де на першому місці завжди повинен йти клас з базовим словником.У даному разі передаються дві групи словників з базовими класами CommonDict
та ErrorDict
. Не дозволяється змішувати словники з різних груп. Якщо ви змішаєте словники з різних груп, TypeScript не зможе вам про це сказати, тому рекомендується використовувати функцію getDictGroup()
для кращого контролю типів класів:
import { DictGroup, getDictGroup } from '@ditsmod/i18n';
import { CommonDict } from '#dict/second/common.dict';
import { CommonUkDict } from './uk/common-uk.dict.js';
import { ErrorDict } from '#dict/second/error.dict';
import { ErrorsUkDict } from './uk/errors-uk.dict.js';
// ...
export const current: DictGroup[] = [
getDictGroup(CommonDict, CommonUkDict, CommonPlDict, CommonFrDict, CommonDeDict),
getDictGroup(ErrorDict, ErrorsUkDict, ErrorsPlDict, ErrorsFrDict, ErrorsDeDict),
// ...
];
Збір словників у групи у теці imported
Нагадаю, що тека imported
містить словники з перекладом для імпортованих модулів, причому зверніть увагу, що вона не містить базових словників (не має теки _base-en
), оскільки базові словники імпортованих модулів знаходяться у самих цих модулях у каталогах current
:
└── modulename
├── ...
├── locales
│ ├── current
│ │ ├── _base-en
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ ├── uk
│ │ └── index.ts
│ └── imported
│ ├── one
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ ├── two
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ └── index.ts
Директорія imported
містить окремі теки кожного модуля, для яких потрібно доповнити або переписати переклад. В даному разі тека imported
містить доповнення чи перепис перекладу для модулів one
і two
. Збір груп словників у теці imported
відбувається аналогічно до того, як це робиться у current
, але базові словники беруться із зовнішніх модулів:
import { DictGroup, getDictGroup } from '@ditsmod/i18n';
import { CommonDict } from '#dict/first/common.dict'; // Базовий словник із зовнішнього модуля із теки current
import { CommonUkDict } from './first/uk/common-uk.dict.js'; // Доповнення п ерекладу для зовнішнього модуля із теки imported
export const imported: DictGroup[] = [
getDictGroup(CommonDict, CommonUkDict),
];
В даному разі базовий словник CommonDict
імпортується з FirstModule
, а доповнення перекладу українською мовою береться у поточному модулі з теки imported
.
Передача перекладів у модуль
Тепер залишилось передати групи словників у модуль:
import { featureModule } from '@ditsmod/core';
import { I18nModule, I18nOptions, I18N_TRANSLATIONS, Translations } from '@ditsmod/i18n';
import { current } from './locales/current.js';
import { imported } from './locales/imported.js';
const translations: Translations = { current, imported };
const i18nOptions: I18nOptions = { defaultLng: 'uk' };
@featureModule({
imports: [
I18nModule,
// ...
],
providersPerMod: [
{ token: I18N_TRANSLATIONS, useValue: translations, multi: true },
{ token: I18nOptions, useValue: i18nOptions },
],
exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
Як бачите, кожен модуль, що містить переклад, повинен:
- імпортувати
I18nModule
; - у масив
providersPerMod
додавати мульти-провайдер, що містить токенI18N_TRANSLATIONS
та контент з типом данихTranslations
, куди якраз і передаються групи словників як для поточного, так і для імпортованого модуля; - у масив
providersPerMod
можна передавати провайдер з токеномI18nOptions
; - у масив
exports
опціонально можна передати токенI18N_TRANSLATIONS
, якщо хочете щоб базові словники з поточного модуля були доступними для зовнішніх модулів. При цьому зверніть увагу, що такий експорт потрібен лише якщо ви хочете безпосередньо використовувати базові словники, тобто у коді вашої програми ви імпортуєте їх. А якщо ви експортуєте певний сервіс, який внутр ішньо використовує базові словники (інкапсулює їх використання), то експортуватиI18N_TRANSLATIONS
не потрібно.
Якщо скористатись хелпером i18nProviders().i18n()
, можна трохи скоротити кількість коду:
import { featureModule } from '@ditsmod/core';
import { I18nModule, I18nProviders } from '@ditsmod/i18n';
import { current } from './locales/current.js';
import { imported } from './locales/imported.js';
@featureModule({
imports: [
I18nModule,
// ...
],
providersPerMod: [
...new I18nProviders().i18n({ current, imported }, { defaultLng: 'uk' }),
],
exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
У якості першого аргументу для i18nProviders().i18n()
передається об'єкт з типом Translations
, на другому місці передаються опції з типом I18nOptions
. Зверніть увагу, що перед хелпером стоїть трикрапка, оскільки він повертає масив, який потрібно змерджити з іншими провайдерами в масиві providersPerMod
.
Використання словників з перекладом
Щоб скористатись словниками, необхідно використовувати DictService
:
import { injectable } from '@ditsmod/core';
import { DictService } from '@ditsmod/i18n';
import { CommonDict } from '#dict/first/common.dict';
@injectable()
export class FirstService {
constructor(private dictService: DictService) {}
countToThree() {
const dict = this.dictService.getDictionary(CommonDict);
return dict.countToThree;
}
}
Як бачите, у якості токену для пошуку потрібної групи словників завжди використовуються класи базових словників. В даному разі цей код спрацює, якщо базовий словник містить властивість countToThree
. Він видасть потрібний переклад, якщо у групі словників CommonDict
є словник з відповідним перекладом. Вказувати локаль можна в другому аргументі
countToThree() {
const dict = this.dictService.getDictionary(CommonDict, 'uk');
return dict.countToThree;
}
Але у більшості випадків вибір мови відбувається через HTTP-запит. За замовчуванням, DictService
бере локаль з URL-параметра lng
, але ви можете змінити назву цього параметра передаючи опцію lngParam
:
// ...
@featureModule({
// ...
providersPerMod: [
...new I18nProviders().i18n({ current, imported }, { defaultLng: 'uk', lngParam: 'locale' }),
],
})
export class SecondModule {}
Майте на увазі, що DictService
передано для інжекторів на рівні HTTP-запиту, тому ви не зможете використовувати цей сервіс у інших сервісах, які передаються до інжекторів на вищих рівнях (на рівні роута чи модуля). Якщо вам потрібен сервіс на вищих рівнях, скористайтесь DictPerModService
, який насправді є батьківським класом для DictService
з майже ідентичним API.