Добавление поддержки нескольких языков в NestJS и Angular приложениях

от автора

Предыдущая статья: Валидация REST-запросов в NestJS-приложении и отображение ошибок в формах Angular-приложения

В этой статье я добавлю поддержку нескольких языков в NestJS и Angular приложениях, для сообщений в ошибках, уведомлениях и данных полученных из базы данных.

1. Устанавливаем все необходимые библиотеки

Команды

npm install --save @jsverse/transloco nestjs-translates class-validator-multi-lang class-transformer-global-storage @jsverse/transloco-keys-manager 

Так как мы используем внешние генераторы, то мы не имеем доступа к сгенерированному коду, но для возможности перевода ошибок валидации нам нужно использовать библиотеку class-validator-multi-lang вместо class-validator, которую добаляет генератор.

Для подмены импортов в тайпскрипт файлах установим и подключим веб-пак плагин для замены строк.

Команды

npm install --save string-replace-loader 

Прописываем правила замены в нашем веб-пак конфиге.

const { composePlugins, withNx } = require('@nx/webpack');  // Nx plugins for webpack. module.exports = composePlugins(   withNx({     sourceMap: true,     target: 'node',   }),   (config) => {     // Update the webpack config as needed here.     // e.g. `config.plugins.push(new MyPlugin())`      config.module.rules = [       ...config.module.rules,       {         test: /\.(ts)$/,         loader: 'string-replace-loader',         options: {           search: `class-validator`,           replace: `class-validator-multi-lang`,           flags: 'g',         },       },       {         test: /\.(ts)$/,         loader: 'string-replace-loader',         options: {           search: 'class-transformer',           replace: 'class-transformer-global-storage',           flags: 'g',         },       },     ];     return config;   } ); 

2. Добавляем поддержку переводов в Angular-приложении

Добавляем новый модуль в конфиг фронтенда.

Обновляем файл apps/client/src/app/app.config.ts

import { provideTransloco } from '@jsverse/transloco'; import { marker } from '@jsverse/transloco-keys-manager/marker'; import { AUTHORIZER_URL } from '@nestjs-mod-fullstack/auth-angular'; import { TranslocoHttpLoader } from './integrations/transloco-http.loader';  export const appConfig = ({ authorizerURL, minioURL }: { authorizerURL: string; minioURL: string }): ApplicationConfig => {   return {     providers: [       // ...       provideTransloco({         config: {           availableLangs: [             {               id: marker('en'),               label: marker('app.locale.name.english'),             },             {               id: marker('ru'),               label: marker('app.locale.name.russian'),             },           ],           defaultLang: 'en',           fallbackLang: 'en',           reRenderOnLangChange: true,           prodMode: true,           missingHandler: {             logMissingKey: true,             useFallbackTranslation: true,             allowEmpty: true,           },         },         loader: TranslocoHttpLoader,       }),     ],   }; }; 

Для загрузки переводов из интернета необходимо создать специальный загрузчик.

Создаем файл apps/client/src/app/integrations/transloco-http.loader.ts

import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Translation, TranslocoLoader } from '@jsverse/transloco'; import { catchError, forkJoin, map, of } from 'rxjs';  @Injectable({ providedIn: 'root' }) export class TranslocoHttpLoader implements TranslocoLoader {   constructor(private readonly httpClient: HttpClient) {}    getTranslation(lang: string) {     return forkJoin({       translation: this.httpClient.get<Translation>(`./assets/i18n/${lang}.json`).pipe(         catchError(() => {           return of({});         })       ),       vendor: this.httpClient.get(`./assets/i18n/${lang}.vendor.json`).pipe(         catchError(() => {           return of({});         })       ),     }).pipe(       map(({ translation, vendor }) => {         const dictionaries = {           ...translation,           ...Object.keys(vendor).reduce((all, key) => ({ ...all, ...vendor[key] }), {}),         };          for (const key in dictionaries) {           if (Object.prototype.hasOwnProperty.call(dictionaries, key)) {             const value = dictionaries[key];             if (!value && value !== 'empty') {               delete dictionaries[key];             }           }         }         return dictionaries;       })     );   } } 

Загрузка переводов будет происходить при запуске приложения

Обновляем файл apps/client/src/app/app-initializer.ts

import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { TranslocoService } from '@jsverse/transloco'; import { AppRestService, AuthorizerRestService, FilesRestService, TimeRestService, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { AuthService, TokensService } from '@nestjs-mod-fullstack/auth-angular'; import { catchError, map, merge, mergeMap, of, Subscription, tap, throwError } from 'rxjs';  @Injectable({ providedIn: 'root' }) export class AppInitializer {   private subscribeToTokenUpdatesSubscription?: Subscription;    constructor(     // ..     private readonly translocoService: TranslocoService,     private readonly tokensService: TokensService   ) {}    resolve() {     this.subscribeToTokenUpdates();     return (       this.authService.getAuthorizerClientID()         ? of(null)         : this.authorizerRestService.authorizerControllerGetAuthorizerClientID().pipe(             map(({ clientID }) => {               this.authService.setAuthorizerClientID(clientID);               return null;             })           )     ).pipe(       // ..       mergeMap(() => {         const lang = localStorage.getItem('activeLang') || this.translocoService.getDefaultLang();          this.translocoService.setActiveLang(lang);         localStorage.setItem('activeLang', lang);          return this.translocoService.load(lang);       })       // ..     );   }    private subscribeToTokenUpdates() {     if (this.subscribeToTokenUpdatesSubscription) {       this.subscribeToTokenUpdatesSubscription.unsubscribe();       this.subscribeToTokenUpdatesSubscription = undefined;     }     this.subscribeToTokenUpdatesSubscription = merge(this.tokensService.tokens$, this.translocoService.langChanges$)       .pipe(         tap(() => {           // ..         })       )       .subscribe();   } } 

Язык по умолчанию будет стоять Английский. Для переключения языка в навигационном меню добавим выпадающий список с доступными для переключения языками.

Обновляем файл apps/client/src/app/app.component.ts

import { LangDefinition, TranslocoDirective, TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { marker } from '@jsverse/transloco-keys-manager/marker'; import { AppRestService, TimeRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; // ...  @UntilDestroy() @Component({   standalone: true,   imports: [RouterModule, NzMenuModule, NzLayoutModule, NzTypographyModule, AsyncPipe, NgForOf, NgFor, TranslocoPipe, TranslocoDirective],   selector: 'app-root',   templateUrl: './app.component.html',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit {   title = marker('client');   serverMessage$ = new BehaviorSubject('');   serverTime$ = new BehaviorSubject('');   authUser$?: Observable<User | undefined>;   lang$ = new BehaviorSubject<string>('');   availableLangs$ = new BehaviorSubject<LangDefinition[]>([]);    constructor(     // ...     private readonly appRestService: AppRestService,     private readonly translocoService: TranslocoService   ) {}    ngOnInit() {     this.loadAvailableLangs();     this.subscribeToLangChanges();      this.fillServerMessage().pipe(untilDestroyed(this)).subscribe();     // ...   }    setActiveLang(lang: string) {     this.translocoService.setActiveLang(lang);     localStorage.setItem('activeLang', lang);   }    private loadAvailableLangs() {     this.availableLangs$.next(this.translocoService.getAvailableLangs() as LangDefinition[]);   }    private subscribeToLangChanges() {     this.translocoService.langChanges$       .pipe(         tap((lang) => this.lang$.next(lang)),         mergeMap(() => this.fillServerMessage()),         untilDestroyed(this)       )       .subscribe();   }    // ...    private fillServerMessage() {     return this.appRestService.appControllerGetData().pipe(tap((result) => this.serverMessage$.next(result.message)));   } } 

3. Обновляем существующий код и шаблоны, для последующего запуска парсинга слов и предложений для перевода Angular-приложения

Изменений в файлах очень много, тут перечислю основные принципы внедрения поддержки переводов в файлах Angular-приложения.

Использование директивы перевода (transloco=)

Пример файла libs/core/auth-angular/src/lib/forms/auth-profile-form/auth-profile-form.component.ts

import { TranslocoDirective } from '@jsverse/transloco';  @Component({   standalone: true,   imports: [     // ...     TranslocoDirective,   ],   selector: 'auth-profile-form',   template: `@if (formlyFields$ | async; as formlyFields) {     <form nz-form [formGroup]="form" (ngSubmit)="submitForm()">       <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form>       @if (!hideButtons) {       <nz-form-control>         <div class="flex justify-between">           <div></div>           <button nz-button nzType="primary" type="submit" [disabled]="!form.valid" transloco="Update"></button>         </div>       </nz-form-control>       }     </form>     } `, }) export class AuthProfileFormComponent implements OnInit {} 

Использование пайпа перевода (| transloco)

Пример файла apps/client/src/app/pages/demo/forms/demo-form/demo-form.component.html

@if (formlyFields$ | async; as formlyFields) { <form nz-form [formGroup]="form" (ngSubmit)="submitForm()">   <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form>   @if (!hideButtons) {   <nz-form-control>     <button nzBlock nz-button nzType="primary" type="submit" [disabled]="!form.valid">{{ id ? ('Save' | transloco) : ('Create' | transloco) }}</button>   </nz-form-control>   } </form> } 

Использование сервиса перевода (translocoService: TranslocoService)

Пример файла apps/client/src/app/pages/demo/forms/demo-form/demo-form.component.html

// ... import { TranslocoService } from '@jsverse/transloco';  @Component({   // ... }) export class AuthSignInFormComponent implements OnInit {   // ...    constructor(     @Optional()     @Inject(NZ_MODAL_DATA)     private readonly nzModalData: AuthSignInFormComponent,     private readonly authService: AuthService,     private readonly nzMessageService: NzMessageService,     private readonly translocoService: TranslocoService   ) {}    ngOnInit(): void {     Object.assign(this, this.nzModalData);     this.setFieldsAndModel({ password: '' });   }    setFieldsAndModel(data: LoginInput = { password: '' }) {     this.formlyFields$.next([       {         key: 'email',         type: 'input',         validation: {           show: true,         },         props: {           label: this.translocoService.translate(`auth.sign-in-form.fields.email`),           placeholder: 'email',           required: true,         },       },       // ...     ]);     // ...   }   // ... } 

Использование маркера (marker)

Вывод перевода через директиву, пайп и сервис используется не только для перевода, но и как маркер для составления словарей с предложениями для перевода. В проекте есть файлы без директив, пайпов и сервиса в которых содержаться предложения для перевода, такие предложнения необходимо оборачивать в функцию marker.

Пример файла apps/client/src/app/app.config.ts

// ... import { marker } from '@jsverse/transloco-keys-manager/marker'; // ...  export const appConfig = ({ authorizerURL, minioURL }: { authorizerURL: string; minioURL: string }): ApplicationConfig => {   return {     providers: [       // ...       provideTransloco({         config: {           availableLangs: [             {               id: marker('en'),               label: marker('app.locale.name.english'),             },             {               id: marker('ru'),               label: marker('app.locale.name.russian'),             },           ],           defaultLang: 'en',           fallbackLang: 'en',           reRenderOnLangChange: true,           prodMode: true,           missingHandler: {             logMissingKey: true,             useFallbackTranslation: true,             allowEmpty: true,           },         },         loader: TranslocoHttpLoader,       }),     ],   }; }; 

4. Добавляем поддержку переводов в NestJS-приложении

Добавляем новый модуль в AppModule.

Обновляем файл apps/server/src/app/app.module.ts

import { TranslatesModule } from 'nestjs-translates'; // ...  export const { AppModule } = createNestModule({   moduleName: 'AppModule',   moduleCategory: NestModuleCategory.feature,   imports: [     // ...     TranslatesModule.forRootDefault({       localePaths: [join(__dirname, 'assets', 'i18n'), join(__dirname, 'assets', 'i18n', 'getText'), join(__dirname, 'assets', 'i18n', 'class-validator-messages')],       vendorLocalePaths: [join(__dirname, 'assets', 'i18n')],       locales: ['en', 'ru'],       validationPipeOptions: {         validatorPackage: require('class-validator'),         transformerPackage: require('class-transformer'),         transform: true,         whitelist: true,         validationError: {           target: false,           value: false,         },         exceptionFactory: (errors) => new ValidationError(ValidationErrorEnum.COMMON, undefined, errors),       },       usePipes: true,       useInterceptors: true,     }),     // ...   ],   // ... }); 

Для того чтобы валидационные ошибки отправлялись на фронтенд в языке которые был указан в запросе к бэкенду, необходимо подключить соответствующие словари с переводами в NX-проект.

Обновляем файл apps/server/project.json

{   "name": "server",   // ...   "targets": {     "build": {       "executor": "@nx/webpack:webpack",       // ...       "options": {         // ...         "assets": [           "apps/server/src/assets",           {             "glob": "**/*.json",             "input": "./node_modules/class-validator-multi-lang/i18n/",             "output": "./assets/i18n/class-validator-multi-lang-messages/"           }         ],         "webpackConfig": "apps/server/webpack.config.js"       }     }     // ...   } } 

5. Обновляем существующий код, для последующего запуска парсинга слов и предложений для перевода NestJS-приложения

Изменений в файлах очень много, тут перечислю основные принципы внедрения поддержки переводов в файлах NestJS-приложения.

Использование декоратора с функцией перевода @InjectTranslateFunctionn() getText: TranslateFunction)

Пример файла apps/server/src/app/app.controller.ts

import { InjectTranslateFunction, TranslateFunction } from 'nestjs-translates'; // ... @AllowEmptyUser() @Controller() export class AppController {   @Get('/get-data')   @ApiOkResponse({ type: AppData })   getData(@InjectTranslateFunction() getText: TranslateFunction) {     return this.appService.getData(getText);   } } 

Использование сервиса перевода (translatesService: TranslatesService)

Пример файла libs/feature/webhook/src/lib/controllers/webhook.controller.ts

// ... import { CurrentLocale, TranslatesService } from 'nestjs-translates';  // ... @Controller('/webhook') export class WebhookController {   constructor(     // ...     private readonly translatesService: TranslatesService   ) {}    // ...    @Delete(':id')   @ApiOkResponse({ type: StatusResponse })   async deleteOne(     // ...     @CurrentLocale() locale: string   ) {     // ...     return { message: this.translatesService.translate('ok', locale) };   } } 

Использование маркера (getText)

Вывод перевода через декоратор с функцией и сервис используется не только для перевода, но и как маркер для составления словарей с предложениями для перевода.

Если вы хотите пометить предложение так, чтобы оно попало в словарь с переводами, то нужно обернуть предложение в функцию getText.

Пример файла libs/core/auth/src/lib/auth.errors.ts

// ... import { getText } from 'nestjs-translates';  // ...  export const AUTH_ERROR_ENUM_TITLES: Record<AuthErrorEnum, string> = {   [AuthErrorEnum.COMMON]: getText('Auth error'),   // ... };  // ... 

6. Автоматическое формирование словарей для переводов

Разметка предложений и слов для перевода бэкенда и фронтенда отличаются, давным давно я сделал для себя утилиту которая собирает словари для таких проектов, ее и буду использовать в этом проекте.

Если утлита ранее не была установлена или версия стояла старая, то необходимо ее переустановить.

Команды

npm install --save-dev rucken@latest 

Запускаем утилиту

Команды

./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false 

После запуска этой команды в проекте появятся множество файлов с расширениями: po, pot, json.

Примеры файлов

Файл с расширением XXX.pot содержит ключи предложений для перевода.

Пример файла apps/client/src/assets/i18n/template.pot

msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" "mime-version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n"  msgid "Create new" msgstr "Create new"  msgid "app.locale.name.english" msgstr "app.locale.name.english"  msgid "app.locale.name.russian" msgstr "app.locale.name.russian"  

Файлы с расширением <lang>.po содержат переводы на необходимый язык.

Пример файла apps/client/src/assets/i18n/en.po

msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" "mime-version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n"  msgid "Create new" msgstr "Create new"  msgid "app.locale.name.english" msgstr "app.locale.name.english"  msgid "app.locale.name.russian" msgstr "app.locale.name.russian"  

Пример файла apps/client/src/assets/i18n/ru.po

msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" "mime-version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n"  msgid "Create new" msgstr ""  msgid "app.locale.name.english" msgstr ""  msgid "app.locale.name.russian" msgstr ""  

Файлы с расширением <lang>.json содержат переводы на необходимый язык в формате json.

Пример файла apps/client/src/assets/i18n/ru.json

{   "Create new": "",   "app.locale.name.english": "",   "app.locale.name.russian": "" } 

Пример файла apps/client/src/assets/i18n/en.json

{   "Create new": "Create new",   "app.locale.name.english": "app.locale.name.english",   "app.locale.name.russian": "app.locale.name.russian" } 

7. Добавляем переводы для всех словарей

Для массового перевода словарей я обычно использую кросплатформенную программу poedit.net.

Я уже писал пост с примером использования этой программы — https://dev.to/endykaufman/add-new-dictionaries-with-translations-to-nestjs-application-using-poeditnet-3ei2.

Сейчас просто приведу пример ручного перевода словарей.

Пример файла apps/client/src/assets/i18n/ru.po

msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" "mime-version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n"  msgid "Create new" msgstr "Создать"  msgid "app.locale.name.english" msgstr "Английский"  msgid "app.locale.name.russian" msgstr "Русский"  

Пример файла apps/client/src/assets/i18n/en.po

msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" "mime-version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n"  msgid "app.locale.name.english" msgstr "English"  msgid "app.locale.name.russian" msgstr "Russian"  

Переводы можно добалять как для po файлов, так и для json.

После добавления всех необходимых переводов нужно запустить команду, которая обьединит все переводы и создаст словари на уровне приложения.

Команды

./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false 

Алгоритм работы с переводами:

  1. Собираем словари для переводов ./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false;

  2. Добавляем переводы во все *.po файлы;

  3. Генерируем json версию переводов ./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false;

  4. Запускаем приложения и они подгружают в себя json файлы с переводами.

8. Добавляем тест для проверки переведенных ответов с бэкенда

Создаем файл apps/server-e2e/src/server/ru-validation.spec.ts

import { RestClientHelper } from '@nestjs-mod-fullstack/testing'; import { AxiosError } from 'axios';  describe('Validation (ru)', () => {   jest.setTimeout(60000);    const user1 = new RestClientHelper({ activeLang: 'ru' });    beforeAll(async () => {     await user1.createAndLoginAsUser();   });    it('should catch error on create new webhook as user1', async () => {     try {       await user1.getWebhookApi().webhookControllerCreateOne({         enabled: false,         endpoint: '',         eventName: '',       });     } catch (err) {       expect((err as AxiosError).response?.data).toEqual({         code: 'VALIDATION-000',         message: 'Validation error',         metadata: [           {             property: 'eventName',             constraints: [               {                 name: 'isNotEmpty',                 description: 'eventName не может быть пустым',               },             ],           },           {             property: 'endpoint',             constraints: [               {                 name: 'isNotEmpty',                 description: 'endpoint не может быть пустым',               },             ],           },         ],       });     }   }); }); 

9. Добавляем тест для проверки корректного переключения переводов в фронтенд приложении

Создаем файл apps/client-e2e/src/ru-validation.spec.ts

import { faker } from '@faker-js/faker'; import { expect, Page, test } from '@playwright/test'; import { get } from 'env-var'; import { join } from 'path'; import { setTimeout } from 'timers/promises';  test.describe('Validation (ru)', () => {   test.describe.configure({ mode: 'serial' });    const user = {     email: faker.internet.email({       provider: 'example.fakerjs.dev',     }),     password: faker.internet.password({ length: 8 }),     site: `http://${faker.internet.domainName()}`,   };   let page: Page;    test.beforeAll(async ({ browser }) => {     page = await browser.newPage({       viewport: { width: 1920, height: 1080 },       recordVideo: {         dir: join(__dirname, 'video'),         size: { width: 1920, height: 1080 },       },     });     await page.goto('/', {       timeout: 7000,     });     await page.evaluate((authorizerURL) => localStorage.setItem('authorizerURL', authorizerURL), get('SERVER_AUTHORIZER_URL').required().asString());     await page.evaluate((minioURL) => localStorage.setItem('minioURL', minioURL), get('SERVER_MINIO_URL').required().asString());   });    test.afterAll(async () => {     await setTimeout(1000);     await page.close();   });    test('should change language to RU', async () => {     await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`EN`);     await page.locator('nz-header').locator('[nz-submenu]').last().click();      await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last()).toContainText(`Russian`);      await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last().click();      await setTimeout(4000);     //      await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`RU`);   });    test('sign up as user', async () => {     await page.goto('/sign-up', {       timeout: 7000,     });      await page.locator('auth-sign-up-form').locator('[placeholder=email]').click();     await page.keyboard.type(user.email.toLowerCase(), {       delay: 50,     });     await expect(page.locator('auth-sign-up-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase());      await page.locator('auth-sign-up-form').locator('[placeholder=password]').click();     await page.keyboard.type(user.password, {       delay: 50,     });     await expect(page.locator('auth-sign-up-form').locator('[placeholder=password]')).toHaveValue(user.password);      await page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]').click();     await page.keyboard.type(user.password, {       delay: 50,     });     await expect(page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]')).toHaveValue(user.password);      await expect(page.locator('auth-sign-up-form').locator('button[type=submit]')).toHaveText('Зарегистрироваться');      await page.locator('auth-sign-up-form').locator('button[type=submit]').click();      await setTimeout(5000);      await expect(page.locator('nz-header').locator('[nz-submenu]').first()).toContainText(`Вы вошли в систему как ${user.email.toLowerCase()}`);   });    test('should catch error on create new webhook', async () => {     await page.locator('webhook-grid').locator('button').first().click();      await setTimeout(7000);      await page.locator('[nz-modal-footer]').locator('button').last().click();      await setTimeout(4000);      await expect(page.locator('webhook-form').locator('formly-validation-message').first()).toContainText('endpoint не может быть пустым');     await expect(page.locator('webhook-form').locator('formly-validation-message').last()).toContainText('eventName не может быть пустым');   }); }); 

10. Запускаем инфраструктуру с приложениями в режиме разработки и проверяем работу через E2E-тесты

Команды

npm run pm2-full:dev:start npm run pm2-full:dev:test:e2e 

Заключение

В этом посте я добавил поддержку работы с несколькими языками в NestJS и Angular приложениях, а также их переключение в реальном времени.

Создал словари для всех предложений которые необходимо перевести и добавил переводы на английский и русский языки.

Выбранный язык пользователя сохраняется в localstorage и используется в качестве активного при полной перезагрузке страницы, в дальнейших постах он будет сохраняться в базу данных.

Планы

В следующем посте я добавлю поддержку работы с тайм зонами, а также сохранение выбранной пользователем тайм зоны в базу данных…

Ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/863590/