wcag-a11y 0.3.3 → 0.3.5

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 CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [![CI](https://github.com/Dannyplusplus12/WCAG-A11y/actions/workflows/ci.yml/badge.svg)](https://github.com/Dannyplusplus12/WCAG-A11y/actions/workflows/ci.yml)
4
4
 
5
- Most accessibility auditors stop at detection — they tell you *what* is broken and leave the rest to you. `wcag-a11y` goes further. It crawls your running dev server with Playwright, runs 40+ WCAG 2.1/2.2 checks, and uses AI to generate a ready-to-paste fix prompt for each violation. You paste it into Cursor, Copilot, or Claude and the fix writes itself.
5
+ Most accessibility auditors stop at detection — they tell you *what* is broken and leave the rest to you. `wcag-a11y` goes further. It crawls your running dev server with Playwright, runs 40+ WCAG 2.1/2.2 checks, and uses AI to generate ready-to-paste fix prompts **or write the fixes directly into your source files**.
6
6
 
7
- The goal is to close the loop between finding an accessibility issue and actually fixing it, without interrupting your existing workflow.
7
+ Two modes:
8
+ - **`scan`** — find violations + generate AI prompts you paste into Cursor, Copilot, or Claude
9
+ - **`fix`** — find violations + patch source files automatically (dry-run by default, `--apply` to write)
8
10
 
9
11
  ---
10
12
 
@@ -140,6 +142,60 @@ wcag-a11y scan -u http://localhost:3000 --no-ai --ci
140
142
 
141
143
  ---
142
144
 
145
+ ### `wcag-a11y fix`
146
+
147
+ Scan for violations and apply AI-generated patches directly to your source files. Works with any framework — React, Vue, Angular, Svelte, or plain HTML.
148
+
149
+ ```bash
150
+ # Dry-run: scan and show what would change (safe, no files written)
151
+ wcag-a11y fix -u http://localhost:3000
152
+
153
+ # Preview specific pages
154
+ wcag-a11y fix -u http://localhost:3000 --pages / /about /contact
155
+
156
+ # Write fixes to disk
157
+ wcag-a11y fix -u http://localhost:3000 --apply
158
+
159
+ # Auto-discover pages + write fixes
160
+ wcag-a11y fix -u http://localhost:3000 --crawl --apply
161
+ ```
162
+
163
+ **How it works:**
164
+
165
+ 1. Runs the same scan as `wcag-a11y scan`
166
+ 2. For each violation, locates the source file — checks `violation.source` (React dev mode) first, then falls back to grepping `./src` for unique identifiers in the HTML snippet (`id=`, `name=`, `for=`, local `src=`, text content)
167
+ 3. Groups violations by file (multiple violations in the same file → one AI call)
168
+ 4. Sends the full file content + violation list to your configured AI provider and asks for the corrected file
169
+ 5. Shows a colored diff before writing anything
170
+ 6. With `--apply`, overwrites the file; without it, only prints the diff
171
+
172
+ ```
173
+ src/components/Navbar.jsx — 2 violation(s)
174
+ · [button-name] Buttons must have an accessible name
175
+ · [aria-valid-role] Elements must use valid ARIA roles
176
+
177
+ Requesting AI patch... done
178
+ +2 -1
179
+ <nav className="navbar">
180
+ - <button onClick={toggle}><MenuIcon /></button>
181
+ + <button onClick={toggle} aria-label="Toggle navigation"><MenuIcon /></button>
182
+ <ul role="navigation">
183
+ - <li role="listbox">Home</li>
184
+ + <li>Home</li>
185
+ ```
186
+
187
+ | Flag | Default | Description |
188
+ |---|---|---|
189
+ | `-u, --url <url>` | required | Base URL of your running dev server |
190
+ | `-p, --pages <pages...>` | `/` | Specific pages to scan |
191
+ | `-c, --crawl` | off | Auto-discover pages by following same-origin links |
192
+ | `--apply` | off | Write patched files to disk (dry-run without this flag) |
193
+ | `--provider <name>` | from config | Override AI provider for this run: `gemini`, `openai`, or `ollama` |
194
+
195
+ > **Tip:** Always run without `--apply` first to review the diff. The dry-run is safe — nothing is written to disk.
196
+
197
+ ---
198
+
143
199
  ## AI Providers
144
200
 
145
201
  | Provider | Model | Cost | API Key |
package/dist/ai/gemini.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildPrompt } from './prompt.js';
2
+ import { buildPatchPrompt } from './patch-prompt.js';
2
3
  import { groupViolations } from './group.js';
3
4
  import { fallbackExplanation } from './fallback-explanation.js';
4
5
  export class GeminiProvider {
@@ -8,11 +9,11 @@ export class GeminiProvider {
8
9
  this.apiKey = apiKey;
9
10
  this.model = model;
10
11
  }
11
- async generateFixes(violations, strategy = 'rule') {
12
+ async generateFixes(violations, strategy = 'rule', framework) {
12
13
  if (violations.length === 0)
13
14
  return [];
14
15
  const groups = groupViolations(violations, strategy);
15
- const prompt = buildPrompt(groups);
16
+ const prompt = buildPrompt(groups, framework);
16
17
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
17
18
  const response = await fetch(url, {
18
19
  method: 'POST',
@@ -27,9 +28,25 @@ export class GeminiProvider {
27
28
  }
28
29
  const data = await response.json();
29
30
  const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
30
- return this.parse(text, groups);
31
+ return this.parse(text, groups, framework);
31
32
  }
32
- parse(text, groups) {
33
+ async generateFilePatch(fileContent, violations, filePath, framework) {
34
+ const prompt = buildPatchPrompt(fileContent, violations, filePath, framework);
35
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
36
+ const response = await fetch(url, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({
40
+ contents: [{ parts: [{ text: prompt }] }],
41
+ generationConfig: { temperature: 0.1, maxOutputTokens: 16384 },
42
+ }),
43
+ });
44
+ if (!response.ok)
45
+ throw new Error(`Gemini API error: ${response.status} ${await response.text()}`);
46
+ const data = await response.json();
47
+ return data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
48
+ }
49
+ parse(text, groups, framework) {
33
50
  try {
34
51
  const jsonMatch = text.match(/\[[\s\S]*\]/);
35
52
  const fixes = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
@@ -37,20 +54,21 @@ export class GeminiProvider {
37
54
  const found = fixes.find((f) => f.ruleId === g.ruleId);
38
55
  return found
39
56
  ? { ...found, selectors: g.selectors, instanceCount: g.count }
40
- : this.fallbackFix(g);
57
+ : this.fallbackFix(g, framework);
41
58
  });
42
59
  }
43
60
  catch {
44
- return groups.map((g) => this.fallbackFix(g));
61
+ return groups.map((g) => this.fallbackFix(g, framework));
45
62
  }
46
63
  }
47
- fallbackFix(g) {
64
+ fallbackFix(g, framework) {
48
65
  const v = g.representative;
49
66
  const selectorList = g.selectors.map((s) => `- ${s}`).join('\n');
50
67
  const explanation = fallbackExplanation(g.ruleId, g.description, g.wcag, g.level);
68
+ const fwNote = framework ? `This project uses ${framework}. ` : '';
51
69
  const prompt = g.count > 1
52
- ? `Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected elements (${g.count} instances):\n${selectorList}\n\nRepresentative HTML:\n\`${v.html.slice(0, 300)}\`\n\nApply the fix to all ${g.count} instances in the codebase to comply with WCAG 2.1 SC ${g.wcag}.`
53
- : `Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected element:\n- Selector: \`${g.selectors[0]}\`\n- HTML: \`${v.html.slice(0, 300)}\`\n\nApply the fix to comply with WCAG 2.1 SC ${g.wcag}.`;
70
+ ? `${fwNote}Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected elements (${g.count} instances):\n${selectorList}\n\nRepresentative HTML:\n\`${v.html.slice(0, 300)}\`\n\nApply the fix to all ${g.count} instances in the codebase to comply with WCAG 2.1 SC ${g.wcag}.`
71
+ : `${fwNote}Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected element:\n- Selector: \`${g.selectors[0]}\`\n- HTML: \`${v.html.slice(0, 300)}\`\n\nApply the fix to comply with WCAG 2.1 SC ${g.wcag}.`;
54
72
  return {
55
73
  ruleId: g.ruleId,
56
74
  selectors: g.selectors,
package/dist/ai/ollama.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildPrompt } from './prompt.js';
2
+ import { buildPatchPrompt } from './patch-prompt.js';
2
3
  import { groupViolations } from './group.js';
3
4
  import { fallbackExplanation } from './fallback-explanation.js';
4
5
  export class OllamaProvider {
@@ -8,11 +9,11 @@ export class OllamaProvider {
8
9
  this.baseUrl = baseUrl;
9
10
  this.model = model;
10
11
  }
11
- async generateFixes(violations, strategy = 'rule') {
12
+ async generateFixes(violations, strategy = 'rule', framework) {
12
13
  if (violations.length === 0)
13
14
  return [];
14
15
  const groups = groupViolations(violations, strategy);
15
- const prompt = buildPrompt(groups);
16
+ const prompt = buildPrompt(groups, framework);
16
17
  const response = await fetch(`${this.baseUrl}/api/generate`, {
17
18
  method: 'POST',
18
19
  headers: { 'Content-Type': 'application/json' },
@@ -26,29 +27,42 @@ export class OllamaProvider {
26
27
  try {
27
28
  const jsonMatch = text.match(/\[[\s\S]*\]/);
28
29
  if (!jsonMatch)
29
- return this.fallback(groups);
30
+ return this.fallback(groups, framework);
30
31
  const fixes = JSON.parse(jsonMatch[0]);
31
32
  return groups.map((g) => {
32
33
  const found = fixes.find((f) => f.ruleId === g.ruleId);
33
34
  return found
34
35
  ? { ...found, selectors: g.selectors, instanceCount: g.count }
35
- : this.fallbackFix(g);
36
+ : this.fallbackFix(g, framework);
36
37
  });
37
38
  }
38
39
  catch {
39
- return this.fallback(groups);
40
+ return this.fallback(groups, framework);
40
41
  }
41
42
  }
42
- fallback(groups) {
43
- return groups.map((g) => this.fallbackFix(g));
43
+ async generateFilePatch(fileContent, violations, filePath, framework) {
44
+ const prompt = buildPatchPrompt(fileContent, violations, filePath, framework);
45
+ const response = await fetch(`${this.baseUrl}/api/generate`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ model: this.model, prompt, stream: false }),
49
+ });
50
+ if (!response.ok)
51
+ throw new Error(`Ollama error: ${response.status}. Is Ollama running? Run: ollama serve`);
52
+ const data = await response.json();
53
+ return data.response ?? '';
54
+ }
55
+ fallback(groups, framework) {
56
+ return groups.map((g) => this.fallbackFix(g, framework));
44
57
  }
45
- fallbackFix(g) {
58
+ fallbackFix(g, framework) {
46
59
  const v = g.representative;
47
60
  const selectorList = g.selectors.map((s) => `- ${s}`).join('\n');
48
61
  const explanation = fallbackExplanation(g.ruleId, g.description, g.wcag, g.level);
62
+ const fwNote = framework ? `This project uses ${framework}. ` : '';
49
63
  const prompt = g.count > 1
50
- ? `Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected elements (${g.count} instances):\n${selectorList}\n\nRepresentative HTML:\n\`${v.html.slice(0, 300)}\`\n\nApply the fix to all ${g.count} instances in the codebase to comply with WCAG 2.1 SC ${g.wcag}.`
51
- : `Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected element:\n- Selector: \`${g.selectors[0]}\`\n- HTML: \`${v.html.slice(0, 300)}\`\n\nApply the fix to comply with WCAG 2.1 SC ${g.wcag}.`;
64
+ ? `${fwNote}Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected elements (${g.count} instances):\n${selectorList}\n\nRepresentative HTML:\n\`${v.html.slice(0, 300)}\`\n\nApply the fix to all ${g.count} instances in the codebase to comply with WCAG 2.1 SC ${g.wcag}.`
65
+ : `${fwNote}Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected element:\n- Selector: \`${g.selectors[0]}\`\n- HTML: \`${v.html.slice(0, 300)}\`\n\nApply the fix to comply with WCAG 2.1 SC ${g.wcag}.`;
52
66
  return {
53
67
  ruleId: g.ruleId,
54
68
  selectors: g.selectors,
package/dist/ai/openai.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildPrompt } from './prompt.js';
2
+ import { buildPatchPrompt } from './patch-prompt.js';
2
3
  import { groupViolations } from './group.js';
3
4
  import { fallbackExplanation } from './fallback-explanation.js';
4
5
  export class OpenAIProvider {
@@ -8,11 +9,11 @@ export class OpenAIProvider {
8
9
  this.apiKey = apiKey;
9
10
  this.model = model;
10
11
  }
11
- async generateFixes(violations, strategy = 'rule') {
12
+ async generateFixes(violations, strategy = 'rule', framework) {
12
13
  if (violations.length === 0)
13
14
  return [];
14
15
  const groups = groupViolations(violations, strategy);
15
- const prompt = buildPrompt(groups);
16
+ const prompt = buildPrompt(groups, framework);
16
17
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
17
18
  method: 'POST',
18
19
  headers: {
@@ -31,9 +32,26 @@ export class OpenAIProvider {
31
32
  }
32
33
  const data = await response.json();
33
34
  const text = data.choices?.[0]?.message?.content ?? '[]';
34
- return this.parse(text, groups);
35
+ return this.parse(text, groups, framework);
35
36
  }
36
- parse(text, groups) {
37
+ async generateFilePatch(fileContent, violations, filePath, framework) {
38
+ const prompt = buildPatchPrompt(fileContent, violations, filePath, framework);
39
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },
42
+ body: JSON.stringify({
43
+ model: this.model,
44
+ messages: [{ role: 'user', content: prompt }],
45
+ temperature: 0.1,
46
+ max_tokens: 16384,
47
+ }),
48
+ });
49
+ if (!response.ok)
50
+ throw new Error(`OpenAI API error: ${response.status} ${await response.text()}`);
51
+ const data = await response.json();
52
+ return data.choices?.[0]?.message?.content ?? '';
53
+ }
54
+ parse(text, groups, framework) {
37
55
  try {
38
56
  const jsonMatch = text.match(/\[[\s\S]*\]/);
39
57
  const fixes = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
@@ -41,20 +59,21 @@ export class OpenAIProvider {
41
59
  const found = fixes.find((f) => f.ruleId === g.ruleId);
42
60
  return found
43
61
  ? { ...found, selectors: g.selectors, instanceCount: g.count }
44
- : this.fallbackFix(g);
62
+ : this.fallbackFix(g, framework);
45
63
  });
46
64
  }
47
65
  catch {
48
- return groups.map((g) => this.fallbackFix(g));
66
+ return groups.map((g) => this.fallbackFix(g, framework));
49
67
  }
50
68
  }
51
- fallbackFix(g) {
69
+ fallbackFix(g, framework) {
52
70
  const v = g.representative;
53
71
  const selectorList = g.selectors.map((s) => `- ${s}`).join('\n');
54
72
  const explanation = fallbackExplanation(g.ruleId, g.description, g.wcag, g.level);
73
+ const fwNote = framework ? `This project uses ${framework}. ` : '';
55
74
  const prompt = g.count > 1
56
- ? `Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected elements (${g.count} instances):\n${selectorList}\n\nRepresentative HTML:\n\`${v.html.slice(0, 300)}\`\n\nApply the fix to all ${g.count} instances in the codebase to comply with WCAG 2.1 SC ${g.wcag}.`
57
- : `Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected element:\n- Selector: \`${g.selectors[0]}\`\n- HTML: \`${v.html.slice(0, 300)}\`\n\nApply the fix to comply with WCAG 2.1 SC ${g.wcag}.`;
75
+ ? `${fwNote}Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected elements (${g.count} instances):\n${selectorList}\n\nRepresentative HTML:\n\`${v.html.slice(0, 300)}\`\n\nApply the fix to all ${g.count} instances in the codebase to comply with WCAG 2.1 SC ${g.wcag}.`
76
+ : `${fwNote}Fix WCAG 2.1 SC ${g.wcag} (Level ${g.level}) — ${g.description}\n\nAffected element:\n- Selector: \`${g.selectors[0]}\`\n- HTML: \`${v.html.slice(0, 300)}\`\n\nApply the fix to comply with WCAG 2.1 SC ${g.wcag}.`;
58
77
  return {
59
78
  ruleId: g.ruleId,
60
79
  selectors: g.selectors,
@@ -0,0 +1,16 @@
1
+ export function buildPatchPrompt(fileContent, violations, filePath, framework) {
2
+ const fwLine = framework ? `\nFramework: ${framework}` : '';
3
+ const vList = violations
4
+ .map((v, i) => `${i + 1}. [${v.ruleId}] WCAG ${v.wcag} Level ${v.level} — ${v.description}\n Selector: ${v.selector}\n HTML: ${v.html.slice(0, 300)}`)
5
+ .join('\n\n');
6
+ return `You are a WCAG accessibility expert. Fix the accessibility violations in the source file below.
7
+ Return ONLY the complete fixed file content — no markdown fences, no explanations, no preamble.
8
+
9
+ File: ${filePath}${fwLine}
10
+
11
+ Violations to fix:
12
+ ${vList}
13
+
14
+ FILE CONTENT:
15
+ ${fileContent}`;
16
+ }
package/dist/ai/prompt.js CHANGED
@@ -1,4 +1,16 @@
1
- export function buildPrompt(groups) {
1
+ const FRAMEWORK_SYNTAX = {
2
+ 'Next.js': 'React/TSX (JSX)',
3
+ 'Gatsby': 'React/JSX',
4
+ 'Remix': 'React/TSX',
5
+ 'Nuxt.js': 'Vue 3 SFC (.vue files)',
6
+ 'Vue 3': 'Vue 3 SFC (.vue files)',
7
+ 'Vue 2': 'Vue 2 SFC (.vue files)',
8
+ 'Angular': 'Angular template',
9
+ 'React': 'React JSX/TSX',
10
+ 'Svelte': 'Svelte (.svelte files)',
11
+ };
12
+ export function buildPrompt(groups, framework) {
13
+ const syntax = framework ? (FRAMEWORK_SYNTAX[framework] ?? 'JSX/TSX') : null;
2
14
  const items = groups
3
15
  .map((g, i) => `${i + 1}. Rule: ${g.ruleId} | WCAG ${g.wcag} (Level ${g.level}) | Impact: ${g.impact}
4
16
  Page: ${g.page}
@@ -7,14 +19,23 @@ export function buildPrompt(groups) {
7
19
  Representative Element: ${g.representative.html}
8
20
  Problem: ${g.description}`)
9
21
  .join('\n\n');
10
- return `You are a WCAG accessibility expert. Analyze these violations and return a JSON array.
22
+ const frameworkLine = framework
23
+ ? `\nFramework: ${framework}. All "fixedCode" values must use ${syntax} syntax — not raw HTML.\n`
24
+ : '';
25
+ const fixedCodeInstruction = syntax
26
+ ? `"fixedCode": the corrected snippet in ${syntax} syntax (component/template code only — no imports, no surrounding boilerplate)`
27
+ : `"fixedCode": the corrected HTML snippet only (no explanation, just code)`;
28
+ const optimalPromptInstruction = framework
29
+ ? `"optimalPrompt": a ready-to-paste prompt for an AI coding assistant (Cursor, Copilot, Claude) working in a ${framework} codebase. Structure it as: (1) state the WCAG 2.1/2.2 criterion being violated, (2) list the affected selectors and HTML snippets, (3) state the exact change needed in ${syntax} syntax. Focus solely on the fix.`
30
+ : `"optimalPrompt": a ready-to-paste prompt for an AI coding assistant (Cursor, Copilot, Claude). Structure it as: (1) state the WCAG 2.1/2.2 criterion being violated, (2) list the affected selectors and HTML snippets, (3) state the exact change needed. Focus solely on the fix.`;
31
+ return `You are a WCAG accessibility expert. Analyze these violations and return a JSON array.${frameworkLine}
11
32
  Each item must have:
12
33
  - "ruleId": the rule id from the input
13
34
  - "selectors": array of CSS selectors affected (copy from input)
14
- - "explanation": 1-2 sentences on concrete user impact — name exactly who is affected (e.g. "screen reader users", "keyboard-only users", "users with low vision") and what they cannot do because of this specific violation. Do not write generic statements like "users with disabilities may be affected."
15
- - "fixedCode": the corrected HTML snippet only (no explanation, just code)
35
+ - "explanation": 1-2 sentences on concrete user impact — name exactly who is affected (e.g. "screen reader users", "keyboard-only users", "users with low vision") and what they cannot do because of this specific violation. Do not write generic statements.
36
+ - ${fixedCodeInstruction}
16
37
  - "wcagReference": the full criterion name, e.g. "WCAG 2.1 SC 1.1.1 Non-text Content (Level A)"
17
- - "optimalPrompt": a ready-to-paste prompt for an AI coding assistant (Cursor, Copilot, Claude). Structure it as: (1) state the WCAG 2.1/2.2 criterion being violated (e.g. "WCAG 2.1 SC 1.1.1 Non-text Content, Level A"), (2) list the affected selectors and HTML snippets, (3) state the exact code change needed to comply. Focus solely on the fix — do not explain why WCAG exists or what accessibility is. Be precise and actionable.
38
+ - ${optimalPromptInstruction}
18
39
 
19
40
  Return ONLY a valid JSON array. No markdown, no code fences, no explanation outside the JSON.
20
41
 
package/dist/cli.js CHANGED
@@ -7,11 +7,13 @@ import { groupViolations } from './ai/group.js';
7
7
  import { printTerminalReport, printAIPrompts } from './reporter/terminal.js';
8
8
  import { generateMarkdownReport } from './reporter/markdown.js';
9
9
  import { runDemo } from './demo.js';
10
+ import { runFix } from './fixer.js';
11
+ import { resolve } from 'path';
10
12
  const program = new Command();
11
13
  program
12
14
  .name('wcag-a11y')
13
15
  .description('WCAG 2.1/2.2 accessibility auditor with AI-powered fixes')
14
- .version('0.3.3');
16
+ .version('0.3.5');
15
17
  program
16
18
  .command('init')
17
19
  .description('Create a11y.config.json in the current directory')
@@ -25,10 +27,10 @@ program
25
27
  .requiredOption('-u, --url <url>', 'Base URL of your dev server (e.g. http://localhost:3000)')
26
28
  .option('-p, --pages <pages...>', 'Specific pages to scan (e.g. / /about /contact)', ['/'])
27
29
  .option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
28
- .option('-r, --report', 'Save a full markdown report to a11y-report.md', false)
30
+ .option('--no-report', 'Skip saving markdown report to a11y-report.md')
29
31
  .option('--no-ai', 'Skip AI fix generation (faster, violations only)')
30
32
  .option('--no-explain', 'Hide AI fix explanations in terminal output')
31
- .option('--no-terminal', 'Suppress terminal output (violations summary)')
33
+ .option('--terminal', 'Print violations summary to terminal', false)
32
34
  .option('--fast-mode', 'Output only AI fix prompts — no summaries or explanations', false)
33
35
  .option('--group <strategy>', 'Group violations by rule or show individually (rule|none)', 'rule')
34
36
  .option('--ci', 'Exit with code 1 if any violations are found (for CI/CD pipelines)', false)
@@ -52,7 +54,7 @@ program
52
54
  if (!opts.fastMode) {
53
55
  console.log(`\nGenerating AI fixes for ${ruleGroups.length} rule groups (${allViolations.length} violations)...`);
54
56
  }
55
- const fixes = await provider.generateFixes(allViolations, strategy);
57
+ const fixes = await provider.generateFixes(allViolations, strategy, result.framework);
56
58
  if (opts.terminal) {
57
59
  printAIPrompts(fixes, { explain: opts.explain, fastMode: opts.fastMode });
58
60
  }
@@ -72,10 +74,38 @@ program
72
74
  process.exit(1);
73
75
  }
74
76
  });
77
+ program
78
+ .command('fix')
79
+ .description('Scan for violations and apply AI fixes directly to source files')
80
+ .requiredOption('-u, --url <url>', 'Base URL of your dev server (e.g. http://localhost:3000)')
81
+ .option('-p, --pages <pages...>', 'Specific pages to scan', ['/'])
82
+ .option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
83
+ .option('--apply', 'Write fixes to source files (default: dry-run, shows diff only)', false)
84
+ .option('--provider <name>', 'Override the AI provider from config (gemini|openai|ollama)')
85
+ .action(async (opts) => {
86
+ try {
87
+ const config = loadConfig();
88
+ if (opts.provider)
89
+ config.provider = opts.provider;
90
+ const provider = createAIProvider(config);
91
+ await runFix({
92
+ url: opts.url,
93
+ pages: opts.pages,
94
+ crawl: opts.crawl,
95
+ apply: opts.apply,
96
+ provider,
97
+ srcDir: resolve(process.cwd(), 'src'),
98
+ });
99
+ }
100
+ catch (err) {
101
+ console.error(`\nError: ${err.message}`);
102
+ process.exit(1);
103
+ }
104
+ });
75
105
  program
76
106
  .command('demo')
77
107
  .description('Scan a built-in demo page with intentional violations — no dev server needed')
78
- .option('-r, --report', 'Save a full markdown report to a11y-report.md', false)
108
+ .option('--no-report', 'Skip saving markdown report to a11y-report.md')
79
109
  .option('--no-ai', 'Skip AI fix generation (faster, violations only)')
80
110
  .action(async (opts) => {
81
111
  try {
package/dist/crawler.js CHANGED
@@ -1,5 +1,43 @@
1
1
  import { chromium } from 'playwright';
2
2
  import { scanPage } from './engine/index.js';
3
+ async function detectFramework(page) {
4
+ try {
5
+ return await page.evaluate(() => {
6
+ const w = window;
7
+ if (w['__NEXT_DATA__'])
8
+ return 'Next.js';
9
+ if (w['___gatsby'])
10
+ return 'Gatsby';
11
+ if (w['__remixContext'])
12
+ return 'Remix';
13
+ if (w['__nuxt'] || w['$nuxt'])
14
+ return 'Nuxt.js';
15
+ if (w['__vue_app__'])
16
+ return 'Vue 3';
17
+ if (w['Vue'])
18
+ return 'Vue 2';
19
+ if (document.querySelector('[ng-version]') !== null)
20
+ return 'Angular';
21
+ // React: hook is registered by react-dom on load (most reliable, works with Vite HMR)
22
+ if (w['__REACT_DEVTOOLS_GLOBAL_HOOK__'])
23
+ return 'React';
24
+ // React fallback: fiber properties on root element
25
+ const root = document.getElementById('root') ?? document.getElementById('app') ?? document.body;
26
+ if (root) {
27
+ const allKeys = Object.getOwnPropertyNames(root);
28
+ if (allKeys.some((k) => k.startsWith('__reactFiber') || k.startsWith('__reactContainer'))) {
29
+ return 'React';
30
+ }
31
+ }
32
+ if (document.querySelector('[data-svelte-h]') !== null)
33
+ return 'Svelte';
34
+ return undefined;
35
+ });
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
3
41
  export async function crawl(options) {
4
42
  const { url, pages = ['/'], crawl: autoCrawl = false } = options;
5
43
  const baseUrl = url.replace(/\/$/, '');
@@ -23,10 +61,13 @@ export async function crawl(options) {
23
61
  }
24
62
  }
25
63
  const results = [];
64
+ let framework;
26
65
  for (const pageUrl of pagesToVisit) {
27
66
  const page = await context.newPage();
28
67
  try {
29
68
  await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
69
+ if (!framework)
70
+ framework = await detectFramework(page);
30
71
  const result = await scanPage(page, pageUrl);
31
72
  results.push(result);
32
73
  }
@@ -46,6 +87,7 @@ export async function crawl(options) {
46
87
  seriousCount: allViolations.filter((v) => v.impact === 'serious').length,
47
88
  moderateCount: allViolations.filter((v) => v.impact === 'moderate').length,
48
89
  minorCount: allViolations.filter((v) => v.impact === 'minor').length,
90
+ framework,
49
91
  };
50
92
  }
51
93
  finally {
package/dist/demo.js CHANGED
@@ -74,7 +74,7 @@ export async function runDemo(opts) {
74
74
  const provider = createAIProvider(config);
75
75
  const violations = result.pages.flatMap((p) => p.violations);
76
76
  console.log(`\nGenerating AI fixes for ${violations.length} violations...`);
77
- const fixes = await provider.generateFixes(violations, 'rule');
77
+ const fixes = await provider.generateFixes(violations, 'rule', result.framework);
78
78
  printAIPrompts(fixes, { explain: true });
79
79
  if (opts.report)
80
80
  generateMarkdownReport(result, fixes);
@@ -1,3 +1,42 @@
1
+ async function resolveSourceLocations(page, violations) {
2
+ const selectors = [...new Set(violations.map((v) => v.selector))];
3
+ if (selectors.length === 0)
4
+ return;
5
+ try {
6
+ const sourceMap = await page.evaluate((sels) => {
7
+ const result = {};
8
+ for (const sel of sels) {
9
+ try {
10
+ const el = document.querySelector(sel);
11
+ if (!el)
12
+ continue;
13
+ const fiberKey = Object.getOwnPropertyNames(el).find((k) => k.startsWith('__reactFiber'));
14
+ if (!fiberKey)
15
+ continue;
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ let fiber = el[fiberKey];
18
+ while (fiber) {
19
+ if (fiber._debugSource) {
20
+ const { fileName, lineNumber } = fiber._debugSource;
21
+ const match = fileName.match(/[/\\]src[/\\].+/);
22
+ const display = match ? match[0].replace(/\\/g, '/').replace(/^\//, '') : fileName;
23
+ result[sel] = `${display}:${lineNumber}`;
24
+ break;
25
+ }
26
+ fiber = fiber.return;
27
+ }
28
+ }
29
+ catch { /* ignore */ }
30
+ }
31
+ return result;
32
+ }, selectors);
33
+ for (const v of violations) {
34
+ if (sourceMap[v.selector])
35
+ v.source = sourceMap[v.selector];
36
+ }
37
+ }
38
+ catch { /* non-React or production build — silently skip */ }
39
+ }
1
40
  import { textAlternativeRules } from './rules/text-alternatives.js';
2
41
  import { colorContrastRules } from './rules/color-contrast.js';
3
42
  import { keyboardRules } from './rules/keyboard.js';
@@ -52,5 +91,6 @@ export async function scanPage(page, url) {
52
91
  });
53
92
  violations.push(...ruleViolations);
54
93
  }
94
+ await resolveSourceLocations(page, violations);
55
95
  return { url, violations };
56
96
  }
package/dist/fixer.js ADDED
@@ -0,0 +1,254 @@
1
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
2
+ import { join, resolve } from 'path';
3
+ import chalk from 'chalk';
4
+ import { crawl } from './crawler.js';
5
+ const SOURCE_EXTS = new Set(['.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.html']);
6
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', '.nuxt', 'coverage', 'public']);
7
+ export async function runFix(opts) {
8
+ console.log(`\nScanning ${opts.url}...`);
9
+ const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl });
10
+ const { framework } = result;
11
+ if (result.totalViolations === 0) {
12
+ console.log(chalk.green('\nNo violations found.'));
13
+ return;
14
+ }
15
+ console.log(`Found ${result.totalViolations} violation(s). Locating source files...\n`);
16
+ const allViolations = result.pages.flatMap((p) => p.violations);
17
+ // Group violations by resolved source file path
18
+ const fileGroups = new Map();
19
+ let unlocated = 0;
20
+ for (const violation of allViolations) {
21
+ const filePath = findSourceFile(violation, opts.srcDir);
22
+ if (!filePath) {
23
+ console.warn(chalk.yellow(` warn: no source file for [${violation.ruleId}] ${violation.selector.slice(0, 60)}`));
24
+ unlocated++;
25
+ continue;
26
+ }
27
+ if (!fileGroups.has(filePath))
28
+ fileGroups.set(filePath, []);
29
+ fileGroups.get(filePath).push(violation);
30
+ }
31
+ if (fileGroups.size === 0) {
32
+ console.log(chalk.yellow('No source files located. Run from your project root and ensure sources are in ./src'));
33
+ return;
34
+ }
35
+ const locatedCount = allViolations.length - unlocated;
36
+ console.log(`Grouped ${locatedCount} violation(s) across ${fileGroups.size} file(s).\n`);
37
+ if (!opts.apply)
38
+ console.log(chalk.gray('Dry-run mode — use --apply to write changes.\n'));
39
+ let modifiedFiles = 0;
40
+ let modifiedViolations = 0;
41
+ for (const [filePath, violations] of fileGroups) {
42
+ const rel = relativize(filePath);
43
+ console.log(chalk.bold(`\n${rel}`) + chalk.gray(` — ${violations.length} violation(s)`));
44
+ for (const v of violations) {
45
+ console.log(chalk.gray(` · [${v.ruleId}] ${v.description.slice(0, 80)}`));
46
+ }
47
+ let oldContent;
48
+ try {
49
+ oldContent = readFileSync(filePath, 'utf8');
50
+ }
51
+ catch {
52
+ console.warn(chalk.yellow(` Cannot read file, skipping.`));
53
+ continue;
54
+ }
55
+ process.stdout.write(' Requesting AI patch...');
56
+ let rawResponse;
57
+ try {
58
+ rawResponse = await opts.provider.generateFilePatch(oldContent, violations, rel, framework);
59
+ }
60
+ catch (err) {
61
+ console.log('');
62
+ console.warn(chalk.yellow(` AI error: ${err.message}`));
63
+ continue;
64
+ }
65
+ console.log(' done');
66
+ const newContent = stripCodeFences(rawResponse).trim();
67
+ if (!newContent || newContent.length < oldContent.length * 0.3) {
68
+ console.warn(chalk.yellow(' AI returned unexpected output, skipping.'));
69
+ continue;
70
+ }
71
+ const normalizedOld = oldContent.trim();
72
+ if (newContent === normalizedOld) {
73
+ console.log(chalk.gray(' No changes.'));
74
+ continue;
75
+ }
76
+ console.log(renderDiff(oldContent, newContent, rel));
77
+ if (opts.apply) {
78
+ writeFileSync(filePath, newContent + '\n', 'utf8');
79
+ console.log(chalk.green(` Written.`));
80
+ }
81
+ modifiedFiles++;
82
+ modifiedViolations += violations.length;
83
+ }
84
+ // Summary
85
+ console.log('\n' + '─'.repeat(60));
86
+ if (modifiedFiles === 0) {
87
+ console.log(chalk.gray('No files changed.'));
88
+ }
89
+ else if (opts.apply) {
90
+ console.log(chalk.green.bold(`Fixed ${modifiedViolations} violation(s) across ${modifiedFiles} file(s).`));
91
+ }
92
+ else {
93
+ console.log(chalk.blue.bold(`Would fix ${modifiedViolations} violation(s) across ${modifiedFiles} file(s).`));
94
+ console.log(chalk.gray('Run with --apply to write changes to disk.'));
95
+ }
96
+ if (unlocated > 0) {
97
+ console.log(chalk.yellow(`${unlocated} violation(s) could not be located in source files.`));
98
+ }
99
+ }
100
+ export function findSourceFile(violation, srcDir) {
101
+ // Priority 1: React dev-mode source annotation
102
+ if (violation.source) {
103
+ const filePart = violation.source.split(':')[0];
104
+ for (const candidate of [resolve(process.cwd(), filePart), resolve(filePart)]) {
105
+ if (existsSync(candidate))
106
+ return candidate;
107
+ }
108
+ }
109
+ // Priority 2: grep src/ for unique attributes/text from the HTML snippet
110
+ for (const needle of extractNeedles(violation.html)) {
111
+ const hits = grepDir(srcDir, needle);
112
+ if (hits.length > 0)
113
+ return hits[0];
114
+ }
115
+ return null;
116
+ }
117
+ function extractNeedles(html) {
118
+ const results = [];
119
+ const id = html.match(/\bid=["']([^"']{2,})["']/)?.[1];
120
+ if (id)
121
+ results.push(`id="${id}"`);
122
+ const name = html.match(/\bname=["']([^"']{2,})["']/)?.[1];
123
+ if (name)
124
+ results.push(`name="${name}"`);
125
+ const forAttr = html.match(/\bfor=["']([^"']{2,})["']/)?.[1];
126
+ if (forAttr)
127
+ results.push(`for="${forAttr}"`);
128
+ // local src paths only
129
+ const src = html.match(/\bsrc=["'](?!https?:\/\/)([^"']{4,})["']/)?.[1];
130
+ if (src)
131
+ results.push(src);
132
+ const text = html.match(/>([^<\s][^<]{4,60})</)?.[1]?.trim();
133
+ if (text)
134
+ results.push(text);
135
+ // fallback: first meaningful class name
136
+ if (results.length === 0) {
137
+ const cls = html.match(/\bclass=["']([^"']+)["']/)?.[1]?.split(/\s+/)[0];
138
+ if (cls && cls.length > 3)
139
+ results.push(cls);
140
+ }
141
+ return results.filter((s) => s.length >= 3);
142
+ }
143
+ function grepDir(dir, needle) {
144
+ if (!existsSync(dir))
145
+ return [];
146
+ const hits = [];
147
+ try {
148
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
149
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name))
150
+ continue;
151
+ const full = join(dir, entry.name);
152
+ if (entry.isDirectory()) {
153
+ hits.push(...grepDir(full, needle));
154
+ }
155
+ else if (entry.isFile() && SOURCE_EXTS.has(extOf(entry.name))) {
156
+ try {
157
+ if (readFileSync(full, 'utf8').includes(needle))
158
+ hits.push(full);
159
+ }
160
+ catch { /* skip */ }
161
+ }
162
+ }
163
+ }
164
+ catch { /* skip unreadable dirs */ }
165
+ return hits;
166
+ }
167
+ function extOf(name) {
168
+ const i = name.lastIndexOf('.');
169
+ return i >= 0 ? name.slice(i) : '';
170
+ }
171
+ function stripCodeFences(text) {
172
+ const m = text.match(/^```[\w]*\n([\s\S]*?)\n?```\s*$/);
173
+ if (m)
174
+ return m[1];
175
+ return text.replace(/^```[\w]*\n?/, '').replace(/\n?```\s*$/, '');
176
+ }
177
+ function relativize(absPath) {
178
+ const cwd = process.cwd();
179
+ return absPath.startsWith(cwd)
180
+ ? absPath.slice(cwd.length + 1).replace(/\\/g, '/')
181
+ : absPath.replace(/\\/g, '/');
182
+ }
183
+ function computeDiff(a, b) {
184
+ // Cap to avoid O(n²) freeze on huge files
185
+ if (a.length * b.length > 800_000) {
186
+ return [
187
+ ...a.map((line) => ({ type: 'del', line })),
188
+ ...b.map((line) => ({ type: 'add', line })),
189
+ ];
190
+ }
191
+ const m = a.length, n = b.length;
192
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
193
+ for (let i = 1; i <= m; i++)
194
+ for (let j = 1; j <= n; j++)
195
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
196
+ const result = [];
197
+ let i = m, j = n;
198
+ while (i > 0 || j > 0) {
199
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
200
+ result.unshift({ type: 'eq', line: a[i - 1] });
201
+ i--;
202
+ j--;
203
+ }
204
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
205
+ result.unshift({ type: 'add', line: b[j - 1] });
206
+ j--;
207
+ }
208
+ else {
209
+ result.unshift({ type: 'del', line: a[i - 1] });
210
+ i--;
211
+ }
212
+ }
213
+ return result;
214
+ }
215
+ function renderDiff(oldContent, newContent, _filePath) {
216
+ const a = oldContent.split('\n');
217
+ const b = newContent.split('\n');
218
+ const diff = computeDiff(a, b);
219
+ const changeIdxs = [];
220
+ for (let k = 0; k < diff.length; k++)
221
+ if (diff[k].type !== 'eq')
222
+ changeIdxs.push(k);
223
+ if (changeIdxs.length === 0)
224
+ return chalk.gray(' (no changes)');
225
+ const CONTEXT = 3;
226
+ const hunks = [];
227
+ for (const idx of changeIdxs) {
228
+ const start = Math.max(0, idx - CONTEXT);
229
+ const end = Math.min(diff.length - 1, idx + CONTEXT);
230
+ const last = hunks[hunks.length - 1];
231
+ if (!last || start > last.end + 1)
232
+ hunks.push({ start, end });
233
+ else
234
+ last.end = Math.max(last.end, end);
235
+ }
236
+ const addCount = changeIdxs.filter((i) => diff[i].type === 'add').length;
237
+ const delCount = changeIdxs.filter((i) => diff[i].type === 'del').length;
238
+ const lines = [];
239
+ lines.push(` ${chalk.green(`+${addCount}`)} ${chalk.red(`-${delCount}`)}`);
240
+ for (let h = 0; h < hunks.length; h++) {
241
+ if (h > 0)
242
+ lines.push(chalk.gray(' ...'));
243
+ for (let k = hunks[h].start; k <= hunks[h].end; k++) {
244
+ const { type, line } = diff[k];
245
+ if (type === 'add')
246
+ lines.push(chalk.green(` + ${line}`));
247
+ else if (type === 'del')
248
+ lines.push(chalk.red(` - ${line}`));
249
+ else
250
+ lines.push(chalk.gray(` ${line}`));
251
+ }
252
+ }
253
+ return lines.join('\n');
254
+ }
@@ -63,11 +63,13 @@ function buildFullReport(result, fixes) {
63
63
  const v = group[0];
64
64
  const fix = fixes.find((f) => f.ruleId === v.ruleId);
65
65
  const others = group.slice(1);
66
- lines.push(`### ${IMPACT_EMOJI[v.impact] ?? '⚪'} [${v.impact.toUpperCase()}] ${v.description}`, '', `**Rule:** \`${v.ruleId}\` `, `**WCAG:** ${fix?.wcagReference ?? `SC ${v.wcag} (Level ${v.level})`} `, `**Instances:** ${group.length}`, '', '**Representative element:**', `\`${v.selector}\``, '```html', v.html, '```', '');
66
+ const sourceRef = v.source ? ` \`${v.source}\`` : '';
67
+ lines.push(`### ${IMPACT_EMOJI[v.impact] ?? '⚪'} [${v.impact.toUpperCase()}] ${v.description}`, '', `**Rule:** \`${v.ruleId}\` `, `**WCAG:** ${fix?.wcagReference ?? `SC ${v.wcag} (Level ${v.level})`} `, `**Instances:** ${group.length}`, '', '**Representative element:**', `\`${v.selector}\`${sourceRef}`, '```html', v.html, '```', '');
67
68
  if (others.length > 0) {
68
69
  lines.push(`**Also affects ${others.length} more element${others.length > 1 ? 's' : ''} on this page:**`);
69
70
  for (const o of others) {
70
- lines.push(`- \`${o.selector}\``);
71
+ const otherSource = o.source ? ` — \`${o.source}\`` : '';
72
+ lines.push(`- \`${o.selector}\`${otherSource}`);
71
73
  }
72
74
  lines.push('');
73
75
  }
@@ -30,7 +30,8 @@ export function printTerminalReport(result) {
30
30
  const tag = color(`[${g.impact.toUpperCase()}]`) + countSuffix;
31
31
  const wcag = chalk.gray(`WCAG ${g.wcag}`);
32
32
  console.log(` ${tag} ${g.description} ${wcag}`);
33
- console.log(` ${chalk.gray('→')} ${chalk.dim(g.selectors[0])}`);
33
+ const sourceHint = g.representative.source ? chalk.dim(` ${g.representative.source}`) : '';
34
+ console.log(` ${chalk.gray('→')} ${chalk.dim(g.selectors[0])}${sourceHint}`);
34
35
  if (g.count > 1) {
35
36
  console.log(` ${chalk.gray(` …and ${g.count - 1} more`)}`);
36
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wcag-a11y",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "WCAG 2.1/2.2 accessibility auditor with AI-powered fixes",
5
5
  "type": "module",
6
6
  "bin": {