import type { BlumeProject } from "../core/project-graph.ts";
import type {
Diagnostic,
DiagnosticSeverity,
RouteManifestEntry,
} from "../core/types.ts";
/**
* What a check needs in order to run. Anything above `static` is opt-in, and a
* skipped tier is reported rather than silently omitted — a crawler that
* quietly doesn't check something is worse than one that says it didn't.
*/
export type AuditTier = "static" | "network" | "external";
export type AuditCategory =
| "content"
| "duplicates"
| "indexability"
| "links"
| "redirects"
| "social"
| "i18n"
| "assets"
| "sitemap"
| "robots"
| "structured-data"
| "ai"
| "network";
/** A check's static metadata. The catalog is the source of truth for all of it. */
export interface CheckMeta {
readonly id: string;
readonly category: AuditCategory;
readonly severity: DiagnosticSeverity;
/** Human title used as the report's group header, e.g. "Title too long". */
readonly title: string;
readonly tier: AuditTier;
/** Default remediation, used as the finding's `suggestion`. */
readonly fix?: string;
}
/** A ``/`` discovered in built HTML. */
export interface SnapshotLink {
href: string;
rel: string | null;
text: string;
/**
* Whether the link sits in the page's prose (``/``) rather
* than site chrome (nav/sidebar/header/footer). Load-bearing: Blume's sidebar
* links every page from every page, so a link graph that can't tell the two
* apart reports zero orphans, forever.
*/
content: boolean;
}
/** An image/script/stylesheet referenced by a built page. */
export interface SnapshotAsset {
src: string;
alt?: string | null;
width?: string | null;
height?: string | null;
/** Absolute path in the static dir, when the ref resolves to a local file. */
file?: string;
bytes?: number;
}
/** Everything one built HTML page contributes to the audit. */
export interface PageSnapshot {
/** Absolute path of the built `.html`. */
file: string;
/** Site-root-relative URL, e.g. `/docs/getting-started`. */
url: string;
bytes: number;
/** The manifest entry this page renders, when it maps to authored content. */
route?: RouteManifestEntry;
/** `route.sourcePath` — the `.mdx` a finding should point the user at. */
source?: string;
indexable: boolean;
lang: string | null;
/** Every ``; more than one is itself a finding. */
titles: string[];
descriptions: string[];
canonical: string | null;
robots: string | null;
viewport: string | null;
metaRefresh: string | null;
headings: { depth: number; text: string }[];
og: Record;
twitter: Record;
hreflang: { lang: string; href: string }[];
jsonld: unknown[];
/** JSON-LD blocks that failed to parse, with the parser's message. */
jsonldErrors: string[];
links: SnapshotLink[];
images: SnapshotAsset[];
scripts: SnapshotAsset[];
styles: SnapshotAsset[];
wordCount: number;
/** Hash of the normalized prose, for exact-duplicate detection. */
contentHash: string;
/** Every element `id` on the page — the targets `#fragment` links can hit. */
ids: Set;
}
/** A configured redirect resolved through to its final destination. */
export interface RedirectResolution {
from: string;
to: string;
status: number;
/** Every hop from `from` to the final target, inclusive. */
chain: string[];
outcome: "ok" | "loop" | "broken" | "chain";
}
/** A parsed `sitemap.xml`. */
export interface SitemapDoc {
file: string;
bytes: number;
/** Absolute `` URLs, in document order. */
urls: string[];
/** Each `` block's ``, keyed by its ``. */
lastmod?: Map;
/** Parse failure, when the document isn't usable. */
error?: string;
}
/** A parsed `llms.txt` index. */
export interface LlmsDoc {
file: string;
/** Markdown link targets in document order, with their 1-based line. */
entries: { url: string; line: number }[];
}
/** A parsed `robots.txt`. */
export interface RobotsDoc {
file: string;
/**
* The file's full text. Rule matching runs through robots-parser (which
* owns longest-match Allow/Disallow semantics), so the raw text is the
* source of truth rather than a pre-extracted rule list.
*/
raw: string;
/** `Sitemap:` declarations. */
sitemaps: string[];
/** Lines that aren't a recognized directive, with their 1-based line number. */
invalid: { line: number; text: string }[];
}
/** Incoming/outgoing internal-link edges, split by where the link sits. */
export interface LinkGraph {
/** url -> urls it links to from its prose. */
contentOut: Map>;
/** url -> urls whose prose links to it. */
contentIn: Map>;
/** url -> urls it links to from chrome (nav/sidebar/footer). */
chromeOut: Map>;
chromeIn: Map>;
}
/** Astro's reserved error routes. Never indexable, never crawlable — by design. */
export const ERROR_ROUTES: ReadonlySet = new Set(["/404", "/500"]);
/**
* Tunable limits. Not yet configurable — CLI-only until the ids settle.
*
* The title and description limits are measured in **display columns**, where
* a fullwidth or wide character counts 2 and everything else 1 — not in
* characters. What a search engine truncates is the space the text takes up,
* and a character count only stands in for that where every character is one
* column wide, which is true of Latin text and of nothing else. Counted in
* characters one range cannot serve both scripts: it is at once too strict for
* a Japanese description (which says in ~60 characters what English needs ~120
* for, and so reads as "too short") and too loose for a Japanese title (60
* characters render as wide as 120 Latin ones, and truncate). ASCII and
* precomposed (NFC) Latin text score identically either way; a decomposed
* (NFD) combining mark counts 0 where a character count saw 1. East-Asian
* "ambiguous"-width characters (★ ※ ①) count 1 per `string-width`'s default,
* though CJK fonts render them fullwidth — resolving that would need the
* page's language, which the audit does not thread through yet.
*/
export interface AuditThresholds {
/** Display columns, not characters. */
titleMin: number;
/** Display columns, not characters. */
titleMax: number;
/** Display columns, not characters. */
descriptionMin: number;
/** Display columns, not characters. */
descriptionMax: number;
minWordCount: number;
maxHtmlBytes: number;
maxAssetBytes: number;
maxRedirectHops: number;
}
export const DEFAULT_THRESHOLDS: AuditThresholds = {
// Ahrefs' guidance: 110–160. Under ~110 wastes the snippet space search
// results give you; over ~160 gets truncated. Stated there in characters of
// English, which is the same number of columns.
descriptionMax: 160,
descriptionMin: 110,
maxAssetBytes: 500 * 1024,
// Googlebot stops reading an HTML document at 2 MB.
maxHtmlBytes: 2 * 1024 * 1024,
maxRedirectHops: 3,
minWordCount: 50,
titleMax: 60,
titleMin: 10,
};
/** Everything the check modules read. Assembled once per run. */
export interface AuditContext {
project: BlumeProject;
staticDir: string;
/** Origin passed via `--url`, for the network tier. */
origin: string | null;
pages: PageSnapshot[];
byUrl: Map;
/** Every file in the static dir: URL path -> size in bytes. */
files: Map;
/**
* Raw text of every page's source file, keyed by absolute path. Read once so
* findings can be anchored to the exact front matter line that fixes them.
*/
sources: Map;
graph: LinkGraph;
redirects: RedirectResolution[];
sitemap: SitemapDoc | null;
robots: RobotsDoc | null;
llms: LlmsDoc | null;
thresholds: AuditThresholds;
}
/** One category's checks. Modules, not per-check closures — see catalog.ts. */
export interface CheckModule {
readonly category: AuditCategory;
readonly tier: AuditTier;
readonly run: (context: AuditContext) => Diagnostic[] | Promise;
}