wcag-a11y 0.3.4 → 0.3.5

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