Ворклог по задаче "Добавить локальный Markdown compatibility-компонент"
Новый кейс: нормализация legacy HTML перед MDX/Markdown-рендерингом
При импорте контента из старого CKEditor проявилась новая категория несовместимости: даже после устранения вложенных <p> часть HTML остаётся синтаксически допустимой для браузера, но плохо переживает MDX-парсинг и последующий SSR-render.
Симптомы
MDX-парсер падал с ошибками вида:
Error parsing markdown: Expected the closing tag `</p>` either after the end of `paragraph` ...
Error parsing markdown: Expected the closing tag `</div>` either after the end of `paragraph` ...
Типичный legacy-фрагмент выглядел так:
<p style="text-align:justify"><img alt="" src="/ckeditor_assets/pictures/40/content_oil.png" style="border-style:solid; border-width:1px; height:503px; width:703px"><br>
<span style="font-family:trebuchet ms,helvetica,sans-serif; font-size:14px">Текст...</span></p>
Здесь одновременно сочетаются несколько проблем:
<br>записан в HTML-форме, но MDX/JSX-пайплайн ожидает self-closing вариант;- старый WYSIWYG активно использует
<p>как универсальный контейнер; - переносы строк и whitespace между HTML-тегами могут быть интерпретированы MDX уже не как нейтральное форматирование исходника, а как границы markdown-параграфов.
Что было проверено
Сначала рассматривалась нормализация через htmlparser2 + dom-serializer, но этот вариант оказался неудобен для задачи: self-closing теги нормализовались не так, как нужно MDX, а текст мог сериализоваться с нежелательными HTML entities.
Рабочим вариантом оказался rehype, который позволяет сначала привести legacy HTML к более стабильной сериализации, а затем уже передать результат в общий Markdown-normalization pipeline.
Текущий pipeline
Нормализация теперь состоит из нескольких последовательных шагов.
1. Legacy <p> заменяются на <div>
Это устраняет невалидную вложенность параграфов и снижает риск hydration mismatch:
function replacePWithDiv(html: string): string {
const $ = cheerio.load(html, { xml: false }, false)
$('p').each((_, el) => {
const $el = $(el)
const div = $('<div></div>')
div.html($el.html() || '')
for (const attr of el.attributes) {
div.attr(attr.name, attr.value)
}
$el.replaceWith(div)
})
return $.html()
}
2. HTML прогоняется через rehype
rehype используется как parser/stringifier для исправления и канонизации legacy-разметки:
const normalized = await rehype()
.data('settings', {
fragment: true,
closeSelfClosing: true,
})
.process(html)
Ключевой эффект — self-closing элементы вроде <br> приводятся к форме, которая безопаснее проходит через MDX/JSX-обработку.
3. Удаляется whitespace между соседними тегами
После сериализации дополнительно схлопываются переносы и пробелы между HTML-тегами:
html = String(normalized).replace(/>\s+</g, '><')
Это важно именно для MDX: перенос строки между inline/block HTML-узлами может влиять на markdown-парсинг и породить неожиданный paragraph boundary. В браузерном HTML такой whitespace часто безразличен, а в смешанном Markdown+HTML формате уже нет.
Итоговая реализация
Логика собрана в helper cleanupOldContent, который работает как отдельный normalization layer между legacy CKEditor HTML и новой Concept/Markdown-системой:
import * as cheerio from 'cheerio'
import { rehype } from 'rehype'
import { normalizeMarkdownContent } from '../../KBConcept/helpers/normalizeMarkdownContent'
function replacePWithDiv(html: string): string {
const $ = cheerio.load(html, { xml: false }, false)
$('p').each((_, el) => {
const $el = $(el)
const div = $('<div></div>')
div.html($el.html() || '')
for (const attr of el.attributes) {
div.attr(attr.name, attr.value)
}
$el.replaceWith(div)
})
return $.html()
}
export async function cleanupOldContent(html: string): Promise<string> {
html = html.replaceAll(/<u>([^<]+)?<\/u>:/g, '<u>$1:</u>')
html = replacePWithDiv(html)
try {
const normalized = await rehype()
.data('settings', {
fragment: true,
closeSelfClosing: true,
})
.process(html)
html = String(normalized).replace(/>\s+</g, '><')
return await normalizeMarkdownContent(html)
} catch (error) {
console.error(error)
console.error('cleanupOldContent html', html)
throw error
}
}
Архитектурный вывод
Этот случай показывает, что локальный compatibility-layer для BiznesHelper должен нормализовать не только Markdown whitespace, но и embedded legacy HTML до того, как контент попадёт в MDX/SSR pipeline.
То есть фактический путь импорта сейчас выглядит так:
legacy CKEditor HTML
→ DOM-level cleanup
→ HTML normalization через rehype
→ whitespace normalization
→ общий normalizeMarkdownContent
→ сохранение Concept
Это полезное разделение ответственности: браузерно-терпимая, но нестабильная legacy-разметка исправляется один раз на входе, а не компенсируется бесконечно в публичном renderer-е.
Статус
Конкретный кейс с переносами между тегами и self-closing HTML сейчас закрыт локальным normalization helper-ом. Общая проблема совместимости MDXEditor / Markdown renderer остаётся предметом отдельной R&D-задачи в haih-agent.
До переработки Markdown в ядре haih-agent добавить в BiznesHelper локальный renderer, совместимый с фактическим Markdown, который сохраняет MDXEditor.