import type { Diagnostic } from "../core/types.ts";
import type { CheckMeta } from "./types.ts";
/**
* Every check `blume audit` can report.
*
* This is deliberately ~76 checks, not the ~173 rows an Ahrefs site audit
* lists. Ahrefs crawls the open web, so its list is padded with rows that are
* structurally impossible for an Astro-built Blume site, and shipping those as
* permanent zeroes teaches people to ignore the report. What was dropped, and
* why:
*
* - **Every `nofollow` check** (~6 rows, including all four "nofollow incoming
* internal links" variants). `RootLayout.astro` only ever emits ``; Blume has no way to emit `nofollow`, so
* they could never fire. Hand-written `rel="nofollow"` in MDX is still caught
* by `INTERNAL_LINK_NOFOLLOW`.
* - **The whole JavaScript + CSS section** (~11 rows: "JS redirects", "CSS
* redirects", "page has redirected JS/CSS", "HTTPS page links to HTTP
* JS/CSS"…). Vite emits content-hashed, existent, non-redirecting bundles.
* Collapsed into one `SUBRESOURCE_MISSING`.
* - **Ahrefs' indexable/non-indexable duplicates** (~20 rows). Carried instead
* by `PageSnapshot.indexable` on a single finding.
* - **"Only one dofollow incoming internal link"** and **"page has no outgoing
* links"**. Blume's sidebar links every page from every page, so these fire on
* ~100% of pages. Pure noise.
* - **"3XX redirect"** and **"302 redirect"**: having a redirect is inventory,
* not a finding. **"HTTP to HTTPS redirect"** is correct behavior.
* - **"Font size too small" / "tap targets too close" / "content not sized
* correctly"**: properties of the theme, identical on every page. A regression
* there is a Blume bug, not a user finding.
* - **"Document uses plugins"** and **"poor FID"** (FID is deprecated; lab
* Lighthouse cannot measure INP, so we report TBT and say so).
* - **"More than three parameters in URL"**: a static docs site emits no query
* strings in its own links.
* - **"Page in multiple sitemaps"**: Blume emits exactly one sitemap.
* - **Core Web Vitals** (LCP/CLS/INP): these need a real browser. Rather than
* ship a `--lighthouse` flag that silently reports nothing, they are deferred
* until Lighthouse is actually wired in. A tier the audit cannot run is worse
* than one it openly doesn't have.
*
* Severity discipline: `error` means "this is definitely broken". Anything
* advisory ships as `warning`/`info`. One noisy check and the whole command gets
* switched off.
*/
export const CHECKS = [
// Content
{
category: "content",
fix: "Add a `title` to the page's frontmatter.",
id: "BLUME_AUDIT_TITLE_MISSING",
severity: "error",
tier: "static",
title: "Title tag missing or empty",
},
{
category: "content",
fix: "Remove the extra
from the page's layout or MDX.",
id: "BLUME_AUDIT_TITLE_MULTIPLE",
severity: "error",
tier: "static",
title: "Multiple title tags",
},
{
category: "content",
fix: "Rewrite `title` in the frontmatter to fit the length range.",
id: "BLUME_AUDIT_TITLE_LENGTH",
severity: "warning",
tier: "static",
title: "Title too long or too short",
},
{
category: "content",
fix: "Add a `description` to the page's frontmatter.",
id: "BLUME_AUDIT_DESCRIPTION_MISSING",
severity: "warning",
tier: "static",
title: "Meta description missing or empty",
},
{
category: "content",
fix: "Remove the extra description from the page's layout or MDX.",
id: "BLUME_AUDIT_DESCRIPTION_MULTIPLE",
severity: "error",
tier: "static",
title: "Multiple meta description tags",
},
{
category: "content",
fix: "Rewrite `description` in the frontmatter to fit the length range.",
id: "BLUME_AUDIT_DESCRIPTION_LENGTH",
severity: "warning",
tier: "static",
title: "Meta description too long or too short",
},
{
category: "content",
fix: "Give the page a `title` — Blume renders it as the page's .",
id: "BLUME_AUDIT_H1_MISSING",
severity: "warning",
tier: "static",
title: "H1 tag missing or empty",
},
{
category: "content",
fix: "Demote the extra `# Heading` in the body to `##` — Blume already renders `title` as the h1.",
id: "BLUME_AUDIT_H1_MULTIPLE",
severity: "warning",
tier: "static",
title: "Multiple H1 tags",
},
{
category: "content",
fix: "Expand the page, or fold it into a larger one.",
id: "BLUME_AUDIT_LOW_WORD_COUNT",
severity: "info",
tier: "static",
title: "Low word count",
},
{
category: "content",
fix: "Adjust the heading to the next level down — skipped levels break table-of-contents nesting and screen-reader outlines.",
id: "BLUME_AUDIT_HEADING_SKIP",
severity: "info",
tier: "static",
title: "Heading levels skip (e.g. h2 to h4)",
},
{
category: "content",
fix: "Correct the `date`, or hold the page back until it is meant to be live.",
id: "BLUME_AUDIT_FUTURE_DATED_PAGE",
severity: "info",
tier: "static",
title: "Page is dated in the future",
},
{
category: "content",
fix: "Restore the viewport in your ejected layout.",
id: "BLUME_AUDIT_VIEWPORT_MISSING",
severity: "error",
tier: "static",
title: "Viewport not set",
},
// Duplicates
{
category: "duplicates",
fix: "Give each page a distinct `title` — search engines show it as the result headline.",
id: "BLUME_AUDIT_DUPLICATE_TITLE",
severity: "warning",
tier: "static",
title: "Duplicate title",
},
{
category: "duplicates",
fix: "Give each page a distinct `description`.",
id: "BLUME_AUDIT_DUPLICATE_DESCRIPTION",
severity: "warning",
tier: "static",
title: "Duplicate meta description",
},
{
category: "duplicates",
fix: "Merge the pages, or set `seo.canonical` on all but one.",
id: "BLUME_AUDIT_DUPLICATE_CONTENT",
severity: "warning",
tier: "static",
title: "Duplicate pages without canonical",
},
// Indexability
{
category: "indexability",
fix: "Set `deployment.site` in blume.config.ts to the site's public URL.",
id: "BLUME_AUDIT_SITE_NOT_SET",
severity: "warning",
tier: "static",
title: "deployment.site is not set",
},
{
category: "indexability",
fix: "Audit a production-like build (e.g. `VERCEL=1 VERCEL_PROJECT_PRODUCTION_URL= blume build`) or the deployment itself with `--url `. Do not hardcode `deployment.site` — the platform sets it on every deploy.",
id: "BLUME_AUDIT_SITE_INFERRED_AT_DEPLOY",
severity: "info",
tier: "static",
title: "deployment.site is inferred at deploy time",
},
{
category: "indexability",
fix: "Drop the canonical from noindex pages — Google treats the pairing as contradictory and may ignore one of the two.",
id: "BLUME_AUDIT_CANONICAL_ON_NOINDEX",
severity: "warning",
tier: "static",
title: "Page is noindex but declares a canonical",
},
{
category: "indexability",
fix: "Rebuild without `--preview` before deploying, or remove `draft: true` if the page is ready to ship.",
id: "BLUME_AUDIT_DRAFT_PAGE_PUBLISHED",
severity: "warning",
tier: "static",
title: "Draft page is in the build",
},
{
category: "indexability",
fix: "Set `deployment.site` so Blume can emit absolute canonical URLs.",
id: "BLUME_AUDIT_CANONICAL_MISSING",
severity: "warning",
tier: "static",
title: "Canonical URL missing",
},
{
category: "indexability",
fix: "Point `seo.canonical` at this page, or remove it to use the default self-canonical.",
id: "BLUME_AUDIT_CANONICAL_NOT_SELF",
severity: "info",
tier: "static",
title: "Non-canonical page",
},
{
category: "indexability",
fix: "Point `seo.canonical` at a page that exists and doesn't redirect.",
id: "BLUME_AUDIT_CANONICAL_BAD_TARGET",
severity: "error",
tier: "static",
title: "Canonical points to a broken or redirecting page",
},
{
category: "indexability",
fix: "Use the same protocol in `seo.canonical` as in `deployment.site`.",
id: "BLUME_AUDIT_CANONICAL_PROTOCOL_MISMATCH",
severity: "error",
tier: "static",
title: "Canonical protocol does not match the site",
},
{
category: "indexability",
fix: "Remove `noindex` from the page's frontmatter if it should be indexed.",
id: "BLUME_AUDIT_ROBOTS_META_UNEXPECTED",
severity: "info",
tier: "static",
title: "Page is not indexable",
},
{
category: "indexability",
fix: "Split the page — Googlebot stops reading an HTML document at 2 MB.",
id: "BLUME_AUDIT_HTML_TOO_LARGE",
severity: "error",
tier: "static",
title: "Page exceeds Googlebot's 2 MB crawl limit",
},
{
category: "indexability",
fix: "Remove the X-Robots-Tag header, or align it with the page's robots meta.",
id: "BLUME_AUDIT_ROBOTS_HEADER_CONFLICT",
severity: "error",
tier: "network",
title: "X-Robots-Tag header conflicts with the page's robots meta",
},
// Links
{
category: "links",
fix: "Fix the link target, or create the page it points at.",
id: "BLUME_AUDIT_LINK_TO_BROKEN",
severity: "error",
tier: "static",
title: "Page has links to a broken page",
},
{
category: "links",
fix: "Link straight to the destination instead of through the redirect.",
id: "BLUME_AUDIT_LINK_TO_REDIRECT",
severity: "warning",
tier: "static",
title: "Page has links to a redirect",
},
{
category: "links",
fix: "Link to this page from the body of a related page.",
id: "BLUME_AUDIT_ORPHAN_PAGE",
severity: "warning",
tier: "static",
title: "Orphan page (only reachable from navigation)",
},
{
category: "links",
fix: "Use a root-relative path (`/docs/x`) so the link survives previews and `basePath`.",
id: "BLUME_AUDIT_INTERNAL_LINK_ABSOLUTE",
severity: "warning",
tier: "static",
title: "Internal link hardcodes the site's own origin",
},
{
category: "links",
fix: 'Drop `rel="nofollow"` — it stops this page passing authority internally.',
id: "BLUME_AUDIT_INTERNAL_LINK_NOFOLLOW",
severity: "info",
tier: "static",
title: "Page has nofollow outgoing internal links",
},
{
category: "links",
fix: "Check `basePath` / `deployment.base` — a trailing slash there produces `//`.",
id: "BLUME_AUDIT_DOUBLE_SLASH_URL",
severity: "error",
tier: "static",
title: "Double slash in URL",
},
{
category: "links",
fix: "Point the fragment at a heading that exists on the target page, or fix the heading it meant.",
id: "BLUME_AUDIT_ANCHOR_BROKEN",
severity: "warning",
tier: "static",
title: "Link fragment matches no id on the target page",
},
{
category: "links",
fix: "Rename the source file to a lowercase, hyphenated slug — and add a redirect from the old URL if it was already published.",
id: "BLUME_AUDIT_URL_STYLE",
severity: "info",
tier: "static",
title: "URL contains uppercase, underscores, or spaces",
},
// Redirects
{
category: "redirects",
fix: "Point the redirect at a page that exists.",
id: "BLUME_AUDIT_REDIRECT_BROKEN",
severity: "error",
tier: "static",
title: "Broken redirect",
},
{
category: "redirects",
fix: "Break the cycle in `redirects` — it never resolves.",
id: "BLUME_AUDIT_REDIRECT_LOOP",
severity: "error",
tier: "static",
title: "Redirect loop",
},
{
category: "redirects",
fix: "Point every hop straight at the final destination.",
id: "BLUME_AUDIT_REDIRECT_CHAIN",
severity: "warning",
tier: "static",
title: "Redirect chain",
},
{
category: "redirects",
fix: "Use a real redirect in `blume.config.ts` instead of a meta refresh.",
id: "BLUME_AUDIT_META_REFRESH",
severity: "warning",
tier: "static",
title: "Meta refresh redirect",
},
{
category: "redirects",
fix: "Remove the redirect, or delete the page it shadows — the page wins and the redirect never fires.",
id: "BLUME_AUDIT_REDIRECT_SOURCE_IS_PAGE",
severity: "error",
tier: "static",
title: "Redirect source is also a real page",
},
{
category: "redirects",
fix: "Redirect to the HTTPS URL.",
id: "BLUME_AUDIT_REDIRECT_TO_HTTP",
severity: "error",
tier: "network",
title: "HTTPS to HTTP redirect",
},
// Social
{
category: "social",
fix: "Add a `description` — Blume fills the rest of the Open Graph tags for you.",
id: "BLUME_AUDIT_OG_INCOMPLETE",
severity: "warning",
tier: "static",
title: "Open Graph tags missing or incomplete",
},
{
category: "social",
fix: "Set `deployment.site` to turn on generated OG images, or set `seo.image` on the page.",
id: "BLUME_AUDIT_OG_IMAGE_MISSING",
severity: "warning",
tier: "static",
title: "Open Graph image missing",
},
{
category: "social",
fix: "Point `seo.image` at a file that exists, or rebuild — a dead og:image renders as a blank card everywhere the page is shared.",
id: "BLUME_AUDIT_OG_IMAGE_BROKEN",
severity: "warning",
tier: "static",
title: "Open Graph image is not in the build",
},
{
category: "social",
fix: "Use an image of at least 1200×630 — smaller ones render blurry or get cropped into small-card layouts.",
id: "BLUME_AUDIT_OG_IMAGE_SMALL",
severity: "warning",
tier: "static",
title: "Open Graph image is too small for large cards",
},
{
category: "social",
fix: "Align `og:url` with the page's canonical URL.",
id: "BLUME_AUDIT_OG_URL_MISMATCH",
severity: "warning",
tier: "static",
title: "Open Graph URL not matching canonical",
},
{
category: "social",
fix: "Set `seo.x.handle` in blume.config.ts so X can attribute the card.",
id: "BLUME_AUDIT_TWITTER_CARD_INCOMPLETE",
severity: "warning",
tier: "static",
title: "X (Twitter) card missing or incomplete",
},
// Localization
{
category: "i18n",
fix: "Restore the `lang` attribute on in your ejected layout.",
id: "BLUME_AUDIT_HTML_LANG_MISSING",
severity: "error",
tier: "static",
title: "HTML lang attribute missing",
},
{
category: "i18n",
fix: "Use a valid BCP 47 tag (e.g. `en`, `en-GB`) for the locale.",
id: "BLUME_AUDIT_HTML_LANG_INVALID",
severity: "error",
tier: "static",
title: "HTML lang attribute invalid",
},
{
category: "i18n",
fix: "The page's must match its own hreflang annotation.",
id: "BLUME_AUDIT_HREFLANG_LANG_MISMATCH",
severity: "error",
tier: "static",
title: "Hreflang and HTML lang mismatch",
},
{
category: "i18n",
fix: "Use a valid BCP 47 tag in the hreflang annotation.",
id: "BLUME_AUDIT_HREFLANG_INVALID",
severity: "error",
tier: "static",
title: "Hreflang annotation invalid",
},
{
category: "i18n",
fix: "A page's hreflang set must include a self-reference.",
id: "BLUME_AUDIT_HREFLANG_SELF_MISSING",
severity: "warning",
tier: "static",
title: "Self-reference hreflang annotation missing",
},
{
category: "i18n",
fix: "Add an `x-default` alternate pointing at the default-locale page.",
id: "BLUME_AUDIT_HREFLANG_XDEFAULT_MISSING",
severity: "info",
tier: "static",
title: "X-default hreflang annotation missing",
},
{
category: "i18n",
fix: "Every page in an hreflang group must link back to every other one.",
id: "BLUME_AUDIT_HREFLANG_NO_RETURN_TAG",
severity: "error",
tier: "static",
title: "Missing reciprocal hreflang (no return-tag)",
},
{
category: "i18n",
fix: "Point the hreflang alternate at a page that exists and is canonical.",
id: "BLUME_AUDIT_HREFLANG_BAD_TARGET",
severity: "error",
tier: "static",
title: "Hreflang points to a broken, redirecting, or non-canonical page",
},
{
category: "i18n",
fix: "Each language in an hreflang group must name exactly one page.",
id: "BLUME_AUDIT_HREFLANG_CONFLICT",
severity: "error",
tier: "static",
title: "Hreflang group has a language conflict",
},
// Assets
{
category: "assets",
fix: 'Add descriptive `alt` text, or `alt=""` if the image is decorative.',
id: "BLUME_AUDIT_IMAGE_ALT_MISSING",
severity: "warning",
tier: "static",
title: "Missing alt text",
},
{
category: "assets",
fix: "Fix the image path, or add the file to public/.",
id: "BLUME_AUDIT_IMAGE_BROKEN",
severity: "error",
tier: "static",
title: "Image broken",
},
{
category: "assets",
fix: "Compress the asset, or serve a modern format (WebP/AVIF).",
id: "BLUME_AUDIT_ASSET_TOO_LARGE",
severity: "warning",
tier: "static",
title: "Asset file size too large",
},
{
category: "assets",
fix: "Set `width` and `height` so the browser can reserve space (avoids layout shift).",
id: "BLUME_AUDIT_IMAGE_MISSING_DIMENSIONS",
severity: "warning",
tier: "static",
title: "Image has no width/height",
},
{
category: "assets",
fix: "Fix the reference, or restore the missing file.",
id: "BLUME_AUDIT_SUBRESOURCE_MISSING",
severity: "error",
tier: "static",
title: "Referenced script, style, or asset is missing from the build",
},
{
category: "assets",
fix: "Load the subresource over HTTPS — browsers block mixed content.",
id: "BLUME_AUDIT_MIXED_CONTENT",
severity: "error",
tier: "static",
title: "HTTPS/HTTP mixed content",
},
// Sitemap
{
category: "sitemap",
fix: "Remove `draft`/`hidden`/`noindex` from the page's frontmatter if it should be indexed.",
id: "BLUME_AUDIT_INDEXABLE_PAGE_NOT_IN_SITEMAP",
severity: "warning",
tier: "static",
title: "Indexable page not in sitemap",
},
{
category: "sitemap",
fix: "A noindex page should not be advertised in the sitemap.",
id: "BLUME_AUDIT_NOINDEX_IN_SITEMAP",
severity: "error",
tier: "static",
title: "Noindex page in sitemap",
},
{
category: "sitemap",
fix: "List only canonical URLs in the sitemap.",
id: "BLUME_AUDIT_NON_CANONICAL_IN_SITEMAP",
severity: "error",
tier: "static",
title: "Non-canonical page in sitemap",
},
{
category: "sitemap",
fix: "Remove the URL from the sitemap, or build the page it names.",
id: "BLUME_AUDIT_SITEMAP_BAD_URL",
severity: "error",
tier: "static",
title: "Sitemap names a page that does not exist or redirects",
},
{
category: "sitemap",
fix: "Sitemaps must be valid XML in the sitemaps.org urlset format.",
id: "BLUME_AUDIT_SITEMAP_INVALID",
severity: "error",
tier: "static",
title: "Sitemap has a syntax error or wrong format",
},
{
category: "sitemap",
fix: "Split the sitemap — the limits are 50 MB and 50,000 URLs.",
id: "BLUME_AUDIT_SITEMAP_TOO_LARGE",
severity: "error",
tier: "static",
title: "Sitemap exceeds 50 MB or 50,000 URLs",
},
{
category: "sitemap",
fix: "Use a real W3C date that is not in the future — search engines that catch a sitemap lying about freshness stop trusting its lastmod entirely.",
id: "BLUME_AUDIT_SITEMAP_LASTMOD_INVALID",
severity: "warning",
tier: "static",
title: "Sitemap lastmod is invalid or in the future",
},
{
category: "sitemap",
fix: "A sitemap may only list URLs on its own origin.",
id: "BLUME_AUDIT_SITEMAP_OUT_OF_SCOPE",
severity: "warning",
tier: "static",
title: "Sitemap includes URLs out of its scope",
},
{
category: "sitemap",
fix: "Make sitemap.xml reachable at the site root.",
id: "BLUME_AUDIT_SITEMAP_NOT_ACCESSIBLE",
severity: "error",
tier: "network",
title: "Sitemap is not accessible",
},
// robots.txt
{
category: "robots",
fix: "Set `seo.robots: true` to generate robots.txt.",
id: "BLUME_AUDIT_ROBOTS_MISSING",
severity: "warning",
tier: "static",
title: "robots.txt missing",
},
{
category: "robots",
fix: "Every robots.txt line must be a `Field: value` directive or a comment.",
id: "BLUME_AUDIT_ROBOTS_INVALID",
severity: "error",
tier: "static",
title: "robots.txt has a syntax error",
},
{
category: "robots",
fix: "A page can't be both disallowed in robots.txt and advertised in the sitemap.",
id: "BLUME_AUDIT_ROBOTS_DISALLOWS_INDEXABLE",
severity: "error",
tier: "static",
title: "robots.txt disallows a page that is in the sitemap",
},
{
category: "robots",
fix: "Set `deployment.site` so robots.txt can reference the sitemap.",
id: "BLUME_AUDIT_ROBOTS_SITEMAP_MISSING",
severity: "info",
tier: "static",
title: "robots.txt does not reference the sitemap",
},
{
category: "robots",
fix: "Make robots.txt reachable at the site root.",
id: "BLUME_AUDIT_ROBOTS_NOT_ACCESSIBLE",
severity: "error",
tier: "network",
title: "robots.txt is not accessible",
},
// AI discovery. `llms.txt` is the sitemap of the AI era, and no SEO crawler
// audits it — they audit for Google. Blume emits it, so Blume checks it.
{
category: "ai",
fix: "Rebuild — `ai.llmsTxt` is enabled but the build has no llms.txt. If that's intentional, set `ai.llmsTxt: false`.",
id: "BLUME_AUDIT_LLMS_TXT_MISSING",
severity: "warning",
tier: "static",
title: "llms.txt missing from the build",
},
{
category: "ai",
fix: "Rebuild so llms.txt matches the site — a stale entry sends an AI agent to a page that is not there.",
id: "BLUME_AUDIT_LLMS_TXT_STALE_ENTRY",
severity: "warning",
tier: "static",
title: "llms.txt lists a page the build does not serve",
},
{
category: "ai",
fix: "Rebuild so llms.txt matches the site; if the page is deliberately excluded, mark it `seo.noindex` or `sidebar.hidden`.",
id: "BLUME_AUDIT_LLMS_TXT_PAGE_MISSING",
severity: "warning",
tier: "static",
title: "Indexable page missing from llms.txt",
},
// DNS-AID (draft-mozleywilliams-dnsop-dnsaid) lives in the DNS zone, not the
// build, so unlike the llms.txt checks these can only observe and advise —
// hence `info`: publishing the records is a DNS-provider action Blume can't
// take for you, and the draft is young enough that absence is the norm.
{
category: "ai",
fix: "Publish a ServiceMode SVCB or HTTPS record at `_index._agents.` with your DNS provider so agents can discover the site via DNS (DNS-AID).",
id: "BLUME_AUDIT_DNS_AID_MISSING",
severity: "info",
tier: "network",
title: "No DNS-AID agent-discovery records",
},
{
category: "ai",
fix: "Enable DNSSEC for the zone so validating resolvers return authenticated DNS-AID answers; if your DNS provider doesn't support DNSSEC, the records still work unsigned.",
id: "BLUME_AUDIT_DNS_AID_UNSIGNED",
severity: "info",
tier: "network",
title: "DNS-AID records are not DNSSEC-authenticated",
},
// Structured data. Note we validate only what Blume itself emits — we do not
// claim Google-rich-results or full schema.org validation (the former is an
// undocumented network API, the latter a vocabulary we don't bundle).
{
category: "structured-data",
fix: "The JSON-LD block must be valid JSON.",
id: "BLUME_AUDIT_JSONLD_INVALID",
severity: "error",
tier: "static",
title: "Structured data is not valid JSON",
},
{
category: "structured-data",
fix: "Every JSON-LD node needs `@context` and `@type`.",
id: "BLUME_AUDIT_JSONLD_INCOMPLETE",
severity: "warning",
tier: "static",
title: "Structured data is missing required properties",
},
// Network (`--url`)
{
category: "network",
fix: "The page is linked or in the sitemap but the deployment 404s it.",
id: "BLUME_AUDIT_HTTP_4XX",
severity: "error",
tier: "network",
title: "4XX page",
},
{
category: "network",
fix: "The deployment is erroring on this page.",
id: "BLUME_AUDIT_HTTP_5XX",
severity: "error",
tier: "network",
title: "5XX page",
},
{
category: "network",
fix: "The page did not respond in time.",
id: "BLUME_AUDIT_HTTP_TIMEOUT",
severity: "error",
tier: "network",
title: "Timed out",
},
{
category: "network",
fix: "Enable gzip or brotli on the host.",
id: "BLUME_AUDIT_NOT_COMPRESSED",
severity: "warning",
tier: "network",
title: "Not compressed",
},
{
category: "network",
fix: "The page was slow to respond.",
id: "BLUME_AUDIT_SLOW_RESPONSE",
severity: "warning",
tier: "network",
title: "Slow page",
},
// External (`--external`)
{
category: "network",
fix: "Fix or remove the outbound link.",
id: "BLUME_AUDIT_EXTERNAL_LINK_BROKEN",
severity: "error",
tier: "external",
title: "External link is broken",
},
{
category: "network",
fix: "Link straight to the destination.",
id: "BLUME_AUDIT_EXTERNAL_LINK_REDIRECT",
severity: "info",
tier: "external",
title: "External link redirects",
},
] as const satisfies readonly CheckMeta[];
export type CheckId = (typeof CHECKS)[number]["id"];
const BY_ID = new Map(
CHECKS.map((check) => [check.id, check])
);
export const checkMeta = (id: CheckId): CheckMeta => {
const meta = BY_ID.get(id);
if (!meta) {
throw new Error(`Unknown audit check: ${id}`);
}
return meta;
};
const DOCS_BASE = "https://useblume.dev/docs/reference/audit";
/** `BLUME_AUDIT_TITLE_TOO_LONG` -> `…/audit#title-too-long`. */
export const checkDocsUrl = (id: CheckId): string =>
`${DOCS_BASE}#${id.replace("BLUME_AUDIT_", "").toLowerCase().replaceAll("_", "-")}`;
/** Where a finding happened: the built URL, plus the source file that fixes it. */
export interface FindingSite {
url: string;
file?: string;
line?: number;
column?: number;
}
/**
* Build a diagnostic for a check. Severity, remediation, and the docs anchor all
* come from the catalog, so a check body only supplies what's specific to the
* occurrence: where it happened and what was actually wrong.
*/
export const finding = (
id: CheckId,
site: FindingSite,
detail: string,
fix?: string
): Diagnostic => {
const meta = checkMeta(id);
return {
code: id,
column: site.column,
docsUrl: checkDocsUrl(id),
file: site.file,
line: site.line,
message: detail,
severity: meta.severity,
suggestion: fix ?? meta.fix,
url: site.url,
};
};