#!/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

const h1s = html.match(/]/gi) ?? []; if (h1s.length !== 1) fail(file, `expected exactly 1

, found ${h1s.length}`); // present + non-empty const title = html.match(/<title>([^<]*)<\/title>/i); if (!title || !title[1].trim()) fail(file, "missing or empty <title>"); // meta description present + non-empty const desc = html.match(/<meta[^>]*name=["']description["'][^>]*>/i); if (!desc) fail(file, "missing <meta name=description>"); else { const content = desc[0].match(/content=["']([^"']*)["']/i); if (!content || !content[1].trim()) fail(file, "empty meta description"); } // canonical present if (!/<link[^>]*rel=["']canonical["'][^>]*>/i.test(html)) fail(file, "missing <link rel=canonical>"); // every <img> has non-empty alt (decorative images must opt out via aria-hidden/role=presentation) for (const tag of html.match(/<img\b[^>]*>/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, `<img> without alt: ${tag.slice(0, 80)}`); else if (!alt[1].trim()) fail(file, `<img> with empty alt: ${tag.slice(0, 80)}`); } // every JSON-LD block parses for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) { try { JSON.parse(m[1]); } catch (e) { fail(file, `invalid JSON-LD: ${e.message}`); } } // heading order: never skip a level going deeper const levels = [...html.matchAll(/<h([1-6])[\s>]/gi)].map((m) => Number(m[1])); for (let i = 1; i < levels.length; i++) { if (levels[i] > levels[i - 1] + 1) { fail(file, `heading jumps from h${levels[i - 1]} to h${levels[i]}`); break; } } } let files = []; try { files = walk(DIST); } catch { console.error(`seo-lint: no ${DIST}/ directory — run \`npm run build\` first.`); process.exit(2); } for (const f of files) checkFile(f); if (failures.length) { console.error(`\nSEO lint FAILED — ${failures.length} issue(s) across ${files.length} page(s):\n`); for (const f of failures) console.error(` ✗ ${f}`); process.exit(1); } console.log(`SEO lint passed — ${files.length} page(s) clean.`);