import stringWidth from "string-width";
import matter from "../../core/frontmatter.ts";
import type { Diagnostic } from "../../core/types.ts";
import { finding } from "../catalog.ts";
import { pageSite } from "../locate.ts";
import { ERROR_ROUTES } from "../types.ts";
import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
// Extraction trims only the ends; interior runs of whitespace (a
// authored across indented source lines) survive. A search engine collapses
// them to single spaces before rendering, so the width is measured on the
// collapsed text — source formatting must not move a page across a threshold.
const WHITESPACE = /\s+/gu;
/** Whether front matter `date` arrived in one of YAML's two date spellings. */
const isDateValue = (
value: string | Date | undefined
): value is string | Date => typeof value === "string" || value instanceof Date;
/**
* The `date` a source file's front matter declares, when it parses to a real
* date. YAML hands back a `Date` for an unquoted `2026-01-01` and a string for
* a quoted one, so both spellings are accepted; malformed front matter or an
* unparseable value simply yields nothing — `BLUME_FRONTMATTER_INVALID` is the
* build's finding, not the audit's.
*/
const frontmatterDate = (source: string): Date | null => {
try {
const { date } = matter(source).data;
if (!isDateValue(date)) {
return null;
}
const parsed = date instanceof Date ? date : new Date(date);
return Number.isNaN(parsed.getTime()) ? null : parsed;
} catch {
return null;
}
};
const titleChecks = (
context: AuditContext,
page: PageSnapshot
): Diagnostic[] => {
const { titleMin, titleMax } = context.thresholds;
const [title] = page.titles;
if (!title) {
return [
finding(
"BLUME_AUDIT_TITLE_MISSING",
pageSite(context, page),
"Page has no ."
),
];
}
const found: Diagnostic[] = [];
if (page.titles.length > 1) {
found.push(
finding(
"BLUME_AUDIT_TITLE_MULTIPLE",
pageSite(context, page),
`Page has ${page.titles.length} tags; search engines use only the first.`
)
);
}
// Columns, not characters: a search engine truncates by the space the text
// takes up. See {@link AuditThresholds}.
const width = stringWidth(title.replaceAll(WHITESPACE, " "));
if (width > titleMax || width < titleMin) {
const direction = width > titleMax ? "long" : "short";
found.push(
finding(
"BLUME_AUDIT_TITLE_LENGTH",
pageSite(context, page, ["title"]),
`Title renders ${width} columns wide — too ${direction} (aim for ${titleMin}–${titleMax}).`
)
);
}
return found;
};
const descriptionChecks = (
context: AuditContext,
page: PageSnapshot
): Diagnostic[] => {
// An error route is noindex by design, so its description never renders as
// a search snippet — grading it would put a guaranteed finding on every
// site whose 404 inherits the site-default description.
if (ERROR_ROUTES.has(page.url)) {
return [];
}
const { descriptionMin, descriptionMax } = context.thresholds;
const [description] = page.descriptions;
if (!description) {
return [
finding(
"BLUME_AUDIT_DESCRIPTION_MISSING",
pageSite(context, page),
"Page has no meta description."
),
];
}
const found: Diagnostic[] = [];
if (page.descriptions.length > 1) {
found.push(
finding(
"BLUME_AUDIT_DESCRIPTION_MULTIPLE",
pageSite(context, page),
`Page has ${page.descriptions.length} meta description tags.`
)
);
}
// Columns, not characters. See {@link AuditThresholds}.
const width = stringWidth(description.replaceAll(WHITESPACE, " "));
if (width > descriptionMax || width < descriptionMin) {
const direction = width > descriptionMax ? "long" : "short";
found.push(
finding(
"BLUME_AUDIT_DESCRIPTION_LENGTH",
pageSite(context, page, ["description"]),
`Meta description renders ${width} columns wide — too ${direction} (aim for ${descriptionMin}–${descriptionMax}).`
)
);
}
return found;
};
const headingChecks = (
context: AuditContext,
page: PageSnapshot
): Diagnostic[] => {
const h1s = page.headings.filter((heading) => heading.depth === 1);
if (h1s.length === 0) {
return [
finding(
"BLUME_AUDIT_H1_MISSING",
pageSite(context, page),
"Page has no ."
),
];
}
if (h1s.length > 1) {
return [
finding(
"BLUME_AUDIT_H1_MULTIPLE",
pageSite(context, page),
`Page has ${h1s.length} tags: ${h1s.map((h) => `"${h.text}"`).join(", ")}.`
),
];
}
// A skipped level (h2 -> h4) breaks table-of-contents nesting and screen-
// reader outlines. The first skip is the finding — one wrong heading early
// in a page cascades, and listing every knock-on skip buries the fix.
let previous: number | null = null;
for (const heading of page.headings) {
if (previous !== null && heading.depth > previous + 1) {
return [
finding(
"BLUME_AUDIT_HEADING_SKIP",
pageSite(context, page),
`Headings jump from h${previous} to h${heading.depth} at "${heading.text}".`
),
];
}
previous = heading.depth;
}
return [];
};
/**
* A page dated in the future usually means scheduled content that leaked into
* the build early. Only the front matter knows — the rendered page looks
* perfectly ordinary.
*/
const futureDateChecks = (
context: AuditContext,
page: PageSnapshot
): Diagnostic[] => {
const source = page.source && context.sources.get(page.source);
if (!source) {
return [];
}
const date = frontmatterDate(source);
if (date && date.getTime() > Date.now()) {
return [
finding(
"BLUME_AUDIT_FUTURE_DATED_PAGE",
pageSite(context, page, ["date"]),
`Page is dated ${date.toISOString().slice(0, 10)}, which is in the future.`
),
];
}
return [];
};
/**
* Per-page head and body content: title, description, headings, word count,
* viewport.
*
* Ahrefs reports each of these twice — once for indexable pages and once for
* non-indexable ones. That's a crawler artifact: the finding is the same either
* way, so it's reported once and the page's indexability rides along on the
* snapshot.
*/
export const contentChecks: CheckModule = {
category: "content",
run(context) {
const found: Diagnostic[] = [];
for (const page of context.pages) {
found.push(
...titleChecks(context, page),
...descriptionChecks(context, page),
...headingChecks(context, page),
...futureDateChecks(context, page)
);
if (page.wordCount < context.thresholds.minWordCount && page.indexable) {
found.push(
finding(
"BLUME_AUDIT_LOW_WORD_COUNT",
pageSite(context, page),
`Page has ${page.wordCount} words of prose (excluding code blocks).`
)
);
}
if (!page.viewport) {
found.push(
finding(
"BLUME_AUDIT_VIEWPORT_MISSING",
pageSite(context, page),
"Page has no viewport — it will not render correctly on mobile."
)
);
}
}
return found;
},
tier: "static",
};