Задача: Добавить локальный Markdown compatibility-компонент

Добавить локальный Markdown compatibility-компонент

09.09.2026bizneshelper.ru

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

Контекст

В ядре react-markdown и @mdxeditor/editor по-разному интерпретируют часть Markdown, особенно whitespace/indentation. Из-за этого корректный после MDXEditor контент с HTML может при SSR-чтении превращаться в code block.

Что сделать

  • Временно добавить локальный Markdown-компонент/normalization layer для BiznesHelper по аналогии с решением, уже использованным на VietnamGuru.
  • Исправить сценарии с четырьмя пробелами и табуляцией, не ломая реальные code blocks.
  • Не переносить это решение в ядро до завершения отдельной R&D-задачи haih-agent.
  • После появления платформенного решения заменить локальный компонент общим.

Результат

Импортированный и AI-обновляемый Markdown корректно рендерится на BiznesHelper через SSR без ложного превращения HTML/контента в листинг кода.

Ворклоги

Legacy HTML normalization: замена <p> на <div> при импорте

При переносе старого контента обнаружился ещё один конфликт между legacy WYSIWYG-разметкой и новым React/SSR-рендерингом. Старый редактор оборачивал большие фрагменты HTML в <p>. В новой версии такой импортированный HTML может оказаться внутри уже существующего параграфа, из-за чего React получает невалидную HTML-структуру и предупреждает:

In HTML, <p> cannot be a descendant of <p>. This will cause a hydration error.

Причина

Проблема не в конкретном тексте, а в семантике тега <p>: HTML не допускает вложенные параграфы. Legacy WYSIWYG использовал <p> как универсальную блочную обёртку, хотя для произвольного вложенного содержимого безопаснее нейтральный контейнер вроде <div>.

При SSR это особенно неприятно: браузер может самостоятельно исправить невалидную HTML-структуру не так, как её ожидает React, и итоговая DOM-структура после парсинга отличается от серверной разметки. Это приводит к hydration mismatch/error.

Принятое решение

В импортер добавлен helper на cheerio, который до сохранения legacy HTML заменяет все <p> на <div>, сохраняя внутренний HTML и атрибуты:

import * as cheerio from 'cheerio'

export 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()
}

Почему нормализация выполняется в импортере

Это legacy-специфичная проблема исходных данных, поэтому безопаснее исправлять её на входе в новую систему, а не заставлять общий Markdown/React renderer постоянно компенсировать старую WYSIWYG-разметку.

Импортер здесь выполняет роль normalization layer: сохраняет смысл старого содержимого, но убирает структуру, которая заведомо конфликтует с валидным HTML и React hydration.

Значение для общей Markdown-задачи

Этот случай дополняет проблему с whitespace/indentation: новый pipeline должен учитывать не только Markdown-синтаксис, но и качество embedded HTML. Даже если Markdown формально один и тот же, legacy HTML внутри него может иметь структуру, которую разные parser/renderer-цепочки обрабатывают по-разному.

Локальное решение остаётся частью BiznesHelper compatibility-слоя до появления более общего normalization/validation pipeline в haih-agent.

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