Ворклог по задаче "Добавить локальный Markdown compatibility-компонент"

10 сент. 2026 г., 02:59:47

Новый кейс: нормализация 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.

09.09.2026

До переработки Markdown в ядре haih-agent добавить в BiznesHelper локальный renderer, совместимый с фактическим Markdown, который сохраняет MDXEditor.