check-links
Check for broken external and internal links in your content files.
hwaro tool check-links
# Output result as JSON
hwaro tool check-links --json
# Custom timeout and concurrency
hwaro tool check-links --timeout 30 --concurrency 4
# Check only external or internal links
hwaro tool check-links --external-only
hwaro tool check-links --internal-only
# Silence a known-flaky host, and accept bot-blocking status codes
hwaro tool check-links --ignore-url twitter.com --allow-status 403,429
Options
| Flag | Description |
|---|---|
| -c, --content-dir DIR | Content directory (default: content) |
| --timeout SECONDS | HTTP request timeout in seconds (default: 10) |
| --concurrency N | Max concurrent requests (default: 8) |
| --external-only | Check external links only |
| --internal-only | Check internal links only |
| --ignore-url PATTERN | Skip links whose URL matches PATTERN (repeatable) |
| --allow-status CODES | Treat these HTTP status codes as healthy (comma-separated) |
| -j, --json | Output result as JSON |
| -h, --help | Show help |
--ignore-url matches the URL as written in the source, as a
case-insensitive substring — --ignore-url twitter.com skips every link
containing twitter.com (or Twitter.com), and * matches any run of
characters (--ignore-url 'https://example.com/*'). The flag can be passed
multiple times; matching links are never contacted at all, the scan line
reports how many were ignored, and the JSON payload carries the same number
as ignored_count — so a machine consumer can tell "all healthy" from "an
over-broad pattern checked nothing".
--allow-status is for hosts that answer link checkers with 403/429
while serving browsers fine: a listed status counts as healthy instead of
failing CI.
How It Works
- Scans all Markdown files in the
content/directory - Finds external URLs (http/https links) and internal links (relative/absolute paths)
- Sends concurrent HEAD requests to external URLs (falling back to GET when a host rejects HEAD with 405/403/501, following up to 5 redirects)
- Verifies internal link targets exist on disk (checks
.md,_index.md,index.md) - Accepts routes the build generates rather than reads from disk
- Reports broken or unreachable links
External links that resolve to private or internal addresses (localhost,
RFC 1918 ranges, .local/.internal hosts) are never contacted — they are
reported as skipped instead, both in the human output and under
skipped_external in the JSON payload.
Generated routes
Some URLs have no source file at all — the build writes them. Those are
resolved from config.toml, so check-links can run before the first
build (the order a lint-then-build CI pipeline uses):
/sitemap.xml,/robots.txt,/llms.txt, the search index, and404.html, each honouring its configuredfilename- Feeds (
/rss.xml,/atom.xml), including the per-section and per-language copies (/posts/rss.xml,/ko/rss.xml) - Taxonomy listing and term pages (
/tags/,/categories/rust/) - Paginated listings (
/posts/page/2/) — only for a section that actually declarespaginate_by, so a/page/N/link under a non-paginated section is still reported
Link Types
| Type | Description |
|---|---|
| External | http:// and https:// links — checked via HTTP HEAD |
| Internal | Relative and absolute path links — checked on filesystem |
| Images |  image references — checked on filesystem |
Example Output
hwaro: check-links content
scan: 30 external, 20 internal
[err] content/blog/post.md
-> https://old-site.com/page 404
[err] content/blog/post.md
-> ../missing-page Internal link target not found
[err] content/about.md
-> /images/photo.png Image not found
checked: 50 links, 3 dead
In a color terminal each dead link renders as a ✗ file item with a → url status detail line under an hwaro check-links heading, closed by a ✦ checked
outcome (checked: 50 links · all healthy when everything resolves). The
command exits non-zero when dead links are found, so it can gate CI.
JSON Output
{
"dead_internal": [
{
"link": {
"file": "content/about.md",
"url": "/images/photo.png",
"kind": "image"
},
"status": -1,
"error": "Image not found"
}
],
"dead_external": [
{
"link": {
"file": "content/blog/post.md",
"url": "https://old-site.com/page",
"kind": "external"
},
"status": 404,
"error": null
}
],
"skipped_external": [
{
"link": {
"file": "content/notes/intranet.md",
"url": "http://wiki.internal/page",
"kind": "external"
},
"status": -1,
"error": "Skipped: private/internal address"
}
],
"ignored_count": 0
}