When I released gomarklint, most people asked the same two questions:
“How does it run so fast?” and “Can I add my own lint rules?”
In this post, we’ll dive into the internals of gomarklint — how it parses Markdown efficiently, how rules are defined and executed, and how you can extend it to fit your own documentation style guide.
If you’re a Go developer interested in building your own linter or contributing to gomarklint, this article is your roadmap inside the engine.
shinagawa-web
/
gomarklint
A fast and configurable Markdown linter written in Go
gomarklint
A fast, opinionated Markdown linter for engineering teams. Built in Go, designed for CI.
- Catch broken links and headings before your docs ship.
- Enforce predictable structure (no more “why is this H4 under H2?”).
- Output that’s friendly for both humans and machines (JSON).
Why
Docs break quietly and trust erodes loudly gomarklint focuses on reproducible rules that prevent “small but costly” failures:
- Heading hierarchies that drift during edits
- Duplicate headings that break anchor links
- Subtle dead links (including internal anchors)
- Large repos where “one-off checks” don’t scale
Goal: treat documentation quality like code quality—fast feedback locally, strict in CI, zero drama.
✨ Features
- Recursive .md search (multi-file & multi-directory)
- Frontmatter-aware parsing (YAML/TOML ignored when needed)
- File name & line number in diagnostics
- Human-readable and JSON outputs
- Fast single-binary CLI (Go), ideal for CI/CD
- Rules with clear rationales (see below)
Planned/ongoing:
- Severity levels per rule
- Customizable rule enable/disable
- VS Code…
Introduction: Why Architecture Matters
When I released gomarklint, most people asked me the same two questions:
“How does it run so fast?”
“Can I add my own lint rules?”
Speed and simplicity don’t happen by accident — they come from design.
gomarklint was built around three principles:
- Performance first: it should scan tens of thousands of lines in milliseconds.
- Simplicity: configuration and execution must stay minimal.
- Extensibility: rules should be easy to read, add, and maintain.
In this post, I’ll open the hood of gomarklint — showing how it parses Markdown efficiently, how rules are executed, and how you can extend it to match your documentation style guide.
Parsing Markdown Efficiently
At the heart of gomarklint is an extremely lightweight file parser.
It doesn’t try to build a full Markdown AST. Instead, it performs structured text scanning that’s optimized for speed and memory locality.
The typical flow looks like this:
- File Discovery: gomarklint walks through your repository recursively, ignoring hidden directories (.git, .vscode) and symbolic links.
- Line Reader: files are streamed line-by-line instead of loaded entirely into memory.
- Frontmatter Skipping: YAML frontmatters (--- ... ---) are automatically ignored to prevent false positives.
- Rule Evaluation: each active rule receives a slice of lines and emits lint errors.
This minimal I/O footprint lets gomarklint handle 50,000+ lines in under 50ms — even on modest CI runners.
The Rule Engine: Anatomy of a Lint Rule
Rules in gomarklint are intentionally simple.
Each rule is just a Go function that accepts file content and returns a list of LintErrors:
type LintError struct {
File string
Line int
Message string
}
type RuleFunc func(filePath string, lines []string) []LintError
A small example — detecting duplicate headings:
func CheckDuplicateHeadings(filePath string, lines []string) []LintError {
seen := make(map[string]int)
var errs []LintError
for i, line := range lines {
if strings.HasPrefix(line, "#") {
if _, ok := seen[line]; ok {
errs = append(errs, LintError{
File: filePath,
Line: i + 1,
Message: fmt.Sprintf("duplicate heading: %s", line),
})
}
seen[line] = i
}
}
return errs
}
This pattern repeats across all built-in rules — headings, code blocks, blank lines, and more.
gomarklint aggregates rule results concurrently, merges them, sorts by file/line, and prints them in a deterministic order.
Adding Your Own Rules
Creating a custom rule is straightforward.
- Create a new file under
internal/rule/ - Implement your rule function — for example, forbid “TODO” comments:
func NoTODO(filePath string, lines []string) []LintError {
var errs []LintError
for i, line := range lines {
if strings.Contains(line, "TODO") {
errs = append(errs, LintError{
File: filePath,
Line: i + 1,
Message: "found TODO comment",
})
}
}
return errs
}
- Register it in the rule set (usually in
rule/register.go):
var Rules = []RuleFunc{
CheckDuplicateHeadings,
NoTODO,
// ...other rules
}
- Rebuild and test:
# Run all tests
go test ./...
# Show CLI help from local source
go run . --help
# Generate a default .gomarklint.json (from your local build)
go run . init
# Lint the included sample files in ./testdata
go run . testdata
You can immediately see your new rule in action.
If it’s useful for others, open a Pull Request — gomarklint’s rule library welcomes contributions.
Configuration and Extensibility
All configuration lives in .gomarklint.json, allowing teams to share consistent rules:
{
"ignore": ["CHANGELOG.md"],
"rules": {
"CheckDuplicateHeadings": true,
"NoTODO": true,
"ExternalLinks": false
}
}
Each rule can be toggled on/off, and some accept parameters (like link checking patterns or minimum heading depth).
Future versions of gomarklint will likely support pluggable external rules, where Go plugins or YAML-based configuration can load custom logic at runtime — without forking the repo.
Lessons from Designing for Extensibility
Building gomarklint taught me that simplicity scales better than abstraction.
Many linters become slower or harder to extend as they grow, because every rule depends on a shared complex framework.
gomarklint took the opposite path — keep each rule isolated, independent, and stateless.
That design choice allowed:
- Linear scalability with repository size
- Easier testing and debugging
- Low entry barrier for contributors
Extensibility doesn’t always mean adding layers.
Sometimes, it’s about not adding them.
Conclusion: Build Your Own Linter, or Improve This One
gomarklint isn’t just a tool — it’s a lightweight foundation for building your own Markdown analysis workflows.
Whether you:
- want to enforce internal documentation standards,
- prototype your own linter ideas, or
- contribute a new rule back to the community —
the architecture is open, simple, and waiting for you.
Repository:
shinagawa-web
/
gomarklint
A fast and configurable Markdown linter written in Go
gomarklint
A fast, opinionated Markdown linter for engineering teams. Built in Go, designed for CI.
- Catch broken links and headings before your docs ship.
- Enforce predictable structure (no more “why is this H4 under H2?”).
- Output that’s friendly for both humans and machines (JSON).
Why
Docs break quietly and trust erodes loudly gomarklint focuses on reproducible rules that prevent “small but costly” failures:
- Heading hierarchies that drift during edits
- Duplicate headings that break anchor links
- Subtle dead links (including internal anchors)
- Large repos where “one-off checks” don’t scale
Goal: treat documentation quality like code quality—fast feedback locally, strict in CI, zero drama.
✨ Features
- Recursive .md search (multi-file & multi-directory)
- Frontmatter-aware parsing (YAML/TOML ignored when needed)
- File name & line number in diagnostics
- Human-readable and JSON outputs
- Fast single-binary CLI (Go), ideal for CI/CD
- Rules with clear rationales (see below)
Planned/ongoing:
- Severity levels per rule
- Customizable rule enable/disable
- VS Code…
Top comments (0)