Перейти до основного вмісту

@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.

Довільне визначення мови запиту

Хоча дефолтне значення мови запиту визначається через URL-параметр, але ви можете легко змінити логіку визначення мови запиту, наприклад, по заголовкам accept-language. Для цього достатньо змінити геттер dictService.lng.

Якщо ви клонували репозиторій, то у модулі examples/15-i18n/src/app/third/third.module.ts ви знайдете приклад із MyDictService. Цей сервіс розширює DictPerModService і переписує лише геттер mydictService.lng, а сеттер також переписується лише для того, щоб mydictService.lng можна було редагувати. Ну після власної імплементації визначення мови запиту, новий сервіс, звичайно ж, потрібно підключити на рівні запиту у модулі.