Verify
Portable gates for a migrated site: identity, indexing, status, hreflang, structured data, navigation and assets — checked over documents you already have, with no network and no dependencies.
npx @contentrain/verify dist --site https://example.comWhy it does not fetch
A hardened crawler is a security surface of its own — SSRF, DNS rebinding, redirect limits, byte and time caps, robots handling — and it belongs where that hardening is maintained, not inside the MIT library every consumer embeds.
So this package takes documents, and that constraint turns out to be the feature: the same gate runs over a dist/ directory, over pages a migration engine already captured, and inside CI — with no network calls to mock and no flake. Whoever holds a safe fetcher supplies the pages.
Usage
import { verify, formatReport } from '@contentrain/verify'
const report = verify({
site: 'https://example.com',
documents: [{ url: '/about', html, status: 200, headers }],
assets: ['/img/hero.png'],
sitemap: sitemapXml,
redirects: [{ from: '/old', to: '/about', status: 301 }],
baseline: { documents: oldPages }, // optional: enables the parity checks
})
report.passed // no error findings — this is the gate
report.findings // every finding, with a stable `check` id
report.skipped // what could not run, and why
console.log(formatReport(report))Over a built directory:
import { loadSiteDirectory, verify } from '@contentrain/verify'
const report = verify(await loadSiteDirectory('dist', 'https://example.com'))A build usually writes a sitemap index rather than one sitemap.xml — @astrojs/sitemap writes sitemap-index.xml naming sitemap-0.xml, … — so the loader follows the index and hands verify the pages every named sitemap lists. A sitemap the index names but the build does not serve is reported as a stale entry. Passed to verify directly, a bare index lists files rather than pages, so the membership checks skip and say so instead of reporting every page missing.
Severity
One rule decides it. error means the built site is wrong — a visitor or a crawler gets something it should not. warning means it is worse than the site it replaced, or worse than it should be. A migration that improves a page must not fail its own gate.
report.passed is counts.error === 0.
The checks
Identity
| Check | Severity | What it catches |
|---|---|---|
identity.title-missing | error | No non-empty <title> |
identity.title-duplicate | error | Two titles in the head — a crawler picks one and it is not defined which. A <title> inside an inline <svg> is an accessibility label and is not counted |
identity.canonical-missing | warning | No canonical link |
identity.canonical-duplicate | error | Two canonicals: the page says two different things about itself |
identity.canonical-mismatch | error | A page carrying another page's canonical |
identity.description-missing | warning | No meta description |
identity.open-graph-incomplete | warning | Missing og:title / og:type / og:url — shares render without a card |
identity.twitter-card-missing | info | No twitter:card |
The clone failure
canonical-mismatch is the reason this group exists. A cloned page that kept the template's canonical points the entire site at one URL and removes it from the index. It is invisible in a browser, survives every visual check, and is worse than having no canonical at all.
Indexing
| Check | Severity | What it catches |
|---|---|---|
indexing.noindex | error | A noindex in the robots meta or X-Robots-Tag |
indexing.sitemap-missing-entry | warning | An indexable page not in the sitemap. A noindex page is not asked to be in it |
indexing.sitemap-stale-entry | warning | The sitemap lists a page the build does not serve |
The staging leak
A noindex set on a staging site survives the deploy, the site builds and serves normally, and it disappears from search over the following weeks. Nothing on the page looks wrong.
Status
| Check | Severity | What it catches |
|---|---|---|
status.mismatch | error | The status changed against the baseline |
status.baseline-page-missing | error | A page the baseline answered with 2xx that the build does not serve and no redirect covers. status.mismatch only compares pages present on both sides, so a page the migration never produced would otherwise go unreported. Build only (input.build); the old site's 404 page is not counted |
status.soft-404 | error | A "not found" page answering 200 |
status.redirect-chain | warning | More hops than maxRedirectHops (default 1) |
status.redirect-loop | error | A chain that returns to itself |
status.redirect-target-missing | error | An internal redirect pointing at a page the build does not serve |
A soft-404 is invisible to every monitor that watches status codes, and a crawler indexes it as content. A page is only called one when it says it was not found and is short — the threshold is soft404MaxTextLength, so an article about HTTP 404 is not caught by it.
International
| Check | Severity | What it catches |
|---|---|---|
international.hreflang-not-reciprocal | error | A names B, B does not name A |
international.hreflang-no-self | warning | A page with alternates that does not list itself |
international.hreflang-unknown-target | warning | An alternate pointing at a page that does not exist |
Search engines ignore one-sided alternates entirely, so a half-migrated set of alternates is not a partial win — it is nothing.
Structured data
| Check | Severity | What it catches |
|---|---|---|
structured.jsonld-invalid | error | A block that does not parse — consumers discard the whole block |
structured.type-lost | warning | A @type the old page had is gone |
Types nested in @graph are counted, which is how themes actually ship them: one graph holding Organization, WebSite and WebPage.
Navigation
| Check | Severity | What it catches |
|---|---|---|
navigation.broken-internal-link | error | An internal link that goes nowhere |
navigation.pagination-broken | warning | rel="next" / rel="prev" pointing at a page the build does not serve |
navigation.feed-lost | warning | The old page advertised a feed and this one does not |
A path a redirect rule covers is not broken. External, mailto:, tel: and fragment links are not checked. One finding per distinct href, not per occurrence — a broken link in a shared header is one problem, not one per page element.
Assets
| Check | Severity | What it catches |
|---|---|---|
assets.broken-image | error | An internal image resolving to nothing the build serves |
assets.missing-alt | warning | An <img> with no alt attribute |
assets.alt-lost | warning | Alt text the old page had for the same image is gone |
alt="" is a decision (the image is decorative) and is not reported; no alt attribute at all is an omission and is. data: URIs and third-party hosts are skipped.
Address identity
Every "does this go somewhere that exists" question is a normalisation question, and getting it wrong in either direction is worse than not asking: too strict and a correct site fails its gate, too loose and a broken one passes.
/about,/about/and/about/index.htmlare one address, because static hosts serve them that way.- Query strings and fragments are not part of a document's identity.
- Case is preserved, because static hosts are case-sensitive even when the author assumed otherwise.
Without site, relative links still resolve (against a reserved origin) and absolute links to real hosts count as external. Supplying site is better: it is what makes canonical-mismatch and the cross-origin checks exact.
Missing inputs are reported, not skipped silently
A run without a baseline, a sitemap or redirect rules names those absences in report.skipped. A green report over a run that compared nothing is the most dangerous output this package could produce.
SKIPPED status — no baseline — parity checks did not run
SKIPPED indexing — no sitemap — membership checks did not runCLI
contentrain-verify <dist-dir> [options]
--site <url> Canonical origin
--baseline <dir> The old site, as a directory of captured pages
--redirects <file> JSON: [{ "from": "/a", "to": "/b", "status": 301 }]
--groups <list> identity,indexing,status,international,structured,navigation,assets
--max-redirect-hops <n>
--jsonExits 1 when any finding is an error, 2 on a usage error. Unknown options and unknown group names are rejected rather than ignored.
Reading HTML
Targeted regexes, not a parser — the same choice @contentrain/emitter-astro makes, for the same reason: these are machine-generated documents whose head the pipeline controls, the extracted set is small and fixed, and a parser would put a dependency into the one package a migration should be able to run anywhere.
The limits are honoured rather than papered over. Attribute reads require whitespace before the name, so data-src never answers a read of src — which matters, because lazy-loading WordPress themes put the real URL in data-src on almost every image. Where a check would be wrong under these limits, it is not written.
Related Pages
- WordPress Migration — the chain this package gates
- Astro Emitter — what usually produces the documents checked here
- WordPress Import — the other open end of the pipeline
- Types — the shared contracts
- Query SDK
- MCP Tools