Привет, Хабр! Я работаю фронтенд-разработчиком в ЛАНИТ. В этой статье я поделюсь опытом изменения подхода к интернационализации и опытом внедрения автоматизированной проверки переводов, что позволяет снизить риски появления багов и ускорить релизы приложения.

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

Мультиязычность – способность приложения отображать интерфейс на разных языках.

Локализация (localization – l10n) – процесс адаптации приложения к конкретной стране с учётом культурных и языковых особенностей. Сюда можно отнести определение локали, перевод интерфейса, контроль формата даты, денежных единиц, символики, цветов и т. д.

Интернационализация (internationalization – i18n) – набор практик, позволяющих легко адаптировать приложение к определённым культурным и языковым особенностям страны. Сюда можно отнести независимое хранение файлов с переводами для удобной замены, использование универсальных кодировок и специальных констант для быстрой замены текста в соответствии с нужным языком и т. д. Вот хороший доклад про разработку в международных реалиях, который позволит глубже разобраться в теме.

Как работает интернационализация в современных приложениях

Рассмотрим механизм интернационализации на примере Angular-приложения с библиотекой ngx-translate (без подробностей реализации). Важно отметить, что подход к интернационализации очень схож и для других технологий.

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

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

Существуют два основных подхода к формированию структуры таких файлов.

Плоская структура, в которой каждый ключ отвечает за один перевод

{
  "title": "Habr demo app",
  "showAppTitle": "Show app title"
}

Вложенная структура, в которой ключ может иметь свою иерархию

{
  "app": {
    "title": "Habr demo app",
    "showAppTitle": "Show app title"
  }
}

После добавления переводов мы можем использовать механизм интернационализации в коде (пример показан для плоской структуры файла переводов):

<!-- component.html -->
<button (click)="showAppTitle()">
  {{ 'showAppTitle' | translate }}
</button>
// component.ts
showAppTitle(): void {
  this.snackBar.open(this.translate.instant('title'));
}

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

Наша проблема

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

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

Когда ваше приложение поддерживает пять языков, какие-то “богатыри” обязательно не переведутся
Когда ваше приложение поддерживает пять языков, какие-то “богатыри” обязательно не переведутся

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

Причины

Подход к формированию файлов с переводами не соответствует общепринятому подходу

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

// en.json
{
  "Скачать": "Download",
  "Установить": "Install"
}

// ru.json
{
  "Скачать": "Скачать",
  "Установить": "Установить"
}

Использование в коде выглядело так:

<!-- some-file.component.html -->
<p>{{ 'Скачать' | translate }}</p>
// some-file.component.ts
this.translate.instant('Установить');

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

Отсутствие автоматизированной проверки

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

Решение

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

Первый этап – автоматизируем проверки

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

Был найден хороший инструмент, который является плагином библиотеки ngx-translate, – ngx-translate-lint. Подробно о его настройке я рассказал в отдельной статье

Этот пакет является плагином для линтера, который одновременно анализирует .json, .html, .ts файлы и позволяет не допустить разработчику ряд ошибок. Не для каждой библиотеки можно найти готовый инструмент. Возможно, придётся написать правила для линтера самостоятельно.

Вот каких проблем можно избежать с помощью автоматизированной проверки.

  • Разработчик забыл добавить перевод в файл с переводами.

  • Разработчик забыл перевести текст и оставил пустой ключ в файле с переводами.

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

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

  • Разработчик опечатался в ключе перевода.

  • Разработчик переживает, что потерял какой-либо перевод при решении конфликтов слияния.

  • Разработчик случайно изменил или удалил ключ перевода.

  • При внедрении нового языка у разработчика нет уверенности в том, что он не потерял какой-либо перевод.

Благодаря данному инструменту удалось удалить около 200 неиспользуемых, 20 повторяющихся и пустых, 10 недостающих переводов. Проверка была добавлена в CI-пайплайн, и теперь она запускается для каждого коммита.

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

Звучит всё хорошо, добавили анализатор, багов нет – задача выполнена! Но не всё так просто.

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

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

  • Из-за наличия знаков препинания в ключах переводов пришлось добавлять их в регулярное выражение для анализатора, что вызывало ложные срабатывания, которые добавили ряд ограничений при написании кода. Например, при инициализации массива const array = ['one', 'two']; анализатор выдавал ошибку о том, что ключа ', ' не существует ни в одном из файлов с переводами и приходилось разносить элементы массива на разные строки.

Второй этап – перерабатываем подход к формированию ключей переводов

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

Был выбран ключ вида someTextInCamelCase_i18n и сформулированы следующие правила его составления.

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

  • В конце необходимо добавлять постфикс _i18n для чёткого отделения ключа перевода от чего-либо другого. Это необходимо для удобного поиска, а также для работы анализатора из прошлого этапа.

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

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

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

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

Третий этап – сводим вероятность ошибки к нулю

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

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

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

import ruTranslations from './i18n/ru.json';
import enTranslations from './i18n/en.json';

const findExtraTranslations = (langKeys1: string[], langKeys2: string[]): string[] => {
  const langKeys1Set = new Set(langKeys1);
  return langKeys2.filter(key => !langKeys1Set.has(key));
}

const validateTranslationKeysPostfix = (keys: string[]): boolean => {
  return keys.every((key: string) => {
    if (!key.endsWith('_i18n')) {
      console.error('Недопустимый ключ перевода: ' + key + ', ключ должен заканчиваться на ' + '_i18n');
      return;
    }
    return true;
  });
};

const validateTranslationKeysBody = (keys: string[]): boolean => {
  if (!/(^[a-zA-Z]+$)/.test(keys.join('').replace(/_i18n/g, ''))) { 
console.error('Некорректный ключ перевод: ключ должен содержать только заглавные и строчные буквы английского алфавита');
    return;
  }
  return true;
};

describe('Internationalization (i18n)', () => {
  describe('translationsFiles', () => {
    const ruKeys = Object.keys(ruTranslations);
    const enKeys = Object.keys(enTranslations);
    
    test('every translation file should contains valid keys (words with specific postfix)', () => {
      // Assert
      expect(validateTranslationKeysPostfix(ruKeys)).toEqual(true);
      expect(validateTranslationKeysPostfix(enKeys)).toEqual(true);
    });

    test('every translation file should contains valid keys (only lowercase and uppercase letters of the English alphabet)', () => {
      // Assert
      expect(validateTranslationKeysBody(ruKeys)).toEqual(true);
      expect(validateTranslationKeysBody(enKeys)).toEqual(true);
    });

    test('count, values and order of translations keys should be same', () => {
      // Assert
      expect(findExtraTranslations(ruKeys, enKeys)).toEqual([]);
      expect(ruKeys.length).toEqual(enKeys.length);
      expect(ruKeys.join(',')).toEqual(enKeys.join(','));
    }); 
  });
});

Как оказалось позднее, валидировать паттерн ключей можно было с помощью ESLint. Я не нашёл такого правила и решил предложить его создать, на что мне ответили, что такого же результата можно добиться с помощью правила no-restricted-syntax при использовании JSON-парсера, но из документации это было совершенно неочевидно. В связи с этим разработчики обновили её, и теперь об этом могут узнать другие. Я пока ещё не пробовал добавлять это правило, т.к. тесты полностью нас устраивают, но ради интереса когда-нибудь попробую.

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

Сделать это удалось с помощью кастомного правила no-cyrillic для ESLint, которое анализирует .ts и .html файлы на наличие кириллицы, при этом допуская её использование в комментариях.

module.exports = {
  meta: {
    type: 'problem',
  },
  create: function (context) {
    return {
      // .ts
      Literal: (node) => {
        if (typeof node.value === 'string' && node.value.match(/[А-Яа-яЁё]/)) {
          context.report({ node, message: Cyrillic characters are not allowed: '${node.value}' });
        }
      },
      // .html (узел Program специфичен для Angular)
      Program: (node) => {
        const htmlWithoutComments = node.value?.replace(/<!--[\s\S]*?-->/g, '');
        const cyrillicString = htmlWithoutComments?.match(/[А-Яа-яЁё]+/g);

        if (cyrillicString?.length) {
          context.report({ node, message: Cyrillic characters are not allowed: '${cyrillicString.join(', ')}' });
        }
      },
    };
  },
}

На правило также были написаны тесты.

import { RuleTester as TSRuleTester } from 'eslint';
import { RuleTester as HTMLRuleTester } from '@angular-eslint/test-utils';
import * as parser from '@angular-eslint/template-parser';
import rule from './no-cyrillic.js';

new TSRuleTester().run('no-cyrillic', rule, {
  valid: [
    { code: 'var variable = \'name\';' },
    { code: 'this.translate.instant(\'argument\'); // Комментарий'},
  ],
  invalid: [
    { code: 'var variable = \'имя\'', errors: [{ message: 'Cyrillic characters are not allowed: \'имя\'' }] },
    { code: 'this.translate.instant(\'аргумент-один\', \'аргумент-два\')', errors: [{ message: 'Cyrillic characters are not allowed: \'аргумент-один\'' }, { message: 'Cyrillic characters are not allowed: \'аргумент-два\'' }] },
  ],
});

new HTMLRuleTester({ languageOptions: { parser } } as any).run('no-cyrillic', rule as any, {
  valid: [
    { code: '<div>English</div>' },
    { code: '<div class="class-name">{{ \'English\' | translate }}</div>' },
    { code: '<!-- Комментарий --><div>English</div>Hello' },
  ],
  invalid: [
    { code: '<div>Русский</div>', errors: [{ message: 'Cyrillic characters are not allowed: \'Русский\'' }] },
    { code: '<div class="класс">{{ \'English\' | translate }}</div>', errors: [{ message: 'Cyrillic characters are not allowed: \'класс\'' }] },
    { code: '<div class="\'класс\'">Hello</div>Привет', errors: [{ message: 'Cyrillic characters are not allowed: \'класс, Привет\'' }] },
], } as any);

Доклад с пошаговой инструкцией по написанию собственного правила для ESLint.

Это правило гарантирует, что в интерфейс случайно не попадёт то, чего там быть не должно. При необходимости регулярное выражение правила можно адаптировать под новые требования, что делает его достаточно гибким.

Вывод

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

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

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

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

Видео-доклад по мотивам этой статьи:
Как мы запретили писать код с багами в локализации, MoscowJS 68
Смотреть на YouTube
Смотреть на VK Видео

 *Статья написана в рамках ХабраЧелленджа 4.0, который прошел в ЛАНИТ весной 2025 года. О том, что такое ХабраЧеллендж, читайте здесь.

Комментарии (2)


  1. DmitryI
    30.09.2025 07:41

    Благодаря статье, я наконец понял, почему "i18n" :) Как-то руки раньше не доходили разобраться.


    1. dananaprey Автор
      30.09.2025 07:41

      Отлично, рад помочь!