Why lint Markdown?

A Python file with a typo does not run. A Markdown file with a typo renders anyway: CommonMark has no syntax errors, only different results. A missing blank line, a heading without a space after the # or a table without its delimiter row produces something, just not what you meant, and nobody notices until a reader does.

A linter reads files without rendering them for people and reports patterns that are probably wrong or inconsistent. For Markdown that catches three kinds of problem:

  • Mistakes that change the result: a skipped heading level, a list that is not separated from the paragraph above, a link to an anchor that does not exist.
  • Accessibility gaps: images without alt text, link texts like "click here".
  • Inconsistency: three bullet characters in one file, two heading styles. Harmless for the reader, but it makes diffs noisy and reviews slower.

Style choices that matter

A style guide is a written set of agreements on how a team writes: which Markdown syntax to use when there are several options, and how to word things (tone, capitalization of headings, product names, terms to avoid). Large organizations publish theirs, such as the Microsoft Writing Style Guide and Google's developer documentation style guide; a team can adopt one and list its own exceptions.

Markdown often offers two ways to write the same thing. The renderer does not care, which is exactly why a team should choose once:

*Planned* maintenance, _not_ an outage.

* Portal
+ Billing
- Outage map
<p><em>Planned</em> maintenance, <em>not</em> an outage.</p>
<ul>
<li>Portal</li>
</ul>
<ul>
<li>Billing</li>
</ul>
<ul>
<li>Outage map</li>
</ul>

Both emphasis styles give <em>. The three bullet characters, however, produce three separate lists: changing the bullet character starts a new list. On screen they look almost the same; to a screen reader they are three lists of one item. The common choices:

Choice Options Common choice
Headings # (ATX) or underlined (setext) #: works for all six levels
Bullets -, * or + -
Emphasis and strong *x* / _x_, **x** / __x__ Any, as long as it is one
Code blocks Fenced (``` or ~~~) or indented Fenced with backticks, always with a language
Numbered lists 1. 2. 3. or 1. 1. 1. Either; see below
Line length Wrap at 80 characters, one sentence per line, or one paragraph per line One sentence per line gives the cleanest diffs

Numbered lists are a real trade-off. The renderer numbers the items itself, starting from the first number, so both sources below give the same HTML:

1. Drain the node
2. Apply the update
3. Return the node to the pool
<ol>
<li>Drain the node</li>
<li>Apply the update</li>
<li>Return the node to the pool</li>
</ol>
1. Drain the node
1. Apply the update
1. Return the node to the pool
<ol>
<li>Drain the node</li>
<li>Apply the update</li>
<li>Return the node to the pool</li>
</ol>

1. 1. 1. means inserting a step changes one line in the diff instead of renumbering everything below it. 1. 2. 3. is easier to read in the source, which matters when someone reads the runbook as plain text in a terminal. Pick one; markdownlint can enforce either (rule MD029).

markdownlint

markdownlint is an open source Markdown linter by David Anson, written in JavaScript. Most people use it in one of two ways: the command-line tool markdownlint-cli2 (version 0.23 as of October 2026), or the markdownlint extension for VS Code, which underlines problems while you type. Both use the same rules and the same configuration files.

Each check is a lint rule: one specific thing the linter tests, with a number, a readable name and usually some settings. markdownlint has 53 rules, numbered MD001 to MD060 (a few numbers were retired). Some of the most useful:

Rule Name Catches
MD001 heading-increment A ### directly under a #
MD013 line-length Lines longer than 80 characters, including headings, code blocks and tables by default
MD022, MD032 blanks-around-headings, blanks-around-lists Headings and lists without blank lines around them
MD033 no-inline-html Raw HTML in the Markdown
MD034 no-bare-urls An address that is not written as a link
MD036 no-emphasis-as-heading A bold line used instead of a heading
MD040 fenced-code-language A code fence without a language
MD041 first-line-heading A file that does not start with a top-level heading
MD045 no-alt-text An image without alt text
MD047 single-trailing-newline A file that does not end with a newline character
MD051 link-fragments A link to #anchor that matches no heading
MD059 descriptive-link-text Link texts such as "click here", "here", "link" and "more"

Several of these rules exist because the problem is invisible in the source but real in the result. A bold line looks like a heading on screen, but it is just a paragraph:

**Rollback**

Restore the previous release.

## Rollback

Restore the previous release.
<p><strong>Rollback</strong></p>
<p>Restore the previous release.</p>
<h2>Rollback</h2>
<p>Restore the previous release.</p>

Only the second version appears in an outline, gets an anchor and is announced as a heading by a screen reader. Likewise, a code fence without a language gives the renderer nothing to highlight with:

```bash
systemctl status portal-login
```

```
systemctl status portal-login
```
<pre><code class="language-bash">systemctl status portal-login
</code></pre>
<pre><code>systemctl status portal-login
</code></pre>

And in plain CommonMark a bare address is not a link, and an image without alt text is silent to anyone who cannot see it:

Status page: https://status.example.com

![](outage-map.png)
<p>Status page: https://status.example.com</p>
<p><img src="outage-map.png" alt=""></p>

Running and configuring it

On the command line, quote the patterns so the tool, not the shell, expands them; #node_modules excludes that folder:

npx markdownlint-cli2 "**/*.md" "#node_modules"

Each problem is reported as file, line (and column), severity, rule and explanation, and the exit code is 1 when there are errors, so a pipeline can fail on it:

README.md:1 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "## Run it locally"]
docs/runbooks/portal-503.md:12 error MD001/heading-increment Heading levels should only increment by one level at a time [Expected: h2; Actual: h3]
docs/runbooks/portal-503.md:20 error MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"]
docs/architecture.md:31 error MD045/no-alt-text Images should have alternate text (alt text)

Many rules are fixable: markdownlint-cli2 --fix repairs what it safely can (blank lines, trailing spaces, list markers) and reports the rest. Settings go in a .markdownlint.yaml (or .jsonc) file in the repository root. Rules are on by default; the file switches rules off or changes their settings:

# .markdownlint.yaml
default: true

# One sentence per line: allow long lines, but not in headings
MD013:
  line_length: 400
  code_blocks: false
  tables: false

# The team writes 1. 1. 1. in numbered lists
MD029:
  style: one

# Allow <kbd> and <details>, nothing else
MD033:
  allowed_elements: [kbd, details, summary]

For a single exception, a comment in the Markdown itself is clearer than a global setting, because the reason sits next to it:

<!-- markdownlint-disable-next-line MD033 -->
<img src="network-map.svg" alt="NOC network map" width="600">

Formatting with Prettier

A code formatter does not report problems, it rewrites the file into one style. Prettier, best known for JavaScript, also formats Markdown (including GitHub Flavored Markdown): it picks one bullet and emphasis marker, normalizes blank lines and indentation, and pads table columns so the pipes line up. Reviews then never have to discuss formatting again.

# Report files that are not formatted (for CI)
npx prettier --check "**/*.md"

# Rewrite them
npx prettier --write "**/*.md"

One option deserves attention: proseWrap. Its default, "preserve", leaves your line breaks in paragraphs alone. Prettier's documentation explains why: some places, such as GitHub comments, render every newline as a line break, so rewrapping would change the result. "always" wraps paragraphs at the print width and "never" puts each paragraph on one line.

A link checker is a tool that follows the links in documents and reports the ones that are broken: a file that was renamed, a heading anchor that no longer exists, a web page that disappeared. Documentation rots mostly through its links, because the things they point to change without the page knowing.

There are two kinds of link, and they need different treatment:

  • Internal links (relative links to other files and #anchors) can be checked quickly, offline and reliably, so check them on every pull request. markdownlint's MD051 checks anchors within a file; VS Code can check links to files and headings across the workspace when markdown.validate.enabled is on, and dedicated link checkers do the same in CI.
  • External links need network access, are slow, and fail for reasons that have nothing to do with your change: a site that is down for an hour, rate limits, pages that refuse automated requests. Check them on a schedule, for example weekly, and open an issue for what broke, instead of blocking every pull request.

Anchors are the tricky part, because Markdown itself does not create them. The renderer does, and each renderer has its own rules. Plain markdown-it adds no id at all, so this link points nowhere:

See [Restart the login service](#restart-the-login-service).

## Restart the login service

Run the restart from the jump host.
<p>See <a href="#restart-the-login-service">Restart the login service</a>.</p>
<h2>Restart the login service</h2>
<p>Run the restart from the jump host.</p>

On GitHub the same link works, because GitHub generates an anchor from every heading (see Markdown on GitHub and GitLab). That is why a link checker must know which platform the files are written for.

Prose linting

A prose linter checks the words rather than the Markdown syntax: banned or outdated terms, product names spelled wrong, passive voice, sentences that are too long, headings in the wrong capitalization. The best known is Vale (version 3.24 as of October 2026), an open source tool that reads Markdown, AsciiDoc, reStructuredText, HTML and more, and skips code blocks. It ships ready-made styles based on published style guides such as Microsoft's and Google's, and a team can add its own rules.

A Vale configuration names the styles per file type:

StylesPath = styles
MinAlertLevel = suggestion
Packages = Microsoft

[*.md]
BasedOnStyles = Vale, Microsoft, Kestrel

vale sync downloads the packages listed under Packages. The team's own rules live as YAML files in a folder named after the style (styles/Kestrel/). A substitution rule suggests a preferred term:

# styles/Kestrel/Terms.yml
extends: substitution
message: "Use '%s' instead of '%s'."
level: warning
ignorecase: true
swap:
  whitelist: allowlist
  blacklist: blocklist
  log-in: sign in

Introduce prose rules gently. A linter that flags every passive sentence on day one produces hundreds of warnings and resentment; start with the handful of terms that really matter (product names, words that are wrong or offensive) and add more once people trust the tool.

Running the checks in CI

Checks that only run on one person's laptop are suggestions. Run the same commands in a continuous integration pipeline on every pull request, and make the result a required status check once the team agrees. A minimal GitHub Actions workflow, with the tool versions pinned so the rules do not change under you:

# .github/workflows/docs.yml
name: Docs checks
on:
  pull_request:
    paths:
      - "**/*.md"
      - ".markdownlint.yaml"

jobs:
  markdown:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Lint Markdown
        run: npx --yes markdownlint-cli2@0.23.3 "**/*.md" "#node_modules"
      - name: Check formatting
        run: npx --yes prettier@3.9.9 --check "**/*.md"

GitHub's hosted Ubuntu runners come with Node.js, so npx works without a setup step. In a real project you would list both tools in package.json and run npm ci, so the versions live in one place and the editor uses the same ones.

Give people the same feedback earlier, too: the markdownlint and Prettier extensions in the editor show problems while typing, which is far less annoying than a red pipeline after pushing.

Introducing checks to an existing repository

Turning on a linter in a repository with years of documentation typically produces hundreds of findings. A calm way in:

  1. Run the linter and look at which rules fire most. Decide per rule: adopt, configure or switch off. Write the decisions down in the configuration file, with a comment for the less obvious ones.
  2. Run --fix and the formatter once, in a separate pull request that changes nothing else, so reviewers can skim it and git blame can skip it later.
  3. Fix the remaining findings by hand, or for older files, accept them for now and lint only changed files.
  4. Make the check blocking in CI only once the main branch is clean, so nobody's pull request fails on problems they did not cause.