Markdown 解析

要点

  • AI 项目经常需要处理 Markdown——知识库文档、技术文档、README,大多数都是 .md 文件
  • 解析的目的分两种:渲染成 HTML 给前端展示,提取纯文本和结构给 LLM 用
  • unified + remark 生态可以把 Markdown 解析成 AST,按需渲染、提取或转换
  • Frontmatter(gray-matter)和 MDX(带组件的 Markdown)是两类常见扩展,需要单独处理
  • 在 Hono 路由中暴露 Markdown 解析接口,前端和其他服务可以按需获取不同格式的输出

1. AI 项目为什么经常碰到 Markdown

几个常见的数据来源:

  • 知识库文档 — 产品手册、FAQ、操作指南,多数团队用 Markdown 写
  • 技术文档站 — Docusaurus、MkDocs、VitePress 输出全是 Markdown
  • README — 开源项目或内部仓库的说明文件
  • 笔记 — Obsidian、Notion 导出格式通常也是 Markdown

这些文件需要被导入系统、建索引、切 chunk、喂给 LLM 做 RAG。第一步就是解析。

解析的目的不同,处理方式也不同:

目的处理方式
给前端渲染Markdown → HTML
给 LLM 做 RAGMarkdown → 纯文本 + 保留结构
提取元数据解析 frontmatter、标题层级、链接列表

2. unified 和 remark 生态

unified 是一个文本处理框架,把文本解析成 AST,通过插件链对 AST 做处理。remark 是 unified 的 Markdown 插件集合。处理链是 Markdown → remark-parse → mdast → remark-rehype → hast → rehype-stringify → HTML

核心插件:

  • remark-parse — Markdown 文本 → AST
  • remark-stringify — AST → Markdown 文本
  • remark-rehype — Markdown AST → HTML AST
  • rehype-stringify — HTML AST → HTML 字符串

安装 npm install unified remark-parse remark-stringify remark-rehype rehype-stringify,基础用法:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
 
const result = await unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('# Hello\n\nThis is **bold**.')
 
console.log(String(result))
// <h1>Hello</h1><p>This is <strong>bold</strong>.</p>

3. Markdown → HTML 渲染

封装一个可复用的渲染函数:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
 
const processor = unified()
  .use(remarkParse)
  .use(remarkGfm)           // GFM:表格、删除线、任务列表
  .use(remarkRehype)
  .use(rehypeHighlight)     // 代码块语法高亮
  .use(rehypeStringify)
 
export async function renderMarkdown(markdown: string): Promise<string> {
  const result = await processor.process(markdown)
  return String(result)
}

常用插件:remark-gfm(GFM 扩展)、rehype-highlight(语法高亮)、rehype-slug(标题加 id 用于目录锚点)。

4. Markdown → 纯文本(给 LLM 用)

给 LLM 用的场景不需要 HTML,需要干净的纯文本,但保留结构信息。

正则清理(简单场景)

function stripMarkdown(markdown: string): string {
  return markdown
    .replace(/!\[([^\]]*)\]\([^)]+\)/g, '[图片: $1]')
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
    .replace(/\*\*(.+?)\*\*/g, '$1')
    .replace(/\*(.+?)\*/g, '$1')
    .replace(/`([^`]+)`/g, '$1')
    .replace(/```[\w]*\n/g, '').replace(/```/g, '')
    .replace(/^>\s+/gm, '')
    .replace(/\n{3,}/g, '\n\n')
    .trim()
}

快、无依赖。但嵌套列表、表格 | 分隔符处理不好。遇到复杂格式需要 AST 方案。

用 remark AST 提取纯文本

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import { visit } from 'unist-util-visit'
 
function markdownToPlainText(markdown: string): string {
  const ast = unified().use(remarkParse).use(remarkGfm).parse(markdown)
  const lines: string[] = []
 
  visit(ast, (node) => {
    switch (node.type) {
      case 'heading':
        lines.push(`${'#'.repeat((node as any).depth)} ${extractText(node)}`)
        lines.push('')
        return 'skip'
      case 'paragraph':
        lines.push(extractText(node))
        lines.push('')
        return 'skip'
      case 'code':
        lines.push(`[代码${(node as any).lang ? ` (${(node as any).lang})` : ''}]`)
        lines.push((node as any).value)
        lines.push('')
        return 'skip'
      case 'table':
        lines.push(tableToPlainText(node))
        lines.push('')
        return 'skip'
    }
  })
 
  return lines.join('\n').trim()
}
 
function extractText(node: any): string {
  let text = ''
  visit(node, (child) => {
    if (child.type === 'text') text += child.value
    else if (child.type === 'link') text += `${extractText(child)} (${child.url})`
    else if (child.type === 'image') text += `[图片: ${child.alt ?? ''}]`
    else if (child.type === 'strong' || child.type === 'emphasis') text += extractText(child)
  })
  return text
}
 
function tableToPlainText(tableNode: any): string {
  const rows: string[][] = []
  visit(tableNode, 'tableRow', (row) => {
    const cells: string[] = []
    visit(row, 'tableCell', (cell) => cells.push(extractText(cell)))
    rows.push(cells)
  })
  if (rows.length < 2) return rows.flat().join(' | ')
  const [header, ...dataRows] = rows
  return dataRows
    .map((row) => header.map((h, i) => `${h}: ${row[i] ?? ''}`).join(','))
    .join('\n')
}

表格输出示例:原始 | 商品 | 价格 | → 输出 商品: iPhone 15,价格: 5999return 'skip' 让 visit 跳过子节点遍历——当前节点已经处理了所有子节点。

5. 提取结构化信息

AST 解析可以精确提取 Markdown 里的特定结构。

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import { visit } from 'unist-util-visit'
 
const ast = unified().use(remarkParse).parse(markdown)
 
// 提取标题层级
const headings: Array<{ depth: number; text: string }> = []
visit(ast, 'heading', (node) => {
  headings.push({ depth: (node as any).depth, text: extractText(node) })
})
 
// 提取代码块
const codeBlocks: Array<{ lang: string; code: string }> = []
visit(ast, 'code', (node) => {
  codeBlocks.push({ lang: (node as any).lang ?? 'unknown', code: (node as any).value })
})
 
// 提取链接
const links: Array<{ text: string; url: string }> = []
visit(ast, 'link', (node) => {
  links.push({ text: extractText(node), url: (node as any).url })
})

这些提取函数可以独立使用——生成目录调 headings,检查死链调 links,提取代码示例调 codeBlocks

6. Frontmatter 解析

很多 Markdown 文件开头有 YAML 元数据:

---
title: 部署指南
author: 白川
tags: [devops, docker]
draft: false
---
 
正文内容……

gray-matter 提取(npm install gray-matter):

import matter from 'gray-matter'
 
const { data, content } = matter(rawMarkdown)
// data = { title: "部署指南", tags: ["devops", "docker"], draft: false }
// content = "正文内容……"

gray-matter 只认文件开头的 --- 分隔符。没有 frontmatter 的文件,data 是空对象。组合使用:

async function processMarkdownFile(raw: string) {
  const { data, content } = matter(raw)
  const html = await renderMarkdown(content)
  const plainText = markdownToPlainText(content)
  return { meta: data, html, plainText, headings: extractHeadings(content) }
}

7. MDX 文件处理

MDX 是 Markdown 的超集——允许嵌入 JSX 组件。Docusaurus、Next.js 文档站大量使用。处理 MDX 分两步:先用 gray-matter 剥 frontmatter,再处理 MDX 内容。

只需要纯文本时,正则移除 JSX 是最简单的做法:

function stripJsx(mdx: string): string {
  return mdx
    .replace(/^import\s+.*$/gm, '')
    .replace(/<[A-Z][^/>]*\/>/g, '')
    .replace(/<[A-Z][^>]*>[\s\S]*?<\/[A-Z][^>]*>/g, '')
    .trim()
}
 
// 然后交给 markdownToPlainText 处理
const plainText = markdownToPlainText(stripJsx(mdxContent))

复杂场景(组件嵌套、传 props)需要用 @mdx-js/mdx 编译,或者用 remark-mdx 插件解析 MDX AST 时跳过 mdxJsxFlowElementmdxJsxTextElement 节点。

8. 在 Hono 路由中提供解析 API

把解析能力封装成 Hono 路由:

import { Hono } from 'hono'
import matter from 'gray-matter'
import { renderMarkdown } from '../lib/render'
import { markdownToPlainText } from '../lib/plaintext'
import { extractHeadings, extractCodeBlocks, extractLinks } from '../lib/extract'
 
const app = new Hono()
 
// 解析 Markdown,返回多种格式
app.post('/api/parse', async (c) => {
  const { content } = await c.req.json<{ content: string }>()
  if (!content) return c.json({ error: 'content is required' }, 400)
 
  const { data, body } = matter(content)
 
  const [html, plainText] = await Promise.all([
    renderMarkdown(body),
    Promise.resolve(markdownToPlainText(body)),
  ])
 
  return c.json({
    meta: data,
    html,
    plainText,
    headings: extractHeadings(body),
    codeBlocks: extractCodeBlocks(body),
    links: extractLinks(body),
  })
})
 
// 按路径读取 R2 中的文件并解析
app.post('/api/parse-file', async (c) => {
  const { path } = await c.req.json<{ path: string }>()
  const object = await c.env.BUCKET.get(path)
  if (!object) return c.json({ error: 'file not found' }, 404)
 
  const raw = await object.text()
  const { data, content } = matter(raw)
 
  return c.json({ meta: data, html: await renderMarkdown(content), plainText: markdownToPlainText(content) })
})
 
export default app

9. 实践:批量导入 Markdown 到知识库

扫描目录,解析所有 .md 文件,按标题切 chunk:

import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import matter from 'gray-matter'
import { markdownToPlainText } from './lib/plaintext'
import { extractHeadings } from './lib/extract'
 
interface KnowledgeEntry {
  path: string
  meta: Record<string, unknown>
  chunks: Array<{ heading: string; level: number; content: string }>
  plainText: string
}
 
async function importDirectory(dirPath: string): Promise<KnowledgeEntry[]> {
  const files = (await scanDir(dirPath)).filter((f) => f.endsWith('.md'))
  const entries: KnowledgeEntry[] = []
 
  for (const file of files) {
    const raw = await readFile(file, 'utf-8')
    const { data, content } = matter(raw)
 
    if (data.draft === true) continue  // 跳过草稿
 
    entries.push({
      path: file,
      meta: data,
      chunks: splitByHeadings(content),
      plainText: markdownToPlainText(content),
    })
  }
 
  return entries
}
 
function splitByHeadings(markdown: string, maxSize = 2000) {
  const lines = markdown.split('\n')
  const sections: Array<{ heading: string; level: number; lines: string[] }> = []
  let current: { heading: string; level: number; lines: string[] } | null = null
 
  for (const line of lines) {
    const match = line.match(/^(#{1,6})\s+(.+)$/)
    if (match) {
      if (current) sections.push(current)
      current = { heading: match[2], level: match[1].length, lines: [line] }
    } else if (current) {
      current.lines.push(line)
    }
  }
  if (current) sections.push(current)
 
  const chunks: Array<{ heading: string; level: number; content: string }> = []
  for (const section of sections) {
    const content = section.lines.join('\n').trim()
    if (content.length <= maxSize) {
      chunks.push({ heading: section.heading, level: section.level, content })
    } else {
      // 超长章节按段落再切
      for (const para of content.split(/\n\n+/)) {
        if (para.trim()) {
          chunks.push({ heading: section.heading, level: section.level, content: para })
        }
      }
    }
  }
  return chunks
}
 
async function scanDir(dir: string): Promise<string[]> {
  const entries = await readdir(dir, { withFileTypes: true })
  const files = await Promise.all(
    entries.map(async (e) => {
      const full = join(dir, e.name)
      return e.isDirectory() ? scanDir(full) : full
    })
  )
  return files.flat()
}

调用方拿到 entries 后,把 chunks 写入向量数据库做 RAG,或把 plainText 喂给 LLM 做摘要。

几个注意点:

  1. 过滤草稿 — frontmatter 里 draft: true 的文件不导入
  2. 按标题切 chunk — 每个章节作为一个 chunk,保留标题信息,比按固定字数硬切效果好
  3. 超长章节再切 — 超过上限的章节按段落再分,避免单个 chunk 过大
  4. 保留路径 — 后续检索时可以追溯到源文件

总结

回顾这篇的要点:

  • Markdown 解析在 AI 项目里是高频需求,来源包括知识库、文档站、README、笔记
  • unified + remark 把 Markdown 解析成 AST,可以按需输出 HTML、纯文本、结构化数据
  • Markdown → HTML 用于前端渲染,搭配 remark-gfmrehype-highlight 覆盖常见格式
  • Markdown → 纯文本用于 LLM,核心是清理语法标记但保留结构
  • gray-matter 处理 frontmatter,把元数据和正文拆开
  • MDX 先剥 frontmatter 再处理,简单场景正则移除 JSX,复杂场景用 remark-mdx
  • Hono 路由暴露解析接口,前端和其他服务按需调用
  • 批量导入知识库的流程:扫描目录 → 过滤草稿 → 解析 frontmatter → 提取结构 → 按标题切 chunk

下一篇讲 HTML 解析——网页内容提取。