almdev Разработка с ИИ

Rich text в продукте: хранить HTML или структуру
Решение, которое принимают за час и живут с ним годами.

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

Подкатегория: Фронтенд

Чтение
6 мин
Технологии / версии
Twig · Безопасность · CKEditor · Санитайзер
20 сен 2026 · 6 мин · 21 просмотр · Архив
Выбор формата хранения текста и структуры документа
Фронтенд 09/2026

Пользователь пишет документ в редакторе. Дальше этот текст надо показать другим, найти поиском, отдать в PDF, отрендерить в письме и не сломать, когда через два года редактор поменяется.

Решение о формате хранения принимается за час на старте и определяет всё перечисленное. Я принимал его трижды в разных проектах и все три раза по-разному.

Два варианта

Готовый HTML. В базе лежит то, что выдал редактор. Показ — это вывод строки. Просто до неприличия.

Структура. В базе лежит JSON: массив блоков с типом и содержимым. Показ — это обход дерева и рендер каждого блока своим шаблоном.

Различие не в удобстве, а в том, кто владеет разметкой. В первом случае — редактор. Во втором — приложение.

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

Почему я выбрал HTML в блоге

Блог на CKEditor, контент хранится готовым HTML. Причины конкретные.

Автор один — я. Мне нужна выразительность: врезки, панели кода, галереи, таблицы. Описывать каждый такой блок структурой означало бы построить свой формат документа, а потом писать под него редактор.

Полнотекстовый поиск по HTML работает после снятия тегов, и в PostgreSQL это одна функция. По JSON-структуре — тоже работает, но нужно доставать текст из вложенных блоков, что уже код.

И главное: контент блога живёт в репозитории файлами. HTML в файле читается и правится руками, JSON с экранированным текстом — нет.

Почему в рабочем продукте наоборот

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

Документы пишут десятки людей, каждый со своим представлением о форматировании. Готовый HTML от них — это разнобой из вложенных списков, инлайновых стилей и вставок из Word с сорока классами.

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

И ревизии. Диффы между версиями по структуре считаются поблочно и показываются понятно. Диффы по HTML — это диффы по строке разметки, где перенос строки выглядит как изменение.

Санитайзер обязателен в обоих вариантах

Даже если редактор ваш и пользователи свои. Даже если HTML пришёл из вашего же приложения.

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

function clean(string $html, string $config = 'content'): string
{
    return app('purifier')->clean($html, $config);
}

В блоге профиль content опирается на безопасный стандартный набор HTML Purifier и отдельно включает нужные атрибуты и iframe с ограниченным списком источников. Это фрагмент действующей конфигурации:

'content' => [
    'HTML.Doctype' => 'HTML 4.01 Transitional',
    'Attr.EnableID' => true,
    'Attr.AllowedFrameTargets' => ['_blank'],
    'HTML.SafeIframe' => true,
    'URI.SafeIframeRegexp' => '%^(https?:)?//(www\.youtube(?:-nocookie)?\.com/embed/|player\.vimeo\.com/video/)%',
],

Кастомные элементы и атрибуты регистрируются отдельно. В продукте с несколькими авторами я предпочитаю ещё более узкий явный HTML.Allowed: новый тег редактора не появляется на сайте, пока его не разрешили осознанно.

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

Цена — время на каждый показ. Кешируется результат, ключ — хеш исходника.

Серверный рендер блоков

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

final class EditorHtmlRenderer
{
    public function render(array $blocks): string
    {
        $out = '';

        foreach ($blocks as $block) {
            $out .= match ($block['type'] ?? null) {
                'paragraph' => $this->twig->render('editor/_paragraph.html.twig', $block),
                'heading' => $this->twig->render('editor/_heading.html.twig', $block),
                'code' => $this->renderCode($block),
                'list' => $this->twig->render('editor/_list.html.twig', $block),
                'callout' => $this->twig->render('editor/_callout.html.twig', $block),
                default => '',
            };
        }

        return $out;
    }
}

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

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

Подсветка кода: сервер или клиент

Развилка, которую я решал в обе стороны.

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

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

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

if (document.querySelector('.prose-paper pre code')) {
    const { default: hljs } = await import('highlight.js/lib/common');
    document.querySelectorAll('.prose-paper pre code').forEach((el) => hljs.highlightElement(el));
}

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

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

Изображения и вложения

Часть контента, о которой вспоминают последней.

Картинка в тексте — это ссылка на файл, который живёт своей жизнью. Три вопроса, на которые надо ответить сразу.

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

Что происходит при удалении документа? Файлы остаются. Сборка мусора обходит вложения, на которые никто не ссылается, и удаляет их с отсрочкой.

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

Смена редактора через два года

Тот сценарий, ради которого весь этот выбор и делается.

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

С HTML миграция — это разбор произвольной разметки в модель нового редактора. Новый редактор почти наверняка не поймёт часть конструкций старого: свои виджеты, атрибуты данных, вложенные структуры. Часть контента придётся править руками.

Я проходил это один раз, переезжая с редактора на редактор в старом проекте. Из 340 статей автоматически перенеслись 300, остальные 40 — вечер ручной работы. Терпимо на таком объёме, невозможно на десяти тысячах документов.

Что бы посоветовал

Первым я по-прежнему задаю вопрос о числе форматов вывода через два года. Но одного его мало. Если текст нужен только на сайте, автор один и совместного редактирования нет, HTML обычно дешевле. Если нужны несколько каналов, строгая схема, API, совместное редактирование или переиспользуемые блоки, структура окупает собственный рендерер.

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

#twig #bezopasnost #ckeditor #sanitayzer