CLI tool to verify markdown-style local links such as [text](./path/to/file.md).
If a scanned file contains markdown-style links in docs or markdown-style comments, those paths must resolve to real files or link_verifier exits with an error.
- Resolves relative links from the source file's directory, not the working directory
- Handles balanced parentheses in filenames, e.g.
[sheet](Onboarding Sheet (1).xlsx) - Decodes percent-encoded URLs, e.g.
Scope%20Doc.mdresolves toScope Doc.md, andPlan%20%E2%80%93%20Draft.mdtoPlan – Draft.md. A link whose filename literally contains%is also checked undecoded - Recognises directory links like
[assessment](assessment/) - Skips any link carrying a URI scheme, so
http://,https://,mailto:,tel:and application schemes such asobsidian://orshortcutapp://are left alone, along with bare anchors (#heading) and query-only links (?tab=...) - Treats a colon inside a filename as part of the path, so
Meeting: notes.mdandC:/Users/a.mdare still checked - Strips
#fragmentsuffixes before checking file paths - Exclude files or directories with
--except/-xregex patterns (repeatable) - Skip links inside fenced code blocks and inline code with
--no-code-links - Require reciprocal links with
--assert-backlinks, so traversal works in both directions
Download the latest release (native binary; no runtime dependency):
# Example: macOS arm64
gh release download --repo C-Sinclair/link_verifier --pattern 'link_verifier-macos-arm64' --output ~/.local/bin/link_verifier --clobber
chmod +x ~/.local/bin/link_verifierPick the asset matching your platform:
link_verifier-macos-arm64link_verifier-linux-x86_64
Homebrew support is planned after 1.0; for now, GitHub release assets are the fastest path.
make build./link_verifier <target> [target ...]Targets can be:
- A file path (e.g.
README.md) - A directory (recursively scans files under it)
- A wildcard pattern (e.g.
docs/*.md) - Multiple targets in one run
./link_verifier --help
./link_verifier --versionUse --no-code-links to ignore links inside fenced code blocks and inline code:
./link_verifier docs/ --no-code-linksUse --assert-backlinks to require that a link is reciprocated. If a.md links to b.md, then b.md must link back to a.md:
./link_verifier docs/ --assert-backlinksA missing backlink is reported as a.md:3: no backlink from b.md and exits 2, the same as a broken link. The flag is off by default, because one-way links are normal in most documentation.
Only edges where both files are in the scanned set are judged. If docs/a.md links to ../README.md and you scan only docs/, the tool has never parsed README.md and says nothing about it. Widen the target to include both ends.
A #fragment is stripped before comparison, and ./.. segments are collapsed, so ../a.md from docs/sub/b.md counts as a backlink to docs/a.md.
Use -x / --except to skip files matching a regex pattern. The flag can be repeated:
./link_verifier docs/ -x vendor/ -x "test/fixtures"0: all links valid1: usage/target/read error2: broken links found, or missing backlinks when--assert-backlinksis set
Run:
./scripts/benchmark.shThe script builds the native binary via dune build and benchmarks the default _build/default/bin/main.exe unless BINARY is set.
Default dataset generated by the script:
- depth:
3 - files per level:
100 - total markdown files:
300 - randomized content with mixed valid/invalid markdown links (
BROKEN_RATE=0.35)
Latest sample run:
single-file-mixed: min=2.59ms avg=3.39ms p95=3.73ms runs=15 exit=2
directory-recursive-mixed: min=8.82ms avg=9.58ms p95=10.32ms runs=15 exit=2
wildcard-pattern-mixed: min=5.25ms avg=5.84ms p95=7.21ms runs=15 exit=2
Environment used for this run (relevant to native binary execution):
OCaml 5.3.0dune 3.21.1macOS 15.7.1(Darwin 24.6.0,arm64)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./link_verifier docs/*.md"
}
]
}
]
}
}#!/bin/sh
./link_verifier README.md docs/*.md
status=$?
if [ "$status" -ne 0 ]; then
echo "link_verifier failed"
exit "$status"
fiWhen a bad link is found, output includes file + line number, for example:
docs/guide.md:42: broken link -> ./missing-file.md