首页
/ 解读 MarkText 的 CommonMark 标题测试夹具:Setext、ATX 与水平分割线的完整规则

解读 MarkText 的 CommonMark 标题测试夹具:Setext、ATX 与水平分割线的完整规则

2026-09-04 09:18:08作者:邓越浪Henry

本篇以 MarkText 仓库中的 CommonMark 标题测试夹具 Headings.md 为主体,完整剖析 CommonMark 规范中 Setext 与 ATX 两种标题语法的书写规则、边界陷阱(如 7 个 ##5#This 不是标题)以及 --- 在 Setext 下划线与水平分割线之间的歧义判定。读完后你既能准确书写符合规范的 Markdown 标题,也能理解这套夹具如何在 MarkText 的 muya 编辑器测试链路中被加载和验证。

一、夹具定位:CommonMark 标题语法的"标准答案"文件

Headings.md 位于 packages/desktop/test/unit/data/common/ 目录下,与 BasicTextFormatting.mdBlockquotes.mdCodeBlocks.mdLists.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
------------------

其要点可以归纳为:

  1. 下划线决定级别:紧跟段落行的 =(一个或多个)构成一级标题,-(一个或多个)构成二级标题。===================== 效果完全一致,--------------------- 效果完全一致——下划线长度无关紧要,因此 Setext 只能表达 H1/H2 两个级别。
  2. 下划线行允许缩进:第二个示例中下划线前带有缩进空白仍然成立,符合 CommonMark 对 Setext 下划线行"最多 3 个前导空格"的宽容处理。
  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

规则拆解:

  1. 级别由井号数量决定####### 依次对应 H1 至 H6,且每条示例后面都跟了正文 bar,说明标题与后续段落之间需要空行分隔,否则 bar 会被视为标题内容的一部分(CommonMark 中 ATX 标题是单行构造,标题行之后的行不构成标题)。

  2. 三个反例是规范的经典陷阱,夹具将它们逐条列出:

    • ####### 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

   - - -  - --   --- --- ----
  1. 独立成行、且不与上文紧贴的 --- 解析为 <hr>(thematic break),而非 Setext 下划线——与第二节"紧邻即 Setext、独立即分割线"的判定互为镜像。
  2. 最后一行 - - - - -- --- --- ---- 展示了分割线的另一组写法:由 -*_ 三种字符中任一种重复至少 3 次构成,字符之间允许任意数量的空白。- - -(带空格)同样合法。

CommonMark 中分割线字符集为 *-_,夹具选用 - 演示恰好能同时凸显它与 Setext 下划线的符号冲突,是理解歧义判定的最佳样本。

五、源码印证:muya 编辑器如何落块标题与分割线

夹具验证的语法最终由 muya 编辑器的块级模型承载。源码中可以确认三类块的独立实现:

  • ATX 标题atxHeading/index.tsAtxHeading 的构造函数依据 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.tsSetextHeading 与 ATX 结构同构,同样按 h${meta.level} 渲染,但使用独立的 mu-setext-heading class 与 setextheading.content 内容块。两种语法在渲染层被统一为同级标题标签,差异只保留在块名(atx-heading / setext-heading)与回写 Markdown 的格式上。

  • 水平分割线thematicBreak/index.tsThematicBreak<p class="mu-thematic-break"> 承载(this.tagName = 'p'),即分割线在 muya 中是独立的块级单元,而非依附于相邻标题块——这与夹具中"空行隔开才是分割线"的独立性语义一致。

六、如何复现与验证

  1. 查看夹具原文:packages/desktop/test/unit/data/common/Headings.md
  2. 查看加载链路:packages/desktop/test/unit/markdown.ts 中的 HeadingsTemplate,注意其中的 LF 归一化逻辑。
  3. 对照块级实现:packages/muya/src/block/commonMark/atxHeading/index.tspackages/muya/src/block/commonMark/setextHeading/index.tspackages/muya/src/block/commonMark/thematicBreak/index.ts
  4. 同目录的 gfm/ 数据组提供 GFM 扩展语法夹具,可与本 CommonMark 基础组对照阅读。

小结

这份仅 68 行的夹具是 CommonMark 标题与分割线规则的浓缩版:Setext 只产出 H1/H2 且依赖"紧邻下划线"判定,ATX 严格限制为 1~6 个井号加空白,而 --- 在有无空行的上下文中分别扮演二级标题下划线与水平分割线两个角色。结合 muya 编辑器中 atx-headingsetext-headingthematic-break 三个块级实现,可以完整理解 MarkText 从 Markdown 文本到 DOM 标题块(h1~h6mu-* class)的解析映射关系。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
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
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384