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>
This commit is contained in:
commit
cd91c4b97c
92 files changed
+10246
No files matched your search
@@ -0,0 +1,90 @@
|
||||
#!/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.`);
|
||||
Reference in new issue
Block a user