Measurement catalog

Rules return verdicts. Collectors return numbers. This is the catalog of the numbers.

Every metric id is category/name, using the same categories as the rules, so only and skip filter both at once. Nothing here touches the score.

import { allCollectors, selectRules } from "safi-studio-scanner";

const report = await audit("https://example.com");
const titleLength = report.pages[0].metrics.find((m) => m.id === "core-seo/title-chars");

Status values

StatusMeaning
okInside the recommended band.
warnOutside it, but not broken. A 22-character title is still a title.
badActively wrong. A 404, a noindex on a page meant to rank.
missingNot present at all. Different from zero: no published date is not a published date of nothing.

A metric with no status is a plain count with no correct answer, like the number of hidden elements.

Core SEO

MetricShapeReports
core-seo/title-charsvalueTitle length in characters. ok between 30 and 60.
core-seo/description-charsvalueMeta description length. ok between 70 and 160.
core-seo/h1-countvalueHow many H1 elements. ok at exactly one.
core-seo/h1-charsvalueLength of the first H1. ok between 10 and 70.
core-seo/og-coveragevalueHow many of the seven Open Graph tags are set.
core-seo/og-tabletableEvery tag: og:title, og:description, og:image, og:url, og:type, og:site_name, og:locale, with its content and state.

A tag's state is present, missing or not set. The difference matters: missing means a card will not render properly without it, not set means the card works and would be sharper with it. Only og:title, og:description and og:image can be missing.

Content

MetricShapeReports
content/declared-languagevalueThe lang attribute on <html>, resolved.
content/hidden-elementsvalueElements hidden by an inline display:none or the hidden attribute.
content/inline-stylesvalueElements carrying a style attribute.
content/body-style-blocksvalue<style> blocks inside <body>, which restyle content already being painted.
content/html-commentsvalueComment nodes anywhere in the document. They ship to every visitor.
content/placeholder-linksvalueAnchors with href="#", an empty href, or javascript:void.
content/heading-countvalueTotal headings. The note carries the per-level split.
content/heading-levelstableH1 through H6 with a count each, for reading in code.
content/heading-outlinetableEvery heading in document order: level, text indented by depth, and what its section contains.

The outline is the page's table of contents as the parser sees it. A heading's section is everything between it and the next heading at any level, and the third column names what that section carries: IMG: 1, UL: 1 says the section has a picture and a list. An empty heading is listed as (empty) rather than vanishing into a blank row. Capped at 40 rows.

Hidden elements are counted from inline styles only. There is no CSS cascade in a static parse, so a class that hides an element in a stylesheet is invisible here.

Crawlability

MetricShapeReports
crawlability/http-statusvalueThe status the page returned.
crawlability/response-timevalueMilliseconds to the response. ok under 800.
crawlability/robots-metavalueThe robots meta content verbatim. bad when it contains noindex.
crawlability/x-robots-tagvalueThe header form of the same directive.
crawlability/canonical-urlvalueThe canonical, resolved to an absolute URL.
crawlability/canonical-matchvalueWhether that canonical is this page. Trailing slashes and host case are ignored.
crawlability/robots-txtvalueDirective count, plus how many sitemaps it declares.
crawlability/sitemap-urlsvalueURLs found in the sitemap.

A canonical that points elsewhere is warn, not bad. It is the correct thing to do on a paginated or filtered page, and only you know which this is.

MetricShapeReports
links/totalvalueEvery anchor on the page. The note splits internal, external, and any href that would not resolve.
links/internal-countvalueSame-origin links.
links/external-countvalueLinks off the origin.
links/dofollow-countvalueLinks without rel="nofollow".
links/nofollow-countvalueLinks with it.
links/duplicate-countvalueURLs linked more than once from this page.
links/dofollow-tabletableURL, anchor, scope.
links/nofollow-tabletableSame shape.
links/duplicate-tabletableOne row per URL, with every distinct anchor used for it, the count, and the scope.

The duplicate table is the one to read. It is sorted by count, and it answers a question no per-link list can: this page links /pricing three times, once as "Pricing", once as "pricing page", once as "the plans". That is either a deliberate spread of anchor text or an accident, and seeing them on one row is what tells you which.

All three tables are omitted when the page has no links of that kind.

Images

MetricShapeReports
images/countvalueImages on the page.
images/with-altvalueImages carrying real alt text.
images/without-altvalueImages with no alt attribute at all. bad above zero.
images/http-countvalueImages loaded over plain HTTP, which break the padlock on an HTTPS page.

alt="" and a missing alt are counted separately. An empty alt is a decision, saying the image is decorative and screen readers should skip it. A missing attribute is an omission. The note on images/with-alt splits the two.

Social media

MetricShapeReports
social-media/twitter-coveragevalueHow many of the six Twitter tags are set.
social-media/twitter-tabletabletwitter:card, title, description, image, site, creator.

Only twitter:card counts as required. Twitter falls back to Open Graph for the title, description and image, so an empty table next to a full Open Graph one is a working card, not a broken one.

Internationalization

MetricShapeReports
internationalization/hreflang-countvalueHow many alternates are declared. warn when none of them is a self-reference.
internationalization/hreflang-tabletableEach alternate: hreflang, resolved URL. Omitted when there are none.

Performance

MetricShapeReports
performance/html-sizevalueRaw document size in KB, before assets. ok under 100.
performance/script-tagsvalueScript tags, split into external and inline in the note.

Reading them

// One value across the whole crawl.
for (const page of report.pages) {
  const size = page.metrics.find((m) => m.id === "performance/html-size");
  if (size?.status !== "ok") console.log(page.url, size?.value, size?.unit);
}

// Every table on the start page.
const tables = report.pages[0].metrics.filter((m) => m.kind === "table");

To add your own, see writing a collector.