#!/usr/bin/env node
// Build-time SEO linter (ADR-0003, docs/seo-aeo-optimization.md §1b).
// Walks dist/**/*.html and fails (exit 1) on structural SEO violations that the Zod frontmatter
// schema cannot see in rendered HTML. Dependency-free.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const DIST = "dist";
const failures = [];
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (entry.endsWith(".html")) out.push(full);
}
return out;
}
function fail(file, msg) {
failures.push(`${relative(DIST, file)}: ${msg}`);
}
function checkFile(file) {
const html = readFileSync(file, "utf8");
// Exactly one
, found ${h1s.length}`);
// present + non-empty
const title = html.match(/([^<]*)<\/title>/i);
if (!title || !title[1].trim()) fail(file, "missing or empty ");
// meta description present + non-empty
const desc = html.match(/]*name=["']description["'][^>]*>/i);
if (!desc) fail(file, "missing ");
else {
const content = desc[0].match(/content=["']([^"']*)["']/i);
if (!content || !content[1].trim()) fail(file, "empty meta description");
}
// canonical present
if (!/]*rel=["']canonical["'][^>]*>/i.test(html)) fail(file, "missing ");
// every has non-empty alt (decorative images must opt out via aria-hidden/role=presentation)
for (const tag of html.match(/]*>/gi) ?? []) {
const decorative = /\baria-hidden=["']true["']/i.test(tag) || /\brole=["']presentation["']/i.test(tag);
if (decorative) continue;
const alt = tag.match(/\balt=["']([^"']*)["']/i);
if (!alt) fail(file, ` without alt: ${tag.slice(0, 80)}`);
else if (!alt[1].trim()) fail(file, ` with empty alt: ${tag.slice(0, 80)}`);
}
// every JSON-LD block parses
for (const m of html.matchAll(/