Examples
EN

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

  1. Scans all Markdown files in the content/ directory
  2. Finds external URLs (http/https links) and internal links (relative/absolute paths)
  3. 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)
  4. Verifies internal link targets exist on disk (checks .md, _index.md, index.md)
  5. Accepts routes the build generates rather than reads from disk
  6. 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):

Type Description
External http:// and https:// links — checked via HTTP HEAD
Internal Relative and absolute path links — checked on filesystem
Images ![alt](path) 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
}