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 +58 -2
- package/dist/ai/gemini.js +27 -9
- package/dist/ai/ollama.js +24 -10
- package/dist/ai/openai.js +28 -9
- package/dist/ai/patch-prompt.js +16 -0
- package/dist/ai/prompt.js +26 -5
- package/dist/cli.js +35 -5
- package/dist/crawler.js +42 -0
- package/dist/demo.js +1 -1
- package/dist/engine/index.js +40 -0
- package/dist/fixer.js +254 -0
- package/dist/reporter/markdown.js +4 -2
- package/dist/reporter/terminal.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[](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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
53
|
-
:
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
?
|
|
51
|
-
:
|
|
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
|
-
|
|
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
|
-
?
|
|
57
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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('-
|
|
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('--
|
|
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('-
|
|
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);
|
package/dist/engine/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|