wcag-a11y 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,62 @@
1
1
  # WCAG A11y
2
2
 
3
- WCAG 2.1/2.2 accessibility CLI auditor with AI-powered fixes.
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
+
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.
6
+
7
+ The goal is to close the loop between finding an accessibility issue and actually fixing it, without interrupting your existing workflow.
8
+
9
+ ---
10
+
11
+ ## Try it instantly
12
+
13
+ No dev server, no config, no setup:
14
+
15
+ ```bash
16
+ npx wcag-a11y demo
17
+ ```
18
+
19
+ ---
20
+
21
+ ## What the output looks like
22
+
23
+ Running a scan prints a violation summary per page, then AI-generated fixes for each rule:
24
+
25
+ ```
26
+ Scanning http://localhost:3000...
27
+
28
+ /
29
+ ✖ critical img-alt 3 violations
30
+ ✖ serious color-contrast-text 2 violations
31
+ ✖ serious label-missing 1 violation
32
+ ✖ moderate no-positive-tabindex 1 violation
33
+
34
+ 7 violations across 1 page
35
+
36
+ Generating AI fixes for 7 violations...
37
+
38
+ ────────────────────────────────────────────
39
+ [img-alt] — 3 elements affected
40
+ #hero-img #logo #banner
41
+
42
+ Why it matters:
43
+ Screen readers cannot describe the image to blind users without an alt attribute.
44
+ Users relying on assistive technology receive no information about the image content.
45
+
46
+ Fixed HTML:
47
+ <img src="banner.jpg" alt="Summer sale — up to 50% off">
48
+
49
+ Prompt for your AI editor:
50
+ Fix accessibility: 3 <img> elements (#hero-img, #logo, #banner) are missing alt
51
+ attributes, violating WCAG 1.1.1. Add descriptive alt text to each image.
52
+ ────────────────────────────────────────────
53
+ ```
54
+
55
+ The **prompt** at the end of each fix is what you copy into Cursor, Copilot, or Claude. It includes the affected selectors, the WCAG rule, and exactly what needs to change — no rewriting needed.
56
+
57
+ With `--report`, the full output is also saved to `a11y-report.md`.
58
+
59
+ ---
4
60
 
5
61
  ## Install
6
62
 
@@ -11,34 +67,232 @@ npm install -g wcag-a11y
11
67
  ## Setup
12
68
 
13
69
  ```bash
14
- wcag-a11y init # creates a11y.config.json
15
- # Add your free Gemini API key from https://aistudio.google.com
70
+ wcag-a11y init # Gemini (free, default)
71
+ wcag-a11y init --provider openai # OpenAI
72
+ wcag-a11y init --provider ollama # Local — no API key needed
73
+ ```
74
+
75
+ Each command creates an `a11y.config.json` pre-wired for that provider. Fill in your API key, then scan.
76
+
77
+ **Get a free Gemini key:** https://aistudio.google.com
78
+ **Get an OpenAI key:** https://platform.openai.com/api-keys
79
+ **Ollama (local):** install from https://ollama.com, then run `ollama serve`
80
+
81
+ ---
82
+
83
+ ## Commands
84
+
85
+ ### `wcag-a11y demo`
86
+
87
+ Scan a built-in page with 10 intentional violations. No dev server or config required — useful for trying the tool before pointing it at your own project.
88
+
89
+ ```bash
90
+ wcag-a11y demo # violations only
91
+ wcag-a11y demo --ai # violations + AI fixes (requires config)
92
+ wcag-a11y demo --ai --report # + save a11y-report.md
16
93
  ```
17
94
 
18
- ## Usage
95
+ | Flag | Description |
96
+ |---|---|
97
+ | `--ai` | Generate AI fix explanations and prompts for each violation |
98
+ | `-r, --report` | Save the full report to `a11y-report.md` in the current directory |
99
+
100
+ ---
101
+
102
+ ### `wcag-a11y init`
103
+
104
+ Create `a11y.config.json` in the current directory, pre-configured for your chosen provider.
19
105
 
20
106
  ```bash
21
- # Scan a single page
22
- wcag-a11y scan --url http://localhost:3000
107
+ wcag-a11y init # Gemini (default)
108
+ wcag-a11y init --provider openai # OpenAI
109
+ wcag-a11y init --provider ollama # Ollama (local)
110
+ ```
111
+
112
+ | Flag | Description |
113
+ |---|---|
114
+ | `--provider <name>` | Which provider to configure: `gemini` (default), `openai`, or `ollama`. Determines which fields are written to the config file. |
23
115
 
24
- # Scan specific pages
25
- wcag-a11y scan --url http://localhost:3000 --pages / /about /contact
116
+ ---
26
117
 
27
- # Auto-crawl all pages + generate report
28
- wcag-a11y scan --url http://localhost:3000 --crawl --report
118
+ ### `wcag-a11y scan`
29
119
 
30
- # Skip AI (violations only, no fixes)
31
- wcag-a11y scan --url http://localhost:3000 --no-ai
120
+ Scan a running dev server for accessibility violations.
121
+
122
+ ```bash
123
+ wcag-a11y scan -u http://localhost:3000
124
+ wcag-a11y scan -u http://localhost:3000 --pages / /about /contact
125
+ wcag-a11y scan -u http://localhost:3000 --crawl --ai --report
126
+ wcag-a11y scan -u http://localhost:3000 --no-ai --ci
32
127
  ```
33
128
 
129
+ | Flag | Default | Description |
130
+ |---|---|---|
131
+ | `-u, --url <url>` | required | Base URL of your running dev server |
132
+ | `-p, --pages <pages...>` | `/` | One or more paths to scan. Separate with spaces: `--pages / /about /contact` |
133
+ | `-c, --crawl` | off | Follow same-origin links and scan all reachable pages automatically |
134
+ | `-r, --report` | off | Save the full scan output to `a11y-report.md` |
135
+ | `--ai` / `--no-ai` | on | Generate AI fix explanations and prompts. Use `--no-ai` for a fast violation-only scan |
136
+ | `--no-explain` | off | Print only the ready-to-paste prompt for each fix, without the AI explanation |
137
+ | `--group <strategy>` | `rule` | `rule` (default) groups all violations of the same type into one fix prompt. `none` produces a separate prompt per element. Use `none` when violations of the same rule need different fixes |
138
+ | `--ci` | off | Exit with code `1` if any violations are found. Use this to fail a CI pipeline |
139
+ | `--provider <name>` | from config | Override the AI provider for this run: `gemini`, `openai`, or `ollama`. Does not modify the config file |
140
+
141
+ ---
142
+
143
+ ## AI Providers
144
+
145
+ | Provider | Model | Cost | API Key |
146
+ |---|---|---|---|
147
+ | `gemini` (default) | `gemini-2.5-flash` | Free tier | [aistudio.google.com](https://aistudio.google.com) |
148
+ | `openai` | `gpt-4o-mini` | Pay-per-use | [platform.openai.com](https://platform.openai.com/api-keys) |
149
+ | `ollama` | `llama3` | Free (local) | None — run `ollama serve` |
150
+
151
+ Set your provider in `a11y.config.json` or override it per-run with `--provider`. If the AI response is unparseable, the tool generates a fix prompt directly from the violation data so you always get something actionable.
152
+
153
+ ---
154
+
34
155
  ## Config (`a11y.config.json`)
35
156
 
36
157
  ```json
37
158
  {
38
159
  "provider": "gemini",
39
- "apiKey": "YOUR_FREE_KEY",
40
- "model": "gemini-2.0-flash"
160
+
161
+ "apiKey": "YOUR_GEMINI_API_KEY",
162
+ "model": "gemini-2.5-flash",
163
+
164
+ "openaiApiKey": "YOUR_OPENAI_API_KEY",
165
+ "openaiModel": "gpt-4o-mini",
166
+
167
+ "ollamaBaseUrl": "http://localhost:11434",
168
+ "ollamaModel": "llama3"
41
169
  }
42
170
  ```
43
171
 
44
- For local AI (no API key): set `"provider": "ollama"` and run `ollama serve`.
172
+ Only the fields for your active provider are required. This file is gitignored by default.
173
+
174
+ ---
175
+
176
+ ## What it checks
177
+
178
+ 40+ rules across 10 categories, mapped to WCAG 2.1/2.2 success criteria.
179
+
180
+ ### Text Alternatives — WCAG 1.1.1
181
+
182
+ | Rule | Impact | Description |
183
+ |---|---|---|
184
+ | `img-alt` | critical | Images must have an `alt` attribute |
185
+ | `input-image-alt` | critical | `<input type="image">` must have `alt` |
186
+ | `svg-title` | serious | Inline SVGs must have `<title>` or `aria-label` |
187
+ | `object-alt` | serious | `<object>` must have fallback text content |
188
+ | `role-img-alt` | serious | Elements with `role="img"` must have an accessible name |
189
+ | `image-redundant-alt` | minor | Image `alt` must not duplicate nearby visible text |
190
+
191
+ ### Color Contrast — WCAG 1.4.3 / 1.4.6
192
+
193
+ | Rule | Impact | Description |
194
+ |---|---|---|
195
+ | `color-contrast-text` | serious | Normal text must meet 4.5:1 contrast ratio |
196
+ | `color-contrast-large-text` | serious | Large text must meet 3:1 contrast ratio |
197
+
198
+ ### Forms — WCAG 1.3.1, 1.3.5, 3.3.1, 3.3.2, 4.1.2
199
+
200
+ | Rule | Impact | Description |
201
+ |---|---|---|
202
+ | `label-missing` | critical | Form inputs must have an associated label |
203
+ | `label-empty` | serious | `<label>` elements must have text content |
204
+ | `error-identification` | serious | Invalid inputs must link to an error message via `aria-describedby` |
205
+ | `input-button-name` | critical | Input buttons must have a discernible label |
206
+ | `fieldset-legend` | moderate | `<fieldset>` must have a `<legend>` with text |
207
+ | `autocomplete` | moderate | Common fields (name, email, phone) should declare `autocomplete` |
208
+ | `form-field-required-label` | moderate | Required inputs should expose state via `aria-required` |
209
+
210
+ ### Keyboard — WCAG 2.1.1, 2.4.1, 2.4.3, 2.4.7
211
+
212
+ | Rule | Impact | Description |
213
+ |---|---|---|
214
+ | `no-positive-tabindex` | serious | `tabindex` > 0 disrupts natural tab order |
215
+ | `interactive-not-focusable` | serious | Clickable `div`/`span` elements must be keyboard accessible |
216
+ | `skip-link` | moderate | Pages should have a skip navigation link |
217
+ | `focus-visible` | serious | Focusable elements must not hide the focus indicator |
218
+ | `scrollable-region-focusable` | moderate | Scrollable regions must be keyboard reachable |
219
+ | `accesskey-unique` | moderate | `accesskey` values must be unique per page |
220
+
221
+ ### ARIA — WCAG 4.1.2
222
+
223
+ | Rule | Impact | Description |
224
+ |---|---|---|
225
+ | `aria-valid-role` | critical | Elements must use valid ARIA roles |
226
+ | `aria-required-attr` | critical | Roles must include all required attributes |
227
+ | `aria-hidden-focus` | serious | `aria-hidden` elements must not be focusable |
228
+ | `button-name` | critical | Buttons must have an accessible name |
229
+ | `aria-required-children` | serious | Roles must contain required child roles |
230
+ | `aria-required-parent` | serious | Roles must be inside a required parent role |
231
+ | `aria-prohibited-attr` | moderate | ARIA attributes must be allowed on the element |
232
+
233
+ ### Structure — WCAG 1.3.1, 2.4.2, 4.1.1
234
+
235
+ | Rule | Impact | Description |
236
+ |---|---|---|
237
+ | `heading-order` | moderate | Heading levels must not skip (e.g. h1 → h4) |
238
+ | `page-title` | serious | Pages must have a non-empty `<title>` |
239
+ | `landmark-one-main` | moderate | Page must have exactly one `<main>` landmark |
240
+ | `list-structure` | serious | `<li>`, `<dt>`, `<dd>` must be inside the correct parent |
241
+ | `region-landmark` | moderate | Content must be inside a landmark region |
242
+ | `duplicate-id` | serious | `id` attributes must be unique per page |
243
+ | `frame-title` | serious | `<iframe>` elements must have a `title` attribute |
244
+ | `meta-viewport` | critical | Viewport must not block user scaling |
245
+ | `marquee` | serious | `<marquee>` is deprecated and inaccessible |
246
+ | `p-as-heading` | moderate | Paragraphs styled as headings should use heading elements |
247
+
248
+ ### Links — WCAG 2.4.4, 2.4.9
249
+
250
+ | Rule | Impact | Description |
251
+ |---|---|---|
252
+ | `link-name` | serious | Links must have descriptive text (not "click here", "read more") |
253
+ | `link-empty` | critical | Links must not be empty |
254
+ | `identical-links-different-purpose` | moderate | Links with the same text must go to the same destination |
255
+ | `link-new-window-warn` | moderate | Links opening a new tab must warn users |
256
+
257
+ ### Media — WCAG 1.2.2, 1.2.3, 1.2.5
258
+
259
+ | Rule | Impact | Description |
260
+ |---|---|---|
261
+ | `video-captions` | critical | `<video>` elements must have a captions track |
262
+ | `audio-description` | serious | Videos must have an audio description track |
263
+ | `audio-transcript` | serious | `<audio>` elements must have a transcript |
264
+
265
+ ### Tables — WCAG 1.3.1
266
+
267
+ | Rule | Impact | Description |
268
+ |---|---|---|
269
+ | `table-headers` | serious | Data tables must have `<th>` header cells |
270
+ | `table-scope-valid` | moderate | `scope` attribute values must be valid |
271
+ | `td-headers-attr` | serious | `headers` attribute must reference valid `th` IDs |
272
+ | `table-duplicate-name` | minor | Table `summary` must not duplicate the `<caption>` |
273
+
274
+ ### Language — WCAG 3.1.1
275
+
276
+ | Rule | Impact | Description |
277
+ |---|---|---|
278
+ | `html-lang` | serious | `<html>` must have a `lang` attribute |
279
+ | `html-lang-valid` | serious | `lang` attribute must be a valid BCP 47 language tag |
280
+
281
+ ---
282
+
283
+ ## Use in CI/CD
284
+
285
+ ```yaml
286
+ # .github/workflows/a11y.yml
287
+ steps:
288
+ - name: Start dev server
289
+ run: npm run dev &
290
+
291
+ - name: Wait for server
292
+ run: npx wait-on http://localhost:3000
293
+
294
+ - name: Accessibility audit
295
+ run: npx wcag-a11y scan -u http://localhost:3000 --no-ai --ci
296
+ ```
297
+
298
+ Exits `0` when clean, `1` when violations are found — gates merges on accessibility.
package/dist/ai/gemini.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { buildPrompt } from './prompt.js';
2
+ import { groupViolations } from './group.js';
2
3
  export class GeminiProvider {
3
4
  apiKey;
4
5
  model;
5
- constructor(apiKey, model = 'gemini-2.0-flash') {
6
+ constructor(apiKey, model = 'gemini-2.5-flash') {
6
7
  this.apiKey = apiKey;
7
8
  this.model = model;
8
9
  }
9
- async generateFixes(violations) {
10
+ async generateFixes(violations, strategy = 'rule') {
10
11
  if (violations.length === 0)
11
12
  return [];
12
- const prompt = buildPrompt(violations);
13
+ const groups = groupViolations(violations, strategy);
14
+ const prompt = buildPrompt(groups);
13
15
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
14
16
  const response = await fetch(url, {
15
17
  method: 'POST',
@@ -24,34 +26,34 @@ export class GeminiProvider {
24
26
  }
25
27
  const data = await response.json();
26
28
  const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
27
- return this.parse(text, violations);
29
+ return this.parse(text, groups);
28
30
  }
29
- parse(text, violations) {
31
+ parse(text, groups) {
30
32
  try {
31
33
  const jsonMatch = text.match(/\[[\s\S]*\]/);
32
34
  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
- };
35
+ return groups.map((g) => {
36
+ const found = fixes.find((f) => f.ruleId === g.ruleId);
37
+ return found
38
+ ? { ...found, selectors: g.selectors, instanceCount: g.count }
39
+ : this.fallbackFix(g);
44
40
  });
45
41
  }
46
42
  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
- }));
43
+ return groups.map((g) => this.fallbackFix(g));
55
44
  }
56
45
  }
46
+ fallbackFix(g) {
47
+ const v = g.representative;
48
+ const instanceNote = g.count > 1 ? ` There are ${g.count} similar instances at: ${g.selectors.join(', ')}.` : '';
49
+ return {
50
+ ruleId: g.ruleId,
51
+ selectors: g.selectors,
52
+ instanceCount: g.count,
53
+ explanation: g.description,
54
+ fixedCode: v.html,
55
+ wcagReference: `WCAG 2.1 SC ${g.wcag}`,
56
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${g.selectors[0]}\` violates ${g.wcag} — ${g.description}.${instanceNote} Please fix it in the codebase.`,
57
+ };
58
+ }
57
59
  }
@@ -0,0 +1,37 @@
1
+ export function groupViolations(violations, strategy) {
2
+ if (strategy === 'none') {
3
+ return violations.map((v) => ({
4
+ ruleId: v.ruleId,
5
+ wcag: v.wcag,
6
+ level: v.level,
7
+ impact: v.impact,
8
+ description: v.description,
9
+ page: v.page,
10
+ representative: v,
11
+ selectors: [v.selector],
12
+ count: 1,
13
+ }));
14
+ }
15
+ const map = new Map();
16
+ for (const v of violations) {
17
+ const existing = map.get(v.ruleId);
18
+ if (existing) {
19
+ existing.selectors.push(v.selector);
20
+ existing.count++;
21
+ }
22
+ else {
23
+ map.set(v.ruleId, {
24
+ ruleId: v.ruleId,
25
+ wcag: v.wcag,
26
+ level: v.level,
27
+ impact: v.impact,
28
+ description: v.description,
29
+ page: v.page,
30
+ representative: v,
31
+ selectors: [v.selector],
32
+ count: 1,
33
+ });
34
+ }
35
+ }
36
+ return Array.from(map.values());
37
+ }
package/dist/ai/index.js CHANGED
@@ -1,9 +1,17 @@
1
1
  import { GeminiProvider } from './gemini.js';
2
2
  import { OllamaProvider } from './ollama.js';
3
+ import { OpenAIProvider } from './openai.js';
3
4
  export function createAIProvider(config) {
4
5
  if (config.provider === 'ollama') {
5
6
  return new OllamaProvider(config.ollamaBaseUrl, config.ollamaModel);
6
7
  }
8
+ if (config.provider === 'openai') {
9
+ if (!config.openaiApiKey) {
10
+ console.error('No OpenAI API key found. Add "openaiApiKey" to a11y.config.json.');
11
+ process.exit(1);
12
+ }
13
+ return new OpenAIProvider(config.openaiApiKey, config.openaiModel);
14
+ }
7
15
  if (!config.apiKey) {
8
16
  console.error('No API key found. Run: wcag-a11y init');
9
17
  process.exit(1);
package/dist/ai/ollama.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildPrompt } from './prompt.js';
2
+ import { groupViolations } from './group.js';
2
3
  export class OllamaProvider {
3
4
  baseUrl;
4
5
  model;
@@ -6,10 +7,11 @@ export class OllamaProvider {
6
7
  this.baseUrl = baseUrl;
7
8
  this.model = model;
8
9
  }
9
- async generateFixes(violations) {
10
+ async generateFixes(violations, strategy = 'rule') {
10
11
  if (violations.length === 0)
11
12
  return [];
12
- const prompt = buildPrompt(violations);
13
+ const groups = groupViolations(violations, strategy);
14
+ const prompt = buildPrompt(groups);
13
15
  const response = await fetch(`${this.baseUrl}/api/generate`, {
14
16
  method: 'POST',
15
17
  headers: { 'Content-Type': 'application/json' },
@@ -23,33 +25,33 @@ export class OllamaProvider {
23
25
  try {
24
26
  const jsonMatch = text.match(/\[[\s\S]*\]/);
25
27
  if (!jsonMatch)
26
- return this.fallback(violations);
28
+ return this.fallback(groups);
27
29
  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
- };
30
+ return groups.map((g) => {
31
+ const found = fixes.find((f) => f.ruleId === g.ruleId);
32
+ return found
33
+ ? { ...found, selectors: g.selectors, instanceCount: g.count }
34
+ : this.fallbackFix(g);
39
35
  });
40
36
  }
41
37
  catch {
42
- return this.fallback(violations);
38
+ return this.fallback(groups);
43
39
  }
44
40
  }
45
- fallback(violations) {
46
- return violations.map((v) => ({
47
- ruleId: v.ruleId,
48
- selector: v.selector,
49
- explanation: v.description,
41
+ fallback(groups) {
42
+ return groups.map((g) => this.fallbackFix(g));
43
+ }
44
+ fallbackFix(g) {
45
+ const v = g.representative;
46
+ const instanceNote = g.count > 1 ? ` There are ${g.count} similar instances at: ${g.selectors.join(', ')}.` : '';
47
+ return {
48
+ ruleId: g.ruleId,
49
+ selectors: g.selectors,
50
+ instanceCount: g.count,
51
+ explanation: g.description,
50
52
  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
- }));
53
+ wcagReference: `WCAG 2.1 SC ${g.wcag}`,
54
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${g.selectors[0]}\` violates ${g.wcag} — ${g.description}.${instanceNote} Please fix it in the codebase.`,
55
+ };
54
56
  }
55
57
  }
@@ -0,0 +1,63 @@
1
+ import { buildPrompt } from './prompt.js';
2
+ import { groupViolations } from './group.js';
3
+ export class OpenAIProvider {
4
+ apiKey;
5
+ model;
6
+ constructor(apiKey, model = 'gpt-4o-mini') {
7
+ this.apiKey = apiKey;
8
+ this.model = model;
9
+ }
10
+ async generateFixes(violations, strategy = 'rule') {
11
+ if (violations.length === 0)
12
+ return [];
13
+ const groups = groupViolations(violations, strategy);
14
+ const prompt = buildPrompt(groups);
15
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ 'Authorization': `Bearer ${this.apiKey}`,
20
+ },
21
+ body: JSON.stringify({
22
+ model: this.model,
23
+ messages: [{ role: 'user', content: prompt }],
24
+ temperature: 0.2,
25
+ max_tokens: 8192,
26
+ }),
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`OpenAI API error: ${response.status} ${await response.text()}`);
30
+ }
31
+ const data = await response.json();
32
+ const text = data.choices?.[0]?.message?.content ?? '[]';
33
+ return this.parse(text, groups);
34
+ }
35
+ parse(text, groups) {
36
+ try {
37
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
38
+ const fixes = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
39
+ return groups.map((g) => {
40
+ const found = fixes.find((f) => f.ruleId === g.ruleId);
41
+ return found
42
+ ? { ...found, selectors: g.selectors, instanceCount: g.count }
43
+ : this.fallbackFix(g);
44
+ });
45
+ }
46
+ catch {
47
+ return groups.map((g) => this.fallbackFix(g));
48
+ }
49
+ }
50
+ fallbackFix(g) {
51
+ const v = g.representative;
52
+ const instanceNote = g.count > 1 ? ` There are ${g.count} similar instances at: ${g.selectors.join(', ')}.` : '';
53
+ return {
54
+ ruleId: g.ruleId,
55
+ selectors: g.selectors,
56
+ instanceCount: g.count,
57
+ explanation: g.description,
58
+ fixedCode: v.html,
59
+ wcagReference: `WCAG 2.1 SC ${g.wcag}`,
60
+ optimalPrompt: `Fix this accessibility violation: The element \`${v.html.slice(0, 120)}\` at selector \`${g.selectors[0]}\` violates ${g.wcag} — ${g.description}.${instanceNote} Please fix it in the codebase.`,
61
+ };
62
+ }
63
+ }
package/dist/ai/prompt.js CHANGED
@@ -1,17 +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}`)
1
+ export function buildPrompt(groups) {
2
+ const items = groups
3
+ .map((g, i) => `${i + 1}. Rule: ${g.ruleId} | WCAG ${g.wcag} (Level ${g.level}) | Impact: ${g.impact}
4
+ Page: ${g.page}
5
+ Instances: ${g.count} element(s)
6
+ Selectors: ${g.selectors.join(', ')}
7
+ Representative Element: ${g.representative.html}
8
+ Problem: ${g.description}`)
7
9
  .join('\n\n');
8
10
  return `You are a WCAG accessibility expert. Analyze these violations and return a JSON array.
9
11
  Each item must have:
10
12
  - "ruleId": the rule id from the input
13
+ - "selectors": array of CSS selectors affected (copy from input)
11
14
  - "explanation": 1-2 sentences explaining why this matters for users with disabilities (plain English, no jargon)
12
15
  - "fixedCode": the corrected HTML snippet only (no explanation, just code)
13
16
  - "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.
17
+ - "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 elements, what rule they break, how many instances there are, and exactly what change is needed. Be specific and actionable.
15
18
 
16
19
  Return ONLY a valid JSON array. No markdown, no code fences, no explanation outside the JSON.
17
20
 
package/dist/cli.js CHANGED
@@ -10,12 +10,13 @@ const program = new Command();
10
10
  program
11
11
  .name('wcag-a11y')
12
12
  .description('WCAG 2.1/2.2 accessibility auditor with AI-powered fixes')
13
- .version('0.1.0');
13
+ .version('0.2.0');
14
14
  program
15
15
  .command('init')
16
16
  .description('Create a11y.config.json in the current directory')
17
- .action(() => {
18
- initConfig();
17
+ .option('--provider <name>', 'AI provider to configure (gemini|openai|ollama)', 'gemini')
18
+ .action((opts) => {
19
+ initConfig(opts.provider);
19
20
  });
20
21
  program
21
22
  .command('scan')
@@ -25,18 +26,26 @@ program
25
26
  .option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
26
27
  .option('-r, --report', 'Save a full markdown report to a11y-report.md', false)
27
28
  .option('--no-ai', 'Skip AI fix generation (faster, violations only)')
29
+ .option('--no-explain', 'Hide AI fix explanations in terminal output')
30
+ .option('--group <strategy>', 'Group violations by rule or show individually (rule|none)', 'rule')
31
+ .option('--ci', 'Exit with code 1 if any violations are found (for CI/CD pipelines)', false)
32
+ .option('--provider <name>', 'Override the AI provider from config (gemini|openai|ollama)')
28
33
  .action(async (opts) => {
29
34
  try {
30
35
  console.log(`\nScanning ${opts.url}...`);
31
36
  const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl });
32
37
  printTerminalReport(result);
38
+ const strategy = opts.group === 'none' ? 'none' : 'rule';
33
39
  if (opts.ai && result.totalViolations > 0) {
34
40
  const config = loadConfig();
41
+ if (opts.provider) {
42
+ config.provider = opts.provider;
43
+ }
35
44
  const provider = createAIProvider(config);
36
45
  const allViolations = result.pages.flatMap((p) => p.violations);
37
46
  console.log(`\nGenerating AI fixes for ${allViolations.length} violations...`);
38
- const fixes = await provider.generateFixes(allViolations);
39
- printAIPrompts(fixes);
47
+ const fixes = await provider.generateFixes(allViolations, strategy);
48
+ printAIPrompts(fixes, { explain: opts.explain });
40
49
  if (opts.report) {
41
50
  generateMarkdownReport(result, fixes);
42
51
  }
@@ -44,6 +53,9 @@ program
44
53
  else if (opts.report) {
45
54
  generateMarkdownReport(result, []);
46
55
  }
56
+ if (opts.ci && result.totalViolations > 0) {
57
+ process.exit(1);
58
+ }
47
59
  }
48
60
  catch (err) {
49
61
  console.error(`\nError: ${err.message}`);
package/dist/config.js CHANGED
@@ -3,9 +3,10 @@ import { join } from 'path';
3
3
  const CONFIG_FILE = 'a11y.config.json';
4
4
  const DEFAULTS = {
5
5
  provider: 'gemini',
6
- model: 'gemini-2.0-flash',
6
+ model: 'gemini-2.5-flash',
7
7
  ollamaBaseUrl: 'http://localhost:11434',
8
8
  ollamaModel: 'llama3',
9
+ openaiModel: 'gpt-4o-mini',
9
10
  };
10
11
  export function loadConfig() {
11
12
  const configPath = join(process.cwd(), CONFIG_FILE);
@@ -16,17 +17,27 @@ export function loadConfig() {
16
17
  const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
17
18
  return { ...DEFAULTS, ...raw };
18
19
  }
19
- export function initConfig() {
20
+ const STARTER_CONFIGS = {
21
+ gemini: [
22
+ { provider: 'gemini', apiKey: 'YOUR_GEMINI_API_KEY', model: 'gemini-2.5-flash' },
23
+ `Created ${CONFIG_FILE} — add your Gemini API key from https://aistudio.google.com`,
24
+ ],
25
+ openai: [
26
+ { provider: 'openai', openaiApiKey: 'YOUR_OPENAI_API_KEY', openaiModel: 'gpt-4o-mini' },
27
+ `Created ${CONFIG_FILE} — add your OpenAI API key from https://platform.openai.com/api-keys`,
28
+ ],
29
+ ollama: [
30
+ { provider: 'ollama', ollamaBaseUrl: 'http://localhost:11434', ollamaModel: 'llama3' },
31
+ `Created ${CONFIG_FILE} — run \`ollama serve\` to start the local model server`,
32
+ ],
33
+ };
34
+ export function initConfig(provider = 'gemini') {
20
35
  const configPath = join(process.cwd(), CONFIG_FILE);
21
36
  if (existsSync(configPath)) {
22
37
  console.log(`${CONFIG_FILE} already exists.`);
23
38
  return;
24
39
  }
25
- const starter = {
26
- provider: 'gemini',
27
- apiKey: 'YOUR_GEMINI_API_KEY',
28
- model: 'gemini-2.0-flash',
29
- };
40
+ const [starter, message] = STARTER_CONFIGS[provider];
30
41
  writeFileSync(configPath, JSON.stringify(starter, null, 2));
31
- console.log(`Created ${CONFIG_FILE} — add your Gemini API key from https://aistudio.google.com`);
42
+ console.log(message);
32
43
  }
package/dist/demo.js CHANGED
@@ -74,8 +74,8 @@ 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);
78
- printAIPrompts(fixes);
77
+ const fixes = await provider.generateFixes(violations, 'rule');
78
+ printAIPrompts(fixes, { explain: true });
79
79
  if (opts.report)
80
80
  generateMarkdownReport(result, fixes);
81
81
  }
@@ -29,7 +29,7 @@ export function generateMarkdownReport(result, fixes, outputPath = 'a11y-report.
29
29
  continue;
30
30
  }
31
31
  for (const v of page.violations) {
32
- const fix = fixes.find((f) => f.ruleId === v.ruleId && v.selector.includes(f.selector?.split(' ')[0] ?? ''));
32
+ const fix = fixes.find((f) => f.ruleId === v.ruleId);
33
33
  lines.push(`### ${IMPACT_EMOJI[v.impact] ?? '⚪'} [${v.impact.toUpperCase()}] ${v.description}`, '', `**Rule:** \`${v.ruleId}\` `, `**WCAG:** ${fix?.wcagReference ?? `SC ${v.wcag} (Level ${v.level})`} `, `**Selector:** \`${v.selector}\``, '', '**Violating element:**', '```html', v.html, '```', '');
34
34
  if (fix) {
35
35
  lines.push('**Why it matters:**', fix.explanation, '', '**Fixed code:**', '```html', fix.fixedCode, '```', '', '**📋 Prompt for your AI assistant (Cursor / Copilot / Claude):**', '```', fix.optimalPrompt, '```', '');
@@ -34,15 +34,18 @@ export function printTerminalReport(result) {
34
34
  console.log(`Total: ${chalk.red(result.criticalCount + ' critical')} · ${chalk.yellow(result.seriousCount + ' serious')} · ${chalk.blue(result.moderateCount + ' moderate')}`);
35
35
  console.log(chalk.gray('Run with --report to save a full markdown report with AI fix suggestions.\n'));
36
36
  }
37
- export function printAIPrompts(fixes) {
37
+ export function printAIPrompts(fixes, opts) {
38
38
  if (fixes.length === 0)
39
39
  return;
40
40
  console.log('\n' + chalk.bold.magenta('AI Fix Prompts') + chalk.gray(' — paste any of these into Cursor, Copilot, or Claude'));
41
41
  console.log(chalk.gray('─'.repeat(60)));
42
42
  for (const fix of fixes) {
43
- const header = chalk.bold(`[${fix.ruleId}]`) + (fix.selector ? chalk.gray(` ${fix.selector}`) : '');
44
- console.log('\n' + header);
45
- if (fix.explanation) {
43
+ const countLabel = fix.instanceCount > 1 ? chalk.gray(` × ${fix.instanceCount} instances`) : '';
44
+ console.log('\n' + chalk.bold(`[${fix.ruleId}]`) + countLabel);
45
+ for (const sel of fix.selectors) {
46
+ console.log(chalk.gray(` → ${sel}`));
47
+ }
48
+ if (opts.explain && fix.explanation) {
46
49
  console.log(chalk.gray(fix.explanation));
47
50
  }
48
51
  console.log(chalk.cyan('┌─ Copy this prompt ──────────────────────────────────────'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wcag-a11y",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "WCAG 2.1/2.2 accessibility auditor with AI-powered fixes",
5
5
  "type": "module",
6
6
  "bin": {