Nhật ký công việc cho nhiệm vụ "Thêm component tương thích Markdown nội bộ"

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

Trường hợp mới: chuẩn hóa legacy HTML trước khi render MDX/Markdown

Khi import nội dung từ CKEditor cũ, một loại không tương thích mới đã xuất hiện: ngay cả sau khi loại bỏ các thẻ <p> lồng nhau, một phần HTML vẫn hợp lệ về mặt cú pháp đối với trình duyệt, nhưng lại xử lý rất kém khi qua MDX parser và quá trình SSR render tiếp theo.

Triệu chứng

MDX parser bị lỗi với các thông báo kiểu như:

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` ...

Một đoạn legacy tiêu biểu trông như thế này:

<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">Văn bản...</span></p>

Ở đây có sự kết hợp đồng thời của nhiều vấn đề:

  • <br> được viết ở dạng HTML thường, nhưng pipeline MDX/JSX lại mong đợi dạng tự đóng (self-closing);
  • WYSIWYG cũ sử dụng tích cực thẻ <p> như một container đa năng;
  • Khoảng trắng và ngắt dòng giữa các thẻ HTML có thể không còn được MDX diễn giải là định dạng trung tính của mã nguồn nữa, mà bị coi là ranh giới của các đoạn markdown (paragraph).

Những gì đã được kiểm tra

Ban đầu, việc chuẩn hóa thông qua htmlparser2 + dom-serializer đã được xem xét, nhưng phương án này tỏ ra bất tiện cho bài toán: các thẻ tự đóng không được chuẩn hóa theo đúng cách mà MDX yêu cầu, và văn bản có thể được serialize kèm theo các HTML entities không mong muốn.

Phương án khả thi hóa ra lại là rehype, cho phép đưa legacy HTML về dạng serialize ổn định hơn trước, sau đó mới chuyển kết quả sang pipeline chuẩn hóa Markdown chung.

Pipeline hiện tại

Quá trình chuẩn hóa hiện bao gồm một số bước tuần tự.

1. Các thẻ <p> cũ được thay thế bằng <div>

Điều này loại bỏ tình trạng lồng nhau không hợp lệ của các đoạn văn và giảm thiểu rủi ro lệch hydration (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 được chạy qua rehype

rehype được sử dụng làm parser/stringifier để sửa lỗi và chuẩn hóa cấu trúc legacy:

const normalized = await rehype()
  .data('settings', {
    fragment: true,
    closeSelfClosing: true,
  })
  .process(html)

Hiệu ứng cốt lõi là các phần tử tự đóng như <br> được đưa về dạng đi qua quá trình xử lý của MDX/JSX một cách an toàn hơn.

3. Xóa khoảng trắng giữa các thẻ liền kề

Sau khi serialize, các dấu xuống dòng và khoảng trắng giữa các thẻ HTML sẽ tiếp tục bị thu gọn lại:

html = String(normalized).replace(/>\s+</g, '><')

Điều này cực kỳ quan trọng đối với MDX: việc xuống dòng giữa các nút HTML dạng inline/block có thể ảnh hưởng đến quá trình phân tích cú pháp markdown và tạo ra ranh giới paragraph bất ngờ. Trong HTML của trình duyệt, khoảng trắng như vậy thường không ảnh hưởng gì, nhưng trong định dạng kết hợp Markdown+HTML thì không còn như vậy nữa.

Triển khai tổng thể

Logic được gom vào helper cleanupOldContent, hoạt động như một tầng chuẩn hóa riêng biệt giữa legacy CKEditor HTML và hệ thống Concept/Markdown mới:

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
  }
}

Kết luận kiến trúc

Trường hợp này cho thấy tầng tương thích cục bộ (local compatibility layer) cho BiznesHelper không chỉ phải chuẩn hóa khoảng trắng trong Markdown mà còn phải chuẩn hóa cả embedded legacy HTML trước khi nội dung đi vào pipeline MDX/SSR.

Nói cách khác, luồng import thực tế hiện tại trông như sau:

legacy CKEditor HTML
→ Dọn dẹp ở cấp độ DOM
→ Chuẩn hóa HTML thông qua rehype
→ Chuẩn hóa khoảng trắng
→ normalizeMarkdownContent chung
→ Lưu trữ Concept

Đây là một sự phân tách trách nhiệm hữu ích: phần đánh dấu cũ dù dễ dãi với trình duyệt nhưng không ổn định sẽ được sửa một lần ngay từ đầu vào, thay vì phải bù đắp vô thời hạn ở bộ render công khai.

Trạng thái

Vấn đề cụ thể với khoảng ngắt giữa các thẻ và HTML tự đóng hiện đã được giải quyết bằng helper chuẩn hóa cục bộ. Vấn đề tương thích chung của MDXEditor / Markdown renderer vẫn là đối tượng của một nhiệm vụ R&D riêng biệt trong haih-agent.

09.09.2026

Trước khi tái cấu trúc Markdown trong core haih-agent, hãy thêm vào BiznesHelper một renderer nội bộ tương thích với định dạng Markdown thực tế được lưu bởi MDXEditor.