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
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...
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/
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
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 .
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)