wcag-a11y 0.1.0 → 0.3.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 +269 -15
- package/dist/ai/gemini.js +30 -24
- package/dist/ai/group.js +37 -0
- package/dist/ai/index.js +8 -0
- package/dist/ai/ollama.js +29 -23
- package/dist/ai/openai.js +67 -0
- package/dist/ai/prompt.js +12 -9
- package/dist/cli.js +31 -9
- package/dist/config.js +19 -8
- package/dist/demo.js +2 -2
- package/dist/reporter/markdown.js +27 -4
- package/dist/reporter/terminal.js +28 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
# WCAG A11y
|
|
2
2
|
|
|
3
|
-
WCAG
|
|
3
|
+
[](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
|
|
15
|
-
|
|
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 + AI fixes (default, requires config)
|
|
91
|
+
wcag-a11y demo --no-ai # violations only, no AI — faster
|
|
92
|
+
wcag-a11y demo --report # + save a11y-report.md
|
|
16
93
|
```
|
|
17
94
|
|
|
18
|
-
|
|
95
|
+
| Flag | Description |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `--no-ai` | Skip AI fix generation — prints violations only, no prompts |
|
|
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
|
-
#
|
|
22
|
-
wcag-a11y
|
|
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
|
-
|
|
25
|
-
wcag-a11y scan --url http://localhost:3000 --pages / /about /contact
|
|
116
|
+
---
|
|
26
117
|
|
|
27
|
-
|
|
28
|
-
wcag-a11y scan --url http://localhost:3000 --crawl --report
|
|
118
|
+
### `wcag-a11y scan`
|
|
29
119
|
|
|
30
|
-
|
|
31
|
-
|
|
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 --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
|
+
| `--no-ai` | on | Skip AI fix generation — scan runs faster and prints violations only |
|
|
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
|
-
|
|
40
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
|
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,38 @@ 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,
|
|
29
|
+
return this.parse(text, groups);
|
|
28
30
|
}
|
|
29
|
-
parse(text,
|
|
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
|
|
34
|
-
const found = fixes.find((f) => f.ruleId ===
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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 selectorList = g.selectors.map((s) => `- ${s}`).join('\n');
|
|
49
|
+
const explanation = `Users relying on assistive technologies are affected: ${g.description.toLowerCase()}. This fails WCAG 2.1 SC ${g.wcag} (Level ${g.level}).`;
|
|
50
|
+
const prompt = g.count > 1
|
|
51
|
+
? `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}.`
|
|
52
|
+
: `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}.`;
|
|
53
|
+
return {
|
|
54
|
+
ruleId: g.ruleId,
|
|
55
|
+
selectors: g.selectors,
|
|
56
|
+
instanceCount: g.count,
|
|
57
|
+
explanation,
|
|
58
|
+
fixedCode: v.html,
|
|
59
|
+
wcagReference: `WCAG 2.1 SC ${g.wcag}`,
|
|
60
|
+
optimalPrompt: prompt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
57
63
|
}
|
package/dist/ai/group.js
ADDED
|
@@ -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
|
|
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,37 @@ export class OllamaProvider {
|
|
|
23
25
|
try {
|
|
24
26
|
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
25
27
|
if (!jsonMatch)
|
|
26
|
-
return this.fallback(
|
|
28
|
+
return this.fallback(groups);
|
|
27
29
|
const fixes = JSON.parse(jsonMatch[0]);
|
|
28
|
-
return
|
|
29
|
-
const found = fixes.find((f) => f.ruleId ===
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
38
|
+
return this.fallback(groups);
|
|
43
39
|
}
|
|
44
40
|
}
|
|
45
|
-
fallback(
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
fallback(groups) {
|
|
42
|
+
return groups.map((g) => this.fallbackFix(g));
|
|
43
|
+
}
|
|
44
|
+
fallbackFix(g) {
|
|
45
|
+
const v = g.representative;
|
|
46
|
+
const selectorList = g.selectors.map((s) => `- ${s}`).join('\n');
|
|
47
|
+
const explanation = `Users relying on assistive technologies are affected: ${g.description.toLowerCase()}. This fails WCAG 2.1 SC ${g.wcag} (Level ${g.level}).`;
|
|
48
|
+
const prompt = g.count > 1
|
|
49
|
+
? `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}.`
|
|
50
|
+
: `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}.`;
|
|
51
|
+
return {
|
|
52
|
+
ruleId: g.ruleId,
|
|
53
|
+
selectors: g.selectors,
|
|
54
|
+
instanceCount: g.count,
|
|
55
|
+
explanation,
|
|
50
56
|
fixedCode: v.html,
|
|
51
|
-
wcagReference: `WCAG 2.1 SC ${
|
|
52
|
-
optimalPrompt:
|
|
53
|
-
}
|
|
57
|
+
wcagReference: `WCAG 2.1 SC ${g.wcag}`,
|
|
58
|
+
optimalPrompt: prompt,
|
|
59
|
+
};
|
|
54
60
|
}
|
|
55
61
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 selectorList = g.selectors.map((s) => `- ${s}`).join('\n');
|
|
53
|
+
const explanation = `Users relying on assistive technologies are affected: ${g.description.toLowerCase()}. This fails WCAG 2.1 SC ${g.wcag} (Level ${g.level}).`;
|
|
54
|
+
const prompt = g.count > 1
|
|
55
|
+
? `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}.`
|
|
56
|
+
: `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}.`;
|
|
57
|
+
return {
|
|
58
|
+
ruleId: g.ruleId,
|
|
59
|
+
selectors: g.selectors,
|
|
60
|
+
instanceCount: g.count,
|
|
61
|
+
explanation,
|
|
62
|
+
fixedCode: v.html,
|
|
63
|
+
wcagReference: `WCAG 2.1 SC ${g.wcag}`,
|
|
64
|
+
optimalPrompt: prompt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/ai/prompt.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
export function buildPrompt(
|
|
2
|
-
const items =
|
|
3
|
-
.map((
|
|
4
|
-
Page: ${
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
11
|
-
- "
|
|
13
|
+
- "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."
|
|
12
15
|
- "fixedCode": the corrected HTML snippet only (no explanation, just code)
|
|
13
|
-
- "wcagReference": e.g. "WCAG 2.1 SC 1.1.1
|
|
14
|
-
- "optimalPrompt": a ready-to-paste prompt
|
|
16
|
+
- "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.
|
|
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
|
@@ -3,6 +3,7 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { loadConfig, initConfig } from './config.js';
|
|
4
4
|
import { crawl } from './crawler.js';
|
|
5
5
|
import { createAIProvider } from './ai/index.js';
|
|
6
|
+
import { groupViolations } from './ai/group.js';
|
|
6
7
|
import { printTerminalReport, printAIPrompts } from './reporter/terminal.js';
|
|
7
8
|
import { generateMarkdownReport } from './reporter/markdown.js';
|
|
8
9
|
import { runDemo } from './demo.js';
|
|
@@ -10,12 +11,13 @@ const program = new Command();
|
|
|
10
11
|
program
|
|
11
12
|
.name('wcag-a11y')
|
|
12
13
|
.description('WCAG 2.1/2.2 accessibility auditor with AI-powered fixes')
|
|
13
|
-
.version('0.
|
|
14
|
+
.version('0.3.0');
|
|
14
15
|
program
|
|
15
16
|
.command('init')
|
|
16
17
|
.description('Create a11y.config.json in the current directory')
|
|
17
|
-
.
|
|
18
|
-
|
|
18
|
+
.option('--provider <name>', 'AI provider to configure (gemini|openai|ollama)', 'gemini')
|
|
19
|
+
.action((opts) => {
|
|
20
|
+
initConfig(opts.provider);
|
|
19
21
|
});
|
|
20
22
|
program
|
|
21
23
|
.command('scan')
|
|
@@ -25,24 +27,44 @@ program
|
|
|
25
27
|
.option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
|
|
26
28
|
.option('-r, --report', 'Save a full markdown report to a11y-report.md', false)
|
|
27
29
|
.option('--no-ai', 'Skip AI fix generation (faster, violations only)')
|
|
30
|
+
.option('--no-explain', 'Hide AI fix explanations in terminal output')
|
|
31
|
+
.option('--no-terminal', 'Suppress terminal output (violations summary)')
|
|
32
|
+
.option('--fast-mode', 'Output only AI fix prompts — no summaries or explanations', false)
|
|
33
|
+
.option('--group <strategy>', 'Group violations by rule or show individually (rule|none)', 'rule')
|
|
34
|
+
.option('--ci', 'Exit with code 1 if any violations are found (for CI/CD pipelines)', false)
|
|
35
|
+
.option('--provider <name>', 'Override the AI provider from config (gemini|openai|ollama)')
|
|
28
36
|
.action(async (opts) => {
|
|
29
37
|
try {
|
|
30
38
|
console.log(`\nScanning ${opts.url}...`);
|
|
31
39
|
const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl });
|
|
32
|
-
|
|
40
|
+
if (opts.terminal && !opts.fastMode) {
|
|
41
|
+
printTerminalReport(result);
|
|
42
|
+
}
|
|
43
|
+
const strategy = opts.group === 'none' ? 'none' : 'rule';
|
|
33
44
|
if (opts.ai && result.totalViolations > 0) {
|
|
34
45
|
const config = loadConfig();
|
|
46
|
+
if (opts.provider) {
|
|
47
|
+
config.provider = opts.provider;
|
|
48
|
+
}
|
|
35
49
|
const provider = createAIProvider(config);
|
|
36
50
|
const allViolations = result.pages.flatMap((p) => p.violations);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
const ruleGroups = groupViolations(allViolations, strategy);
|
|
52
|
+
if (!opts.fastMode) {
|
|
53
|
+
console.log(`\nGenerating AI fixes for ${ruleGroups.length} rule groups (${allViolations.length} violations)...`);
|
|
54
|
+
}
|
|
55
|
+
const fixes = await provider.generateFixes(allViolations, strategy);
|
|
56
|
+
if (opts.terminal) {
|
|
57
|
+
printAIPrompts(fixes, { explain: opts.explain, fastMode: opts.fastMode });
|
|
58
|
+
}
|
|
40
59
|
if (opts.report) {
|
|
41
|
-
generateMarkdownReport(result, fixes);
|
|
60
|
+
generateMarkdownReport(result, fixes, { fastMode: opts.fastMode });
|
|
42
61
|
}
|
|
43
62
|
}
|
|
44
63
|
else if (opts.report) {
|
|
45
|
-
generateMarkdownReport(result, []);
|
|
64
|
+
generateMarkdownReport(result, [], { fastMode: opts.fastMode });
|
|
65
|
+
}
|
|
66
|
+
if (opts.ci && result.totalViolations > 0) {
|
|
67
|
+
process.exit(1);
|
|
46
68
|
}
|
|
47
69
|
}
|
|
48
70
|
catch (err) {
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
|
@@ -5,7 +5,31 @@ const IMPACT_EMOJI = {
|
|
|
5
5
|
moderate: '🟡',
|
|
6
6
|
minor: '🟢',
|
|
7
7
|
};
|
|
8
|
-
export function generateMarkdownReport(result, fixes, outputPath = 'a11y-report.md') {
|
|
8
|
+
export function generateMarkdownReport(result, fixes, opts = {}, outputPath = 'a11y-report.md') {
|
|
9
|
+
const lines = opts.fastMode
|
|
10
|
+
? buildFastReport(fixes)
|
|
11
|
+
: buildFullReport(result, fixes);
|
|
12
|
+
writeFileSync(outputPath, lines.join('\n'), 'utf-8');
|
|
13
|
+
console.log(`\nReport saved → ${outputPath}`);
|
|
14
|
+
}
|
|
15
|
+
function buildFastReport(fixes) {
|
|
16
|
+
const lines = [
|
|
17
|
+
'# WCAG A11y — Fix Prompts',
|
|
18
|
+
`> Generated: ${new Date().toLocaleString()}`,
|
|
19
|
+
'',
|
|
20
|
+
];
|
|
21
|
+
for (const fix of fixes) {
|
|
22
|
+
const countLabel = fix.instanceCount > 1 ? ` ×${fix.instanceCount}` : '';
|
|
23
|
+
lines.push(`## \`${fix.ruleId}\`${countLabel}`, '');
|
|
24
|
+
lines.push('**Selectors:**');
|
|
25
|
+
for (const sel of fix.selectors) {
|
|
26
|
+
lines.push(`- \`${sel}\``);
|
|
27
|
+
}
|
|
28
|
+
lines.push('', '```', fix.optimalPrompt, '```', '', '---', '');
|
|
29
|
+
}
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
32
|
+
function buildFullReport(result, fixes) {
|
|
9
33
|
const lines = [
|
|
10
34
|
'# WCAG A11y Report',
|
|
11
35
|
`> Generated: ${new Date().toLocaleString()}`,
|
|
@@ -29,7 +53,7 @@ export function generateMarkdownReport(result, fixes, outputPath = 'a11y-report.
|
|
|
29
53
|
continue;
|
|
30
54
|
}
|
|
31
55
|
for (const v of page.violations) {
|
|
32
|
-
const fix = fixes.find((f) => f.ruleId === v.ruleId
|
|
56
|
+
const fix = fixes.find((f) => f.ruleId === v.ruleId);
|
|
33
57
|
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
58
|
if (fix) {
|
|
35
59
|
lines.push('**Why it matters:**', fix.explanation, '', '**Fixed code:**', '```html', fix.fixedCode, '```', '', '**📋 Prompt for your AI assistant (Cursor / Copilot / Claude):**', '```', fix.optimalPrompt, '```', '');
|
|
@@ -37,6 +61,5 @@ export function generateMarkdownReport(result, fixes, outputPath = 'a11y-report.
|
|
|
37
61
|
lines.push('---', '');
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
|
-
|
|
41
|
-
console.log(`\nReport saved → ${outputPath}`);
|
|
64
|
+
return lines;
|
|
42
65
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { groupViolations } from '../ai/group.js';
|
|
2
3
|
const IMPACT_COLOR = {
|
|
3
4
|
critical: chalk.red,
|
|
4
5
|
serious: chalk.yellow,
|
|
@@ -22,27 +23,44 @@ export function printTerminalReport(result) {
|
|
|
22
23
|
m > 0 ? chalk.blue(`${m} moderate`) : '',
|
|
23
24
|
].filter(Boolean).join(' ');
|
|
24
25
|
console.log(`\n ${chalk.red('✖')} ${url} ${counts}`);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const groups = groupViolations(page.violations, 'rule');
|
|
27
|
+
for (const g of groups) {
|
|
28
|
+
const color = IMPACT_COLOR[g.impact] ?? chalk.white;
|
|
29
|
+
const countSuffix = g.count > 1 ? chalk.gray(` ×${g.count}`) : '';
|
|
30
|
+
const tag = color(`[${g.impact.toUpperCase()}]`) + countSuffix;
|
|
31
|
+
const wcag = chalk.gray(`WCAG ${g.wcag}`);
|
|
32
|
+
console.log(` ${tag} ${g.description} ${wcag}`);
|
|
33
|
+
console.log(` ${chalk.gray('→')} ${chalk.dim(g.selectors[0])}`);
|
|
34
|
+
if (g.count > 1) {
|
|
35
|
+
console.log(` ${chalk.gray(` …and ${g.count - 1} more`)}`);
|
|
36
|
+
}
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
console.log('\n' + chalk.gray('─'.repeat(60)));
|
|
34
40
|
console.log(`Total: ${chalk.red(result.criticalCount + ' critical')} · ${chalk.yellow(result.seriousCount + ' serious')} · ${chalk.blue(result.moderateCount + ' moderate')}`);
|
|
35
41
|
console.log(chalk.gray('Run with --report to save a full markdown report with AI fix suggestions.\n'));
|
|
36
42
|
}
|
|
37
|
-
export function printAIPrompts(fixes) {
|
|
43
|
+
export function printAIPrompts(fixes, opts) {
|
|
38
44
|
if (fixes.length === 0)
|
|
39
45
|
return;
|
|
46
|
+
if (opts.fastMode) {
|
|
47
|
+
for (let i = 0; i < fixes.length; i++) {
|
|
48
|
+
console.log(fixes[i].optimalPrompt);
|
|
49
|
+
if (i < fixes.length - 1)
|
|
50
|
+
console.log('\n' + chalk.gray('─'.repeat(60)));
|
|
51
|
+
}
|
|
52
|
+
console.log('');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
40
55
|
console.log('\n' + chalk.bold.magenta('AI Fix Prompts') + chalk.gray(' — paste any of these into Cursor, Copilot, or Claude'));
|
|
41
56
|
console.log(chalk.gray('─'.repeat(60)));
|
|
42
57
|
for (const fix of fixes) {
|
|
43
|
-
const
|
|
44
|
-
console.log('\n' +
|
|
45
|
-
|
|
58
|
+
const countLabel = fix.instanceCount > 1 ? chalk.gray(` × ${fix.instanceCount} instances`) : '';
|
|
59
|
+
console.log('\n' + chalk.bold(`[${fix.ruleId}]`) + countLabel);
|
|
60
|
+
for (const sel of fix.selectors) {
|
|
61
|
+
console.log(chalk.gray(` → ${sel}`));
|
|
62
|
+
}
|
|
63
|
+
if (opts.explain && fix.explanation) {
|
|
46
64
|
console.log(chalk.gray(fix.explanation));
|
|
47
65
|
}
|
|
48
66
|
console.log(chalk.cyan('┌─ Copy this prompt ──────────────────────────────────────'));
|