Markdown 不是“替换几个符号”:从语法歧义到 AST,解析器到底做了什么

Markdown 看起来像一种可以用正则表达式完成的格式:把 **text** 换成 <strong>text</strong>,把 # title 换成 <h1>title</h1>,再处理一下链接和列表,似乎就结束了。

这个思路对几十行的玩具实现有效,却很难成为可靠的解析器。真正的问题并不是“认识多少种符号”,而是:当同一个符号在不同位置承担不同角色时,解析器如何根据上下文得到唯一结构?

本文不介绍 Markdown 入门语法,而是从几个可复现实例出发,拆解 Markdown 从源文本到抽象语法树(AST),再到 HTML 的完整过程。

一段 Markdown,为什么不能从左到右直接替换

先看一个只有 14 个字符的例子:

***foo** bar*

这里有三组互相接触的 *。它至少会让人想到几种解释:

  • 外层是斜体,foo 是粗体;
  • 外层是粗体,内部还有斜体;
  • 某些星号只是普通字符;
  • 从最先遇到的两个星号开始匹配,剩下的再处理。

按照 CommonMark 0.31.2,这段文本应当生成:

<p><em><strong>foo</strong> bar</em></p>

也就是下面这棵简化后的树:

document
└─ paragraph
   └─ emphasis
      ├─ strong
      │  └─ text("foo")
      └─ text(" bar")

这里最重要的一点是:解析器不是把星号直接替换成标签,而是先判断每组星号能否作为开始符、结束符,或同时承担两种角色,再建立嵌套结构。

CommonMark 的强调规则不仅区分分隔符左右两边是空白、标点还是普通字符,还需要处理“三的倍数规则”、重叠优先级以及最小嵌套等问题。规则复杂并不是规范设计者故意炫技,而是因为 *_ 在自然文本里本来就可能是标点、单词组成部分或格式标记。

例如:

foo_bar_baz
foo*bar*baz
a * foo bar*

下划线通常不会在单词内部触发强调,星号却可以;而最后一行中,开始星号后紧跟空格,因此它不能打开强调范围。仅靠寻找“下一枚相同字符”的算法无法稳定处理这些情况。

Markdown 解析通常分成两个阶段

CommonMark 给出了一种很有代表性的解析策略:先确定块级结构,再处理块内结构。

第一阶段:识别块级结构

解析器逐行读取输入,识别:

  • 段落;
  • ATX 或 Setext 标题;
  • 引用块;
  • 有序与无序列表;
  • 缩进或围栏代码块;
  • HTML 块;
  • 链接引用定义。

块级标记通常比行内标记拥有更高优先级。下面的输入不是“一段含有反引号的文字”,而是两个列表项:

- `one
- two`

原因是每一行开头的 - 先被块级解析器识别为列表项边界。等行内解析开始时,块的范围已经确定,反引号不能再跨越两个列表项重写外部结构。

列表尤其容易暴露简单解析器的问题。列表项的内容缩进并不是一个永远固定的数字,它取决于列表标记本身、标记后的空格数量以及后续块的相对位置。解析器必须维护“当前打开了哪些容器”的状态,而不是只看这一行以几个空格开头。

再看一个更隐蔽的例子:

上一段没有空行
2. 这是不是列表?

在 CommonMark 中,这两行组成同一个段落。一个有序列表要在没有空行的情况下打断段落,其起始数字必须是 1。因此,把 /^\d+\. / 匹配到的每一行都当成列表项,会改变原文结构。

第二阶段:识别行内结构

块级结构稳定后,解析器才处理段落和标题内部的:

  • 普通文本;
  • 强调与加粗;
  • 代码片段;
  • 链接和图片;
  • 自动链接;
  • 软换行与硬换行;
  • 行内 HTML。

强调符和链接括号通常会进入“分隔符栈”。解析器扫描到 *_[![ 时,不会立即决定它们的最终含义,而是记录字符类型、连续长度、是否可能开启、是否可能闭合等信息。遇到闭合符或块结束时,再回看栈中的候选项并建立节点。

这解释了为什么 AST 是关键中间层。源文本描述“作者输入了什么”,AST 描述“解析器认为它是什么”。HTML、纯文本、语法高亮、目录、文档索引等输出,都可以从同一棵树派生,而不需要重新猜测原始符号。

“都支持 Markdown”不代表输出相同

我在 2026 年 8 月 7 日用以下三个 JavaScript 解析器做了一次小型对照:

  • commonmark 0.31.2
  • markdown-it 15.0.0,使用默认配置;
  • marked 18.0.9,使用默认配置。

先输入一张常见的管道表格:

| A | B |
|---|---|
| 1 | 2 |

结果并不一致:

解析器 实际结果
commonmark 一个包含三行文本的普通段落
markdown-it HTML table 结构
marked HTML table 结构

这不是谁“解析错了”。管道表格不属于核心 CommonMark,而是 GitHub Flavored Markdown(GFM)等方言提供的扩展。markdown-it 默认预设启用了表格规则,Marked 默认面向 GFM;commonmark 则忠实执行核心规范。

再测试不带尖括号的裸链接:

访问 https://example.com 获取资料

得到的结果是:

解析器 实际结果
commonmark URL 保持普通文本
markdown-it 默认配置 URL 保持普通文本
marked 默认配置 URL 变成可点击链接

核心 CommonMark 的自动链接形式是 <https://example.com>。GFM 扩展了识别范围,允许某些裸 URL 自动成为链接。markdown-it 也能做到这一点,但需要显式开启 linkify。因此,“解析器支持自动链接”这句话如果没有说明采用的方言和配置,信息并不完整。

GFM 规范明确把表格、删除线、任务列表和扩展自动链接标记为扩展。这层区别在工程上非常重要:迁移渲染器时,最先损坏的往往不是标题和粗体,而是那些团队已经习惯、却误以为属于“标准 Markdown”的扩展。

用一段脚本复现解析差异

下面的脚本可以直接复现前面的对照。关键不是选择哪一个库,而是把版本和配置写进测试条件:

import * as commonmark from "commonmark";
import markdownIt from "markdown-it";
import { marked } from "marked";

const source = `| A | B |
|---|---|
| 1 | 2 |`;

const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();

const outputs = {
  commonmark: writer.render(reader.parse(source)),
  markdownIt: markdownIt().render(source),
  marked: marked.parse(source),
};

console.log(outputs);

如果文档需要长期保存,测试还应该断言“结构”,而不只是比较整段 HTML 字符串。不同渲染器可能只在换行、空标签写法或属性顺序上有差异,但语义完全相同。更稳妥的测试对象包括:

  • AST 中是否存在 tablelinkcode 等预期节点;
  • 标题层级和列表嵌套是否正确;
  • 链接目标是否保留;
  • 代码内容是否被当作纯文本;
  • 渲染后的 DOM 是否符合允许的结构。

换句话说,兼容性测试应该检查“不变量”,而不是把某个版本的格式化细节永久冻结。

AST 不只是为了生成 HTML

如果应用只需要一次性显示可信 Markdown,parse → HTML 可能已经够用。但只要功能继续增长,AST 很快会成为更合适的边界。

生成目录

遍历 heading 节点即可收集层级和文本。若直接从 HTML 中查找标题,程序会把渲染结果重新解析一遍;若用正则扫描源文件,则很容易误把代码块里的 # 当成标题。

检查文档规范

诸如“只能有一个一级标题”“图片必须有替代文本”“标题层级不能从二级跳到四级”等规则,本质上都是树结构检查。AST 能让规则针对节点类型工作,而不是对字符串做脆弱猜测。

转换为其他格式

HTML 只是渲染目标之一。同一棵语法树还可以生成纯文本、编辑器节点、搜索索引,或继续映射到另一种文档模型。这里真正可复用的是结构,而不是某一份 HTML。

精确修改文档

如果节点保留源位置,就能定位某个链接或标题在原文中的起止偏移,进行诊断和局部修复。相比“全局替换字符串”,这种修改更不容易碰到代码块、转义字符或同名文本。

解析正确,不等于渲染安全

这是 Markdown 系统中最容易被混淆的两件事。

CommonMark 允许原始 HTML。一个解析器可以百分之百正确地识别下面的输入,并原样生成 HTML:

<img src="x" onerror="alert('xss')">

从语法角度看,它没有解析错误;从安全角度看,把结果直接写入网页却可能造成跨站脚本攻击。Marked 的官方文档也明确说明它不负责净化输出 HTML。

一个面向不可信输入的渲染流程至少要区分三种职责:

Markdown 源文本
    ↓ 解析
AST 或 HTML
    ↓ 按安全策略净化
允许的 HTML
    ↓ 注入受控位置
页面 DOM

解析器负责语法,净化器负责安全策略,两者不能互相替代。如果产品不需要原始 HTML,最简单的策略通常是直接禁用它;如果确实需要,就应在最终输出位置使用维护中的 HTML 净化方案,并限制链接协议、元素和属性。OWASP 的 XSS 防护指南同样强调:需要保留富文本能力时,应对 HTML 进行净化,而不是依赖黑名单式字符串过滤。

还要注意处理顺序:净化后的 HTML 如果又被其他插件修改,原有安全保证可能失效。因此,插件扩展、渲染和净化的顺序必须是系统设计的一部分,而不是上线前临时补一层过滤。

怎样设计一个可迁移的 Markdown 系统

如果 Markdown 会进入知识库、评论系统、静态站点或长期文档流程,我更倾向于把下面几项写成显式约定。

1. 声明方言,而不是只写“支持 Markdown”

至少说明使用 CommonMark、GFM,还是包含自定义插件的内部方言。表格、脚注、任务列表、数学公式和删除线都不应靠读者猜测。

2. 固定解析器版本与选项

breakslinkifyhtml 等选项可能直接改变文档含义。升级依赖之前,用真实文档语料运行回归测试,而不是只看库的更新日志。

3. 保存原始 Markdown

HTML 是某个解析器在某组配置下的派生结果。只保存 HTML 会丢掉原文格式、转义方式以及未来重新解析的机会。通常更稳妥的做法是保存源文本,把 HTML 当作可重建的缓存。

4. 建立“边界语料库”

除了常规文档,还应长期保留容易出错的输入:嵌套强调、列表中的代码块、连续反引号、括号链接、包含管道符的表格单元格、原始 HTML、Unicode 标点和行尾空格。每次切换解析器或配置时,比较这些案例的结构变化。

5. 把解析与安全分开测试

解析测试回答“结构是不是预期的”,安全测试回答“危险内容能不能进入最终页面”。两组测试目标不同,任何一组通过都不能替代另一组。

结语

Markdown 的成功来自宽松:人在纯文本里也能看懂它,写作时不必像编写 XML 一样处处闭合标签。但同一种宽松也把复杂度推给了解析器——它必须在自然文本、标点符号、嵌套结构和各类方言之间做出稳定判断。

所以,一个可靠的 Markdown 系统不应从“找几个正则表达式”开始,而应先回答三个问题:采用哪一种语法规范,内部结构如何表示,最终输出由谁负责安全。

最后留下一个问题:Markdown 应该继续保持宽松、允许不同平台发展自己的方言,还是应该牺牲一部分书写自由,换取跨解析器严格一致的结果?

posted @ 2026-08-07 10:26  frank-666  阅读(1)  评论(0)    收藏  举报