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>
91 lines
3.1 KiB
JavaScript
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.`);
|