解读 MarkText 的 CommonMark 标题测试夹具:Setext、ATX 与水平分割线的完整规则
本篇以 MarkText 仓库中的 CommonMark 标题测试夹具 Headings.md 为主体,完整剖析 CommonMark 规范中 Setext 与 ATX 两种标题语法的书写规则、边界陷阱(如 7 个 #、#5、#This 不是标题)以及 --- 在 Setext 下划线与水平分割线之间的歧义判定。读完后你既能准确书写符合规范的 Markdown 标题,也能理解这套夹具如何在 MarkText 的 muya 编辑器测试链路中被加载和验证。
一、夹具定位:CommonMark 标题语法的"标准答案"文件
Headings.md 位于 packages/desktop/test/unit/data/common/ 目录下,与 BasicTextFormatting.md、Blockquotes.md、CodeBlocks.md、Lists.md 等文件共同构成 CommonMark 基础语法的测试数据组。它由 markdown.ts 中的 HeadingsTemplate 统一导出:
const loadMarkdownContent = (pathname: string): string => {
// Load file and ensure LF line endings.
return fs
.readFileSync(path.resolve('test/unit/data', pathname), 'utf-8')
.replace(/(?:\r\n|\n)/g, '\n')
}
export const HeadingsTemplate = () => {
return loadMarkdownContent('common/Headings.md')
}
注意加载器会先把 CRLF 归一化为 LF 再返回——这对标题类语法尤其重要:Setext 标题的下划线(=== / ---)必须位于文字所在行的紧邻下一行,若行尾混入 \r 会直接改变块级结构的解析结果。
夹具本身是一份"规则演示文档",覆盖三大块内容:Setext 标题、ATX 标题、水平分割线(Horizontal Rule)。下面逐块对照 CommonMark 规范拆解。
二、Setext 标题:仅支持一级和二级
夹具开头的 Setext 部分原文如下:
## Setext heading
This is a huge header
===
this is a smaller header
---
header
---
This is a huge header
==================
this is a smaller header
------------------
其要点可以归纳为:
- 下划线决定级别:紧跟段落行的
=(一个或多个)构成一级标题,-(一个或多个)构成二级标题。===与==================效果完全一致,---与------------------效果完全一致——下划线长度无关紧要,因此 Setext 只能表达 H1/H2 两个级别。 - 下划线行允许缩进:第二个示例中下划线前带有缩进空白仍然成立,符合 CommonMark 对 Setext 下划线行"最多 3 个前导空格"的宽容处理。
header+---是合法二级标题:这与后文"水平分割线"一节看似矛盾,判定规则取决于---之前是否有空行——Setext 下划线要求与上文紧邻(无空行),而水平分割线独立成块时上下通常留空行。
这一"紧邻 vs 空行"的上下文敏感正是 Setext 语法最易出错的地方,也是夹具特意安排两段相同内容(Setext heading 节与 Horizontal Rule 节)的原因:同一条 header + --- 序列,上下文不同,解析结果不同。
三、ATX 标题:1~6 个井号,且必须后跟空格
夹具的 ATX 部分原文如下:
## Atx heading
## ATX Headings
# foo
bar
## foo
bar
### foo
bar
#### foo
bar
##### foo
bar
###### foo
bar
####### This isn't a heading
bar
#5 This isn't a heading
#This isn't a heading
规则拆解:
-
级别由井号数量决定:
#至######依次对应 H1 至 H6,且每条示例后面都跟了正文bar,说明标题与后续段落之间需要空行分隔,否则bar会被视为标题内容的一部分(CommonMark 中 ATX 标题是单行构造,标题行之后的行不构成标题)。 -
三个反例是规范的经典陷阱,夹具将它们逐条列出:
####### This isn't a heading:7 个及以上井号不识别为标题(CommonMark 只定义 1~6 级);#5 This isn't a heading:井号后紧跟非空白字符(这里是5),不构成标题;#This isn't a heading:井号后无空格(CommonMark 也接受制表符,但不接受中文全角空格之外的任意字符粘连),不构成标题。
这三行是 ATX 标题校验正则的关键约束来源:
^(#{1,6})(?:\s+|$)中"1~6 个#+ 必须后随空白或行尾"两条缺一不可。
四、水平分割线:--- 的另一种身份
夹具结尾的 Horizontal Rule 部分:
## Horizontal Rule
---
foo
---
bar
## Horizontal Rule
- - - - -- --- --- ----
- 独立成行、且不与上文紧贴的
---解析为<hr>(thematic break),而非 Setext 下划线——与第二节"紧邻即 Setext、独立即分割线"的判定互为镜像。 - 最后一行
- - - - -- --- --- ----展示了分割线的另一组写法:由-、*、_三种字符中任一种重复至少 3 次构成,字符之间允许任意数量的空白。- - -(带空格)同样合法。
CommonMark 中分割线字符集为 *、-、_,夹具选用 - 演示恰好能同时凸显它与 Setext 下划线的符号冲突,是理解歧义判定的最佳样本。
五、源码印证:muya 编辑器如何落块标题与分割线
夹具验证的语法最终由 muya 编辑器的块级模型承载。源码中可以确认三类块的独立实现:
-
ATX 标题:atxHeading/index.ts 中
AtxHeading的构造函数依据meta.level动态决定标签:constructor(muya: Muya, { meta }: IAtxHeadingState) { super(muya); this.tagName = `h${meta.level}`; this.meta = meta; this.classList = ['mu-atx-heading']; this.createDomNode(); }这与夹具"井号数量决定级别"的规则一一对应(级别合法域为 1~6,正对应 1~6 个
#)。static create中还挂接了heading-copy-link附件块,为标题提供复制锚点链接能力。 -
Setext 标题:setextHeading/index.ts 的
SetextHeading与 ATX 结构同构,同样按h${meta.level}渲染,但使用独立的mu-setext-headingclass 与setextheading.content内容块。两种语法在渲染层被统一为同级标题标签,差异只保留在块名(atx-heading/setext-heading)与回写 Markdown 的格式上。 -
水平分割线:thematicBreak/index.ts 中
ThematicBreak以<p class="mu-thematic-break">承载(this.tagName = 'p'),即分割线在 muya 中是独立的块级单元,而非依附于相邻标题块——这与夹具中"空行隔开才是分割线"的独立性语义一致。
六、如何复现与验证
- 查看夹具原文:packages/desktop/test/unit/data/common/Headings.md。
- 查看加载链路:packages/desktop/test/unit/markdown.ts 中的
HeadingsTemplate,注意其中的 LF 归一化逻辑。 - 对照块级实现:packages/muya/src/block/commonMark/atxHeading/index.ts、packages/muya/src/block/commonMark/setextHeading/index.ts、packages/muya/src/block/commonMark/thematicBreak/index.ts。
- 同目录的 gfm/ 数据组提供 GFM 扩展语法夹具,可与本 CommonMark 基础组对照阅读。
小结
这份仅 68 行的夹具是 CommonMark 标题与分割线规则的浓缩版:Setext 只产出 H1/H2 且依赖"紧邻下划线"判定,ATX 严格限制为 1~6 个井号加空白,而 --- 在有无空行的上下文中分别扮演二级标题下划线与水平分割线两个角色。结合 muya 编辑器中 atx-heading、setext-heading、thematic-break 三个块级实现,可以完整理解 MarkText 从 Markdown 文本到 DOM 标题块(h1~h6、mu-* class)的解析映射关系。
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