# Lint Your Markdown with markdownlint


Markdown is flexible, but when multiple people contribute to the same documentation, you can end up with inconsistencies. One author uses asterisks for lists, while another uses dashes. Someone forgets blank lines around headings. Another person indents with tabs instead of spaces. The rendered output might look fine, but the source files become inconsistent and harder to maintain.

[Markdownlint](https://github.com/DavidAnson/markdownlint) checks Markdown files for structural and formatting issues. It catches things like inconsistent heading styles, missing blank lines, trailing whitespace, and improper list indentation.

Markdownlint focuses on structure, not prose. It won't tell you that your sentences are too long or that you're using passive voice. To solve for those cases, use [Vale](https://vale.sh) instead. If you want to dig deeper into Vale, check out [Write Better with Vale](https://thevalebook.com/). Together, markdownlint and Vale give you comprehensive coverage: one handles the structure of your Markdown, the other handles the quality of your writing.

To explore how Markdownlint works, you'll create a Markdown document with some issues, run Markdownlint against the file, configure it to ignore certain rules, and then write a custom rule.

## What You Need

To complete this tutorial, you need:

- Node.js installed, which you can do by following the [Install Node.js]({{< ref "install_nodejs" >}}) tutorial.
- On macOS, you need Homebrew installed, which you can do by following the [Install Homebrew]({{< ref "homebrew" >}}) tutorial.

## Setting Up a Project

To use Markdownlint, you'll create a small project and install Markdownlint's command-line tool as a project dependency using `npm`.

Create a directory for this project and switch to it:

```command
mkdir markdown-lint-demo
```

Switch to the directory you just created:

```command
cd markdown-lint-demo
```

Initialize a new `npm` project in this directory using the defaults:

```command
npm init -y
```

Now install the Markdownlint CLI tool as a project dependency:

```command
npm install markdownlint-cli2
```

The `markdownlint-cli2` package is the modern command-line interface for markdownlint. It lets you use a single `.markdownlint-cli2.jsonc` configuration file that handles rule settings, file ignores, and custom rules all in one place.

Now you need a file with some errors so you can lint it. Create a file called `example.md` with the following content:

```markdown
# My Article
This paragraph has no blank line above it.
##  Installation

First, download the package. This line has a trailing tab.

#### Deep Heading

This heading skipped a level.

## Getting Started
Some content here.
```

This file has several structural problems. The first paragraph has no blank line separating it from the heading. The "Installation" heading has an extra space after the hashes. There's a trailing tab character on one line. The "Deep Heading" section skips from level 2 to level 4. And "Getting Started" has no blank line before it. This file would absolutely render, but it could cause problems and would be inconsistent.

Now run Markdownlint against the sample file:

```command
npx markdownlint-cli2 example.md
```

The output shows each issue found:

```output
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 5 error(s)
example.md:1 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "# My Article"]
example.md:3:4 error MD019/no-multiple-space-atx Multiple spaces after hash on atx style heading [Context: "##  Installation"]
example.md:3 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Above] [Context: "##  Installation"]
example.md:7 error MD001/heading-increment Heading levels should only increment by one level at a time [Expected: h3; Actual: h4]
example.md:11 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Getting Started"]
```

Each line tells you the file, line number, rule ID, rule name, and a description of the problem. The rule IDs like `MD019` and `MD022` are useful when you want to ignore specific rules.

## Configure Markdownlint to Ignore Rules

You might not want to use every rule. Maybe your team allows skipped heading levels for stylistic reasons, or you don't care about trailing whitespace because your editor strips it when you save. You can turn rules off by using a configuration file.

Create a file called `.markdownlint-cli2.jsonc` in your project directory. Add the following code to the file to configure some rules:

```json
{
  "config": {
    "default": true,
    "MD001": false,
    "MD009": false
  }
}
```

This configuration starts with all default rules enabled, then disables `MD001`, which enforces heading level increments, and `MD009`, which flags trailing whitespace.

Run Markdownlint again:

```command
npx markdownlint-cli2 example.md
```

The output now shows fewer errors:

```output
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 4 error(s)
example.md:1 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "# My Article"]
example.md:3:4 error MD019/no-multiple-space-atx Multiple spaces after hash on atx style heading [Context: "##  Installation"]
example.md:3 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Above] [Context: "##  Installation"]
example.md:11 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Getting Started"]
```

The heading increment and trailing whitespace violations are gone.

## Automatically Fix Errors

Markdownlint can fix your files for you.  Run `markdownlint-cli2` with the  `--fix` flag:

```command
npx markdownlint-cli2 --fix example.md
```

The results now show that things are fixed:

```output
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 0 error(s)
```

Markdownlint can't automatically fix everything, but it can fix basic things like extra spaces, indentation issues, or missing blank lines around elements. This can be a huge time-saver overall.

## Writing a Custom Rule

The built-in rules cover common issues, but sometimes you need project-specific checks. For example, suppose your style guide requires every article to end with a "Conclusion" section. You won't find a built-in rule to enforce this, but you can write your own  custom rule.

Create a file called `custom-rules.js` to hold the rule definition. Add the following code to define a rule that finds all level-2 headings, then checks if the last one is `## Conclusion`:

```javascript
module.exports = {
  names: ["require-conclusion"],
  description: "Documents must end with a ## Conclusion heading",
  tags: ["headings"],
  function: function rule(params, onError) {
    const lines = params.lines;

    // Find all h2 headings
    const h2Headings = [];
    lines.forEach((line, index) => {
      if (/^## /.test(line)) {
        h2Headings.push({ line: line, lineNumber: index + 1 });
      }
    });

    // Check if there are any H2 headings
    if (h2Headings.length === 0) {
      onError({
        lineNumber: lines.length,
        detail: "No ## headings found"
      });
      return;
    }

    // Check if the last h2 heading is "## Conclusion"
    const lastH2 = h2Headings[h2Headings.length - 1];
    if (!/^## Conclusion\s*$/.test(lastH2.line)) {
      onError({
        lineNumber: lastH2.lineNumber,
        detail: "Last ## heading must be '## Conclusion'",
        context: lastH2.line
      });
    }
  }
};
```

The rule exports an object with a few properties. The `names` array contains identifiers for the rule. The `description` explains what the rule checks. The `tags` array groups related rules together. The `function` property contains the actual check logic.

The function receives two arguments: `params`, which contains information about the document, and `onError`, a callback function you call when you find a violation. The `params.lines` array contains each line of the document as a string.

When the function is unable to find the "Conclusion" header, it executes the `onError` callback, sending back the line number, a message, and the text of the line. That's what markdownlint displays in its output.

To use the custom rule, pass it to markdownlint with the `--rules` flag:

```command
npx markdownlint-cli2 --rules ./custom-rules.js example.md
```

The output now includes your custom rule violation:

```output
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 1 error(s)
example.md:13 error require-conclusion Documents must end with a ## Conclusion heading [Last ## heading must be '## Conclusion'] [Context: "## Getting Started"]
```

Markdownlint can't automatically fix this issue when you use the `--fix` flag. Add a `## Conclusion` section to the end of your document to fix the issue manually.

You can also load custom rules through the configuration file instead of passing the `--rules` flag every time. Update your `.markdownlint-cli2.jsonc`:

```jsonc
{
  // Load custom rules from a local file
  "customRules": ["./custom-rules.js"],

  // Standard rule configuration
  "config": {
    "default": true,
    "MD001": false,
    "MD009": false
  }
}
```

Run Markdownlint without specifying the rules flag:

```command
npx markdownlint-cli2 example.md
```

Now Markdownlint loads your custom rules along with the other configuration changes you made.

## Ignoring Files

You can run markdownlint against all files in the current directory and child directories, but then you might scan things you don't care about. To ignore specific files, add an `ignores` array to your configuration. For example, add the following to ignore the `node_modules` directory, the `vendor/` folder, and the `CHANGELOG.md` file:

```jsonc
{
  "ignores": ["node_modules/", "vendor/", "CHANGELOG.md"],
  "config": {
    "default": true
  }
}
```

Now run the command against all Markdown files in the current directory and its children:

```command
npx markdownlint-cli2 "**/*.md"
```

This time the output shows the errors as well as the files that markdownlint ignored:

```output
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: **/*.md !node_modules/ !vendor/ !CHANGELOG.md
Linting: 1 file(s)
Summary: 1 error(s)
example.md:13 error require-conclusion Documents must end with a ## Conclusion heading [Last ## heading must be '## Conclusion'] [Context: "## Getting Started"]
```

With this, you can run the tool against all of your project's documentation without having to review things you don't need to be responsible for.

## Conclusion

Now that you have Markdownlint working, tie it into your workflow. The [VS Code extension](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) highlights issues as you write, so you get real-time feedback. You can also add markdownlint to your CI pipeline to catch issues before you publish. And you can combine markdownlint with Vale to cover both structure and prose as part of your publication flow.

Consistent documentation is easier to read, easier to maintain, and easier to convert to other formats. Set up the rules once, and let the tools do the enforcement for you.
