MarkText muya 解析器的 CommonMark / GFM 规范一致性测试:基线锁定与回归防线
本文围绕 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.ts的lib/dts插件链混用test.projects时出现路径错根问题;environment: 'happy-dom':规格断言在 DOM 环境中执行;testTimeout: 30000:spec 套件通过it.each(670+)生成数百个用例,放宽超时可避免慢速 CI 环境(如 Windows runner 的尾部延迟)造成假阳性。
示例来源:一个来自 npm,一个来自本地 fixture
两个 spec 文件的用例来源不同(见 commonmark.spec.ts 与 gfm.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),section 与 number 直接来自上游规范文本,因此测试报告标题(如 `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-L32 与 commonmark.spec.ts L25-L36):
const actual = renderToStaticHTML(example.markdown, {
footnote: false,
math: false,
superSubScript: false,
isGitlabCompatibilityEnabled: false,
frontMatter: false,
sanitize: false, // 绕过 DOMPurify——见下方说明
});
sanitize: false 是 IRenderToStaticHTMLOptions 中明确标注“仅限规格 runner”的选项:CommonMark §6.9 “Raw HTML” 章节专门测试 <bab> 这类未知标签原样保留,而 DOMPurify 会改写这些示例,因此净化器本身另由 renderToStaticHTML 的单元测试单独覆盖。这正呼应了 conformance.md 中“测量的是解析器的规范合规性,不是 DOMPurify 净化器”的表述。
expected-failures:一份“合规率只升不降”的回归锁
expected-failures.json 顶层只有 _comment(说明该契约)、commonmark 与 gfm 两个示例编号数组。它的消费逻辑在 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)语义非常明确:
- 清单内示例:断言它仍然失败。一旦解析器修复了它,测试会以“unexpected pass”失败,强制维护者把编号从 expected-failures.json 删掉——失败清单只能做减法;
- 清单外示例:断言它必须通过。任何回归(新引入的不合规)都会直接红掉,且诊断信息由 formatFailureMessage 生成,把原始 markdown 源、期望 HTML(规范化后)、实际 HTML(规范化后)并排打印进 vitest 的 diff 输出,方便逐例定位。
比较前的 HTML 规范化层:为什么不能直接 diff 原始字符串
cmark 参考实现输出紧凑 HTML,而 muya 使用的 marked + 自有 wrapper 输出“等价但略有差异”的 HTML:自闭合 <br/> 前后空格、DOMPurify 带来的属性排序、可选的尾部换行。直接字符串 diff 会让大量纯风格差异冒充成合规失败。因此 runner.ts 手写了一个规范化器 normalizeHtml,规则与顺序是:
- 统一 void 自闭合标签形态:
br/hr/img/wbr/input的<br />、<br/>、<br>一律归一成<br />(属性保留); - 标签内属性按字典序排序:
<a href="x" title="y">与<a title="y" href="x">视为相等。属性 token 的正则特意把起点锚定在合法属性名字符[a-z_:]上,从而排除<br />里的/被误吞,也避免了尾部[^>]*?造成的多项式回溯(正则注释 L68-L76 有解释); - 折叠相邻标签之间的纯空白:
>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 规格示例所依赖的; - 剥除 void 自闭合标签后的空白:覆盖 cmark 的
<br>\nfoo与 marked 的<br>foo,两者渲染完全一致; - 最终整理:仅去掉整体首尾换行,不折叠内部连续空行——代码块可以包含有语义空行,全局
\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.md、expected-failures.json 和 runner.ts 放在一起看,可以提炼出一套可迁移的“规格一致性工程”做法,适用于任何要对接 Markdown 或其他语言规范的解析器项目:
- 基线快照与真值分离:
conformance.md是人类可读的通过率快照(2026-05-20 截取,属于时点数据);机器执行的真值是expected-failures.json。两者不一致时以 JSON 为准,文档需要在基线变动时更新; - 修复闭环:修改解析器 → 运行
pnpm --filter @muyajs/core test:spec→ 若某清单内示例意外通过,按测试提示将其编号从 expected-failures.json 删除 → 失败清单单调收缩、合规率单调上升,且每次变更都有测试输出作为证据; - 比较前先规范化:用“属性排序 + 形态归一 + 纯标签间空白折叠”消除风格差异,同时用单元测试(runner.spec.ts)守住“代码块内容、文本空白不可被规范化吞掉”的语义边界——规范化层本身也要被测试;
- 被测对象要纯粹:通过
sanitize: false与全关扩展的参数组合,让规格 runner 只测“标准 Markdown → HTML 解析”这一件事,DOMPurify 等安全层由独立单元测试覆盖(净化行为见 renderToStaticHTML.ts L68-L71)。
同目录下还有一个 roundTrip.spec.ts,面向“Markdown → 状态 → Markdown”往返场景,与本文讨论的静态 HTML 规格套件互补,读者可结合 fixtures/marktext-round-trip 目录下的往返数据一并阅读。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00