Skip to main content

Improving your Markdown workflow with linters in Neovim

Learn how to configure and use various Markdown linting tools within Neovim to improve document quality and consistency.

Introduction

Markdown has become the de facto standard for technical documentation, README files, and even blogs. Its simplicity makes it accessible, but maintaining consistency across documents can be challenging. Just as developers use tools like shellcheck to validate shell scripts, having a "shellcheck for Markdown" can significantly improve document quality.

This article explores various options for checking and linting Markdown files directly within Neovim on Linux systems, particularly Debian-based distributions. We'll focus on tools that integrate well with Neovim's editing workflow, allowing you to catch issues in real-time while editing your Markdown files.

Markdown linting fundamentals

Before diving into specific tools, let's understand what Markdown linting involves. Unlike syntax highlighting, which simply colours your text, linting actively checks your content against established rules and best practices, flagging potential issues such as:

  • Inconsistent heading levels
  • Missing alt text for images
  • Improper list formatting
  • Overly long lines
  • Inconsistent spacing
  • Table formatting issues
  • Broken links

Good Markdown linters not only identify these issues but also provide suggestions for fixing them, helping you maintain consistent, readable, and accessible documentation.

Native Neovim capabilities

Neovim itself offers some basic Markdown validation through its built-in features:

Spell checking

While not strictly a Markdown linter, Neovim's spell checking is essential for document quality:

" Enable spell checking for Markdown files
autocmd FileType markdown setlocal spell spelllang=en_au

Neovim diagnostics

Neovim's built-in diagnostic framework (available in Neovim 0.5+) provides the infrastructure for displaying linting results. Most of the tools we'll discuss leverage this system to show warnings and errors inline.

Dedicated Markdown linters

markdownlint-cli2

One of the most comprehensive Markdown linters is markdownlint-cli2, which checks files against a configurable set of rules. While it's technically npm-based, it's worth mentioning because of its thoroughness, and we'll discuss non-npm alternatives.

For Debian/Linux installation:

# Install with pip (Python-based alternative)
pip install pymarkdownlnt

Integration with Neovim via null-ls/none-ls

To use pymarkdownlnt with Neovim, the null-ls (or its fork none-ls) plugin provides an excellent bridge:

-- Configure pymarkdownlnt with null-ls
local null_ls = require("null-ls")

null_ls.setup({
  sources = {
    null_ls.builtins.diagnostics.markdownlint,
  },
})

Vale - Markdown and prose linter

Vale is a powerful, syntax-aware linter for prose that works exceptionally well with Markdown. It focuses on style issues rather than just syntax, making it perfect for maintaining editorial consistency.

Installation on Debian/Linux

# Download the latest Debian package
wget https://github.com/errata-ai/vale/releases/download/v2.29.0/vale_2.29.0_Linux_64-bit.deb

# Install the package
sudo dpkg -i vale_2.29.0_Linux_64-bit.deb

Configuring Vale

Vale uses a .vale.ini file in your project root:

# Example .vale.ini
StylesPath = styles
MinAlertLevel = suggestion

[*.md]
BasedOnStyles = write-good, proselint

Integrating Vale with Neovim

Use the ALE (Asynchronous Lint Engine) plugin:

" Configure ALE to use Vale for Markdown
let g:ale_linters = {
\   'markdown': ['vale'],
\}

Or with null-ls:

null_ls.setup({
  sources = {
    null_ls.builtins.diagnostics.vale,
  },
})

Textlint - Pluggable linting tool

Textlint is a linting tool that can be configured with various rules for checking both technical and non-technical writing.

Python-based alternative: proselint

Instead of textlint, you can use proselint, a Python-based linter for prose:

# Install proselint
pip install proselint

Integration with null-ls:

null_ls.setup({
  sources = {
    null_ls.builtins.diagnostics.proselint,
  },
})

mdformat - Opinionated Markdown formatter

While not strictly a linter, mdformat is a Python-based Markdown formatter that enforces consistent styling:

# Install mdformat
pip install mdformat
pip install mdformat-gfm  # for GitHub Flavored Markdown

Integration with Neovim

You can use mdformat as a formatter via null-ls:

null_ls.setup({
  sources = {
    null_ls.builtins.formatting.mdformat,
  },
})

LSP-based solutions for Markdown

Neovim's Language Server Protocol (LSP) support opens up additional options for Markdown linting and validation.

marksman - Markdown Language Server

Marksman is a language server for Markdown that provides more than just linting. It offers navigation, completion, and diagnostics specifically for Markdown files.

Installation on Debian/Linux

# Download the latest release
curl -L https://github.com/artempyanykh/marksman/releases/download/2023-01-29/marksman-linux > ~/.local/bin/marksman

# Make it executable
chmod +x ~/.local/bin/marksman

Configuring with Neovim LSP

local lspconfig = require('lspconfig')

lspconfig.marksman.setup({
  -- Optional configuration
  settings = {
    marksman = {
      includePaths = {"docs", "README.md"},
    }
  }
})

markdown-language-server

Another option is the official Markdown language server, which provides diagnostics and formatting:

# Install with pip
pip install pymarkdown-language-server

Configure with Neovim LSP:

lspconfig.markdownlsp.setup{}

Comprehensive solution with nvim-lint

nvim-lint is a Neovim plugin that provides a framework for integrating multiple linters seamlessly:

-- Configure nvim-lint
require('lint').linters_by_ft = {
  markdown = {'vale', 'proselint'}
}

-- Run linters when reading or writing a buffer
vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost" }, {
  callback = function()
    require("lint").try_lint()
  end,
})

Advanced configuration with custom formatters

For more specific needs, you can create custom formatters using Neovim's Lua API:

-- Custom Markdown formatter
vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = "*.md",
  callback = function()
    -- Function to fix common Markdown issues
    local cursor_pos = vim.api.nvim_win_get_cursor(0)
    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)

    -- Example transformation: ensure one blank line before headings
    local new_lines = {}
    local prev_was_blank = true

    for i, line in ipairs(lines) do
      if line:match("^#") and not prev_was_blank then
        table.insert(new_lines, "")
      end
      table.insert(new_lines, line)
      prev_was_blank = (line == "")
    end

    vim.api.nvim_buf_set_lines(0, 0, -1, false, new_lines)
    vim.api.nvim_win_set_cursor(0, cursor_pos)
  end
})

Integrating with file watchers

For a smoother experience, you can use file watchers to automatically run linters whenever files change:

-- Use vim.loop (libuv) for file watching
local function setup_markdown_watcher()
  local path = vim.fn.expand('%:p')
  local handle = vim.loop.new_fs_event()

  vim.loop.fs_event_start(handle, path, {}, function()
    vim.schedule(function()
      require('lint').try_lint()
    end)
  end)

  -- Store handle to avoid garbage collection
  _G.markdown_watcher = handle
end

vim.api.nvim_create_autocmd("BufReadPost", {
  pattern = "*.md",
  callback = setup_markdown_watcher
})

Building a comprehensive Markdown validation setup

For the most thorough Markdown validation, combine multiple tools:

  1. Use LSP for structural validation and navigation
  2. Add Vale for prose quality and style consistency
  3. Include pymarkdownlnt for Markdown-specific syntax rules
  4. Configure mdformat for consistent formatting
  5. Enable spell checking for typo detection

Here's a complete setup that combines all these elements:

-- Complete Markdown setup
local setup_markdown = function()
  -- 1. LSP setup
  require('lspconfig').marksman.setup{}

  -- 2. Linting setup
  require('lint').linters_by_ft = {
    markdown = {'vale', 'markdownlint'}
  }

  -- 3. Formatting setup
  require('null-ls').setup({
    sources = {
      require('null-ls').builtins.formatting.mdformat,
    }
  })

  -- 4. Spell checking
  vim.opt_local.spell = true
  vim.opt_local.spelllang = "en_au"

  -- 5. Auto-run linters and formatters
  vim.api.nvim_create_autocmd("BufWritePre", {
    pattern = "*.md",
    callback = function()
      vim.lsp.buf.format()
    end
  })

  vim.api.nvim_create_autocmd({"BufWritePost", "BufReadPost"}, {
    pattern = "*.md",
    callback = function()
      require('lint').try_lint()
    end
  })
end

-- Apply setup to Markdown files
vim.api.nvim_create_autocmd("FileType", {
  pattern = "markdown",
  callback = setup_markdown
})

Custom style guide implementation

For teams working with a specific Markdown style guide, you can implement custom rules:

  1. Create a Vale style:
# .vale/styles/Company/HeadingPunctuation.yml
extends: existence
message: "Don't use punctuation in headings"
level: warning
scope: heading
tokens:
  - '[:\.\?!]$'
  1. Configure Neovim to use it:
-- Configure Vale with custom styles
require('null-ls').setup({
  sources = {
    require('null-ls').builtins.diagnostics.vale.with({
      extra_args = {"--config", vim.fn.expand("~/.vale.ini")},
    }),
  }
})

Conclusion

While there's no perfect equivalent to "shellcheck for Markdown," combining the tools discussed in this article creates a powerful Markdown linting setup within Neovim. This approach enables you to catch issues early, maintain consistency across documents, and focus on content rather than formatting.

The best approach is often to combine multiple tools: an LSP for structure and navigation, Vale or proselint for prose quality, and custom formatters for team-specific conventions. This layered approach provides comprehensive validation while keeping your editing workflow smooth and efficient.

By implementing these solutions in Neovim, you can significantly improve your Markdown workflow and document quality without leaving your editor or relying heavily on external tools.

Further reading