- 开发工具
- 代码质量
- CLI
【免费下载链接】vale
:pencil: A markup-aware linter for prose built with speed and extensibility in mind.
导读
本文以 Vale 开源仓库中的测试夹具 testdata/fixtures/scopes/heading/test.rst 为核心入口,拆解 Vale 如何识别 reStructuredText(reST)文档的标题层级、将其归入heading作用域体系,并驱动scope: heading等规则精确命中标题文本。读完本文,你将掌握 Vale 中 heading 作用域的完整取值表(heading.h1至heading.h6)、基于包含关系的匹配语义、doc(...)选择器对标题结构树的利用方式,以及一套可直接复用的规则与 e2e 测试写法。
关联文档解读:一个「一鱼多吃」的标题测试夹具
夹具的内容与意图
test.rst 只有 9 行,是一份刻意构造的 reST 文档骨架:
==================== This is XXX heading ==================== Section 1.1 Title TODO ---------------------- TODO它用 reST 的 overline + underline 标题语法定义了两个层级:This is XXX heading是文档级标题(相当于 HTML 的h1),Section 1.1 Title TODO是一级小节标题(相当于h2)。正文区留有TODO占位符,同时标题文本中混入了XXX、TODO这类「遗留标记词」——这正是用于验证「规则能否命中标题文本」的最小样本。
值得注意的是,同目录下还并列着 test.md、test.adoc、test.html、test.dita 和 test.xml 五份同主题文件,它们把 Markdown 的#/##/###/####、AsciiDoc 的===/==、HTML 的<h1>–<h2>、DITA 的<title>、DocBook XML 的<title>全部映射到同一套heading作用域上。这个目录的定位是「跨格式的 heading 行为测试矩阵」:无论源格式是什么,只要元素被识别为标题,它就必须落入同一套 heading 作用域体系。而test.rst是其中 reST 格式的代表。
驱动夹具运行的配置
目录下的 _vale 文件定义了本目录专属的运行配置:
StylesPath = ../../scopes MinAlertLevel = suggestion [*] rules.Heading = YES rules.H2 = YES rules.H3 = YES [*.md] rules.MinH2 = YES rules.HN = YES [*.xml] Transform = ../../XSL/docbook-xsl-snapshot/html/docbook.xsl它指向上级目录 testdata/fixtures/scopes 下的样式规则集,并按文件类型差异化启用规则:[*.md]额外启用MinH2与HN,[*.xml]则通过 DocBook XSL(../../XSL/docbook-xsl-snapshot/html/docbook.xsl)把 XML 先变换成 HTML 再交给解析器。由于test.rst不在任何[*.xxx]专属段内,它只受[*]段约束,即Heading、H2、H3三条规则。这也说明:reST 解析是 Vale 的原生能力,不需要像 XML 那样借助外部变换。
heading 作用域体系:从元素到作用域名的映射
作用域命名规则
Vale 把每个被解析出的块元素标注为一个点分作用域名。对标题来说,其命名规律是:
text.heading.<level>.<file-ext>其中<level>对应标题层级数字 1–6。这个结构在 internal/lint/ast.go 中清晰可见:遍历元素标签历史时,若命中 HTML 标题标签(h1–h6),则把作用域记为text.heading.+ 标签名(例如h2),再拼接元作用域与真实扩展名;若命中非行内标签的映射标签,则使用tagToScope表给出的作用域名(如blockquote、list)。标题识别的正则定义在 internal/lint/html.go:
var heading = regexp.MustCompile(`^h\d$`)任何h后跟一位数字的标签都被视为标题。同目录的 test.html 正是以真实 HTML 的<h1 class="title">、<h2 class="title">结构来验证这一点。
匹配语义:按包含关系,而非严格相等
scope: heading并不是要求块作用域恰好等于heading,而是按部分包含(containment)匹配。在 internal/check/scope_test.go 的注释中明确写道:
Matching is by containment: a rule matches a block when every section of the rule's scope appears somewhere in the block's.
即:规则声明的每个作用域段(如heading、h2)只要都出现在块作用域的段集合中,就命中。因此:
heading命中text.heading.h2.md(包含heading段);heading.h2命中text.heading.h2.md,但不命中text.heading.h3.md(缺少h2段);heading.h1、heading.h2、heading.h3的列表声明(union)分别命中对应层级。
这一点由 TestScopeMatches 中的测试用例矩阵逐行固定,例如:
{[]string{"heading"}, "text.heading.h2.md", true}, {[]string{"heading.h2"}, "text.heading.h2.md", true}, {[]string{"heading.h3"}, "text.heading.h2.md", false},标题也是「散文」:参与分句与词性标注
一个容易忽略的事实是:标题不是孤立的块,它内部同样要做句子切分(segmentation)与词性标注(POS tagging)。在 internal/lint/ast.go 中有一段关键注释(关联 issue #1124 与 #1132):
- 标题、列表项、表格单元与段落一样由句子构成,只有进入
lintProse路径才会生成sentence.*块; - 若把标题直接交给
lintBlock,则sentence作用域(所有sequence规则依赖它)会静默跳过标题中的大部分散文; - 反之,
paragraph作用域专门指代段落,不能波及标题与表格单元。
因此test.rst标题里的XXX、TODO之所以能被命中并报告,正是因为这夹具同时验证了「标题文本已进入句子级处理管线」这一底层事实。
规则实战:用 heading 作用域编写可复用检查规则
夹具通过_vale配置启用的规则全部定义在 testdata/fixtures/scopes/rules 下,这些规则就是「heading 作用域」最直接的实战样例。
全层级命中:rules/Heading.yml
Heading.yml 面向全部标题层级:
message: "'%s' left in text" extends: existence ignorecase: false scope: heading level: error tokens: - XXX - FIXME - TODO - NOTEscope: heading通过包含匹配命中所有text.heading.*块,用于在任意层级标题中拦截遗留标记词。根据 scopes.yaml 的 e2e 断言,该规则在六种格式中全部生效,reST 样本的输出为test.rst:2:9(XXX)与test.rst:5:19(TODO),行号、列号与标题文本位置一一对应。
层级定向命中:H2.yml / H3.yml
H2.yml 与 H3.yml 演示了「只查某一级标题」:
message: "'%s' left in text" extends: existence ignorecase: false scope: heading.h2 level: error tokens: - TWOscope: heading.h2只命中text.heading.h2.*。在 reST 中,h2对应二级标题(即test.rst中Section 1.1 Title TODO这类用------下划线标记的标题);而在 test.adoc 中对应== Level 2 Section Title,在 test.md 中对应##标题——不同源格式的相同层级汇聚到同一个作用域名,这正是跨格式一致性的体现。
多层级联合声明:HN.yml
HN.yml 展示了scope的列表形式(union):
scope: - heading.h1 - heading.h2 - heading.h3同一规则可以声明多个作用域,任一命中即生效。它在 Markdown 夹具上把FOUR从 h1–h3 三层标题中全部找出(test.md:1:35、test.md:3:29、test.md:9:26),但对只有两级的 reST 样本则不产生告警。
计数型规则的延伸:MinH2.yml
MinH2.yml 把 heading 作用域与occurrence规则结合,实现「标题长度门槛」:
message: "Headings should have at least 100 characters." extends: occurrence scope: heading.h4 level: suggestion min: 100 token: '[\w\s]'它对heading.h4内的字符([\w\s])计数,不足 100 则报告「标题太短」。在 Markdown 样本中触发test.md:11:6:rules.MinH2,而test.rst没有h4级标题,故不受影响——再次印证层级作用域会精确地「挑」出目标标题。
在真实样式中更精细的用法
仓库的真实样式集 testdata/styles/demo 中还有更贴近生产环境的例子:
- SentenceCase.yml:
scope: heading+extends: capitalization+match: $sentence,实现「标题采用句首大写(sentence case)」检查,并提供exceptions、indicators、prefix等修饰项; - ScopedHeading.yml:
scope: heading.h2+match: $title,仅要求二级标题采用标题式大写; - HeadingStartsWithCapital.yml:
scope: heading.h1,配合raw正则'^(?:#+\s)?[a-z].+',检查一级标题是否以小写开头(如# intro→ 告警'intro' should be capitalized)。
这三条规则说明:heading 作用域是标题大小写策略类规则的天然载体,因为标题的文本内容已被完整、独立地暴露给规则引擎。
作用域的高级组合:doc(...)选择器与标题结构树
标题如何构建「文档结构树」
doc(...)选择器是 Vale 中把标题提升到「文档结构」层面的机制。其实现位于 internal/lint/selection.go,核心是wrapSections(internal/lint/selection.go):
- 遍历 HTML 节点树,遇到
h1–h6时新建<section>元素包裹该标题及其后的同级兄弟节点; - 小节按层级嵌套:
h3出现在h2之后时,会落入h2的 section 内部; - 包裹只在同级节点间进行,因此列表项或块引用内部的标题只会切分其所在容器。
headingLevel(internal/lint/selection.go)正是从h后第一位数字解析出 1–6 的层级。之后,每个被选择器命中的元素都会被打上data-vale-doc标记(internal/lint/selection.go),该标记最终转化为块作用域上的in.<id>段。
在标题上使用 doc(...) 的实战形态
从 e2e 用例 scopes.yaml 可以整理出针对标题的典型选择器:
| 选择器 | 语义 |
|---|---|
doc(h2) | 以 h2 标题的文本作为唯一块(该块不存在时为 line 1 的空块) |
text & doc(h2, h3) | 只检查 h2/h3 标题内的文本 |
doc(section > :is(h2, h3)) | 检查 section 的直接子标题 |
doc(section:has(> h2)) | 以「含 h2 的 section」为块(可用于存在性/计数) |
doc(section:has(> h2 + p)) | 定位「紧跟在 h2 之后的段落」 |
其中doc(h2)的「叶子选择器」行为由doc-leaf用例(scopes.yaml)固定:对标题这类无子元素的叶子节点,选择器直接取其全部文本作为一个块来 lint。此外,命名作用域(config/scopes)可以把doc(section:has(> h2:contains("Methods")))这样的长表达式压缩为一个名字,供多条规则复用(见named用例,scopes.yaml)。
排除语义:~heading
作用域前的~表示排除。例如scope: ~blockquote & ~heading会命中除 blockquote 与 heading 外的所有文本(见 TestScopeMatches 与 TestScopeExcluded)。这在「标题与正文采用不同大小写规范」的场景下尤其有用:正文用小写风格,标题保留标题式大写,就可以用~heading把标题排除在外。
e2e 断言:heading 行为如何被验证
测试套件的组织方式
testdata/e2e/scopes.yaml 是 scope 行为的端到端测试定义。文件头部声明:
name: scopes dir: fixtures/scopes/*即:每个fixtures/scopes/下的子目录构成一个独立测试目录。其中heading用例(scopes.yaml)这样运行:
- name: heading args: . requires: asciidoctor, dita, rst2html, xsltproc exit: 1 want: | test.adoc:1:20:rules.Heading:'XXX' left in text ... test.rst:2:9:rules.Heading:'XXX' left in text test.rst:5:19:rules.Heading:'TODO' left in text ...其requires字段列出外部依赖:rst2html(Docutils 提供的 reST→HTML 转换器)、asciidoctor、DITA 工具与xsltproc。这直接印证了reST 的读取依赖rst2html——Vale 通过把 reST 文档交给 Docutils 渲染成 HTML,再对渲染结果做块解析。
reST 渲染管线的底层细节
reST 的处理实现在 internal/lint/rst.go,几个关键点:
- 固定参数(internal/lint/rst.go):每次渲染都带上
--no-section-numbering(保证标题不带自动编号,以免干扰检测)、--no-toc-backlinks、--no-footnote-backlinks、--no-file-insertion、--quiet、--halt=5等; - 标题去重处理(internal/lint/rst.go):reST 的
.. contents::指令会重复文档标题,因此用reSphinx正则把这类指令按代码块处理; - Sphinx 兼容:
[sphinx]配置段可注入额外的指令/角色(internal/lint/rst.go),Docutils 不认识的指令体按散文保留,只有少数命名指令例外; - 性能优化:每份文档单独
rst2html的进程启动开销大,Vale 用常驻 Python 进程池(newProcPool)批量转换(internal/lint/rst.go),并设置PYTHONUTF8=1、PYTHONIOENCODING=utf-8避免 Windows 下控制台代码页导致的乱码(internal/lint/rst.go)。
断言结果说明了什么
从heading用例的输出可以看到作用域行为的几个事实:
- 层级归属正确:
test.rst的文档标题(This is XXX heading,第 2 行)与Section 1.1 Title TODO(第 5 行)分别被rules.Heading命中,行号与XXX/TODO的实际位置一致; - 跨格式一致:同一批规则在 adoc/dita/html/md/rst/xml 六种格式上全部生效;
- 格式差异被抹平:DocBook XML 经 XSL 变换为 HTML 后同样进入
heading作用域,说明XML 格式的标题识别依赖Transform配置(见 heading/_vale 的[*.xml]段)。
从「测试夹具」到「生产规则」:可复用的操作清单
结合上述源码与配置,如果你要在自己的项目中为标题编写风格规则,推荐按以下步骤落地:
- 确定目标层级:先用
scope: heading命中所有标题,或用scope: heading.h1(直到.h6)精确到某一级;需要多级时用列表scope: [heading.h1, heading.h2]; - 选择规则扩展类型:
- 遗留标记/禁用词 →
extends: existence+tokens(参考 Heading.yml); - 标题大小写 →
extends: capitalization+match: $title/$sentence(参考 ScopedHeading.yml 与 SentenceCase.yml); - 标题长度 →
extends: occurrence+min/max(参考 MinH2.yml);
- 遗留标记/禁用词 →
- 处理正文与标题的差异:标题用标题式大写、正文用句首大写时,给正文规则加
scope: ~heading; - 按文档结构定向检查:需要「Methods 小节必须出现且必须提及某词」时,用
doc(section:has(> h2:contains("Methods")))+extends: occurrence+min: 1; - 分格式配置:reST 文件直接受 heading 作用域管理,无需额外变换;XML/DITA 类格式则需要在
[*.xml]段配置Transform(参考_vale中的 XSL 路径写法)。
需要特别说明的是运行前提:对 reST 文件执行 lint 时,系统 PATH 中必须有rst2html,否则会报错rst2html not found; it's needed to read reStructuredText(对应源码 internal/lint/rst.go)。这与scopes.yaml中heading用例声明requires: rst2html的事实完全一致。
小结
test.rst虽只占 9 行,却是理解 Vale 标题作用域体系的「最小标本」:它以 reST 原生标题语法驱动完整的渲染管线,产出与 Markdown、AsciiDoc、HTML、DITA、DocBook 完全统一的text.heading.<level>作用域;与之配套的 heading/_vale 配置、rules 规则集和 scopes.yaml 的 e2e 断言共同构成了一条可复现的验证链路。无论你是想为标题写一条大小写规则,还是计划把doc(...)选择器用于结构化文档检查,这条链路都可以作为起点。
- 开发工具
- 代码质量
- CLI
【免费下载链接】vale
:pencil: A markup-aware linter for prose built with speed and extensibility in mind.
相关推荐
vale 的 capitalization 测试夹具详解:Capitalization/test.md 如何验证标题大小写、句首大写与阈值行为
vale 的 capitalization 测试夹具详解:Capitalization/test.md 如何验证标题大小写、句首大写与阈值行为 本篇技术文章围绕
开发工具代码质量CLImdBook 如何处理跳级标题层级:unusual-heading-levels 测试场景与侧边栏标题导航源码剖析
mdBook 如何处理跳级标题层级:unusual heading levels 测试场景与侧边栏标题导航源码剖析 在 Markdown 写作中,标题层级跳级(
开发工具文档Vale 标题作用域(heading scope)实战指南:从 Markdown 标题到逐级规则匹配的完整解析
Vale 标题作用域(heading scope)实战指南:从 Markdown 标题到逐级规则匹配的完整解析 本文以 Vale 仓库中 scopes/head
开发工具代码质量CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考