Verify web links + SEO

Crawl a site from its sitemap, catch broken links, validate anchors, and diff OG / meta tags against fixtures.

Goal: keep a site honest. Crawl every page in the sitemap, follow internal links, catch broken URLs / dead anchors / stale redirects, and diff Open Graph and meta tags against fixtures. The site-health counterpart to test-fixture β€” same .crust.ts shape, same predicate-aware diff. Built on Bun’s native HTMLRewriter, no extra dependencies.

Run it

# Direct sitemap URL
verify-web-links https://example.com/sitemap.xml

# Auto-discover: probes /robots.txt for `Sitemap:` lines, falls back to /sitemap.xml
verify-web-links https://example.com

# Full SEO sweep: crawl + diff OG / meta against fixtures
verify-web-links https://example.com --fixtures site/*.meta.crust.ts

# Local sitemap (e.g. from `astro build` output). The URLs inside it are
# absolute, so no base URL is needed β€” and passing one is an error: the
# sitemap and base-url forms are mutually exclusive.
verify-web-links ./dist/sitemap-0.xml

# Sitemap-only, no recursion into internal links
verify-web-links ./public/sitemap.xml --no-recurse

What gets checked

All four behaviors are on by default. Each has a --no-* opt-out.

CheckWhat it doesDisable with
HTTP statusEvery URL must return 2xx (or 3xx if redirects are allowed).β€” (always on)
Recurse into internal pagesWalks <a href> / <img src> / <link href> / <script src> / <iframe src> on same-origin pages up to --max-depth (default 5).--no-recurse
Anchor fragmentsFor every path#id link, the destination page must contain an element with id="id". Validated against ids harvested during crawl β€” no second fetch.--no-anchors
Redirect chains301/302 are followed, but the chain is reported as a failure β€” a strong signal of stale internal links.--no-redirect-warnings

External (off-origin) links are skipped by default. Pass --include-external to status-check them (never recursed).

Some URLs redirect because the site is working β€” WooCommerce bounces an empty-cart /checkout/ to /cart/, /wp-admin/ bounces to the login page. Exclude those subtrees instead of silencing all redirect warnings:

crust -c "verify-web-links https://shop.example.com \
  --exclude /checkout/ --exclude /wp-admin/"

OG images get an extra check: every <meta property="og:image" content="..."> URL is fetched and the response content-type must start with image/.

Flags

FlagDefaultNotes
--site-map-url <url-or-path>β€”URL or local file. Mutually exclusive with --base-url.
--base-url <url>β€”Auto-discovers the sitemap from /robots.txt then /sitemap.xml.
--fixtures <glob>β€”.crust.ts files exporting { url, meta } (or array).
--concurrency N4Parallel fetches.
--timeout ms10000Per-request timeout.
--user-agent <s>crust-verify-web-links/0.1Header sent on every request.
--max-depth N5Recursion cap when --no-recurse isn’t set.
--no-recurseoffOnly check URLs listed in the sitemap.
--no-anchorsoffSkip #fragment validation.
--no-redirect-warningsoffTreat 3xx chains as informational.
--include-externaloffAlso status-check external links.
--exclude <substring>β€”Skip URLs containing the substring (repeatable). For subtrees that redirect by design β€” a WooCommerce /checkout/ with an empty cart, a /wp-admin/ login wall.
--max-pages N0 (unlimited)Stop fetching after N URLs; the report says how many discovered URLs were left unchecked. Safety valve for crawls that explode (Woo filter URLs).
--no-progressoffSilence the 5-second progress heartbeat on stderr.
--jsonoffEmit a machine-readable report on stdout.

Meta fixtures

Meta fixtures use the same .crust.ts default-export shape as test-fixture. Values can be literals (exact-equality) or single-arg functions (predicates) for anything you want to assert loosely:

// site/about.meta.crust.ts
export default {
  url: "https://example.com/about",
  meta: {
    title: "About Us β€” Example",
    description: (d: string) => d.length > 50 && d.length < 160,
    "og:title": "About Us",
    "og:type": "website",
    "og:image": (u: string) => u.startsWith("https://") && /\.(png|jpg|jpeg)$/.test(u),
    "twitter:card": "summary_large_image",
  },
};

Or export an array to cover several URLs from one file:

const base = "https://example.com";
export default [
  { url: `${base}/`,       meta: { title: "Example", "og:type": "website" } },
  { url: `${base}/about`,  meta: { title: "About Us β€” Example", "og:type": "website" } },
  { url: `${base}/blog`,   meta: { title: (t: string) => t.endsWith(" β€” Example") } },
];

Keys are the literal property/name attribute β€” title, description, og:title, og:image, twitter:card, article:published_time, etc.

Pairing with CI

verify-web-links exits non-zero on any failure, so it drops straight into a pipeline:

# After a build, run against the deployed preview URL
verify-web-links "$PREVIEW_URL" --fixtures site/**/*.meta.crust.ts

Or chain with other crust builtins for a full pre-deploy gate:

mock-server ./openapi.yaml -p4000 &
test-fixture fixtures/*.crust.ts
verify-web-links http://localhost:3000 --fixtures site/*.meta.crust.ts

Exit codes

CodeMeaning
0All checks pass.
1One or more failures (broken link, missing anchor, redirect chain, OG image issue, meta mismatch).
2Bad args, sitemap unreachable, or fixtures malformed.
  • API smoke tests β€” test-fixture for the API surface; verify-web-links for the public site.
  • Mock server β€” pair with verify-web-links http://localhost:PORT to smoke-test rendered pages against mocked backends.