// The build gate. A `site/` route without a title or description does not warn — // it throws X_SEO_META_MISSING naming the exact file and the exact edit. Also // catches the three failures that only show up weeks later in Search Console: // duplicate meta, an over-length title, and a canonical that lies. import { finiteCount } from '@ultimat3/core'; import { charCount } from '@ultimat3/schema'; import { canonicalMismatch, duplicateMeta, metaMissing, metaTooLong, SeoError, type SeoErrorCode, titleTemplateSlotMissing, } from './errors'; import { linkError, linkProblem } from './links'; import { applyTitleTemplate, DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH, TITLE_SLOT } from './meta'; import { indexableRoutes, isDynamic, type RouteRecord } from './routes'; import { absoluteUrl } from './xml'; export interface MetaIssue { readonly code: string; readonly route: string; readonly file: string; readonly cause: string; readonly fix: string; } /** `--json`-shaped: the CLI prints this verbatim under `x verify --json`. */ export interface MetaValidationReport { readonly ok: boolean; readonly checked: number; readonly issues: readonly MetaIssue[]; } export interface ValidateMetaOptions { /** Required to check canonicals. Without it, canonical checks are skipped. */ baseUrl?: string; titleMaxLength?: number; descriptionMaxLength?: number; /** Report duplicates across routes. On by default. */ checkDuplicates?: boolean; } function issueOf(error: SeoError, route: string, file: string): MetaIssue { return { code: error.code, route, file, cause: error.cause, fix: error.fix }; } export function validateMeta( routes: readonly RouteRecord[], options: ValidateMetaOptions = {}, ): MetaValidationReport { // `rendered.length > NaN` is false for every title, so a ceiling that is not a number is not a // loose rule — it is no rule, and the report comes back clean on a site whose every title is // truncated in the SERP. // // `=== undefined`, never `??`: `??` also coalesces on `null`, so an explicit `null` from a // decoded JSON config was swapped for the default BEFORE `finiteCount` could refuse it. Only an // ABSENT option takes the default; a present one that is not a number is a refusal naming it. const titleMax = finiteCount( 'validateMeta', 'titleMaxLength', options.titleMaxLength === undefined ? TITLE_MAX_LENGTH : options.titleMaxLength, 1, ); const descriptionMax = finiteCount( 'validateMeta', 'descriptionMaxLength', options.descriptionMaxLength === undefined ? DESCRIPTION_MAX_LENGTH : options.descriptionMaxLength, 1, ); const checked = indexableRoutes(routes); const issues: MetaIssue[] = []; const titles = new Map(); const descriptions = new Map(); for (const route of checked) { const meta = route.meta ?? {}; if (meta.title === undefined || meta.title.trim() === '') { issues.push(issueOf(metaMissing(route.file, route.path, 'title'), route.path, route.file)); } else { // An empty template is "no template" and applies nothing; a non-empty one that cannot place // the title silently discards it, which is the one the renderer cannot report. const template = meta.titleTemplate ?? ''; if (template !== '' && !template.includes(TITLE_SLOT)) { issues.push( issueOf(titleTemplateSlotMissing(route.file, route.path), route.path, route.file), ); } const rendered = applyTitleTemplate(meta.title, meta.titleTemplate); // `charCount`, never `.length`: the framework counts a length in CODE POINTS — the unit // JSON Schema's `maxLength` means, the unit every "N characters" message here says, and the // unit Postgres' `char_length` counts. `'👍'.length` is 2, so a title one emoji short of the // ceiling was refused as over it and the cause quoted a number nobody can reproduce by // counting. One statement of the unit, in `@ultimat3/schema`, read here. const renderedLength = charCount(rendered); if (renderedLength > titleMax) { issues.push( issueOf( metaTooLong(route.file, 'title', renderedLength, titleMax), route.path, route.file, ), ); } push(titles, rendered, route.file); } if (meta.description === undefined || meta.description.trim() === '') { issues.push( issueOf(metaMissing(route.file, route.path, 'description'), route.path, route.file), ); } else { const descriptionLength = charCount(meta.description); if (descriptionLength > descriptionMax) { issues.push( issueOf( metaTooLong(route.file, 'description', descriptionLength, descriptionMax), route.path, route.file, ), ); } push(descriptions, meta.description, route.file); } // The same predicate the renderer throws on, reported here at the route FILE and before any // request: an unsafe href in a static route's meta fails the build rather than a visitor. for (const link of meta.links ?? []) { const problem = linkProblem(link); if (problem !== undefined) { issues.push(issueOf(linkError(link, problem, route.file), route.path, route.file)); } } // A canonical is only checkable for a static path: a dynamic route's // canonical is produced per-item at render time. if (meta.canonical !== undefined && options.baseUrl !== undefined && !isDynamic(route.path)) { const expected = absoluteUrl(options.baseUrl, route.path); const actual = absoluteUrl(options.baseUrl, meta.canonical); if (actual !== expected) { issues.push( issueOf(canonicalMismatch(route.file, actual, expected), route.path, route.file), ); } } } if (options.checkDuplicates !== false) { issues.push(...duplicates(titles, 'title'), ...duplicates(descriptions, 'description')); } return { ok: issues.length === 0, checked: checked.length, issues }; } function push(index: Map, key: string, file: string): void { const existing = index.get(key); if (existing === undefined) index.set(key, [file]); else existing.push(file); } function duplicates(index: Map, field: string): MetaIssue[] { const out: MetaIssue[] = []; for (const [value, files] of index) { if (files.length < 2) continue; const error = duplicateMeta(field, value, files); out.push({ code: error.code, route: files.join(', '), file: files[0] ?? '', cause: error.cause, fix: error.fix, }); } return out; } /** * Throws on the first issue. `x verify` does NOT call this — `As of 2026-08` nothing outside this * package imports it, and wiring it is a `HostCheck` on an existing step in * `packages/cli/src/cmd-verify.ts`. Today it fails an app that calls it itself, which is what * `README.md` says and what this line claimed the opposite of. */ export function assertMeta(report: MetaValidationReport): void { const first = report.issues[0]; if (first === undefined) return; throw new SeoError({ code: first.code as SeoErrorCode, cause: first.cause, fix: first.fix, meta: { route: first.route, file: first.file, totalIssues: report.issues.length }, }); }