bcs-website/scripts/seo-lint.mjs
Brad Rodgers cd91c4b97c Initial commit: BlueCap Strategies Astro site
Static Astro 5 site for BlueCap Strategies with exact live-site content
parity, deployed to k3s via GitOps (ArgoCD).

- Page copy in Markdown content collections (services, focus-areas, pages,
  insights) with a Zod SEO schema enforced at build time
- Homepage + About restored to exact live copy; real live-site imagery
- Build-time SEO linter (scripts/seo-lint.mjs) and Playwright e2e suite
- Multi-stage Dockerfile (nginx serves dist/) and Kustomize manifests (k8s/)
- Per-agent robots.txt; config-driven PostHog + Umami analytics scaffold
- ADRs and engineering docs under docs/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:40:46 -04:00

91 lines
3.1 KiB
JavaScript

#!/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 <h1>
const h1s = html.match(/<h1[\s>]/gi) ?? [];
if (h1s.length !== 1) fail(file, `expected exactly 1 <h1>, found ${h1s.length}`);
// <title> 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.`);