Angular i18n


Цель статьи — это описать детальные шаги интернационализации вашего приложения на Angular с помощью родного функционала.


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


Angular i18n
Illustration by Thomas Renon


Есть два подхода — использовать встроенный в angular i18n: генерация под каждую локаль своего бандла приложения, либо использовать библиотеки вроде transloco, которые предлагают хранить переводы во внешних json файлах или разных других форматах и динамически подставлять/менять локаль по запросу пользователя. Не мало холиваров было о вопросе как удобнее, но однозначно ясно одно — если у нас уже написаное приложение — расставлять в нем токены дело не сильно приятное. В то время как родные средства Angular более подходят, для того чтобы взять и готовое приложение сделать многоязычным.


Тут вы найдете ответы на вопросы:


  • Как вынести текущий язык в токены
  • Как добавить новый язык переводов
  • Как модифицировать языки
  • Как деплоить и собирать приложение
  • Как быть если есть токены в ts файле или они приходят по API

Вступление


Как это работает?


Помимо черной магии в angular есть специальный атрибут i18n для поддержи интернационализации. Работает он совсем не так как обычные атрибутивные компоненты в angular (как ngClass к примеру). Потому что на самом деле это не компонента, Это фактически директива препроцессора. Да, для интернационализации Angular предлагает не использовать Angular, а использовать хитрый препроцессор во время сборки проекта. Именно такой подход отчасти и позволяет нам локализовать приложение которое уже написанно с минимальными вложениями в этот процесс (оставим за кадром RTL языки, поддержка которых хромает на обе ноги везде). Соответственно разметив все строки в шаблонах мы говорим angular-cli — извлеки все строки в проекте и сделай мне файл для переводов.


Итого — Для создания мультиязычных интерфейсов Angular предлагает использовать механизм разметки HTML шаблонов при помощи специального атрибута i18n который после компиляции удаляется из финального кода.


1. Вопрос: "Как токенизировать текущий язык и не создать путаницу токенов"


  • Советую всегда привязываться к id
  • Для себя выберите правила токенизации приложения которым должны следовать все на проекте

Теперь вопрос: "как создать сам id"?
Какие правила придумать?


В проекте следует по возможности для маркера указывать дополнительные параметры которые отображаются в специализированных редакторах использующихся для перевода и дополняют переводимый текст служебной информацией призванной помочь переводчику. Это параметры передаются в формате «Значение|Описание» или только «Описание». Обязательно следует указывать @@id это будет токен для перевода. Идентификатор пишется своеобразным синтаксисом используя префикс @@.


<div i18n="форма логина | поле @@login.email">Email</div>
<button i18n="форма логина | кнопка @@login.post">save</button>

Правила для именования можно выбрать такие:
Название компонента или роут используются как префикс токена — это позволит переводчику понять где искать токен, грубо говоря не явное описание контекста. Кроме того при удалении компонента из проекта легко вычленить и удалить все связанные с ним переводы.


Слова метки: кнопка, переключатель, поле, колонка для переводчиков как дополнительный контекст


Пример соглашения по наименованию токенов


Находясь в компоненте <info-statuses> токен следует называть таким образом:


<th i18n="Статусы покупателя | колонка @@(селектор компонента и поле)info-statuses.date">Дата добавления</th>

Различные варианты использования токенов


Иногда у нас есть необходимость задать перевод для текста который является фрагментом внутри большего блока или текст вне тегов. Используем ng-container, который не рендерится в финальный код.


<ng-container 
    i18n="Генерация архива | поле @@generate-archive.title"
>I don't output any element</ng-container>

Возможно так же делать перевод для атрибутов тегов. Указывается i18n-attrName


<img 
    [src]="logo" 
    i18n-title="картинка @@company.logo"
    title="Angular logo" 
/>

Вот мы заполнили все шаблоны тегами i18n и что теперь? Теперь нужно создать файл переводов, Angular приходит на помощь и говорит, просто вызываем команду i18n-extract и генерируем файл с переводами. Глянуть описание аргументов можно тут.


В моём случае команда выглядит таким образом (я указываю исходную локаль файлов перевода. "uk")


"extract-i18n": "ng xi18n projectName --i18n-format xlf --output-path i18n --i18n-locale uk

Теперь вы знаете ответ как локализовать приложение под один язык.


2. Как добавить новый язык для переводов


Сейчас мы поговорим о инструментах, что помогают работать с дефолтным форматом выгрузки ключей в angular i18n — это xliffmerge


Проблема возникает при генерации нового языка. Необходимо синхронизировать токены дефолтного языка и нового. Также есть процесс обновления локалей, после редактирования переводчиком или после добавления новых токенов в проект. Инструмент xliffmerge решает эти проблемы одним конфигом и одной командой.


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


https://www.npmjs.com/package/@ngx-i18nsupport/ngx-i18nsupport
https://github.com/martinroob/ngx-i18nsupport/wiki/Tutorial-for-using-xliffmerge-with-angular-cli


"xliffmerge": {
  "builder": "@ngx-i18nsupport/tooling:xliffmerge",
  "options": {
    "xliffmergeOptions": {
      "i18nFormat": "xlf",
      "srcDir": "projects/my-test/i18n",
      "genDir": "projects/my-test/i18n",
      "verbose": true,
      "defaultLanguage": "uk",
      "languages": [
        "uk",
        "en"
      ]
    }
  }
}

В настройках angular.json мы добавляем новую конфигурацию.


Эта конфигурация при запуске, принимает дефолтный исходный файл. B зависимости от настроек генерирует производные или дополняет уже существующие файлы с переводом новыми ключами. Важно! При добавлении ключа в базовую локаль он будет добавлен всем производным локалям. Это все делается автоматически, не нужно править XML руками.


Таким образом. Когда мне нужно добавить новую локаль. Я добавляю поле в блок languages с именем языка, к примеру en и запускаю ng run my-test:xliffmerge чтобы на выходе получить новый файл xlf с локалью en.


Теперь команда генерации файлов переводов выглядит таким образом


"extract-i18n": "ng xi18n crm --i18n-format xlf --output-path i18n --i18n-locale ru && ng run my-test:xliffmerge",

Было бы классно ещё пропускать переводы через google translate, чтобы сэкономить на переводах и иметь какой-то черновой вариант подумал я. Как выяснилось xliffmerge имеет и такую опцию.


Дополняем конфиг xliffmerge а angular.json:


"autotranslate": ["en"],
"apikey": "yourAPIkey",

Хорошо, теперь при изменениях в нашем html запуск команды extract-i18n будет обновлять все локали.


Осталось последнее, как собирать бандл для деплоя.


"build-prod:my-test:en": "ng build my-test --configuration=productionEN --base-href /en/ --resources-output-path ../"
"build-prod:my-test:uk": "ng build my-test --configuration=productionUK --base-href /uk/ --resources-output-path ../",
"build-prod:locales": "npm run build-prod:my-test:en && npm run build-prod:my-test:uk",

Под каждую локаль своя команда, к сожалению, в А8 на то время нельзя было ставить аргументы через запятую --configuration=production,en, поэтому пришлось дублировать конфиги в angular.json


"productionEN": {
  "outputPath": "dist/my-test/en",
  "fileReplacements": [
    {
      "replace": "projects/my-test/src/environments/environment.ts",
      "with": "projects/my-test/src/environments/environment.en.prod.ts"
    }
  ],
  ... like in production
},

Мы настроили билд так, чтобы assets были общими (resources-output-path ../), вы можете убрать resources path и максимально отделить разные версии между собой. Для большинства приложений ресурсы в разных языковых версиях не будут отличаться, поэтому такой ход оправдан. В случае общих ресурсов перезагрузка бандла при смене языка будет происходить существенно быстрее, потому что часть ресурсов уже будет в кеше браузера.


Теперь запоминаем самое главное: файлы .xlf никогда руками не правим, через специальные инструменты (weblate к примеру OpenSource инструмент) можно записывать верные переводы и пушить в ветку, а там ваш билд это всё подхватит и всё супер.


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


Таким образом мы теперь должны доработать свой CI/CD папйплайн чтобы генерировать несколько языковых версий вместо одной.


Всё работает, но оказывается у в проекте есть текст который зашит в ts файлах и как его переводить, если подстановка i18n атрибута работает только в шаблонах.


Переводы в коде


Есть список с текстом, который никак не завязан на бэкенд, поэтому и переводов у нас с бэканда этой сущности нет. Что делать? Ответ один — локализируй через шаблон.


Вот пример как это будет


  list = [
    {
      token: 'login-info-1',
      value: 1,
    },
    {
      token: 'login-info-2',
      value: 2,
    },
  ];

 <div style="display: none"
       #el
       i18n-login-info-1="поле @@login-form.first"
       login-info-1="первое условие это..."
       i18n-login-info-2="поле @@login-form.second"
       i18n-login-info-2="второе условие это..."
  >
  </div>

   <div *ngFor="let item of list">
      <label>
        {{ item.token | customPipeI18n: el }}
      </label>
  </div>

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


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


Пример пайпа


@Pipe({ name: 'customPipeI18n', pure: true })
export class TranslatePipe {
  transform(key: string, value: HTMLElement): any {
    const lowerKey = key.toLowerCase();
    if (value && value.hasAttribute(lowerKey)) {
      return value.getAttribute(lowerKey);
    }
    console.log('key: ', lowerKey);
    return '*not found key*';
  }
}

Хорошо это работает. А что делать если у меня схожие тексты повторяются на многих страницах? Добавлять на каждую по скрытому элементу с идентичной логикой?


Согласен, что вариант с таким пайпом не сильно удобен и было бы круто сделать так, чтобы все тексты, что в есть в ts файлах определенного модуля были вынесены куда-то в одном место где их легко смотреть редактировать и менять. Было принято решение написать реестр переводов.


Для этого создана комбинация:


сервис(ElementRegistry) — для хранения элемента;
директива(ElementDirective) — для регистрации шаблона с атрибутами и сохранения его в сервис;
пайп(ElementPipe) — для получения перевода из сервиса;


Пример использования:


Имеем модуль auth
в корневом компоненте создаём элемент с атрибутами объявляем директиву и регистрируем имя шаблона auth


<div
  i18nElement="auth"
  le="display: none"
  i18n-login-info-1="поле @@login-form.first"
  login-info-1="первое условие это..."
  i18n-login-info-2="поле @@login-form.second"
  i18n-login-info-2="второе условие это..."
>
</div>

Для перевода вызывается pipe i18nElement туда передаётся название шаблона в котором объявлены атрибуты с токенами.


   <div *ngFor="let item of list">
      <label>
        {{ item.token | i18nElement: 'auth' }}
      </label>
  </div>

Это решает следующие проблемы:


  • Eсли мы используем текст который приходит из сторонего API, а локализовать необходимо ответ
  • У вас в ts файле просто почему-то оказался текст который нужно локализовать

Итог


Simple-Made-Easy нативные средства i18n в Angular не смотря на меньшую популярность чем классический подход с кучей json файлов тоже работают и весьма удобны\продуманны в практическом применении.


xliff как формат хранения переводов помимо непригодности для редактирования руками имеет много удобных инструментов для переводчиков, позволяющих аннотировать и группировать переводы. Отказ от использования json и переход на xliff позволяет упростить работу с переводами для команды локализации, особенно вместе с инструментами вроде weblate или аналогами.


Некоторые сложности вызывает использование переводов вне шаблонов, но все они в целом решаемые при помощи подходов описанных в статье.


В NodeArt много проектов на angular, и мы пробуем систематизировать их для упрощения поддержки. Если ранее при старте проектов на Angular версий 2 и 4 мы однозначно выбирали и использовали NGX-Translate, то для новых проектов мы уже по возможности испльзуем нативные инструменты Angular. В то время как инструменты автоматизации работы переводчиков прекрасно интегрируются с обоими подходами.


P.S.


в 9-10м Ангуляре есть изменения в работе с локализацией. Ставьте палец вверх и будет ещё одна статья про облегчение с 9-м.


Пригласить автора на Хабр: skochkobo