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

Всем привет! Меня зовут Даша, я инженер по тестированию на платформе web в Иви. Со мной мой коллега, разработчик Антон. Мы расскажем вам о нашем опыте интернационализации сайта Иви.

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

Оглавление:

  1. Первичный план интернационализации

  2. Подготовительные работы: рельсы для перевода сайта на мультиязычность

  3. Основные работы: перевод сайта на мультиязычность

  4. Делаем выводы и раздаем советы после релиза

  5. Заключение

Первичный план интернационализации

Интернационализация — интересная задача, затрагивающая многие аспекты разработки сайта. Вот то, с чем мы работали в процессе реализации:

  • Сервисная часть

  • Механизм замены ключей на конкретный язык и обновление словарей, включая ci/cd

  • Замена хардкодов (самый большой кусок по объему).

  • Интеграция с переводчиком

  • Продуктовые доработки

    • Облегченный дизайн

    • Логика для новых элементов

Далее мы попробуем ответить на вопрос: «С какими проблемами столкнулась платформа Web при построении рельс для реализации и тестирования этой задачи?». Ответ разобьем на 2 взгляда: разработчик и тестировщик.

Разработчик

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

Проблема русского текста в коде.

Мысли разработчика: да, мы используем хардкод, а кто не без греха. Весь русский текст хранится прям в коде. Что-то прописано внутри реактовских компонентов, что-то хранится в отдельных ts-файлах как свойства объекта, а объект потом передаётся в реакт-компонент. Обсуждать выбранное решение в этой статье мы не будем, если вам оно интересно — пишите в комментарии, и мы напишем об этом подробнее.

Решение: заменить имеющийся хардкод на ключи из json-файла. Для ускорения процесса можно создать вспомогательный скрипт, который посчитает количество хардкодов в коде и сможет показывать хардкоды с фильтрацией по директории.

Проблема отсутствия рельс для возможности интернационализации сайта в коде

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

Решение: заложить рельсы для реализации задачи — написать скрипты endcoder/decoder, которые помогут конвертировать наши json-файлы в формат SmartCAT и наоборот.


Проблема с наименованиями ключей.

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

Разделение Javascript-кода на несколько файлов называется разделением «бандл» или сборки (bundle splitting). Это позволяет загружать только тот код, который который используется приложением в данный момент, другие части загружаются по необходимости (по запросу пользователя).

Решение: формируем имя ключа на основе пути до файла, где содержится текст + добавляем контекст (например, если это текст на кнопке, то buttonText, для заголовка в модалке — popupTitle и т.п.)


Проблема с большим количеством хардкода.

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

Решение: 

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

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


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

Мысли разработчика: какие-то отдельные блоки или даже отдельные страницы не нужно показывать для иноязычных пользователей, а значит, пришло время обложиться feature toggle.

Решение: реализовать feature toggle, которые будут ориентироваться на параметр языка пользователя и скрывать/показывать необходимые блоки на страницах.

В итоге у нас много планов, поэтому предлагаю послушать Дашу и перейти к реализации подготовительного этапа.

Тестировщик

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

Проблемы с устоявшейся версией сайта.

Мысли тестировщика: у Иви есть несколько версий: русская — привычный всем ivi.ru, и мировая — ivi.tv. Изменения, вносимые разработчиком, затронут все версии сайта. Поэтому предстояло не только правильно приоритизировать и расписать план тестирования, но и учесть «узкие горлышки». В добавок к этому необходимо точно понять, что делать с тест-кейсами для новой версии сайта.

Решение: 

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

  • Актуализировать написанные ранее тест-кейсы


Проблема с не пришедшим ключом не пришел или с не созданным словарем.

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

Решение: покрыть unit-тестами такой сценарий, а всё, что покрыть ими не удалось, допокрыть тест-кейсами.

Проблема с различными метриками веба.

Мысли тестировщика: соглашусь с мыслями Антона про названия ключей и увеличение размера бандла. Вес словаря, как и любого другого js-бандла, небольшой. Но если файл будет увеличиваться за счёт добавления новых ключей, то необходимы идеи по оптимизации наименований. Помимо этого с увеличением веса могут увеличиться значения метрик, например TTFB. То есть страница начнет дольше грузиться у пользователя.

Решение:

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

  • Сами ключи будут передаваться через функцию t(), а значит после внедрения необходимо произвести замеры метрик страниц

Проблема с тестовыми окружениями.

Мысли тестировщика: вторая большая проблема для команды тестирования — это, конечно же, отсутствие тестового окружения или условий для тестирования подобной задачи. Да, можно потестить локально, но мы все помним, что тестировать локально для QA — это не best practice. Создавать отдельный хост тоже опасно, потому что поисковые боты не должны раньше официальной публикации сайта проиндексировать русский каталог на узбекской версии сайта. Ведь узбекский и русский сайт — это, по сути, одно и тоже, тот же код с небольшими различиями, а значит, индексация узбекского сайта с дублирующим контентом может привести к признанию его дублем, и наши показатели SEO окажутся в плачевном состоянии.

Решение: заведение хоста или создание cookie, которая позволит переключаться между языками. А также закрытие хоста ivi.ru/uz (сайт на узбекском языке) базовой авторизацией на время всех наших работ.

Проблема с показателями SEO.

Мысли тестировщика: всего есть три способа реализации мультиязычности:

1. Отдельные версии на разных доменах

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

2. Языковые версии на поддомене

Поддомены позволяют создать полностью независимую структуру для каждой языковой версии. Это упрощает управление контентом, так как каждая версия может иметь свои собственные настройки, плагины и функционал, что может быть полезно для адаптации под специфические требования разных рынков. Такая архитектура требует больше ресурсов на оптимизацию, чем, если бы речь шла о создании разделов. Помимо основных работ (проработка структуры, наполнение контентом, заполнение метатегов и пр.) для всех поддоменов необходимо создать отдельный robot.txt и sitemap.xml. 

3. Создание разделов на основном сайте

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

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

Решение: 

  • Мы выбрали способ мультиязычности — создание разделов на основном сайте

  • Внедрить SEO-ревью, чтобы не потерять достигнутые показатели SEO

План реализации после обсуждения возможных проблем

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

  • Подготовительный этап

    • Сервисная часть  

      1. Заводим новый роут с учетом возможных проблем с SEO

        1. Проделываем кучу работы над тем, чтобы Иви корректно работал с ссылками и переключался между страницами

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

    • Реализовать механизм замены ключей на конкретный язык и обновление словарей

    • Дополнительные работы (самый большой кусок по объему)

      1. Трансформация внутренних словарей в формат SmartCAT и обратно — скрипты encoder/decoder

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

    • Интеграция со SmartCat

  • Основные работы

    • Замена хардкодов на ключи

    • Перевод страниц сайта

  • Продуктовые доработки

    • Облегченный дизайн

    • Логика для новых элементов

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

Подготовительные работы: рельсы для перевода сайта на мультиязычность

Разработчик и тестировщик — Боли от нового роута

Задача: определение языка по параметру в URL

Описание:

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

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

  • При переходе между страницами должен сохраняться параметр в url. Все ссылки в html должны иметь параметр языка

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

  • Абсолютные ссылки должны обрабатываться сравнивая текущий домен. Если текущий домен равен домену в ссылке — локализируем её

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

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

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

  • Обновлены все редиректы и места, где используется 'сырой' url

  • Обновлены все ссылки в приложении

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

Мысли тестировщика: при прочтении этой задачи у меня не возникло вопроса «как тестировать?» — у меня просто появилась потребность в помощи. Так как были обновлены все ссылки внутри приложения, требовались дополнительные руки для проведения полноценной проверки приложения. Из советов или интересных багов могу вспомнить:

  • Если на вашем проекте есть хорошее покрытие UI-автотестов, то перед началом ручной проверки прогоните тесты. Именно благодаря автотестам были найдены ошибки в дизайне страниц, которые обычному глазу были бы не заметны

  • Если на вашем проекте следят за SEO, то будьте внимательны к base tag, hreflang, lang и canonical. Не во всех значениях может быть нужен новый роут

  • Если ваш сайт работает на SPA, то при SPA-переходах роут может теряться

  • Обращайте внимание на редирект с / в конце url. То есть при вводе url ivi.ru/ должен быть редирект на ivi.ru, если он у вас был ранее настроен

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

Разработчик — Поговорим о скриптах encoder/decoder

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

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

{
    "keyName": {
        "value": "русский текст"
    }
}

Почему не просто "keyName": "русский текст" ? 
Мы предположили, что кроме самого значения нам могут понадобиться дополнительные поля. Например, если текст должен будет содержать числа в единственном или множественном числе, а значит тексты (или как минимум окончания слов) могут быть разными для разных чисел.

SmartCAT же требует немного другую структуру json-файла, с дополнительными полями. Указанный выше вариант для SmartCAT должен быть формат .locjson и выглядеть он должен так:

{
    "units": [
        {
            "key": "keyName",
            "source": [
                "русский текст"
            ]
        },
    ]
}

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

После перевода SmartCAT даёт нам скачать переведённый locjson файл, который будет выглядеть так:

{
  "units": [
    {
      "key": "keyName",
      "source": [
        "русский текст"
      ],
      "target": [
        "Ruscha matn"
      ],
      "properties": {
        "x-smartcat-status": "final"
      }
    }, 
  ]
}

Как можно заметить здесь появилось новое поле "properties", а также сохраняется оригинал текста. Всё это утяжеляет json —  нам же для словаря  нужен только ключ и перевод. Поэтому нам понадобился ещё один скрипт decoder для конвертации из формата SmartCAT в наш формат. После его использования мы получим вот такой словарь для узбекского языка:

{
    "keyName": {
        "value": "Ruscha matn"
    }
}  

Тестировщик — Поговорим о вспомогательном скрипте для замены харкодов в коде

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

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

  • Имя ключа содержит путь до файла без первичной директории

    • Все буквы в названиях переводятся в нижний регистр

  • Далее добавляется на выбор разработчика:

    • имя переменной

    • номер строки, где был найден хардкод

    • имя переменной и номер строки

Мысли тестировщика: чтобы достать имя переменной, да и сам хардкод в коде, есть два стабильных варианта — регулярки или AST-дерево. Мы пробовали оба варианта, но удобнее оказалось использовать AST. AST (Abstract Syntax Tree) — это абстрактное синтаксическое дерево, которое работает как один из промежуточных слоев при преобразовании языков высокого уровня. Подробно рассказывать про синтаксическое дерево мы не будем, есть масса статей в свободном доступе для этого. Просто пройдемся по схеме работы скрипта.

Схема работы скрипта
от вызова до конечного файла на примере:

1) Вызов программы: 

node /Projects/name_script.js "**/folder/Qwery" output.json

/Projects/name_script.js — путь до скрипта
"**/folder/Qwery" — glob-паттерн или "где искать нужные файлы?"
output.json — название выходного файла или "куда записать результат?"

2) Берутся файлы подходящие под glob-паттерн "**/folder/Qwery"

Пусть для примера у нас лежат src/folder/Qwery/ButtonText.tsx и src/folder/Qwery/BuyBread.tsx в которых содержится код:

src/folder/Qwery/ButtonText.html
src/folder/Qwery/ButtonText.html

<html dir="ltr" lang="ru" class="" lazy-loaded="true"><head>

    <meta charset="utf-8">
    </head>

<body>

<button class="my-button">Текст кнопки</button>
</body>
src/folder/Qwery/BuyBread.tsx
src/folder/Qwery/BuyBread.tsx

const var2 = 'привет мир 2'; 
const var3 = привет мир 3, ${var2}; 
const var4 = привет мир 4, ${var2} + привет мир 4, ${var2} ${var3} + привет мир 4, ${var2 + var3};

function helloWorldConsole(){
    console.log(var4)
}

helloWorldConsole(var4)

3) Скрипт проходится по файлам в поисках хардкодов и находит следующие:

src/folder/Qwery/ButtonText.html
<button class="my-button">Текст кнопки</button>
src/folder/Qwery/BuyBread.tsx
const var2 = 'привет мир 2';

const var3 = привет мир 3, ${var2};

const var4 = привет мир 4, ${var2} + привет мир 4, ${var2} ${var3} + привет мир 4, ${var2 + var3};

4) Скрипт предлагает варианты замены строк на выбор разработчику.

Рассмотрим примеры подмен на ключи для const var3 = привет мир 3, ${var2}

folder_qwery_buybread_var3;
folder_qwery_buybread_7_13;
t('folder_qwery_buybread_var3', {var2});
t('folder_qwery_buybread_7_13', {var2});
  • Можно подменить только на ключ, сохранить и отредактировать самостоятельно

  • Можно заменить сразу же на функцию t()

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

5) Разработчик выбирает из предложенных вариантов, на что заменить в коде

И содержимое файлов меняется на:

src/folder/Qwery/ButtonText.html
<html dir="ltr" lang="ru" class="" lazy-loaded="true"><head>

    <meta charset="utf-8">
    </head>

<body>

<button class="my-button">folder_qwery_buttontext_5_73</button>
</body>
src/folder/Qwery/BuyBread.tsx
const var2 = t('folder_qwery_buybread_var2'); 
const var3 = t('folder_qwery_buybread_var3', {var2}); 
const var4 = t('folder_qwery_buybread_8_13', {var2}) + t('folder_qwery_buybread_8_39', {var2, var3}) + t('folder_qwery_buybread_8_73', {variable1: var2 + var3});
вариант реализации функций, которые предлагают варианты подмен и выводят в консоль
// вариант реализации функций, которые предлагают варианты подмен и выводят в консоль
function generateReplacementKeys(filePath, literal) {
    const primaryKey = filePath
        .replace(/\..*?$/, '')
        .replace(/\.\/|\./g, '')
        .replace(/[\W_]+/g, "_");
 
    const variants = [primaryKey].flatMap((key) => {
        const context =
            literal?.context?.variable || literal?.context?.property || literal?.context?.attribute;
 
        const variants = [
            [key, literal.nodePath.node.loc.start.line, literal.nodePath.node.loc.start.column],
        ];
 
        if (context) {
            variants.push([key, context]);
            variants.push([key, context, literal.nodePath.node.loc.start.line, literal.nodePath.node.loc.start.column]);
        }
 
        return variants.map((variant) => variant.filter(Boolean).join('_'))
    });
 
    if (literal.hasOwnProperty('literal')) {
        return [...new Set(
            variants
                .flatMap((variant) => ([
                    `${variant}`,
                    `t('${variant}')`
                ]))
                .sort()
        )];
    } else if (literal.hasOwnProperty('template')) {
        let expressionVariablesCount = 0;
 
        let templateWithReplacedInjections = literal.template;
 
        const params =
            literal?.context?.variables ?
                literal?.context?.variables?.map((variable) => {
                    if (variable.hasOwnProperty('variable')) {
                        templateWithReplacedInjections = templateWithReplacedInjections.replaceAll("${" + variable.variable + "}", `{{${variable.variable}}}`);
 
                        return variable.variable;
                    } else if (variable.hasOwnProperty('expression')) {
                        const variableName = `variable${++expressionVariablesCount}`;
 
                        templateWithReplacedInjections = templateWithReplacedInjections.replaceAll("${" + variable.expression + "}", `{{${variableName}}}`);
 
                        return `${variableName}: ${variable.expression}`;
                    }
 
                    return null;
                }) :
                null;
 
        literal.context.templateWithReplacedInjections = templateWithReplacedInjections;
 
        return [...new Set(
            variants
                .flatMap((variant) => {
                    return [
                        `${variant}`,
                        params && params.length ? `t('${variant}', { ${params.join(', ')} })` : `t('${variant}')`
                    ];
                })
                .filter((variant) => params && params.length ? variant.startsWith('t(') : true)
                .sort()
        )];
    }
 
    return [];
}
 
 
async function resolveChanges (filePath, code, ast, literals) {
    const resolvedChanges = [];
 
    for (const literal of literals) {
        // eslint-disable-next-line no-console
        console.log(`File: ${filePath} \n`);
 
        // eslint-disable-next-line no-console
        console.log('Trying to apply changes to the following content:');
 
        // eslint-disable-next-line no-console
        console.log(codeFrameColumns(
            code, literal.nodePath.node.loc, { highlightCode: true }
        ), '\n');
 
        const answer = await prompt([
            {
                type: 'list',
                name: 'option',
                message: 'Choose replacement option:',
                choices: [
                    ...generateReplacementKeys(filePath, literal),
                    'Skip this part',
                    'Skip this file',
                ]
            }
        ]);
 
        switch (answer.option) {
            case 'Skip this part':
                break;
            case 'Skip this file':
                return [];
            default:
                resolvedChanges.push({
                    changeTo: answer.option,
                    literal,
                });
                break;
        }
    }
 
    return resolvedChanges;
}

6) После того, как все файлы по переданному glob-паттерну закончились, сохраняется json с ключами в output файл.

Содержимое json файла выглядит так:

{
    "folder_qwery_buttontext_5_73": {
        "value": "Текст кнопки"
    },
    "folder_qwery_buybread_var2":{
         "value": "привет мир 2"
},
    "folder_qwery_buybread_var3":{
         "value": "привет мир 3, {{var2}}"
},
    "folder_qwery_buybread_8_13":{
         "value": "привет мир 4, {{var2}}"
},
    "folder_qwery_buybread_8_39":{
         "value": "привет мир 4, {{var2}} {{var3}}"
},
    "folder_qwery_buybread_8_73":{
         "value": "привет мир 4, {{variable1}}"
}
}

Мысли тестировщика: обычно написание подобных скриптов не задача QA, но процесс решения задачи увлек и очень хотелось попробовать, а резюме "насколько такое эффективно" лучше спросить у Антона, как конечного пользователя скрипта.

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

Основные работы: перевод сайта на мультиязычность

Рассматривать перевод полностью всего сайта мы в данной статье не будем. Расскажем подробно лишь про два «блока»: общий для всего сайта — шапку/футер/тапбар и одну отдельно взятую страницу — главную.

Разработчик — Поговорим о переводе сквозных элементов сайта

У нас есть два словаря. Один на сервере — он создаётся при сборке проекта и всегда заполнен всеми языками и всеми ключами. При открытии сайта мы видим значение из словаря именно с сервера. Далее у нас создается два чанка — один js-чанк, который должен заполнять клиентский словарь (то есть заполнить объект window.DICT), второй js-чанк содержал объект в виде { some_key : 'Текст для одного ключа ', some_key_2 : 'Текст для другого ключа '}. Далее при прогрузке чанков запускается клиентсткий код, где мы сначала определяем язык, потом в браузерном объекте window создаем и заполняем словарь на клиенте. Когда весь js-код прогружен и исполнился, то происходит гидратация кода.

Bug: отображение ключей вместо заголовков в шапке сайта. 

Мысли тестировщика во время нахождения бага: после небольшого расследования было замечено, что словарь уже создан и ключи для шапки были записаны в нем. Плавающая бага, которая воспроизводилась не стабильно, но была замечена и в автотестах. Точки останова не помогли локализовать дефект, но было ясно сразу, что бага не в серверном словаре, а в клиентском. 

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

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

Результат: бага перестала воспроизводиться.

После правки бага реализация словарей выглядит так:

Для сервера мы сразу создаем пустой объект с двумя языками, что фактически преобразовывается в 

const dictionaryTargets = {
     ru: { },
     uz: { },
};

В файловой структуре у нас так же созданы две отдельные папки:

  • ru — здесь лежат файлы с ключами для русского текста

  • uz — здесь лежат файлы с ключами для узбекского текста (файлы, которые мы получаем из SmartCAT и декодируем в обычный json-формат)

На стороне сервера мы пробегаемся по каждому файлу в папке ru и заполняем dictionary.ru . Аналогично заполняем dictionary.uz. То есть объект со всеми ключами все также создаётся при сборке проекта.

Для клиентской части договорились делить словари на отдельные json-файлы:

  • В general.json поместим общие ключи, которые относятся к каким-то общим компонентам на всех страницах: прежде всего это меню для десктопа, футер, мобильное меню

  • Ключи, специфичные для конкретной страницы, помещаем в отдельный json. Например, для главной это будет home.json

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

Когда запускается клиентский код мы имеем пустой словарь window.DICT. Далее на стороне клиента определяется язык. Например, если определили, что язык русский, то по мере подгрузки чанков заполняется объект window.DICT.ru ключами из json-файлов. Ключи из general.json заполняются всегда.

Когда весь js-код прогружен и исполнился, то происходит гидратация кода и на странице мы видим значения ключей из объекта window. Но здесь не учитываем блоки с ленивой загрузкой — ключи для них динамически добавляются в момент вызова.

Тестировщик — С чем мы столкнулись при тестировании таких задач

Помимо бага, который описал Антон выше, мы столкнулись с еще одной крупной ошибкой.

Bug: ошибка вызова ключа из несуществующего словаря.

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

Как мы это правили: полностью понять почему словарь не заполняется так и не вышло, но после нескольких часов разбирательств всё же при добавлении if (key in undefined) в коде получилось отловить ошибку (но тоже не стабильно, 1 раз из 5). Заметили, что словарь не создан, а уже происходит запрос ключа из этого несуществующего словаря. Теперь проверяется, что словарь есть и только после этого уже запрашивается ключ - клиентский код теперь не падает, так как до in код дойдёт только если словарь существует.

Также важная часть тестирования и одна из самых больших болей это ответ на вопрос "Что делать с тест-кейсами?". Давайте разберем подробнее.

Проблема с реализацией тест-кейсов для новой версии сайта.

Мысли тестировщика: нам сразу стало известно, что узбекской версией сайта будет заниматься отдельный стрим на бэке, но что же будет с клиентской частью — неизвестно. У нас было 2 идеи для реализации тест-кейсов:

  • Создать флаг, которым помечать все кейсы, которые подходят для узбекской версии сайта

    • Плюсы такой реализации:

  • Корректное переиспользование кейсов. Не придется выполнять огромную работу по переносу и копированию кейсов

    1. Минусы такой реализации:

      • Легко запутаться в версиях. В Иви есть 3 основные версии: русская (на ru домене), мировая (на tv домене) и теперь узбекская (ivi.ru/uz)

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

      • Не удастся оптимизировать покрытие

  • Создать отдельный раздел для узбекского Иви и по-этапно переносить актуальные кейсы

    • Плюсы такой реализации:

      • Оптимизация древа тест-кейсов во время переноса. Можно пересмотреть саму структурированность папок и уменьшить количество дублей

      • Расширение покрытия. Аудит покрытия и выявления «белых дыр» позволяет дописать нехватающие тест-кейсы

    • Минусы такой реалиазации:

      • Есть дублирование кейсов из основной папки

      • Нет корректного переиспользования кейсов. По сути мы их не переиспользуем, а полностью копируем или переписываем

      • Затрачивается больше времени

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

Результат:

  • Проведен аудит имеющегося покрытия в необходимых разделах и фичах

    • Закрыты «белые дыры», выявленные во время аудита

    • Снижен процент дублей в тест-кейсов благодаря верно выбранной структуре древа

  • Перенесены и актуализированы, соответствующие требованиям продукта, кейсы для узбекской версии сайта

Разработчик — Поговорим о переводе отдельно взятой страницы

Как в целом реализованы словари мы поговорили выше, но пришло время для больших страниц. Давайте обсудим перевод Главной страницы Иви — из чего она состоит и тонкости её перевода на ключи:

  • На Главной есть хедер, футер, мобильное меню — это общие компоненты и мы их перевели в отдельной задаче

  • Между футером и хедером есть промоблок и подборки. Эти блоки приходят с бэка, то есть на нашей стороне мы не можем их перевести

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

И вот как раз-таки последний пункт является проблемой для разработки и тестирования.

Проблема: ключи в коде переиспользуются множество раз и теряются «нити».

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

Совет: здесь нет точного решения. Можем лишь посоветовать «играть в команде». Разработчик должен постараться наилучшим образом объяснить, как выглядит элемент, а тестировщик —замокать элемент и воссоздать его по описанию. В общем, это напоминает игру «Крокодил»: один объясняет на пальцах, другой рисует. Нам такой вариант помог разобраться.

Проблема: ухудшения значений метрик по сравнению с мастером.

Мысли разработчика: Даша начала замечать, что страница стала дольше грузиться, что мы и предполагали ранее в проблеме с различными метриками веба. Конечно, я периодически после мержа изменений делал замеры, но колебания метрик были в пределах нормы. Однако нельзя забывать, что файл раздувается, да и сама t() в больших количествах требует времени на обработку. Вот результаты замеров на мастере и на мета-ветке c узбекской версией сайта после мержа нескольких больших разделов с ключами:  

Наименование метрики

Мастер

Мета ветка

TTFB (median)

693 ms

762 ms

[finish] [handler] render - [start] [handler] render

159ms

223ms

LCP (median)

2498ms

2622ms

Как мы это правили: есть функция, которую мы вызываем в компонентах, использующих ключи. Оказалось, что хук с этой функцией не имел своего глобального «хранилища» (контекста), где бы он хранил эту функцию. Поэтому при использовании хука каждый раз создавалась новая функция, вместо того, чтобы ссылаться на один раз созданную. Мы реализовали этот контекст и обернули им всё приложение. И теперь при вызове хука функция возвращается из одного и того же контекста, тем самым не создавая на каждый новый компонент новую функцию.

Результат:

Наименование метрики

Мастер

Ветка с фиксом

TTFB (median)

693 ms

574 ms

[finish] [handler] render - [start] [handler] render

159ms

111ms

LCP (median)

2498ms

2417ms

Тестировщик — С чем мы столкнулись при тестировании таких задач

Продолжим тему ухудшения метрик. Мы искали причины в разных местах и заметили, что у нас в js-чанках, грузится сразу два словаря.

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

Мысли разработчика: баг был обнаружен задолго до того, как он мог нам всё испортить — пока словари небольшие, они не сильно влияют на метрики.  Но советуем вам быть внимательными с загрузкой словарей внутри чанков. «Пропуск» такой ошибки может стоить вам отката и долгих месяцев повторной работы над Web Vitals и другими метриками сайта.

Как мы это правили: обновили динамический импорт словарей в файлах для страниц. Для этого нам пришлось изменить структуру хранения json-файлов с locale -> namespace.json на namespace -> locale.json. Теперь каждый неймспейс мы разбиваем на разные чанки по языкам. Это позволило нам, помимо исправления вышеуказаного бага, удобно объединять разные словари в один чанк или дробить большие словари при разработке на маленькие для удобства.

Было

Стало

home.js

i18n-home-{lang}-json.js

Результат: на каждой странице загружаются словари только для одного языка.

Bug: некорректный перевод в местах где используется формат «строка-число-строка».

Мысли тестировщика:  К примеру, в блоке «Продолжить просмотр» на постерах отображается шильдик времени в формате «еще N минут». И это место уникально тем, что часть строки переводится через SmartCAT, а часть средствами браузера. Поэтому после перевода на узбекский язык, я видела не полноценную строку, а формат «узбекское_слово-число-русское_слово». Антон исправил баг используя Intl.NumberFormat. Когда мы используем современные возможности JavaScript, как при реализации перевода данной строки, некоторые движки могут не поддерживать их и важно проверить на старых версиях браузеров. В ином случае при использовании, как у нас Intl.NumberFormat, вы рискуете в версиях Safari ниже 14 получить ошибки в консоль.

Ошибка
Ошибка

Как мы это правили: мы внедрили полифилы https://formatjs.github.io/docs/polyfills/

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

Разработчик — Завершающие штрихи или продуктовые задачи

Bug: Некорректный перевод временных шильдов на постерах.

Мысли разработчика: на контенте, которому только предстоит появиться на сайте, мы всегда показываем шильды. Для дат и времени мы использовали API Intl . Для русских и английских строк с датами и временем это работало отлично. Но при переводе на узбекский столкнулись с ситуацией, что в Chrome по умолчанию нет языковых файлов для узбекского языка (в FF и Safari они есть). Это приводит к странным строкам, показанным на скрине ниже:

Отображение шильдов с датами и временем на узбекском
Отображение шильдов с датами и временем на узбекском

Для Узбекистана время отображается на том языке, какой использован в браузере. Месяцы отображаются в виде буквы M и номер месяца.

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

Результат: отображаются английские слова в шильдах.

Отображение шильдов с датами и временем на английском
Отображение шильдов с датами и временем на английском


Задача: реализовать механизм скрытия определенных блоков на странице.

Реализация: для такой задачи есть несколько вариантов решения. Мы попробовали два из них:

  1. Feature toggle

    1. Часть компонентов на странице не нужны для узбекской версии сайта. Отключение компонентов реализовано через механизм Feature toggle (или Feature flag). Когда пользователь, находящийся в Узбекистане, открывает сайт, то в одном из запросов к беку мы получаем поле

      ...
      "feature_toggle" : {
        "is_uz":  true,
      }
      ...

      В коде мы завязываемся на этот флаг для отображения блоков — если свойство is_uz отсутствует или равно false (это случай, если на сайт зашёл пользователь не из Узбекистана ), то считаем это поведением по умолчанию, отображая все блоки. Если свойство is_uz имеет значение true, то считаем, что это узбекская версия сайта и ненужные блоки скрываем.

  2. Блэклисты и признак языка

    1. Был реализован универсальный i18nBlacklistHandler, который работает с блэклистами.

То есть мы создаем массив с ссылками на ресурсы, которые должны быть страты с основного сайта.

[I18nLanguages.Uzbek]: {
                playerAssetJsPattern: /-uz\.js/,
                localeCode: 'uz-UZ',
                blacklist: [
                    '/goodmovies',
                    '/tvplus',
                ],
            },
        },
    },

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

const isTvplusEnabled = (appVersionInfo: IAppVersionInfo, laguage?: I18nLanguages): boolean => {
    return (appVersionInfo?.parameters?.tvplus_enabled ?? false) && laguage !== I18nLanguages.Uzbek;
};
// — переменная, который контролирует показ раздела ТВ+ на сайте (в тапбаре или шапке сайта)
 
const tvPlusEnabled = isTvplusEnabled(appVersionInfo, language);
// не забываем при вызове добавить проверку языка
 
...case 'goodmovies': {
                        if (isWorld || isChildProfile) {
                        if (isWorld || language === I18nLanguages.Uzbek ||
                            isChildProfile) {
                            skipItem = true;
 
                            if (language === I18nLanguages.Uzbek) {
                                links.forEach((link) => {
                                    if (link.submenu?.widgetLinks) {
                                        link.submenu.widgetLinks = link.submenu?.widgetLinks?.filter(
                                            (widgetLink) => !widgetLink.href?.includes('/goodmovies'),
                                        );
                                    }
                                });
                            }
                        }
                        break;
// — сокрытие раздела из меню сайта

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

Задача: реализовать свитчер для переключения языка.

Мысли разработчика: мне не нравится слово свитчер — это переключатель, который одно на другое переключает. Тут мы по факту просто по двум ссылкам переходим на другой домен (что технически является вообще другим сайтом) и это не выглядит как переключение двух языков. Однако я не могу придумать для этой фичи другого названия, поэтому путь будет свитчер :) 

Решение: язык мы определяем по роуту: www.ivi.ru/uz — это сайт на узбекском языке, а www.ivi.ru — сайт на русском языке. Чтобы переключаться между двумя сайтами мы добавили свитчер, который содержит список, на данный момент, из двух языков. Нажатие по одному из языков открывает один из сайтов www.ivi.ru/uz или www.ivi.ru.

В коде этот свитчер разделён на два компонента — дропдаун в шапке на широких экранах (mobile/desktop), а на узких экранах (mobile) используется компонент интерфейса, называемый Bottom Sheet. Функции они выполняют одинаковые — по сути это просто прямые ссылки на два домена с разными языками. Дропдаун предполагается отображать только для пользователей за пределами России. 

Мысли тестировщика: из-за данной реализации свитчера и особенностей тестовых окружений не выйдет проверить юзер-кейсы (недоступность страниц). Поэтому мой вам совет, не пренебрегайте удобством тестирования, сразу настраивайте верное тестовое окружение. Также при тестировании свитчера важно учесть, что шапки очень разные на двух сайтах, поэтому могут быть проблемы с кешированием. К примеру, вы находитесь на сайте www.ivi.ru, где в шапке более 3 элементов, переключаетесь на сайт ivi.ru/uz, где в шапке 4 элемента. После переключения до ручного обновления страницы вы можете видеть контент и шапку взятые с www.ivi.ru, но отредактированные по языку.

Тестировщик — Выходим из тестирования

Bug: нет дефолтного значения языка.

Мысли тестировщика: при переходе на страницу у нас были видны ключи. Первое, что приходит в голову, — это «нет ключа в словаре», но перейдя в DevTools/Console, я заметила сообщение об ошибке. "Failed to load resource: the server responded with a status of 404 (Not Found) - i18n-home-undefined-json.js:1" — ошибка понятна, но название файла странное. Как мы помним из бага «Прогрузка сразу двух словарей вне зависимости от языка сайта», теперь у нас в названии словаря есть параметр {lang}, который определяется по названию языка на странице. В данной ошибке хорошо видно, что язык не определен из-за чего нужный словарь не был найден.

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

Результат: ключи пропали со страницы, ошибки больше нет в Console.

Bug: черный экран на странице поиска при заблокированном чанке со словарем.

Мысли тестировщика: безусловно, прежде чем заводить этот баг, необходимо посмотреть, что происходит в DevTools/Console в момент падения страницы. В нашем случае мы получали ошибку гидратации. Ошибка гидратации — пришедшая разметка с сервера не совпадает с той, которая должна была отрисоваться на клиенте. После перепроверки на других страницах стало понятно, что «Поиск» единственная страница где такое происходит. Но почему?

Наша функция поиска основана на React-порталах. React Portals это способ визуализации элемента в узле DOM, который существует вне иерархии DOM родительского компонента. Когда запускается портал, то все события управляются иерархией компонентов React, а не иерархией, заданной положением DOM элемента. Или по простому, React Portals — это механизм в React, который позволяет рендерить элементы внутри другого родительского элемента, не учитывая, где именно он был объявлен в дерево компонентов. Это полезно, например, для создания модальных окон, которые должны быть видимы поверх прочего контента, независимо от иерархии компонентов. 

Проблема, с которой мы столкнулись, заключается в том, что страница поиска (/search) фактически функционирует как модальное окно. На ней используется компонент, который в своей структуре включает портал. При рендеринге страницы на сервере (что называется серверный рендеринг, или SSR) порты игнорируются, поскольку на сервере отсутствует DOM-дерево. В результате это приводило к тому, что рендеринг элемента мог происходить неправильно или с ошибками, что привело к черному экрану.

Как мы это правили: мы добавили параметр isPage для компонента поиска. В зависимости от его значения компонент будет рендериться либо через портал, либо обычным образом. Если isPage установлен в true, то рендеринг будет происходить без использования портала, а если false, то мы применим портал. Такой подход позволяет нам избавиться от использования порталов на странице поиска, что устраняет возникшие проблемы, оставляя портал для рендеринга только в тех случаях, когда это необходимо на клиенте.

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

И заключительный этап — выход из тестирования. Не забудьте заранее обсудить критерии выхода из тестирования. Что было у нас:

  • Сайт полностью переведен, а если есть не переведенные куски текста на них заведены задачи для перевода

  • Текста полностью вычитаны с переводчиками

  • После финального перевода проверены критические сценарии

  • После финального перевода просмотрен визуал страниц сайта

  • Нет блокеров и критов

  • Все продуктовые задачи проверены и закрыты

Делаем выводы и раздаем советы после релиза

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

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

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

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

Что мы сделали в Иви: был добавлен обязательный замер метрик на этапе тестирования в тикетах, где был перевод.

Совет: позаботьтесь о тестовом окружении сразу — не воротите костыли, лишь бы не создавать тестовый сервер с нужными настройками. 

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

Что мы сделали в Иви: давно уже есть этап приемки тикетов из разных команд core (платформенной) командой, это значит, что любой тикет затрагивающий веб проверяется на предмет неучтенных сценарий, зависимостей и т.д. И все qa core (платформенной) команды были проинформированы о том, что на этапе приемки важно смотреть покрытие кейсами аналитики новой версии сайта и то, что часть проверок обязательно проводятся на не русской версии сайта.

Также важно помнить, что мы не перевели все файлы сайта и для ускорения написали линтер, который «смотрит», если разработчик затронул файл, в котором есть хардкода, то просит его перевести этот документ. А значит в тестировании нельзя забить на проверки новых версий и переводов. Чтобы разработчик каждый раз самостоятельно не «подсвечивал», что переведены какие-либо файлы, был создан генерируемый нейро-отчет, который пишет какие файлы затронул разработчик и на что эти файлы влияют. Это поможет погрузить qa в задачу лучше и составить более точный план тестирования тикета.

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

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

  • Было 3653 строк с хардкодами в 723 файлах. Мы перевели не весь сайт Иви целиком, а лишь основные страницы: Главная, Профиль и его подразделы, чаты оплат/покупок/регистрации/авторизации, карточна контента, страница коллекции.

  • Осталось 1714 строк с хардкодами 287 файлах.

Заключение

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

Мысли разработчика: когда задача прилетела было ощущение, что это супер-сложная задача с гигантским объёмом работы, так как у нас вообще не было в коде никакой подготовки под интернационализацию. После небольших исследований и кучи обсуждений появились какие-то прояснения. Далее была реализация «рельс» для интернационализации, внедрение ключей, продуктовые задачи и много мелочей. Но после реализации такой объемной задачи я понял, что уже ничто меня не напугает. Плюсы реализации такой задачи для DEV:

  • Лучше ориентируешься в продукте

  • Рефакторинг больших кусков кода для удобства перехода на рельсы многоязычности

  • Возможность изучения такой объемной темы как интернационализация

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

  • Лучше ориентируешься в продукте, так как приходится разбираться и реагировать на всю логику. Даже ту, что уже никто не может вспомнить

  • Возможность найти проблемы в тестовом покрытии и пересмотреть структуру

  • Расширение своих технических знаний. Если встречаются такие задачи как у нас с поиском и React-порталами

  • Тренируется стрессоустойчивость и выносливость благодаря таком огромному потоку задач

В общем, попрощаемся, как обычно: не стойте на месте, с удовольствием изучайте новое и улучшайте себя! До новых встреч на Хабре!

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


  1. kuzvac
    03.09.2025 06:18

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

    В тексте написано, что вы используете функцию t() для переводов, но не упоминается ни какая библиотека интернационализации, вы самописную/доработанную библиотеку для интернационализации использовали?
    Были ли у вас какие-нибудь ограничения на использование тех или иных библиотек и экосистем интернационализации?
    Исследовали ли вы какие на момент выбора существуют системы/библиотеки локализации? т.к. судя по статье вы наткнулись на большинство из проблем интернационализации, таких как одинаковые ключи с разным контекстом, необходимость автоматически генерировать ключи, разделение на неймспейсы, локальные особенности интернационализации, особенно интересно были про даты в узбекском, локализации которых нет в Хроме.
    Когда вы изначально начинали интернационализацию, вы ставили удобство локализации и его процесса, или это было необходимо зло, которое нужно добавить в систему, не сломав ничего по пути? :)

    И поделюсь знаниями: на данный момент, есть два больших фреймворка/платформы, для интернационализации:
    i18next - очень массивный фреймворк для всего что связано с интернационализацией, можно добавть интернационализацию везде, начиная с бекенда, заканчивая нативными приложениями. Для разных применений есть специализированные библиотеки (фронтенд/бекенд/нативные приложения/cli mode). Так же есть платный сервис хранения и управления локализациями, locize.com, с множественными фишками, типа версионированния переводом, объединения, автоматического перевода, привлечения переводчиков для перевода текстов, cdn для переводов и переводов текстов прямо с dev/prod сайта, при подключении дополнительного плагина.
    tolgee - целая платформа для интернационализации. Предоставляет как библиотеки для локализации, так и саму платформу для управления переводами, которую можно захостить у себя. Для фронта есть библиотеки для всех популярных фреймворков и библиотек, включая поддержку SSR. Есть отдельный SDK для Android. Для задания контекста используется международный формат ICU https://docs.tolgee.io/platform/translation_process/icu_message_format.
    Систему управления переводами можно захостить у себя и подключить cdn для переводов, который будет автоматически обновляться после сохранения переводов.
    Есть ещё киллер-фича, перевод текстов сразу с dev/prod сайта, без добавления каких-либо плагинов в сборку, через расширение для Хрома. https://docs.tolgee.io/js-sdk