Работа с HTML в сообщениях

ICU MessageFormat, используемый в экосистеме FormatJS, изначально не оперирует «чистым HTML» как частью строки сообщения. Вместо этого применяется модель параметризованных сообщений, где разметка либо эмулируется через плейсхолдеры, либо реализуется через механизм rich text formatting. Это принципиально меняет подход к локализации: сообщения остаются текстовыми, а структурность добавляется на этапе рендеринга.

В классическом представлении интернационализации возникает соблазн включать HTML-теги прямо в переводимые строки:

"message": "Нажмите <b>подтвердить</b>, чтобы продолжить"

Однако ICU MessageFormat не интерпретирует такие конструкции как HTML. Это просто текст с символами < и >. Попытка обрабатывать такие строки как HTML приводит к нескольким проблемам:

  • смешение логики представления и перевода
  • риск XSS при динамических вставках
  • невозможность безопасной композиции UI-компонентов
  • усложнение перевода (переводчики вынуждены работать с «техническим» текстом)

Вместо этого FormatJS предлагает два корректных механизма: rich text formatting и компонентную подстановку.


Рич-текст форматирование в FormatJS

Rich text formatting позволяет использовать в сообщениях псевдо-теги, которые затем преобразуются в функции при форматировании строки.

Пример сообщения:

{
  "welcome": "Нажмите <bold>подтвердить</bold>, чтобы продолжить"
}

При использовании formatMessage или <FormattedMessage /> эти теги не становятся HTML. Вместо этого они сопоставляются с функциями:

import { useIntl } from "react-intl";

const Component = () => {
  const intl = useIntl();

  const text = intl.formatMessage(
    {
      id: "welcome",
      defaultMessage: "Нажмите <bold>подтвердить</bold>, чтобы продолжить"
    },
    {
      bold: (chunks) => <strong>{chunks}</strong>
    }
  );

  return <div>{text}</div>;
};

Ключевой момент заключается в том, что <bold> — это не HTML, а синтаксический маркер ICU, который заменяется функцией.

Поддерживаются вложенные конструкции:

{
  "info": "См. <link>документацию <bold>FormatJS</bold></link>"
}
const message = intl.formatMessage(
  { id: "info" },
  {
    link: (chunks) => <a href="/docs">{chunks}</a>,
    bold: (chunks) => <strong>{chunks}</strong>
  }
);

Таким образом формируется дерево React-элементов без использования опасного innerHTML.


Отличие от HTML-интерполяции

Подход FormatJS принципиально отличается от вставки HTML-строк:

Подход Модель Риски Гибкость
HTML в строках строковая интерполяция высокий XSS-риск низкая
ICU rich text функция-рендеринг отсутствует при корректном использовании высокая
React компоненты композиция UI отсутствует максимальная

HTML-подобные конструкции в сообщениях не парсятся как DOM. Это защищает систему от случайного или намеренного внедрения скриптов.


Безопасность и XSS

Основная проблема работы с HTML в локализованных сообщениях — внедрение небезопасного содержимого.

Типичный опасный паттерн:

<div dangerouslySetInnerHTML={{ __html: message }} />

Если message содержит пользовательский ввод или непроверенный перевод, возникает XSS-уязвимость.

FormatJS избегает этой проблемы через модель:

  • сообщения → строки ICU
  • разметка → функции форматирования
  • итог → React-дерево, а не HTML-строка

Дополнительно важно:

  • не преобразовывать результат formatMessage в HTML
  • не хранить HTML внутри переводов
  • не использовать DOMPurify как основной механизм исправления архитектуры (он лишь компенсация)

React-intl: компоненты вместо HTML

В React-интеграции FormatJS ключевая практика — замена HTML-тегов компонентами.

Пример:

import { FormattedMessage } from "react-intl";

const App = () => (
  <FormattedMessage
    id="terms"
    defaultMessage="Я согласен с <link>условиями использования</link>"
    values={{
      link: (chunks) => <a href="/terms">{chunks}</a>
    }}
  />
);

Внутри происходит:

  • парсинг ICU строки
  • построение массива частей
  • замена тегов функциями
  • рендер React-элементов

Этот механизм масштабируется на сложные случаи:

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

Работа с форматированными частями (formatToParts)

Низкоуровневый API formatToParts позволяет получить структуру сообщения без финального HTML или JSX:

const parts = intl.formatMessage(
  { id: "welcome" },
  {
    bold: (chunks) => `<b>${chunks}</b>`
  }
);

или через formatToParts:

const parts = intl.formatMessageToParts(
  { id: "welcome" },
  { bold: (chunks) => `<b>${chunks}</b>` }
);

Результатом является массив сегментов:

  • текстовые части
  • форматированные блоки
  • метаданные сегментов

Это используется при:

  • кастомных рендерах UI
  • интеграции с non-React системами
  • генерации email-шаблонов
  • SSR без DOM

Серверный рендеринг и HTML-вывод

В серверных сценариях FormatJS часто применяется для генерации HTML-страниц или email.

Важно различать два подхода:

1. Безопасный React SSR

const html = renderToString(
  <IntlProvider locale="ru" messages={messages}>
    <App />
  </IntlProvider>
);

Здесь rich text остаётся React-деревом до финального рендера.

2. Генерация HTML через функции

Если требуется HTML-строка, форматирование должно быть явным:

const message = intl.formatMessage(
  { id: "email" },
  {
    bold: (chunks) => `<strong>${chunks}</strong>`
  }
);

При этом критично:

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

Смешивание HTML и ICU: проблемные сценарии

Некорректная архитектура часто возникает при попытке объединить HTML и ICU:

{
  "bad": "Нажмите <button>OK</button> и перейдите на страницу"
}

Проблемы:

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

Корректная модель:

{
  "good": "Нажмите <btn>OK</btn> и перейдите на страницу"
}
btn: (chunks) => <button>{chunks}</button>

Вложенные структуры и композиция

Rich text форматирование позволяет строить сложные композиции:

{
  "complex": "Согласие с <link><bold>условиями</bold> сервиса</link> обязательно"
}
{
  link: (chunks) => <a href="/terms">{chunks}</a>,
  bold: (chunks) => <strong>{chunks}</strong>
}

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


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

На уровне архитектуры выделяются устойчивые подходы:

1. Полный отказ от HTML в переводах

Переводы содержат только ICU-разметку без реальных тегов.

2. Компонентная модель UI

Любая визуальная структура определяется через values-функции.

3. Централизация ссылок

Все внешние ссылки задаются через именованные плейсхолдеры:

values: {
  helpLink: (chunks) => <a href={HELP_URL}>{chunks}</a>
}

4. Разделение данных и представления

Сообщение не знает о DOM, CSS или HTML — только о структуре текста.


Итоговые ограничения HTML-подхода внутри FormatJS

  • HTML не интерпретируется ICU
  • безопасная модель основана на функциях
  • React-компоненты являются основной абстракцией
  • строки сообщений остаются неизменяемыми с точки зрения DOM
  • любой HTML должен быть результатом явного рендеринга, а не частью перевода