A heading linter sounds like a one-line regex:
/^#{1,6}\s+(.+)$/gm
It works until a documentation page contains a fenced example:
# Real heading
```md
## This is code, not navigation
```
The regex finds two headings. A Markdown parser finds one heading and one code block.
That difference is why I prefer to put document automation behind an AST boundary:
source -> parser -> mdast -> transformer -> hast -> HTML
A small, testable plugin
For this experiment I pinned unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2, rehype-stringify@10.0.1, and unist-util-visit@5.1.0 on Node 25.3.0.
The plugin has two jobs:
- collect headings for a table of contents;
- report a diagnostic when heading depth jumps by more than one level.
function headingAudit() {
return (tree, file) => {
const headings = []
let previousDepth = 0
visit(tree, 'heading', node => {
const title = textOf(node)
if (previousDepth && node.depth > previousDepth + 1) {
file.message(
`Heading jumps from h${previousDepth} to h${node.depth}`,
node
)
}
headings.push({depth: node.depth, title})
previousDepth = node.depth
})
file.data.headings = headings
}
}
The parser decides what a heading is. The plugin only evaluates heading nodes. This keeps syntax decisions separate from project policy.
Four cases worth keeping in CI
1. Normal structure
# Guide
## Install
Result: two heading nodes, no diagnostic, and two TOC entries.
2. Inline markup and duplicate labels
# Guide
## *API* Reference
## API Reference
The first heading does not contain one flat text node. Its children include an emphasis node, so a robust text extractor must walk descendants. Both visible labels normalize to the same slug; the second needs a stable suffix such as api-reference-1.
3. Valid Markdown that violates a project rule
# Guide
### Internals
Parsing and HTML rendering succeed, but the plugin reports:
Heading jumps from h1 to h3
This is a useful distinction. A parser error means the syntax cannot be interpreted under the configured grammar. A linter diagnostic means the syntax is valid but conflicts with a documentation policy.
4. A heading marker inside code
# Real heading
```md
## Not a heading
```
The top-level mdast nodes are heading and code. The TOC contains only Real heading. No special-case regex is needed because the parser has already resolved the context.
Keep plugins on the right side of the bridge
Here is the complete processing order:
const file = await unified()
.use(remarkParse)
.use(headingAudit)
.use(remarkRehype)
.use(addHeadingIds)
.use(rehypeStringify)
.process(markdown)
headingAudit expects mdast and therefore runs before remarkRehype. addHeadingIds writes HTML properties and therefore runs on hast after the bridge. Plugin order is part of the contract, not cosmetic configuration.
When checking the same samples with a Markdown-to-HTML converter, I compare structural output—headings, code blocks, links—not only whether the preview looks plausible. Two pages can look similar while exposing different trees to a TOC generator, sanitizer, or editor.
What an AST does not solve
An AST removes a lot of context guessing, but it does not make a pipeline automatically safe:
- two plugins can mutate the same nodes;
- generating a TOC before another plugin rewrites headings creates stale data;
- slug behavior is not defined by CommonMark and differs across platforms;
- raw HTML still needs an explicit trust and sanitization policy;
- syntax-extension plugins can change what inputs the parser accepts.
The practical rule I use is to document every plugin's input tree, output tree, mutations, diagnostics, and ordering constraints. Then test both the tree shape and the serialized output.
CommonMark 0.31.2 supports the underlying separation: block structure is resolved before inline structure. Unified and remark make that structure available as mdast and provide a plugin pipeline around it. The experiment above only claims the pinned versions and inputs listed here; it is not proof that every Markdown dialect shares the same tree or slug rules.
Where would you draw the boundary between plugin freedom and syntax stability in a long-lived documentation pipeline?
Top comments (0)