DEV Community

frank
frank

Posted on

Build a Markdown Heading Linter on the AST, Not With Regex

A heading linter sounds like a one-line regex:

/^#{1,6}\s+(.+)$/gm
Enter fullscreen mode Exit fullscreen mode

It works until a documentation page contains a fenced example:

# Real heading

```md
## This is code, not navigation
```
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. collect headings for a table of contents;
  2. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Result: two heading nodes, no diagnostic, and two TOC entries.

2. Inline markup and duplicate labels

# Guide

## *API* Reference

## API Reference
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Parsing and HTML rendering succeed, but the plugin reports:

Heading jumps from h1 to h3
Enter fullscreen mode Exit fullscreen mode

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
```
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)