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 and lightweight Markdown linter written in Go.
gomarklint checks your Markdown files for common issues such as heading structure problems, trailing blank lines, unclosed code blocks, and more. Designed to be minimal, fast, and CI-friendly.
✨ Features
- ✅ Lint individual
.md
files or entire directories - ✅ Checks for heading level consistency (
# → ## → ###
) - ✅ Detects duplicate headings (case-insensitive, trims trailing spaces)
- ✅ Detects missing trailing blank lines
- ✅ Detects unclosed code blocks
- ✅ Ignores YAML frontmatter correctly when linting
- ✅ Detects broken external links (e.g.
[text](https://...)
,https://...
) with--enable-link-check
- ✅ Supports config file (
.gomarklint.json
) to store default options - ✅ Supports ignore patterns (e.g.
**/CHANGELOG.md
) via config file - ✅ Supports structured JSON output via
--output=json
- ⚡️ Blazing fast — 157 files and 52,000+ lines scanned in under 50ms
- 🐢 External link checking is slower (e.g. ~160s for 157…
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 LintError
s:
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 and lightweight Markdown linter written in Go.
gomarklint checks your Markdown files for common issues such as heading structure problems, trailing blank lines, unclosed code blocks, and more. Designed to be minimal, fast, and CI-friendly.
✨ Features
- ✅ Lint individual
.md
files or entire directories - ✅ Checks for heading level consistency (
# → ## → ###
) - ✅ Detects duplicate headings (case-insensitive, trims trailing spaces)
- ✅ Detects missing trailing blank lines
- ✅ Detects unclosed code blocks
- ✅ Ignores YAML frontmatter correctly when linting
- ✅ Detects broken external links (e.g.
[text](https://...)
,https://...
) with--enable-link-check
- ✅ Supports config file (
.gomarklint.json
) to store default options - ✅ Supports ignore patterns (e.g.
**/CHANGELOG.md
) via config file - ✅ Supports structured JSON output via
--output=json
- ⚡️ Blazing fast — 157 files and 52,000+ lines scanned in under 50ms
- 🐢 External link checking is slower (e.g. ~160s for 157…
Top comments (0)