DevGang
Авторизоваться

Типизированные переводы в Angular

Поддержка интернационализации — одна из сложных тем во фронтенд-разработке, все всегда добавляют только ключи и значения для основной локали, обычно английского (en или en-gb), оставляя переводы для других языков на потом.

Мы столкнулись с этой проблемой в одном из наших проектов при реализации локализации.

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

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

Решением проблемы является использование резервного языка (fallbackLang) при разрешении переводов по токену.

Решает fallbackLang вопрос с отображением текста, чтобы не путать пользователя, но следить за наличием ключей всё равно остаётся, а также привлекать QA для выявления проблем.

Многие, кто использует переводы во время выполнения, полностью понимают проблему: «как можно гарантировать, что ключевой токен правильный и можно найти соответствующий перевод для текущей локали во время выполнения браузера?»

Я тоже задавался этим вопросом, но все, что мне удалось найти, это статью от 21 октября 2018 года. Набранные переводы в Angular.

Решение для того времени было отличным, но автор использует объект напрямую, а нативная множественность через js с наличием ICU кажется излишней.

Несомненно, это решение можно было бы улучшить, например, сделав токен TRANSLATIONпотоковым, добавив сервис-агрегатор с переводами при смене языка и так далее. Но опять же, это совсем другая история.

ESLINT — i18n-json

Ситуацию частично спасает eslint-плагин eslint-plugin-i18n-json, который проверяет идентичность ключей в json-файлах, но для надежности этого тоже недостаточно.

Потому что это односторонняя проверка, и она не гарантирует, что разработчик, используя текущую базу перевода, укажет правильный ключ, и QA это заметит, если что-то пойдет не так.

Нам нужно будет установить eslintcommitlint проверяет изменения в json, а также интегрирует его в пайплайны, если у нас есть встраивание переводов из внешних источников.

Мое предложение

А что, если я скажу вам, что все эти проверки не нужны?

Чтобы обеспечить согласованность переводов под локаль, забудьте про fallbackLang и missingKeyHandler и больше не беспокойтесь о правильности ключа, можно ли этого добиться исключительно TypeScript?

Данное решение подходит как для текущих реализаций на ngx-translate или transloco, так и для новых.

Итак, каким требованиям должно соответствовать решение?

  • Сборка приложения завершается неудачей, если в одном из переводов отсутствует ключ.
  • Разработчики уведомляются об ошибках с неправильным ключом, а также есть подсказки о доступном списке ключей.
  • Обеспечьте масштабируемость, добавляя новые переводы по локали, без прямого взаимодействия с кодом, но обеспечивайте все проверки.
  • Текущие ключевые переводы ngx-translate и transloco работа по-прежнему. Их можно параметризовать, и они поддерживают отделение интенсивной терапии.
  • Переводы можно использовать в библиотеках и публиковать как самодостаточные пакеты.

Вторично:

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

Решение

Демонстрационный репозиторий состоит из:

  • assets — каталог с подкаталогом i18n, в котором хранятся файлы перевода.
  • libs — набор библиотек для:ui-actions и ui-layouts— набор демо-библиотек.i18n-wrapper — содержит сущности для ввода сервиса transloco.typed-transloco — типизированная версия transloco со своим файлом перевода и основанная на i18n-обертке.
  • ui-actions и ui-layouts— набор демо-библиотек.
  • i18n-wrapper — содержит сущности для ввода сервиса transloco.
  • typed-transloco — типизированная версия transloco со своим файлом перевода и основанная на i18n-обертке.
  • src — каталог хост-приложения.
  • scripts — каталог для сервисных скриптов.

Для простоты и удобства я решил взять за основу библиотеку @ngneat-transloco, общую для двух библиотек репозитория.
Она предоставляет единый файл перевода и сервис, с которым они взаимодействуют.

В случае публикации библиотек пользовательского интерфейса эта библиотека также будет опубликована и будет служить одноранговой зависимостью.

Это решение больше подходит для крупных монорепозиториев, где переводы управляются через основную библиотеку с assets/i18n. Ничто не мешает применить мое решение к ngx-translate, где есть механизм наследования или разделения сервисов.

1. Библиотека i18n-wrapper

Эта библиотека будет содержать два каталога:

  • transloco — каталог для оберток transloco.
  • types — распространенные типы набора файла перевода.

Ввод токенов для перевода

Мы привыкли указывать токен перевода в виде строки, разделенной точками. Пример: lib.scope.module.component.somekey.

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

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

type TAddPrefix<Prefix extends string, Key extends string> = [Prefix] extends [never]
 ? `${Key}`
 : `${Prefix}.${Key}`;


export type TObjectKeyPaths<T extends Record<string, any>, Prefix extends string = never> = {
 [P in string & keyof T]: T[P] extends object
   ? TObjectKeyPaths<T[P], TAddPrefix<Prefix, P>>
   : T[P] extends string
     ? TAddPrefix<Prefix, P>
     : never;
}[string & keyof T];

Имея рекурсивный тип для получения путей перевода, все, что нам остается сделать, — это создать тип, который преобразует тип только для чтения в i18n.db.ts карту ключей для каждой локали, обеспечивая синхронизацию между языками.  

export type TTranslatePath<
 DB extends object,
 Langs extends keyof DB = keyof DB
> = TObjectKeyPaths<DB[Langs]>

С этими двумя типами мы получили основную часть нашей типизации, которая решает 80% вопросов из поставленной задачи, которую уже можно применять к разным частям проекта, а не только к локализации.

Осталось всего 20% — набранная обертка для transloco.

Оберток будет всего три, и все они абстрактные:

  • AbstractTranslocoConfigService — сервис, регулирующий список доступных языков при настройке TRANSOLO_CONFIG. Он также реализует TranslocoLoader интерфейс для загрузки нашего файла перевода.
  • AbstractTranslocoService — сервис-обертка над TranslocoService, предлагающая аналогичный список методов из TranslocoService, но при этом сужающая типы доступных языков и ключей до доступных из i18n.db.ts.
  • AbstractTranslocoComponent — одна из сущностей, которая будет вставлять переводы в шаблон на основе предоставленного токена (аналогично каналу и директиве).

AbstractTranslocoConfigService

Как я упоминал ранее, необходимо предоставить функции для токенов TRANSLOCO_CONFIG и TRANSLOCO_LOADER, когда значения доставляются через фабрику.

Этот сервис прост и принимает один общий тип нашего файла перевода для ввода доступного списка языков.

Поскольку мы знаем доступный список языков и получаем язык по умолчанию в конструкторе, для метода, предоставляющего конфиг, мы можем исключить, defaultLang и availableLangsfallbackLang оставив настройки для transloco.

export type TTypedTranslocoConfig = Partial<Omit<TranslocoConfig, 'defaultLang' | 'availableLangs' | 'fallbackLang'>>;

Результат:

export abstract class AbstractTranslocoConfigService<
   i18nDb extends Record<string, Translation>,
   Lang extends keyof i18nDb = keyof i18nDb,
> implements TranslocoLoader {
   public readonly AVAILABLE_LANGS: Lang[]


   protected constructor(
       public readonly DB: i18nDb,
       public readonly DEFAULT_LANG: Lang,
   ) {
       this.AVAILABLE_LANGS = Array.from(
           new Set<Lang>([
               DEFAULT_LANG,
               ...Object.keys(DB) as Lang[],
           ]),
       );
   }


   public getProvidedConfig(config: TTypedTranslocoConfig): TranslocoConfig {
       const patchedConfig: Partial<TranslocoConfig> = {
           ...config,
           defaultLang: this.initializeDefaultLanguage() as string,
           availableLangs: this.AVAILABLE_LANGS as string[],
           fallbackLang: this.DEFAULT_LANG as string
       }


       return translocoConfig(patchedConfig);
   }


   private initializeDefaultLanguage(): Lang {
       const browserLanguage = getBrowserLang() as Lang | undefined;


       if (!browserLanguage) {
           return this.DEFAULT_LANG
       }


       return this.isAvailableLang(browserLanguage) ? browserLanguage : this.DEFAULT_LANG;
   }




   public isAvailableLang(lang: Lang | string): lang is Lang {
       return this.AVAILABLE_LANGS.includes(lang as Lang)
   }


   public getTranslation(lang: string): Observable<Translation> {
       return of(this.DB[lang])
   }
}

AbstractTranslocoService

Этот сервис не содержит какой-либо конкретной логики и служит типизированным посредником между объектами, желающими использовать TranslocoService.

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

Единственное отличие этого сервиса от нативного TranslocoService — это фильтрация языков по признаку во AbstractTranslocoConfigService избежание установки недоступного языка.

export abstract class AbstractTranslocoService<
   i18nDb extends Record<string, Translation>,
   Lang extends keyof i18nDb = keyof i18nDb,
> {


   protected constructor(
       protected readonly translocoService: TranslocoService,
       protected readonly translocoConfigService: AbstractTranslocoConfigService<i18nDb, Lang>
   ) {
   }


   public getActiveLang(): Lang {
       return this.translocoService.getActiveLang() as Lang;
   }


   public setActiveLang(candidate: Lang): void {
       this.translocoService.setActiveLang(this.checkLanguage(candidate) as string);
   }


   public getDefaultLang(): Lang {
       return this.translocoService.getDefaultLang() as Lang;
   }


   public setDefaultLang(candidate: Lang): void {
       this.translocoService.setDefaultLang(this.checkLanguage(candidate) as string);
   }


   public getAvailableLangs(): Lang[] {
       return this.translocoService.getAvailableLangs() as Lang[];
   }


   private checkLanguage(language: Lang): Lang {


       if (this.translocoConfigService.isAvailableLang(language)) {
           return language as Lang
       }


       const browserLanguage: string | undefined = getBrowserLang();
       if (browserLanguage && this.translocoConfigService.isAvailableLang(browserLanguage)) {
           return browserLanguage;
       }


       return this.getActiveLang() || this.translocoConfigService.DEFAULT_LANG;
   }
}

AbstractTranslocoComponent

Я решил реализовать это через компонент, и этот выбор осознан по нескольким причинам:

  • Это позволяет нам не объявлять в шаблоне функции, которые могут быть ограничены правилами eslint.
  • Есть возможность отсоединить компонент от дерева компонентов для оптимизации при переключении языков.
  • В целях контроля качества существует возможность привязать наш токен к элементу с помощью HostBinding, что позволяет определить, какой токен используется в определенном месте.
  • Предусмотрены автоматические предложения доступных токенов.

Минусы:

  • Необходимо указать полный ключ из файла перевода.
  • Невозможно передать значение от одного компонента к другим, которые принимают текст только в строковом формате и не допускают настройки с помощью <ng-content> или TemplateRef.

К счастью, эти недостатки устраняются путем создания оболочек для translocoPipe и translocoDirective.

Чтобы уменьшить связанность, я представлю интерфейс с именем ISelectTranslateService, который будет регулировать реализацию сервиса для переводов на основе параметров.

export interface ISelectTranslateService<
   i18nDB extends Record<string, Translation>,
   Lang extends keyof i18nDB = keyof i18nDB> {
   selectTranslate$: (
       path: TTranslatePath<i18nDB>,
       params?: HashMap,
       lang?: Lang
   ) => Observable<string>
}

Результат:

@Directive()
export abstract class AbstractTranslocoComponent<
 i18nDb extends Record<string, Translation>, Lang extends keyof i18nDb = keyof i18nDb,
> implements OnChanges {
 private readonly update$: ReplaySubject<void> = new ReplaySubject<void>(1);


 public translation$: Observable<string> = this.update$.asObservable().pipe(
     switchMap(() => this.getCurrentTranslation$())
 );


 @Input()
 public key!: TTranslatePath<i18nDb>


 @Input()
 public params?: HashMap;


 @Input()
 public lang?: Lang;


 protected constructor(
   protected service: ISelectTranslateService<i18nDb, Lang>
 ) {
 }


 public ngOnChanges(): void {
   this.update();
 }


 public update(): void {
   this.update$.next();
 }


 private getCurrentTranslation$(): Observable<string> {
   return this.service.selectTranslate$(
       // @ts-expect-error: not be infinite
       this.key,
       this.params,
       this.lang
   )
 }
}

2. Библиотека typed-transloco

Единая база данных переводов

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

  • assets/i18n/en.json
{
 "actions": {
   "back": "Back",
   "confirm": "Are you sure?",
   "new": "New"
 },
 "layouts": {
   "about": "About",
   "account": "Account",
   "app_store": "App Store"
 }
}
  • assets/i18n/de.json
{
 "actions": {
   "back": "Zurück",
   "confirm": "Bist du sicher?",
   "new": "Neu"
 },
 "layouts": {
   "about": "Um",
   "account": "Konto",
   "app_store": "Appstore"
 }
}

Он экспортирует один объект со всеми переводами для каждой локали.

  • i18n.db.ts
export const TRANSLATE_DB = {
 "de": {
   "actions": {
     "back": "Zurück",
     "confirm": "Bist du sicher?",
     "new": "Neu"
   },
   "layouts": {
     "about": "Um",
     "account": "Konto",
     "app_store": "Appstore"
   }
 },
 "en": {
   "actions": {
     "back": "Back",
     "confirm": "Are you sure?",
     "new": "New"
   },
   "layouts": {
     "about": "About",
     "account": "Account",
     "app_store": "App Store"
   }
 }
};

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

Конечно, вы можете импортировать файлы JSON в TS, но тогда вам придется настроить tsconfig.json формат resolveJsonModule. Кроме того, по личным причинам я считаю этот подход неоптимальным, если существует процесс экспорта одного файла перевода из проекта.

Типизированные сущности

Благодаря наличию единого файла перевода и абстрактных оберток мы можем реализовать типизированные версии для наших переводов:

  • TypedTranslocoConfigService
@Injectable()
export class TypedTranslocoConfigService extends AbstractTranslocoConfigService<typeof TRANSLATE_DB> {
   constructor() {
       super(TRANSLATE_DB, 'en');
   }
}
  • TypedTranslocoService
@Injectable()
export class TypedTranslocoService extends AbstractTranslocoService<typeof TRANSLATE_DB>{
   constructor(
       translocoService: TranslocoService,
       typedTranslocoConfigService: TypedTranslocoConfigService
   ) {
       super(translocoService, typedTranslocoConfigService);
   }
}
  • TypedTranslocoComponent
@Component({
 selector: 'typed-transloco-component',
 standalone: true,
 imports: [CommonModule],
 template: '{{ translation$ | async }}',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TypedTranslocoComponent extends AbstractTranslocoComponent<typeof TRANSLATE_DB>{
 constructor(
     public typedTranslocoService: TypedTranslocoService
 ) {
   super(typedTranslocoService);
 }
}

TypedTranslocoModule

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

@NgModule({
   imports: [TranslocoModule],
   providers: [TranslocoService]
})
export class TypedTranslocoModule {
   static forRoot(config: TTypedTranslocoConfig = {}): ModuleWithProviders<TypedTranslocoModule> {
       return {
           ngModule: TypedTranslocoModule,
           providers: [
               TypedTranslocoService,
               TypedTranslocoConfigService,
               {
                   provide: TRANSLOCO_LOADER,
                   useExisting: TypedTranslocoConfigService,
               },
               {
                   provide: TRANSLOCO_CONFIG,
                   deps: [TypedTranslocoConfigService],
                   useFactory: (configService: TypedTranslocoConfigService) => {
                       return configService.getProvidedConfig(config)
                   }
               }
           ]
       }
   }
}

3. Интеграция в библиотеки пользовательского интерфейса.

Основная цель этой статьи — продемонстрировать решение для типизации переводов, поэтому примеры для этих библиотек будут простыми и содержат минимум логики.

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

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

Я взаимодействую только с шаблоном компонента и пока создам один элемент, поддерживающий типизированную локализацию.

Поскольку мы передали вычисленный тип при TRANSLATE_DB создании нашего компонента, редактор кода уже может предложить нам, какие доступные токены у нас есть для перевода.

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

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

Результат

  • Библиотека UI-действий
<button>
   <typed-transloco-component key="actions.back"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="actions.confirm"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="actions.new-wrong"></typed-transloco-component>
</button>
  • Библиотека UI-макетов
<button>
   <typed-transloco-component key="layouts.app_store"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="layouts.account"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="layouts.about"></typed-transloco-component>
</button>

Использование библиотек в приложении

Вы не сможете продемонстрировать локализацию в библиотеках без использования демо-приложения. Он будет небольшим и будет содержать минимум кода.

Отображает два наших компонента из библиотек, а также переключает язык через файл TypedTranslocoService.

@Component({
 selector: 'app-root',
 template: `
   <lib-actions-showcase></lib-actions-showcase>
   <lib-layouts-showcase></lib-layouts-showcase>
   <div>
     <button (click)="onChangeLang('en')">EN</button>
     <button (click)="onChangeLang('de')">DE</button>
   </div>
 `,
 styles: [`:host {display: flex; flex-direction: column; gap: 8px}`]
})
export class AppComponent {
 constructor(
     private typedTranslocoService: TypedTranslocoService
 ) {}


 public onChangeLang($event: any): void {
   console.log('lang: ', $event)
   this.typedTranslocoService.setActiveLang($event)
 }
}

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

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

Это тот результат, к которому мы стремились на протяжении всей статьи — ловим ошибки от неправильных токенов и обеспечиваем типизацию.

Давайте исправим ошибку.

И давайте пересоберем приложение. Оно успешно скомпилировалось, и теперь мы можем переключиться в браузер, чтобы увидеть конечный результат.

И наша локализация работает.

Конечный результат

Источник:

#Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу