首页
/ MarkText muya 解析器的 CommonMark / GFM 规范一致性测试:基线锁定与回归防线

MarkText muya 解析器的 CommonMark / GFM 规范一致性测试:基线锁定与回归防线

2026-09-04 23:43:54作者:房伟宁

本文围绕 MarkText 编辑器内置解析器 muya(npm 包名 @muyajs/core)的一致性测试基线文档 conformance.md 展开:它记录了 muya 对 CommonMark 0.31 与 GFM 0.29 官方示例套件的逐节通过率、被“冻结”的失败示例清单,以及背后那套“合规率只升不降”的回归锁定契约。读完后,你既能看懂这份基线数据的含义与复现方式,也能掌握 HTML 规范化比较层、expected-failures.json 双断言机制等可复用的规格测试工程手段。

基线文档记录了什么

conformance.md 是 muya 规范一致性测试的“记分牌”(scoreboard),基线截取于 PR-6a(2026-05-20)。文档开头给出了复现入口与锁定机制的一句话契约:

  • 复现命令:pnpm --filter @muyajs/core test:spec,由 runner.ts 读取 expected-failures.json 锁定基线——清单里当前仍被列出的示例如果开始通过,测试套件会失败(你必须把它删掉);而未被列出的示例必须继续通过。净结果是:合规率只升不降(compliance can only go up)。
  • 规格 runner 调用 renderToStaticHTML(..., { sanitize: false })——测量的是解析器的规范合规性,而不是 DOMPurify 净化器(它“正确地过于激进”,会把 raw-HTML 放行类示例剥掉)。

文档头部的汇总数据(Headline):

套件 通过 总数 通过率
CommonMark 0.31 572 652 87.7%
GFM 0.29-gfm 580 672 86.3%

如何运行这套一致性套件

muya 的 package.json 定义了三个脚本(L54-L56):

pnpm --filter @muyajs/core test:spec                 # 跑全部 spec 套件
pnpm --filter @muyajs/core test:spec:commonmark     # 只跑 CommonMark 0.31
pnpm --filter @muyajs/core test:spec:gfm            # 只跑 GFM 0.29-gfm

它们共同使用专用的 Vitest 配置 vitest.spec.config.ts,关键项(L17-L24):

  • include: ['test/spec/**/*.{spec,test}.ts']:每个配置入口各自拥有 include glob,避免与主 vite.config.tslib/dts 插件链混用 test.projects 时出现路径错根问题;
  • environment: 'happy-dom':规格断言在 DOM 环境中执行;
  • testTimeout: 30000:spec 套件通过 it.each(670+) 生成数百个用例,放宽超时可避免慢速 CI 环境(如 Windows runner 的尾部延迟)造成假阳性。

示例来源:一个来自 npm,一个来自本地 fixture

两个 spec 文件的用例来源不同(见 commonmark.spec.tsgfm.spec.ts):

  • CommonMark 0.31:直接 import npm 包 commonmark-spec,取其 tests 字段(cms.tests as ISpecExample[]),即 CommonMark 官方 652 个示例;
  • GFM 0.29-gfm:读取本地 fixture fixtures/gfm-spec-0.29-gfm.json,共 672 个示例(含 GFM 扩展章节:表格、删除线、任务列表、自动链接扩展、Disallowed Raw HTML 扩展)。

每条示例都是 { markdown, html, section, number } 四元组(接口定义见 runner.ts 的 ISpecExample),sectionnumber 直接来自上游规范文本,因此测试报告标题(如 `GFM 0.29 §$section #$number`)能与规范章节逐条对齐——这也是两个 spec 文件顶部特意关闭 test/prefer-lowercase-title lint 规则的原因。

被测量的渲染路径:renderToStaticHTML

套件调用的是 renderToStaticHTML——一个同步的 Markdown → HTML 渲染器,与交互式编辑器用的异步 MarkdownToHtml 有三点差异(源码注释 L24-L42 有完整说明):

  • 同步执行,不做 mermaid / vega-lite / plantuml 图表渲染,图表代码块保留为惰性的 <pre><code class="language-*">…</code></pre> 占位;
  • 只返回裸 body HTML,不带 <article class="markdown-body"> 包裹层和 <!DOCTYPE> / <head>——规格 runner 需要拿裸块级 HTML 与 CommonMark / GFM 的期望输出比对;
  • 默认路径同样走 EXPORT_DOMPURIFY_CONFIG 做 DOMPurify 净化,XSS 载荷(script 标签、事件处理器属性、javascript: URL)与编辑器导出路径保持一致。

spec runner 的调用参数把 muya 的私有扩展全部关闭、并绕过净化器(见 gfm.spec.ts L24-L32commonmark.spec.ts L25-L36):

const actual = renderToStaticHTML(example.markdown, {
    footnote: false,
    math: false,
    superSubScript: false,
    isGitlabCompatibilityEnabled: false,
    frontMatter: false,
    sanitize: false,   // 绕过 DOMPurify——见下方说明
});

sanitize: falseIRenderToStaticHTMLOptions 中明确标注“仅限规格 runner”的选项:CommonMark §6.9 “Raw HTML” 章节专门测试 <bab> 这类未知标签原样保留,而 DOMPurify 会改写这些示例,因此净化器本身另由 renderToStaticHTML 的单元测试单独覆盖。这正呼应了 conformance.md 中“测量的是解析器的规范合规性,不是 DOMPurify 净化器”的表述。

expected-failures:一份“合规率只升不降”的回归锁

expected-failures.json 顶层只有 _comment(说明该契约)、commonmarkgfm 两个示例编号数组。它的消费逻辑在 runner.ts 的 getExpectedFailures:按套件名取出编号并包装成 Set<number> 供 O(1) 查询。

真正执行契约的是两个 spec 文件里结构相同的双分支断言(以 commonmark.spec.ts L37-L54 为例):

if (isExpectedFailure) {
    // 回归锁:该示例已在清单中。若现在通过了,说明清单过期,
    // 测试故意失败,提示维护者把它从 expected-failures.json 移除。
    expect(result.passed, `CommonMark #${...} is on expected-failures but now PASSES. ...`)
        .toBe(false);
} else {
    expect(result.passed, formatFailureMessage(example, result)).toBe(true);
}

这套“expected-failures 回归锁”(regression-lock)语义非常明确:

  1. 清单内示例:断言它仍然失败。一旦解析器修复了它,测试会以“unexpected pass”失败,强制维护者把编号从 expected-failures.json 删掉——失败清单只能做减法;
  2. 清单外示例:断言它必须通过。任何回归(新引入的不合规)都会直接红掉,且诊断信息由 formatFailureMessage 生成,把原始 markdown 源、期望 HTML(规范化后)、实际 HTML(规范化后)并排打印进 vitest 的 diff 输出,方便逐例定位。

比较前的 HTML 规范化层:为什么不能直接 diff 原始字符串

cmark 参考实现输出紧凑 HTML,而 muya 使用的 marked + 自有 wrapper 输出“等价但略有差异”的 HTML:自闭合 <br/> 前后空格、DOMPurify 带来的属性排序、可选的尾部换行。直接字符串 diff 会让大量纯风格差异冒充成合规失败。因此 runner.ts 手写了一个规范化器 normalizeHtml,规则与顺序是:

  1. 统一 void 自闭合标签形态br / hr / img / wbr / input<br /><br/><br> 一律归一成 <br />(属性保留);
  2. 标签内属性按字典序排序<a href="x" title="y"><a title="y" href="x"> 视为相等。属性 token 的正则特意把起点锚定在合法属性名字符 [a-z_:] 上,从而排除 <br /> 里的 / 被误吞,也避免了尾部 [^>]*? 造成的多项式回溯(正则注释 L68-L76 有解释);
  3. 折叠相邻标签之间的纯空白>WS<><。这是 cmark 与 marked v16 在松散列表 / 引用块输出风格上差异的主要来源(cmark 的 <li>\n<p>foo</p>\n</li> 对 marked 的 <li><p>foo</p>\n</li>)。关键点:>foo\n<foo 不是纯空白,内容不会被折叠,所以 <pre><code>foo\n</code></pre> 的尾随换行得以保留——这正是 fenced code block 规格示例所依赖的;
  4. 剥除 void 自闭合标签后的空白:覆盖 cmark 的 <br>\nfoo 与 marked 的 <br>foo,两者渲染完全一致;
  5. 最终整理:仅去掉整体首尾换行,折叠内部连续空行——代码块可以包含有语义空行,全局 \n{2,} → \n 的旧写法曾掩盖过 fenced-code-block 示例里的真实差异(这一点有专门的回归单测,见下)。

比较入口 compareHtml 对两侧分别规范化后做全等判断,并同时返回原始与规范化结果用于诊断。

规范化器自身有 8 个单元测试(runner.spec.ts),逐条锁住上述行为:loose/tight 列表等价、块级标签间空白折叠、void 标签后空白剥除、<pre><code> 内容保留(含“单空行与双空行的代码块必须判不等”的回归用例)、属性排序、void 形态归一、以及标签内文本空白保留。这些测试保证比较层“只消风格差异、不吞语义差异”。

CommonMark 0.31 分节通过率

以下表格完整继承自 conformance.md 基线(572 / 652 通过):

章节 通过 总数 通过率
ATX headings 17 18 94.4%
Autolinks 14 19 73.7%
Backslash escapes 12 13 92.3%
Blank lines 1 1 100.0%
Block quotes 23 25 92.0%
Code spans 22 22 100.0%
Emphasis and strong emphasis 132 132 100.0%
Entity and numeric character references 5 17 29.4%
Fenced code blocks 28 29 96.6%
Hard line breaks 14 15 93.3%
HTML blocks 41 44 93.2%
Images 21 22 95.5%
Indented code blocks 11 12 91.7%
Inlines 1 1 100.0%
Link reference definitions 26 27 96.3%
Links 75 90 83.3%
List items 42 48 87.5%
Lists 20 26 76.9%
Paragraphs 4 8 50.0%
Precedence 1 1 100.0%
Raw HTML 18 20 90.0%
Setext headings 22 27 81.5%
Soft line breaks 1 2 50.0%
Tabs 1 11 9.1%
Textual content 2 3 66.7%
Thematic breaks 18 19 94.7%

当前有 80 个示例失败,编号全部锁定在 expected-failures.json

1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 25, 26, 27, 28, 30, 32, 33, 34, 37, 38, 39, 40, 49, 70, 82, 84, 87, 89, 93, 113, 133, 148, 155, 174, 197, 222, 223, 224, 226, 241, 252, 255, 275, 276, 280, 294, 296, 307, 318, 319, 320, 321, 323, 503, 512, 518, 519, 520, 524, 526, 528, 532, 533, 536, 538, 540, 552, 556, 587, 595, 602, 608, 611, 612, 620, 622, 645, 649, 650

从分节数据看,muya 的强项是行内强调(Emphasis 132/132 全对)与代码 span;短板集中在 Tabs(1/11,9.1%)——制表符展开与缩进代码块边界是最难的边角;其次是 Paragraphs(50.0%)Setext headings(81.5%)Entity 字符引用(29.4%) 这类边角规则。这些章节也就是失败编号的主要来源区间(如 300+ 区间的密集失败对应 Links 与 Lists 章节)。

GFM 0.29-gfm 分节通过率

GFM 套件在 CommonMark 基础之上叠加了扩展章节(580 / 672 通过):

章节 通过 总数 通过率
ATX headings 17 18 94.4%
Autolinks 14 19 73.7%
Autolinks (extension) 9 11 81.8%
Backslash escapes 12 13 92.3%
Blank lines 1 1 100.0%
Block quotes 23 25 92.0%
Code spans 22 22 100.0%
Disallowed Raw HTML (extension) 0 1 0.0%
Emphasis and strong emphasis 122 131 93.1%
Entity and numeric character references 5 17 29.4%
Fenced code blocks 28 29 96.6%
Hard line breaks 14 15 93.3%
HTML blocks 40 43 93.0%
Images 21 22 95.5%
Indented code blocks 11 12 91.7%
Inlines 1 1 100.0%
Link reference definitions 27 28 96.4%
Links 73 87 83.9%
List items 42 48 87.5%
Lists 20 26 76.9%
Paragraphs 4 8 50.0%
Precedence 1 1 100.0%
Raw HTML 18 20 90.0%
Setext headings 22 27 81.5%
Soft line breaks 1 2 50.0%
Strikethrough (extension) 2 2 100.0%
Tables (extension) 8 8 100.0%
Tabs 1 11 9.1%
Task list items (extension) 1 2 50.0%
Textual content 2 3 66.7%
Thematic breaks 18 19 94.7%

GFM 侧值得注意两点:

  • 扩展特性基本达标:Tables 8/8、Strikethrough 2/2 全对,Autolinks 扩展 9/11,Task list items 1/2;
  • Disallowed Raw HTML (extension) 0/1:从 GFM 规范语义看,该扩展要求解析器对特定 raw HTML 采取“不允许”策略,而 muya 的解析路径是 raw HTML 透传(净化交由 DOMPurify 在 sanitize: true 的默认导出路径上处理),因此该示例在纯解析器层面无法匹配期望输出——这也再次印证了 conformance 文档强调的“本套件测解析器、不测净化器”的边界。

当前有 92 个示例失败,编号同样锁定在 expected-failures.json

1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 19, 40, 52, 54, 57, 59, 63, 83, 103, 118, 125, 143, 166, 192, 193, 194, 196, 219, 230, 233, 253, 254, 258, 272, 274, 280, 287, 298, 299, 300, 301, 303, 308, 321, 322, 323, 324, 326, 328, 329, 330, 333, 334, 335, 336, 398, 426, 434, 435, 436, 473, 474, 475, 477, 511, 520, 526, 527, 528, 532, 534, 536, 540, 541, 544, 546, 560, 564, 595, 603, 610, 616, 619, 620, 626, 630, 639, 641, 652, 665, 669, 670

工程要点小结:如何消费这份基线

conformance.mdexpected-failures.jsonrunner.ts 放在一起看,可以提炼出一套可迁移的“规格一致性工程”做法,适用于任何要对接 Markdown 或其他语言规范的解析器项目:

  1. 基线快照与真值分离conformance.md 是人类可读的通过率快照(2026-05-20 截取,属于时点数据);机器执行的真值是 expected-failures.json。两者不一致时以 JSON 为准,文档需要在基线变动时更新;
  2. 修复闭环:修改解析器 → 运行 pnpm --filter @muyajs/core test:spec → 若某清单内示例意外通过,按测试提示将其编号从 expected-failures.json 删除 → 失败清单单调收缩、合规率单调上升,且每次变更都有测试输出作为证据;
  3. 比较前先规范化:用“属性排序 + 形态归一 + 纯标签间空白折叠”消除风格差异,同时用单元测试(runner.spec.ts)守住“代码块内容、文本空白不可被规范化吞掉”的语义边界——规范化层本身也要被测试;
  4. 被测对象要纯粹:通过 sanitize: false 与全关扩展的参数组合,让规格 runner 只测“标准 Markdown → HTML 解析”这一件事,DOMPurify 等安全层由独立单元测试覆盖(净化行为见 renderToStaticHTML.ts L68-L71)。

同目录下还有一个 roundTrip.spec.ts,面向“Markdown → 状态 → Markdown”往返场景,与本文讨论的静态 HTML 规格套件互补,读者可结合 fixtures/marktext-round-trip 目录下的往返数据一并阅读。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384