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:
- Use LSP for structural validation and navigation
- Add Vale for prose quality and style consistency
- Include pymarkdownlnt for Markdown-specific syntax rules
- Configure mdformat for consistent formatting
- 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:
- Create a Vale style:
# .vale/styles/Company/HeadingPunctuation.yml
extends: existence
message: "Don't use punctuation in headings"
level: warning
scope: heading
tokens:
- '[:\.\?!]$'
- 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.