Skip to content

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.

bash
npx @contentrain/verify dist --site https://example.com

Why 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

ts
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:

ts
import { loadSiteDirectory, verify } from '@contentrain/verify'

const report = verify(await loadSiteDirectory('dist', 'https://example.com'))

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

CheckSeverityWhat it catches
identity.title-missingerrorNo non-empty <title>
identity.title-duplicateerrorTwo 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-missingwarningNo canonical link
identity.canonical-duplicateerrorTwo canonicals: the page says two different things about itself
identity.canonical-mismatcherrorA page carrying another page's canonical
identity.description-missingwarningNo meta description
identity.open-graph-incompletewarningMissing og:title / og:type / og:url — shares render without a card
identity.twitter-card-missinginfoNo 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

CheckSeverityWhat it catches
indexing.noindexerrorA noindex in the robots meta or X-Robots-Tag
indexing.sitemap-missing-entrywarningAn indexable page not in the sitemap. A noindex page is not asked to be in it
indexing.sitemap-stale-entrywarningThe 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

CheckSeverityWhat it catches
status.mismatcherrorThe status changed against the baseline
status.soft-404errorA "not found" page answering 200
status.redirect-chainwarningMore hops than maxRedirectHops (default 1)
status.redirect-looperrorA chain that returns to itself
status.redirect-target-missingerrorAn 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

CheckSeverityWhat it catches
international.hreflang-not-reciprocalerrorA names B, B does not name A
international.hreflang-no-selfwarningA page with alternates that does not list itself
international.hreflang-unknown-targetwarningAn 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

CheckSeverityWhat it catches
structured.jsonld-invaliderrorA block that does not parse — consumers discard the whole block
structured.type-lostwarningA @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.

CheckSeverityWhat it catches
navigation.broken-internal-linkerrorAn internal link that goes nowhere
navigation.pagination-brokenwarningrel="next" / rel="prev" pointing at a page the build does not serve
navigation.feed-lostwarningThe 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

CheckSeverityWhat it catches
assets.broken-imageerrorAn internal image resolving to nothing the build serves
assets.missing-altwarningAn <img> with no alt attribute
assets.alt-lostwarningAlt 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.html are 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 run

CLI

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>
  --json

Exits 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.