wcag-a11y 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # WCAG A11y
2
+
3
+ WCAG 2.1/2.2 accessibility CLI auditor with AI-powered fixes.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g wcag-a11y
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ wcag-a11y init # creates a11y.config.json
15
+ # Add your free Gemini API key from https://aistudio.google.com
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ # Scan a single page
22
+ wcag-a11y scan --url http://localhost:3000
23
+
24
+ # Scan specific pages
25
+ wcag-a11y scan --url http://localhost:3000 --pages / /about /contact
26
+
27
+ # Auto-crawl all pages + generate report
28
+ wcag-a11y scan --url http://localhost:3000 --crawl --report
29
+
30
+ # Skip AI (violations only, no fixes)
31
+ wcag-a11y scan --url http://localhost:3000 --no-ai
32
+ ```
33
+
34
+ ## Config (`a11y.config.json`)
35
+
36
+ ```json
37
+ {
38
+ "provider": "gemini",
39
+ "apiKey": "YOUR_FREE_KEY",
40
+ "model": "gemini-2.0-flash"
41
+ }
42
+ ```
43
+
44
+ For local AI (no API key): set `"provider": "ollama"` and run `ollama serve`.
@@ -0,0 +1,57 @@
1
+ import { buildPrompt } from './prompt.js';
2
+ export class GeminiProvider {
3
+ apiKey;
4
+ model;
5
+ constructor(apiKey, model = 'gemini-2.0-flash') {
6
+ this.apiKey = apiKey;
7
+ this.model = model;
8
+ }
9
+ async generateFixes(violations) {
10
+ if (violations.length === 0)
11
+ return [];
12
+ const prompt = buildPrompt(violations);
13
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
14
+ const response = await fetch(url, {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ body: JSON.stringify({
18
+ contents: [{ parts: [{ text: prompt }] }],
19
+ generationConfig: { temperature: 0.2, maxOutputTokens: 8192 },
20
+ }),
21
+ });
22
+ if (!response.ok) {
23
+ throw new Error(`Gemini API error: ${response.status} ${await response.text()}`);
24
+ }
25
+ const data = await response.json();
26
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
27
+ return this.parse(text, violations);
28
+ }
29
+ parse(text, violations) {
30
+ try {
31
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
32
+ const fixes = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
33
+ return violations.map((v) => {
34
+ const found = fixes.find((f) => f.ruleId === v.ruleId && f.selector === v.selector)
35
+ ?? fixes.find((f) => f.ruleId === v.ruleId);
36
+ return found ?? {
37
+ ruleId: v.ruleId,
38
+ selector: v.selector,
39
+ explanation: v.description,
40
+ fixedCode: v.html,
41
+ wcagReference: `WCAG 2.1 SC ${v.wcag}`,
42
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${v.selector}\` violates ${v.wcag} — ${v.description}. Please fix it in the codebase.`,
43
+ };
44
+ });
45
+ }
46
+ catch {
47
+ return violations.map((v) => ({
48
+ ruleId: v.ruleId,
49
+ selector: v.selector,
50
+ explanation: v.description,
51
+ fixedCode: v.html,
52
+ wcagReference: `WCAG 2.1 SC ${v.wcag}`,
53
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${v.selector}\` violates ${v.wcag} — ${v.description}. Please fix it in the codebase.`,
54
+ }));
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,12 @@
1
+ import { GeminiProvider } from './gemini.js';
2
+ import { OllamaProvider } from './ollama.js';
3
+ export function createAIProvider(config) {
4
+ if (config.provider === 'ollama') {
5
+ return new OllamaProvider(config.ollamaBaseUrl, config.ollamaModel);
6
+ }
7
+ if (!config.apiKey) {
8
+ console.error('No API key found. Run: wcag-a11y init');
9
+ process.exit(1);
10
+ }
11
+ return new GeminiProvider(config.apiKey, config.model);
12
+ }
@@ -0,0 +1,55 @@
1
+ import { buildPrompt } from './prompt.js';
2
+ export class OllamaProvider {
3
+ baseUrl;
4
+ model;
5
+ constructor(baseUrl = 'http://localhost:11434', model = 'llama3') {
6
+ this.baseUrl = baseUrl;
7
+ this.model = model;
8
+ }
9
+ async generateFixes(violations) {
10
+ if (violations.length === 0)
11
+ return [];
12
+ const prompt = buildPrompt(violations);
13
+ const response = await fetch(`${this.baseUrl}/api/generate`, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify({ model: this.model, prompt, stream: false }),
17
+ });
18
+ if (!response.ok) {
19
+ throw new Error(`Ollama error: ${response.status}. Is Ollama running? Run: ollama serve`);
20
+ }
21
+ const data = await response.json();
22
+ const text = data.response ?? '[]';
23
+ try {
24
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
25
+ if (!jsonMatch)
26
+ return this.fallback(violations);
27
+ const fixes = JSON.parse(jsonMatch[0]);
28
+ return violations.map((v) => {
29
+ const found = fixes.find((f) => f.ruleId === v.ruleId && f.selector === v.selector)
30
+ ?? fixes.find((f) => f.ruleId === v.ruleId);
31
+ return found ?? {
32
+ ruleId: v.ruleId,
33
+ selector: v.selector,
34
+ explanation: v.description,
35
+ fixedCode: v.html,
36
+ wcagReference: `WCAG 2.1 SC ${v.wcag}`,
37
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${v.selector}\` violates ${v.wcag} — ${v.description}. Please fix it in the codebase.`,
38
+ };
39
+ });
40
+ }
41
+ catch {
42
+ return this.fallback(violations);
43
+ }
44
+ }
45
+ fallback(violations) {
46
+ return violations.map((v) => ({
47
+ ruleId: v.ruleId,
48
+ selector: v.selector,
49
+ explanation: v.description,
50
+ fixedCode: v.html,
51
+ wcagReference: `WCAG 2.1 SC ${v.wcag}`,
52
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${v.selector}\` violates ${v.wcag} — ${v.description}. Please fix it in the codebase.`,
53
+ }));
54
+ }
55
+ }
@@ -0,0 +1,20 @@
1
+ export function buildPrompt(violations) {
2
+ const items = violations
3
+ .map((v, i) => `${i + 1}. Rule: ${v.ruleId} | WCAG ${v.wcag} (Level ${v.level}) | Impact: ${v.impact}
4
+ Page: ${v.page}
5
+ Element: ${v.html}
6
+ Problem: ${v.description}`)
7
+ .join('\n\n');
8
+ return `You are a WCAG accessibility expert. Analyze these violations and return a JSON array.
9
+ Each item must have:
10
+ - "ruleId": the rule id from the input
11
+ - "explanation": 1-2 sentences explaining why this matters for users with disabilities (plain English, no jargon)
12
+ - "fixedCode": the corrected HTML snippet only (no explanation, just code)
13
+ - "wcagReference": e.g. "WCAG 2.1 SC 1.1.1 — Non-text Content"
14
+ - "optimalPrompt": a ready-to-paste prompt the developer can give to an AI coding assistant (Cursor, GitHub Copilot, Claude) to fix this issue in their codebase. Include the violating element, what rule it breaks, and exactly what change is needed. Be specific and actionable.
15
+
16
+ Return ONLY a valid JSON array. No markdown, no code fences, no explanation outside the JSON.
17
+
18
+ Violations:
19
+ ${items}`;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { loadConfig, initConfig } from './config.js';
4
+ import { crawl } from './crawler.js';
5
+ import { createAIProvider } from './ai/index.js';
6
+ import { printTerminalReport, printAIPrompts } from './reporter/terminal.js';
7
+ import { generateMarkdownReport } from './reporter/markdown.js';
8
+ import { runDemo } from './demo.js';
9
+ const program = new Command();
10
+ program
11
+ .name('wcag-a11y')
12
+ .description('WCAG 2.1/2.2 accessibility auditor with AI-powered fixes')
13
+ .version('0.1.0');
14
+ program
15
+ .command('init')
16
+ .description('Create a11y.config.json in the current directory')
17
+ .action(() => {
18
+ initConfig();
19
+ });
20
+ program
21
+ .command('scan')
22
+ .description('Scan a running dev server for accessibility violations')
23
+ .requiredOption('-u, --url <url>', 'Base URL of your dev server (e.g. http://localhost:3000)')
24
+ .option('-p, --pages <pages...>', 'Specific pages to scan (e.g. / /about /contact)', ['/'])
25
+ .option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
26
+ .option('-r, --report', 'Save a full markdown report to a11y-report.md', false)
27
+ .option('--no-ai', 'Skip AI fix generation (faster, violations only)')
28
+ .action(async (opts) => {
29
+ try {
30
+ console.log(`\nScanning ${opts.url}...`);
31
+ const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl });
32
+ printTerminalReport(result);
33
+ if (opts.ai && result.totalViolations > 0) {
34
+ const config = loadConfig();
35
+ const provider = createAIProvider(config);
36
+ const allViolations = result.pages.flatMap((p) => p.violations);
37
+ console.log(`\nGenerating AI fixes for ${allViolations.length} violations...`);
38
+ const fixes = await provider.generateFixes(allViolations);
39
+ printAIPrompts(fixes);
40
+ if (opts.report) {
41
+ generateMarkdownReport(result, fixes);
42
+ }
43
+ }
44
+ else if (opts.report) {
45
+ generateMarkdownReport(result, []);
46
+ }
47
+ }
48
+ catch (err) {
49
+ console.error(`\nError: ${err.message}`);
50
+ process.exit(1);
51
+ }
52
+ });
53
+ program
54
+ .command('demo')
55
+ .description('Scan a built-in demo page with intentional violations — no dev server needed')
56
+ .option('-r, --report', 'Save a full markdown report to a11y-report.md', false)
57
+ .option('--no-ai', 'Skip AI fix generation (faster, violations only)')
58
+ .action(async (opts) => {
59
+ try {
60
+ await runDemo({ ai: opts.ai, report: opts.report });
61
+ }
62
+ catch (err) {
63
+ console.error(`\nError: ${err.message}`);
64
+ process.exit(1);
65
+ }
66
+ });
67
+ program.parse();
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readFileSync, existsSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ const CONFIG_FILE = 'a11y.config.json';
4
+ const DEFAULTS = {
5
+ provider: 'gemini',
6
+ model: 'gemini-2.0-flash',
7
+ ollamaBaseUrl: 'http://localhost:11434',
8
+ ollamaModel: 'llama3',
9
+ };
10
+ export function loadConfig() {
11
+ const configPath = join(process.cwd(), CONFIG_FILE);
12
+ if (!existsSync(configPath)) {
13
+ console.error(`No ${CONFIG_FILE} found. Run: wcag-a11y init`);
14
+ process.exit(1);
15
+ }
16
+ const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
17
+ return { ...DEFAULTS, ...raw };
18
+ }
19
+ export function initConfig() {
20
+ const configPath = join(process.cwd(), CONFIG_FILE);
21
+ if (existsSync(configPath)) {
22
+ console.log(`${CONFIG_FILE} already exists.`);
23
+ return;
24
+ }
25
+ const starter = {
26
+ provider: 'gemini',
27
+ apiKey: 'YOUR_GEMINI_API_KEY',
28
+ model: 'gemini-2.0-flash',
29
+ };
30
+ writeFileSync(configPath, JSON.stringify(starter, null, 2));
31
+ console.log(`Created ${CONFIG_FILE} — add your Gemini API key from https://aistudio.google.com`);
32
+ }
@@ -0,0 +1,54 @@
1
+ import { chromium } from 'playwright';
2
+ import { scanPage } from './engine/index.js';
3
+ export async function crawl(options) {
4
+ const { url, pages = ['/'], crawl: autoCrawl = false } = options;
5
+ const baseUrl = url.replace(/\/$/, '');
6
+ const browser = await chromium.launch({ headless: true });
7
+ try {
8
+ const context = await browser.newContext();
9
+ let pagesToVisit = pages.map((p) => `${baseUrl}${p}`);
10
+ if (autoCrawl) {
11
+ const discoveredPage = await context.newPage();
12
+ try {
13
+ await discoveredPage.goto(baseUrl, { waitUntil: 'networkidle' });
14
+ const hrefs = await discoveredPage.$$eval('a[href]', (anchors) => anchors.map((a) => a.href));
15
+ const sameOrigin = hrefs
16
+ .filter((href) => href.startsWith(baseUrl))
17
+ .map((href) => href.split('#')[0])
18
+ .filter((v, i, arr) => arr.indexOf(v) === i);
19
+ pagesToVisit = sameOrigin.length > 0 ? sameOrigin : pagesToVisit;
20
+ }
21
+ finally {
22
+ await discoveredPage.close();
23
+ }
24
+ }
25
+ const results = [];
26
+ for (const pageUrl of pagesToVisit) {
27
+ const page = await context.newPage();
28
+ try {
29
+ await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
30
+ const result = await scanPage(page, pageUrl);
31
+ results.push(result);
32
+ }
33
+ catch (err) {
34
+ console.error(`Failed to scan ${pageUrl}: ${err.message}`);
35
+ results.push({ url: pageUrl, violations: [] });
36
+ }
37
+ finally {
38
+ await page.close();
39
+ }
40
+ }
41
+ const allViolations = results.flatMap((r) => r.violations);
42
+ return {
43
+ pages: results,
44
+ totalViolations: allViolations.length,
45
+ criticalCount: allViolations.filter((v) => v.impact === 'critical').length,
46
+ seriousCount: allViolations.filter((v) => v.impact === 'serious').length,
47
+ moderateCount: allViolations.filter((v) => v.impact === 'moderate').length,
48
+ minorCount: allViolations.filter((v) => v.impact === 'minor').length,
49
+ };
50
+ }
51
+ finally {
52
+ await browser.close();
53
+ }
54
+ }
package/dist/demo.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createServer } from 'http';
2
+ import { crawl } from './crawler.js';
3
+ import { createAIProvider } from './ai/index.js';
4
+ import { loadConfig } from './config.js';
5
+ import { printTerminalReport, printAIPrompts } from './reporter/terminal.js';
6
+ import { generateMarkdownReport } from './reporter/markdown.js';
7
+ const DEMO_HTML = `<!DOCTYPE html>
8
+ <html lang="">
9
+ <head>
10
+ <meta charset="UTF-8">
11
+ <title></title>
12
+ </head>
13
+ <body>
14
+
15
+ <h1>Welcome to Acme Shop</h1>
16
+
17
+ <!-- 1. img-alt: missing alt -->
18
+ <img src="banner.jpg">
19
+
20
+ <!-- 2. color contrast: fails 4.5:1 -->
21
+ <p style="color:#aaa;background:#fff;font-size:14px;">
22
+ Summer sale — up to 50% off selected items.
23
+ </p>
24
+
25
+ <!-- 3. keyboard: div with click but no role/tabindex -->
26
+ <div onclick="addToCart()">Add to Cart</div>
27
+
28
+ <!-- 4. form: input with no label -->
29
+ <form id="newsletter">
30
+ <input type="email" placeholder="your@email.com">
31
+ <button type="submit"></button>
32
+ </form>
33
+
34
+ <!-- 5. ARIA: invalid role -->
35
+ <div role="widget" id="promo-banner">Special offer!</div>
36
+
37
+ <!-- 6. Structure: heading skips h2 → h4 -->
38
+ <h4>Featured Products</h4>
39
+
40
+ <!-- 7. Link: non-descriptive text -->
41
+ <a href="/sale">Click here</a>
42
+
43
+ <!-- 8. Media: video without captions -->
44
+ <video src="promo.mp4" controls></video>
45
+
46
+ <!-- 9. ARIA: aria-hidden but focusable -->
47
+ <button aria-hidden="true" tabindex="0">Hidden action</button>
48
+
49
+ <!-- 10. Link: empty anchor -->
50
+ <a href="/about"></a>
51
+
52
+ </body>
53
+ </html>`;
54
+ function startDemoServer() {
55
+ return new Promise((resolve) => {
56
+ const server = createServer((_, res) => {
57
+ res.writeHead(200, { 'Content-Type': 'text/html' });
58
+ res.end(DEMO_HTML);
59
+ });
60
+ server.listen(0, '127.0.0.1', () => {
61
+ const addr = server.address();
62
+ resolve({ server, url: `http://127.0.0.1:${addr.port}` });
63
+ });
64
+ });
65
+ }
66
+ export async function runDemo(opts) {
67
+ const { server, url } = await startDemoServer();
68
+ try {
69
+ console.log('\nRunning demo scan against a built-in page with intentional WCAG violations...\n');
70
+ const result = await crawl({ url, pages: ['/'] });
71
+ printTerminalReport(result);
72
+ if (opts.ai && result.totalViolations > 0) {
73
+ const config = loadConfig();
74
+ const provider = createAIProvider(config);
75
+ const violations = result.pages.flatMap((p) => p.violations);
76
+ console.log(`\nGenerating AI fixes for ${violations.length} violations...`);
77
+ const fixes = await provider.generateFixes(violations);
78
+ printAIPrompts(fixes);
79
+ if (opts.report)
80
+ generateMarkdownReport(result, fixes);
81
+ }
82
+ else if (opts.report) {
83
+ generateMarkdownReport(result, []);
84
+ }
85
+ }
86
+ finally {
87
+ server.close();
88
+ }
89
+ }
@@ -0,0 +1,56 @@
1
+ import { textAlternativeRules } from './rules/text-alternatives.js';
2
+ import { colorContrastRules } from './rules/color-contrast.js';
3
+ import { keyboardRules } from './rules/keyboard.js';
4
+ import { formRules } from './rules/forms.js';
5
+ import { ariaRules } from './rules/aria.js';
6
+ import { structureRules } from './rules/structure.js';
7
+ import { linkRules } from './rules/links.js';
8
+ import { languageRules } from './rules/language.js';
9
+ import { mediaRules } from './rules/media.js';
10
+ import { tableRules } from './rules/tables.js';
11
+ const ALL_RULES = [
12
+ ...textAlternativeRules,
13
+ ...colorContrastRules,
14
+ ...keyboardRules,
15
+ ...formRules,
16
+ ...ariaRules,
17
+ ...structureRules,
18
+ ...linkRules,
19
+ ...languageRules,
20
+ ...mediaRules,
21
+ ...tableRules,
22
+ ];
23
+ export async function scanPage(page, url) {
24
+ const violations = [];
25
+ for (const rule of ALL_RULES) {
26
+ // Serialize the check function and run it inside the browser page
27
+ const ruleViolations = await page.evaluate(({ checkFn, meta }) => {
28
+ // eslint-disable-next-line no-new-func
29
+ // __name is injected by esbuild/tsx at compile time but is not available
30
+ // inside page.evaluate's isolated context — provide a no-op shim.
31
+ const fn = new Function(`var __name=(t,_)=>t; return (${checkFn})`)();
32
+ const results = fn();
33
+ return results.map((r) => ({
34
+ ruleId: meta.id,
35
+ wcag: meta.wcag,
36
+ level: meta.level,
37
+ impact: meta.impact,
38
+ description: meta.description,
39
+ selector: r.selector,
40
+ html: r.html,
41
+ page: window.location.href,
42
+ }));
43
+ }, {
44
+ checkFn: rule.check.toString(),
45
+ meta: {
46
+ id: rule.id,
47
+ wcag: rule.wcag,
48
+ level: rule.level,
49
+ impact: rule.impact,
50
+ description: rule.description,
51
+ },
52
+ });
53
+ violations.push(...ruleViolations);
54
+ }
55
+ return { url, violations };
56
+ }