Все заметки
  • Angular
  • i18n
  • Node.js

Перевод Angular-приложения одной командой: утилита translate-xlf

Каждая новая строка в интерфейсе — правка во всех языковых файлах. Рассказываю, как небольшая утилита на Node.js забрала эту рутину и какие решения в ней по-настоящему экономят время.

Angular хранит переводы в XLF-файлах: messages.xlf с исходными строками и по файлу на каждый язык — messages.en.xlf, messages.tr.xlf и так далее. Пока проект живёт, строки появляются постоянно: новая кнопка, подсказка, текст ошибки. И каждая такая строка — это правка во всех языковых файлах сразу.

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

Я написал утилиту, которая делает это за меня, — translate-xlf. Ниже — какие проблемы она решает и какие решения внутри оказались по-настоящему полезными.

Что должно было получиться

Требования я сформулировал от болей, а не от технологий:

  • одна команда, без настроек и ключей;
  • служебные теги внутри строк не ломаются;
  • то, что уже перевёл человек, не перезаписывается;
  • в merge request видны только переведённые строки;
  • если что-то пошло не так, это видно сразу, а не после релиза.

Каждый следующий раздел — про один из этих пунктов.

Одна команда и ноль настроек

Утилита опубликована в npm и не тянет ни одной зависимости — только встроенные модули Node.js fs и https. Ставится одной командой и запускается в папке с переводами:

Bash
npm install -g translate-xlf
cd src/locale
translate-xlf

Конфиг не нужен: язык берётся из имени файла. messages.en.xlf — английский, messages.kk.xlf — казахский. Файл без кода языка — это исходный messages.xlf, его утилита пропускает. Если какой-то язык ведёт живой переводчик, его можно исключить:

Bash
translate-xlf --exclude=en

Новые строки попадают в языковые файлы с state="new" и с русским текстом вместо перевода — так их добавляет, например, ng-extract-i18n-merge. Утилита берёт в работу всё, что ещё не помечено как переведённое. В итоге цепочка «извлечь строки → перевести» — это две команды, которые легко повесить на один npm-скрипт.

Теги внутри строк остаются целыми

Самое неприятное в машинном переводе XLF — разметка внутри текста. Angular превращает интерполяцию и HTML-теги шаблона в плейсхолдеры:

messages.en.xlf
<source>Итого: <x id="INTERPOLATION" equiv-text="{{ total }}"/> ₽</source>

Если отправить такую строку в Google Translate как есть, он увидит в теге обычный текст и вправе сделать с ним что угодно: перевести, переставить, потерять. Сломанный плейсхолдер — это пропавшая сумма в корзине или ссылка, которая перестала быть ссылкой.

Поэтому перед отправкой утилита заменяет каждый тег на нейтральную метку — __XML_TAG_0__, __XML_TAG_1__ и так далее. Такие метки переводчик оставляет как есть, а после ответа утилита возвращает теги на место. Регистр при этом не важен: бывает, что Google возвращает метку как __xml_tag_0__, и восстановление это учитывает. Результат:

messages.en.xlf
<target state="translated">Total: <x id="INTERPOLATION" equiv-text="{{ total }}"/> ₽</target>

Ручная работа не перезаписывается

Машинный перевод — черновик, и утилита исходит из этого. Если строку поправили руками и пометили state="translated" или state="final", утилита её не тронет — ни сейчас, ни при следующих запусках. Поэтому запускать её можно сколько угодно раз: каждый прогон переводит только новое.

Зачем черновику проверка, видно на живом примере. В одном из прогонов «Перейти в каталог» стало «Go to directory»: без контекста переводчик решил, что речь о папке на диске. Для таких случаев есть флаг --keep-state: переведённые строки остаются со state="new", и тот, кто проверяет переводы, точно знает, какие из них написала машина.

После проверки строке ставят translated, и утилита её больше не трогает. Менять статус лучше сразу: пока строка в new, повторный запуск переведёт её заново.

В merge request — только переведённые строки

XLF — это XML, и первое желание — прочитать его XML-библиотекой, поменять нужные узлы и записать обратно. Но при записи библиотека переписывает файл по-своему: отступы, кавычки, порядок атрибутов, самозакрывающиеся теги. В merge request приходит дифф на весь файл, и найти в нём сами переводы невозможно.

Поэтому утилита работает со строками файла напрямую: находит <target> нужной записи и заменяет только его, сохраняя отступ. Остальные строки не меняются. Вот изменение после прогона на тестовом файле:

messages.en.xlf
-        <target state="new">Итого: <x id="INTERPOLATION" equiv-text="{{ total }}"/> ₽</target>
+        <target state="translated">Total: <x id="INTERPOLATION" equiv-text="{{ total }}"/> ₽</target>

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

Ошибка в одной строке не роняет весь прогон

Строки переводятся пачками по пять параллельно: так быстрее, чем по одной, и не настолько агрессивно, чтобы упереться в лимиты. Размер пачки меняется флагом --batch-size. Если строка не перевелась, утилита через секунду пробует ещё раз. Не получилось и со второй попытки — в перевод записывается исходный текст с меткой:

messages.en.xlf
<target state="translated">[ОШИБКА ПЕРЕВОДА] Доставим завтра, если оформить заказ до 18:00</target>

Прогон при этом продолжается, а в конце утилита пишет, сколько строк перевелось и сколько — нет. Строки с меткой при следующем запуске переводятся заново, поэтому после сбоя достаточно повторить команду:

messages.en.xlf
-        <target state="translated">[ОШИБКА ПЕРЕВОДА] Доставим завтра, если оформить заказ до 18:00</target>
+        <target state="translated">We will deliver tomorrow if you place your order before 18:00</target>

Метку видно сразу — и в диффе, и поиском по файлам. Коммитить файлы с ней нельзя: Angular покажет её пользователю как обычный текст.

Для пробного запуска на большом проекте есть переменная окружения: MAX_ELEMENTS=50 translate-xlf переведёт первые 50 строк каждого файла. Можно посмотреть на результат, прежде чем запускать всё.

Так работает версия 1.0.3. До неё ошибки вели себя совсем иначе — об этом следующий раздел.

Октябрь 2026: Google начал отвечать 429

Утилита бесплатная, потому что ходит в тот же эндпоинт Google Translate, что и браузерные расширения, — без ключа, биллинга и квот Google Cloud. Цена этого — отсутствие гарантий, и в октябре 2026 года она пришла: все строки разом стали падать.

Хуже самой поломки было то, как падала версия 1.0.2:

  • Google отвечал не JSON, а HTML-страницей «We're sorry... your computer or network may be sending automated queries». Скрипт не проверял HTTP-код и пытался разобрать её как JSON, поэтому в логе была только невнятная «Ошибка парсинга ответа».
  • Метка ошибки записывалась со state="translated". Следующий запуск считал такие строки переведёнными и пропускал — ошибки оседали в файлах навсегда.
  • Итог в конце сообщал «Переведено элементов: N/N», даже если не перевелось ничего.

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

Дальше — диагностика. Один и тот же запрос через curl возвращал 200, а через Node — 429. Значит, блокировали не сеть и не IP, а сам клиент. Встроенный в Node fetch тоже проходил, а модуль https — нет. Разница нашлась в TLS-рукопожатии: https по умолчанию не отправляет расширение ALPN, и по этому отпечатку Google принимал скрипт за бота. Лечится одной настройкой агента:

translate-xlf.js
const translateAgent = new https.Agent({
  keepAlive: true,
  ALPNProtocols: ['http/1.1'],
});

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

Но главное в версии 1.0.3 — не обход блокировки, а то, что следующая поломка не будет тихой:

  • в тексте ошибки — HTTP-код и начало ответа, например HTTP 429 Too Many Requests: We're sorry...;
  • строки с меткой ошибки переводятся заново при следующем запуске;
  • в итоге отдельно указано, сколько строк переведено и сколько упало;
  • в README появился раздел «Если перевод перестал работать»: две команды, curl и Node, и подсказки, как читать их ответы. В следующий раз диагностика займёт минуту.

Чего утилита не делает

  • Не заменяет переводчика. Это быстрый черновик, его стоит вычитать — особенно короткие строки без контекста.
  • Не работает с XLIFF 2.0. Поддерживается XLF 1.2 — формат, который Angular выдаёт по умолчанию.
  • Не разбирает ICU-выражения. plural и select уходят в переводчик как обычный текст, их лучше проверять глазами.
  • Не подходит для пользовательского контента. Эндпоинт неофициальный, и для сервиса, который переводит тексты пользователей, нужен платный API. Для черновиков интерфейса его хватает.

Итог

Утилита — около 800 строк на чистом Node.js. Запрос к переводчику — малая часть кода, а польза — в решениях вокруг него:

  • теги не ломаются — результату можно доверять;
  • ручные правки не перезаписываются — запускать можно сколько угодно;
  • в диффе только переводы — их можно проверить на ревью;
  • ошибки видны и исправляются повторным запуском — сбой не прячется в файлах.

Из процесса исчез ручной копипаст, и новая строка в интерфейсе больше не означает поход по всем языковым файлам. Код и инструкция — на GitHub, пакет — в npm.