Built from the kit specs (CLAUDE.md, docs/01-05): question index import, status seals, connections graph, Pagefind search, Scripture margin, and 157 evidence articles (drafts unless published) with sources and verified "Learn more" links. Includes link, owner-notes, a11y and publish scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
3.4 KiB
JavaScript
100 lines
3.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Accessibility check: runs axe-core against the built site in light and dark themes, on desktop
|
|
// and phone widths, using the Chrome already installed on this machine (no browser download).
|
|
// Run with: npm run a11y (after npm run build)
|
|
// Fails if axe reports any serious or critical issue.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { chromium } from "playwright-core";
|
|
import { AxeBuilder } from "@axe-core/playwright";
|
|
import data from "../src/data/questions.json" with { type: "json" };
|
|
|
|
const PORT = 4329;
|
|
const BASE = `http://localhost:${PORT}`;
|
|
|
|
const q = (id) => `/questions/${data.questions.find((x) => x.id === id).slug}/`;
|
|
const pages = [
|
|
"/",
|
|
"/questions/",
|
|
"/questions/?status=red",
|
|
q("3.1"), // links to a whole group
|
|
q("2.12"), // italics and curly quotes in the answer
|
|
q("3B.14"), // a "missing book" row (no domains)
|
|
q("7.9"),
|
|
`/groups/${data.groups[0].slug}/`,
|
|
"/groups/missing-books/",
|
|
"/about/",
|
|
"/connections/",
|
|
"/this-page-does-not-exist/",
|
|
];
|
|
|
|
const viewports = [
|
|
{ name: "desktop", width: 1280, height: 900 },
|
|
{ name: "phone", width: 390, height: 844 },
|
|
];
|
|
|
|
// Astro allows one preview server per project; --force replaces any that's already running.
|
|
const server = spawn("npx", ["astro", "preview", "--force", "--port", String(PORT)], {
|
|
shell: true,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
async function waitForServer() {
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
if ((await fetch(BASE)).ok) return;
|
|
} catch {}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error("Preview server did not start");
|
|
}
|
|
|
|
let failures = 0;
|
|
const browser = await chromium.launch({ channel: "chrome" });
|
|
try {
|
|
await waitForServer();
|
|
for (const theme of ["light", "dark"]) {
|
|
for (const vp of viewports) {
|
|
const context = await browser.newContext({
|
|
viewport: { width: vp.width, height: vp.height },
|
|
colorScheme: theme,
|
|
reducedMotion: "reduce",
|
|
});
|
|
const page = await context.newPage();
|
|
for (const path of pages) {
|
|
await page.goto(BASE + path, { waitUntil: "networkidle" });
|
|
const { violations } = await new AxeBuilder({ page })
|
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"])
|
|
.analyze();
|
|
const serious = violations.filter((v) => ["serious", "critical"].includes(v.impact));
|
|
const minor = violations.filter((v) => !serious.includes(v));
|
|
const label = `${theme.padEnd(5)} ${vp.name.padEnd(7)} ${path}`;
|
|
if (!violations.length) {
|
|
console.log(`ok ${label}`);
|
|
continue;
|
|
}
|
|
failures += serious.length;
|
|
console.log(`${serious.length ? "FAIL" : "note"} ${label}`);
|
|
for (const v of [...serious, ...minor]) {
|
|
console.log(` [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} element(s))`);
|
|
for (const n of v.nodes.slice(0, 3)) console.log(` ${n.target.join(" ")}`);
|
|
}
|
|
}
|
|
await context.close();
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
// Killing the npx wrapper doesn't reliably stop the server on Windows; Astro's own command does.
|
|
await new Promise((resolve) =>
|
|
spawn("npx", ["astro", "preview", "stop"], { shell: true, stdio: "ignore" }).on(
|
|
"exit",
|
|
resolve,
|
|
),
|
|
);
|
|
server.kill();
|
|
}
|
|
|
|
console.log(failures ? `\n${failures} serious issue(s).` : "\nNo serious accessibility issues.");
|
|
process.exit(failures ? 1 : 0);
|