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 // Programmatic β exit code drives CI
import { runCli } from "crust/verifyWebLinks";
const code = await runCli([
"--base-url", "https://example.com",
"--fixtures", "site/*.meta.crust.ts",
"--json",
]);
process.exit(code); What gets checked
All four behaviors are on by default. Each has a --no-* opt-out.
| Check | What it does | Disable with |
|---|---|---|
| HTTP status | Every URL must return 2xx (or 3xx if redirects are allowed). | β (always on) |
| Recurse into internal pages | Walks <a href> / <img src> / <link href> / <script src> / <iframe src> on same-origin pages up to --max-depth (default 5). | --no-recurse |
| Anchor fragments | For 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 chains | 301/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
| Flag | Default | Notes |
|---|---|---|
--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 N | 4 | Parallel fetches. |
--timeout ms | 10000 | Per-request timeout. |
--user-agent <s> | crust-verify-web-links/0.1 | Header sent on every request. |
--max-depth N | 5 | Recursion cap when --no-recurse isnβt set. |
--no-recurse | off | Only check URLs listed in the sitemap. |
--no-anchors | off | Skip #fragment validation. |
--no-redirect-warnings | off | Treat 3xx chains as informational. |
--include-external | off | Also 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 N | 0 (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-progress | off | Silence the 5-second progress heartbeat on stderr. |
--json | off | Emit 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
| Code | Meaning |
|---|---|
0 | All checks pass. |
1 | One or more failures (broken link, missing anchor, redirect chain, OG image issue, meta mismatch). |
2 | Bad args, sitemap unreachable, or fixtures malformed. |
Related
- API smoke tests β
test-fixturefor the API surface;verify-web-linksfor the public site. - Mock server β pair with
verify-web-links http://localhost:PORTto smoke-test rendered pages against mocked backends.