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 做 RAG | Markdown → 纯文本 + 保留结构 |
| 提取元数据 | 解析 frontmatter、标题层级、链接列表 |
2. unified 和 remark 生态
unified 是一个文本处理框架,把文本解析成 AST,通过插件链对 AST 做处理。remark 是 unified 的 Markdown 插件集合。处理链是 Markdown → remark-parse → mdast → remark-rehype → hast → rehype-stringify → HTML。
核心插件:
remark-parse— Markdown 文本 → ASTremark-stringify— AST → Markdown 文本remark-rehype— Markdown AST → HTML ASTrehype-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,价格: 5999。return '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 时跳过 mdxJsxFlowElement 和 mdxJsxTextElement 节点。
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 app9. 实践:批量导入 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 做摘要。
几个注意点:
- 过滤草稿 — frontmatter 里
draft: true的文件不导入 - 按标题切 chunk — 每个章节作为一个 chunk,保留标题信息,比按固定字数硬切效果好
- 超长章节再切 — 超过上限的章节按段落再分,避免单个 chunk 过大
- 保留路径 — 后续检索时可以追溯到源文件
总结
回顾这篇的要点:
- Markdown 解析在 AI 项目里是高频需求,来源包括知识库、文档站、README、笔记
unified+remark把 Markdown 解析成 AST,可以按需输出 HTML、纯文本、结构化数据- Markdown → HTML 用于前端渲染,搭配
remark-gfm、rehype-highlight覆盖常见格式 - Markdown → 纯文本用于 LLM,核心是清理语法标记但保留结构
gray-matter处理 frontmatter,把元数据和正文拆开- MDX 先剥 frontmatter 再处理,简单场景正则移除 JSX,复杂场景用
remark-mdx - Hono 路由暴露解析接口,前端和其他服务按需调用
- 批量导入知识库的流程:扫描目录 → 过滤草稿 → 解析 frontmatter → 提取结构 → 按标题切 chunk
下一篇讲 HTML 解析——网页内容提取。