@ditsmod/i18n
i18n is an abbreviation of the word internationalization. The @ditsmod/i18n module provides basic functionality for translating system messages (issued by the Ditsmod application at runtime) and provides the ability to easily extend dictionaries. In fact, you use ordinary services as dictionaries for translation, so the text for translation can be taken both from TypeScript files and from databases. The @ditsmod/i18n work is designed so that each current module can have its own translation, and that the translation of any imported module can be modified or supplemented.
You can view the code with examples of using @ditsmod/i18n in the Ditsmod repository, although it is more convenient to view it locally, so it is better to clone it first:
git clone https://github.com/ditsmod/ditsmod.git
cd ditsmod
npm i
cd examples/15*
The example can be run with the command:
npm run start:dev
Installation
The above shows how to clone the ready-made example with @ditsmod/i18n, and when you need to install this module in your application, you can do it like this:
npm i @ditsmod/i18n
Directory structure
The recommended directory structure of translated dictionaries is as follows:
└── modulename
├── ...
├── i18n
│ ├── current
│ │ ├── _base-en
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ ├── uk
│ │ └── index.ts
│ └── imported
│ ├── one
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ ├── two
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ └── index.ts
This is the recommended directory structure. As you can see, each module has a translation in the i18n folder, which contains two folders:
current- translation for the current module;imported- changed or supplemented translation for imported modules.
Moreover, only the current folder contains the _base-en folder, which contains basic dictionaries (in this case - in English), from which dictionaries with translations into other languages are branched. An underscore character is used in the name so that _base-en is always above other folders.
Base and extended classes with translations
As already mentioned, dictionaries are ordinary services:
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}!`;
}
}
This is a base dictionary with English localization. Note that the class name of the base dictionary does not include the locale, which is why in this case the class is named CommonDict, not CommonEnDict. Child dictionaries that contain translations will branch off from this base dictionary. It is recommended to name translation dictionaries using the following pattern: *Dict<locale>, and the file should end with *.dict-<locale>.ts. For example, the class name would be CommonDictUk, and its file would be common.dict-uk.ts.
It is not necessary to include all translations in a single dictionary; you can use other dictionaries, for example, ErrorDict, EmailDict, etc. Each base dictionary must implement the Dictionary interface, which has a single requirement — the dictionary must have a getLng() method that returns the language code according to the ISO 639 standard (for example, the codes for English and Ukrainian are en and uk).
Why should a method, not a property, return the language code? The reason is that in JavaScript, a class property cannot be accessed until an instance of that class is created. However, the getLng() method can be easily accessed through YourClass.prototype.getLng(). This allows retrieving statistics on available translations even before the dictionaries are used.
Each dictionary class containing a translation must extend the base dictionary class:
import { ISO639 } from '@ditsmod/i18n';
import { injectable } from '@ditsmod/core';
import { CommonDict } from '#dict/second/common.dict';
@injectable()
export class CommonDictUk extends CommonDict {
override getLng(): ISO639 {
return 'uk';
}
override hello(name: string) {
return `Привіт, ${name}!`;
}
}
At a minimum, every translation dictionary should override the getLng() method. For more strict control of dictionaries with translations, it is recommended to use TypeScript 4.3+, as well as the following setting in tsconfig.json:
{
"compilerOptions": {
"noImplicitOverride": true,
// ...
}
}
In this case, TypeScript will require the child class to add the override keyword before each method or property from the parent class. This improves the readability of the child class and prevents a mistake in the name of the method or property. If you create a method with an incorrect name, such as helo instead of hello, and mark it as override, TypeScript will issue a warning that no such method exists in the parent class. The same scenario will work if the previously written method has been removed from the parent class.
As you can see, the class names of dictionaries with translations already contain the locale CommonDictUk - this is a dictionary with Ukrainian localization. And since this dictionary extends the base dictionary class, all missing translations will be rendered in the language of the base dictionary. In this case, the base dictionary CommonDict has this property:
/**
* Hi, there!
*/
hi = `Hi, there!`;
And the CommonDictUk dictionary does not have a translation of this phrase, so when requesting localization into the Ukrainian language, the English version from the base class will be used.
Note that each property or method that is directly used for translation is commented out with the template of the phrase they will return. This adds convenience to using the dictionary in your application code, because your IDE will show these comments:
const dict = this.dictService.getDictionary(CommonDict);
dict.hi;
In this case, when hovering over dict.hi, the IDE will show Hi, there!.
Collection of dictionaries in groups in the current folder
Let me remind you that the current folder contains dictionaries with translations for the current module. These dictionaries must be collected in a single array in the index.ts file:
import { DictGroup, getDictGroup } from '@ditsmod/i18n';
import { CommonDict } from '#dict/second/common.dict';
import { CommonDictUk } from './uk/common.dict-uk.js';
import { ErrorDict } from '#dict/second/error.dict';
import { ErrorsDictUk } from './uk/errors.dict-uk.js';
// ...
export const current: DictGroup[] = [
[CommonDict, CommonDictUk, CommonDictPl, CommonDictFr, CommonDictDe],
[ErrorDict, ErrorsDictUk, ErrorsDictPl, ErrorsDictFr, ErrorsDictDe],
// ...
];
As you can see, groups of dictionaries are transferred in the array, where the class with the base dictionary must always go first. In this case, two groups of dictionaries with base classes CommonDict and ErrorDict are transferred. It is not allowed to mix dictionaries from different groups. If you mix dictionaries from different groups, TypeScript won't be able to tell you about it, so it's recommended to use the getDictGroup() function for better control of class types:
import { DictGroup, getDictGroup } from '@ditsmod/i18n';
import { CommonDict } from '#dict/second/common.dict';
import { CommonDictUk } from './uk/common.dict-uk.js';
import { ErrorDict } from '#dict/second/error.dict';
import { ErrorsDictUk } from './uk/errors.dict-uk.js';
// ...
export const current: DictGroup[] = [
getDictGroup(CommonDict, CommonDictUk, CommonDictPl, CommonDictFr, CommonDictDe),
getDictGroup(ErrorDict, ErrorsDictUk, ErrorsDictPl, ErrorsDictFr, ErrorsDictDe),
// ...
];
Collection of dictionaries in groups in the imported folder
Let me remind you that the imported directory contains dictionaries with translations for imported modules, and note that it does not contain base dictionaries (it does not have a _base-en folder), since the base dictionaries of imported modules are located in these modules in the current directories:
└── modulename
├── ...
├── i18n
│ ├── current
│ │ ├── _base-en
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ ├── uk
│ │ └── index.ts
│ └── imported
│ ├── one
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ ├── two
│ │ ├── de
│ │ ├── fr
│ │ ├── pl
│ │ └── uk
│ └── index.ts
The directory imported contains individual folders of each module, for which you need to supplement or rewrite the translation. In this case, the imported folder contains additions or translation rewrites for modules one and two. Collection of groups of dictionaries in the imported folder is similar to how it is done in current, but the base dictionaries are taken from external modules:
import { DictGroup, getDictGroup } from '@ditsmod/i18n';
import { CommonDict } from '#dict/first/common.dict'; // Базовий словник із зовнішнього модуля із теки current
import { CommonDictUk } from './first/uk/common.dict-uk.js'; // Доповнення перекладу для зовнішнього модуля із теки imported
export const imported: DictGroup[] = [
getDictGroup(CommonDict, CommonDictUk),
];
In this case, the basic dictionary CommonDict is imported from FirstModule, and the addition of the Ukrainian translation is taken in the current module from the imported folder.
Transfer of translations to the module
Now it remains to transfer groups of dictionaries to the module:
import { restRootModule } from '@ditsmod/rest';
import { I18nModule, I18nOptions, I18N_TRANSLATIONS, Translations } from '@ditsmod/i18n';
import { current } from './i18n/current.js';
import { imported } from './i18n/imported.js';
const translations: Translations = { current, imported };
const i18nOptions: I18nOptions = { defaultLng: 'uk' };
@restRootModule({
imports: [
I18nModule,
// ...
],
providersPerMod: [
{ token: I18N_TRANSLATIONS, useValue: translations, multi: true },
{ token: I18nOptions, useValue: i18nOptions },
],
exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
As you can see, each module containing a translation must:
- import
I18nModule; - to the
providersPerModarray, add a multi-provider containing theI18N_TRANSLATIONStoken and content with theTranslationsdata type, where dictionary groups for both the current and the imported module are transferred; - the provider with the token
I18nOptionscan be transferred to theprovidersPerModarray; - you can optionally pass the
I18N_TRANSLATIONStoken to theexportsarray, if you want the base dictionaries from the current module to be available to external modules. At the same time, please note that such an export is only necessary if you want to directly use the base dictionaries, that is, in the code of your program, you import them. And if you export a certain service that internally uses base dictionaries (encapsulates their use), then you do not need to exportI18N_TRANSLATIONS.
If you use the i18nProviders().i18n() helper, you can slightly reduce the amount of code:
import { restModule } from '@ditsmod/rest';
import { I18N_TRANSLATIONS, I18nModule, I18nProviders } from '@ditsmod/i18n';
import { current } from './i18n/current.js';
import { imported } from './i18n/imported.js';
@restModule({
imports: [
I18nModule,
// ...
],
providersPerMod: [
...new I18nProviders().i18n({ current, imported }, { defaultLng: 'uk' }),
],
exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
As the first argument for i18nProviders().i18n(), an object of type Translations is passed, options of type I18nOptions are passed in the second place. Note that the helper is preceded by an ellipsis, as it returns an array to be merged with the other providers in the providersPerMod array.
Use of dictionaries with translation
To use dictionaries, you need to use 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;
}
}
As you can see, base dictionary classes are always used as a token to search for the required group of dictionaries. In this case, this code will work if the base dictionary contains the countToThree property. It will output the required translation if there is a dictionary with the corresponding translation in the dictionary group CommonDict. You can specify the locale in the second argument:
countToThree() {
const dict = this.dictService.getDictionary(CommonDict, 'uk');
return dict.countToThree;
}
But in most cases, the language is selected through an HTTP request. By default, DictService takes the locale from the lng URL parameter, but you can change the name of this parameter by passing the lngParam option:
// ...
@restModule({
// ...
providersPerMod: [
...new I18nProviders().i18n({ current, imported }, { defaultLng: 'uk', lngParam: 'locale' }),
],
})
export class SecondModule {}
Note that DictService is passed to injectors at the HTTP request level, so you will not be able to use this service in other services that are passed to injectors at higher levels (route or module level). If you need a higher-level service, use DictPerModService, which is actually a parent class for DictService with an almost identical API.
Arbitrary definition of the request language
Although the default value of the request language is determined through the URL parameter, but you can easily change the logic of determining the request language, for example, by accept-language headers. To do this, it is enough to change the dictService.lng getter.
If you cloned the repository, you will find an example of MyDictService in the examples/15-i18n/src/app/third/third.module.ts module. This service extends DictPerModService and only overwrites the getter of mydictService.lng and the setter is also overwritten so that mydictService.lng can be edited. Well, after your own implementation of the definition of the language of the request, of course, you need to add the new service providersPerReq in the module.