DEV Community

Cover image for I Built a Markdown Linter in Go — Here's How It Stacks Up Against markdownlint and remark-lint
Kazu
Kazu

Posted on

I Built a Markdown Linter in Go — Here's How It Stacks Up Against markdownlint and remark-lint

Documentation breaks quietly. Trust erodes loudly.

If you've worked on an engineering team with shared documentation, you've probably seen this:

  • An H4 heading appearing directly under an H2 with no H3 in between
  • A [see details](#overview) link silently 404ing because someone renamed the section
  • A fenced code block missing its closing backticks — making everything below it render as code

Unlike code bugs, documentation quality degrades without throwing errors. By the time someone notices, someone else is already confused.

That frustration led me to build gomarklint — a fast, opinionated Markdown linter written in Go, designed for CI.


Why not just use an existing tool?

That was my first question too.

The existing tools are excellent, but they each have tradeoffs:

  • markdownlint (JavaScript): Requires Node.js. Adding it to a Go project's CI means pulling in a JS runtime just for linting.
  • remark-lint (JavaScript): Extremely powerful and pluggable, but configuration complexity can be a barrier.
  • mdformat (Python): A formatter, not a linter — it silently rewrites files rather than reporting violations.

I wanted something I could drop into any CI pipeline with a single binary, no runtime dependencies. In Go, that's natural.


I did a deep dive into the Markdown linter ecosystem

Before planning gomarklint's next phase, I did a structured comparison of the major tools. Here's what the landscape looks like:

Tool Language Stars Rules Nature
markdownlint (DavidAnson) JavaScript ~5,900 60 built-in Structural linter
remark-lint JavaScript ~1,000 ~80 packages Structural linter (AST-based)
markdownlint (Ruby) Ruby ~2,009 ~50 Structural linter
mdformat Python ~726 N/A Auto-formatter
gomarklint Go 8 Structural linter

I'll be honest: gomarklint currently has 8 rules. Compared to markdownlint's 60, that's a significant gap. But rule count isn't the whole story.


What makes gomarklint different

Digging into how these tools work revealed some genuine differentiators.

1. Live HTTP link validation

gomarklint . --enable-link-check
Enter fullscreen mode Exit fullscreen mode

Neither markdownlint nor remark-lint validates whether external URLs actually resolve. gomarklint makes real HTTP requests and reports unreachable links — with concurrent validation that can check ~2,000 links in under 10 seconds.

This turns out to be a meaningful differentiator. Dead external links are one of the most common ways documentation silently degrades over time.

2. Unclosed code block detection

func main() {
    // This code block was never closed...
Enter fullscreen mode Exit fullscreen mode

Most linters use tolerant AST parsers that silently repair structural errors. gomarklint uses a text-based approach that catches the raw structural breakage — the kind that makes everything below a fence render as a code block.

3. Single static binary — no runtime required

# Download the binary — no Node.js, no Python, no Go needed
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
Enter fullscreen mode Exit fullscreen mode

No Node.js. No Python. One line and you're ready. This makes CI integration trivially simple, especially for Go projects or polyglot repos where you don't want to manage a JS/Python environment.

4. Frontmatter-aware parsing

gomarklint strips YAML/TOML frontmatter before applying rules, so it works cleanly with Hugo, Jekyll, and other SSG-based documentation setups without false positives.

5. Goroutine-based concurrent processing

185 files, 104,000+ lines — under 60ms
Enter fullscreen mode Exit fullscreen mode

Fast enough to run on every save locally. Practical even at scale in CI.


Where gomarklint stands today

gomarklint's current 8 rules map to well-known equivalents in the ecosystem:

gomarklint Rule markdownlint remark-lint
final-blank-line MD047 final-newline
unclosed-code-block — (unique) — (unique)
empty-alt-text MD045
heading-level MD001 + MD041 heading-increment
duplicate-heading MD024 no-duplicate-headings
no-multiple-blank-lines MD012 no-consecutive-blank-lines
external-link — (unique) — (unique)
no-setext-headings MD003 heading-style

The two rules with no equivalent — unclosed-code-block and external-link — are where gomarklint offers something the major tools don't.


What's coming next

The gap analysis made the roadmap clear. Here's what I'm planning to add, starting with the highest-impact rules:

Priority 1 — Simple to implement, high real-world value:

Rule Description
fenced-code-language Fenced code blocks must specify a language
single-h1 Only one H1 heading per document
blanks-around-headings Headings must be surrounded by blank lines
no-bare-urls URLs must use proper Markdown link syntax
no-trailing-spaces No trailing whitespace at end of lines
no-emphasis-as-heading Bold/italic text must not substitute for headings
no-empty-links Links must not have an empty destination
blanks-around-lists Lists must be surrounded by blank lines

Each rule will be designed with the same pattern: a toggle flag, a config field, unit tests, a benchmark, and documentation. No shortcuts.

The full gap analysis and planned rule set is tracked in Issue #76.


Try it

go install github.com/shinagawa-web/gomarklint@latest
gomarklint init
gomarklint .
Enter fullscreen mode Exit fullscreen mode

Full documentation: https://shinagawa-web.github.io/gomarklint/

If you try it and hit an edge case, open an issue. If you want to contribute a rule, PRs are very welcome. The codebase is small and the patterns are consistent — a new rule is typically 50–100 lines of Go plus tests.


Docs break quietly. Let's make them break loudly — in CI, before they ship.

Top comments (0)