ally-a11y 1.0.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/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally pr-check command - Post accessibility results to GitHub PR
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import boxen from 'boxen';
|
|
10
|
+
import { printBanner, createSpinner, printError, printSuccess, printInfo, printWarning, } from '../utils/ui.js';
|
|
11
|
+
import { suggestInit } from '../utils/errors.js';
|
|
12
|
+
/**
|
|
13
|
+
* Get current GitHub repo from git remote
|
|
14
|
+
*/
|
|
15
|
+
function getGitHubRepo() {
|
|
16
|
+
try {
|
|
17
|
+
const remote = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
|
|
18
|
+
// Parse GitHub URL (supports both HTTPS and SSH)
|
|
19
|
+
// https://github.com/owner/repo.git
|
|
20
|
+
// git@github.com:owner/repo.git
|
|
21
|
+
const httpsMatch = remote.match(/github\.com\/([^/]+)\/([^/.]+)/);
|
|
22
|
+
const sshMatch = remote.match(/github\.com:([^/]+)\/([^/.]+)/);
|
|
23
|
+
const match = httpsMatch || sshMatch;
|
|
24
|
+
if (match) {
|
|
25
|
+
return {
|
|
26
|
+
owner: match[1],
|
|
27
|
+
repo: match[2].replace(/\.git$/, ''),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Not in a git repo or no remote
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get current PR number from environment or git
|
|
38
|
+
*/
|
|
39
|
+
function getCurrentPR() {
|
|
40
|
+
// Check GitHub Actions environment
|
|
41
|
+
if (process.env.GITHUB_EVENT_NAME === 'pull_request') {
|
|
42
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
43
|
+
if (eventPath && existsSync(eventPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const event = JSON.parse(require('fs').readFileSync(eventPath, 'utf-8'));
|
|
46
|
+
return event.pull_request?.number || null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Check GITHUB_REF for PR number
|
|
54
|
+
const ref = process.env.GITHUB_REF;
|
|
55
|
+
if (ref) {
|
|
56
|
+
const prMatch = ref.match(/refs\/pull\/(\d+)/);
|
|
57
|
+
if (prMatch) {
|
|
58
|
+
return parseInt(prMatch[1], 10);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Format severity for display
|
|
65
|
+
*/
|
|
66
|
+
function getSeverityEmoji(severity) {
|
|
67
|
+
const emojis = {
|
|
68
|
+
critical: ':red_circle:',
|
|
69
|
+
serious: ':orange_circle:',
|
|
70
|
+
moderate: ':yellow_circle:',
|
|
71
|
+
minor: ':blue_circle:',
|
|
72
|
+
};
|
|
73
|
+
return emojis[severity] || ':white_circle:';
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Generate markdown comment for PR
|
|
77
|
+
*/
|
|
78
|
+
function generatePRComment(report) {
|
|
79
|
+
const { summary } = report;
|
|
80
|
+
const scoreEmoji = summary.score >= 90 ? ':star:' : summary.score >= 75 ? ':white_check_mark:' : summary.score >= 50 ? ':warning:' : ':x:';
|
|
81
|
+
let comment = `## ${scoreEmoji} Accessibility Report
|
|
82
|
+
|
|
83
|
+
**Score:** ${summary.score}/100
|
|
84
|
+
|
|
85
|
+
| Severity | Count |
|
|
86
|
+
|----------|-------|
|
|
87
|
+
| :red_circle: Critical | ${summary.bySeverity.critical || 0} |
|
|
88
|
+
| :orange_circle: Serious | ${summary.bySeverity.serious || 0} |
|
|
89
|
+
| :yellow_circle: Moderate | ${summary.bySeverity.moderate || 0} |
|
|
90
|
+
| :blue_circle: Minor | ${summary.bySeverity.minor || 0} |
|
|
91
|
+
|
|
92
|
+
`;
|
|
93
|
+
if (summary.topIssues.length > 0) {
|
|
94
|
+
comment += `### Top Issues\n\n`;
|
|
95
|
+
for (const issue of summary.topIssues.slice(0, 5)) {
|
|
96
|
+
const emoji = getSeverityEmoji(issue.severity);
|
|
97
|
+
comment += `- ${emoji} **${issue.id}**: ${issue.description} (${issue.count} occurrences)\n`;
|
|
98
|
+
}
|
|
99
|
+
comment += '\n';
|
|
100
|
+
}
|
|
101
|
+
if (summary.totalViolations > 0) {
|
|
102
|
+
comment += `<details>
|
|
103
|
+
<summary>How to fix</summary>
|
|
104
|
+
|
|
105
|
+
\`\`\`bash
|
|
106
|
+
# Get detailed explanations
|
|
107
|
+
ally explain
|
|
108
|
+
|
|
109
|
+
# Apply AI-powered fixes
|
|
110
|
+
ally fix
|
|
111
|
+
\`\`\`
|
|
112
|
+
|
|
113
|
+
</details>
|
|
114
|
+
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
comment += `---
|
|
118
|
+
*Generated by [ally](https://github.com/forbiddenlink/ally) :robot:*`;
|
|
119
|
+
return comment;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Post comment to GitHub PR using gh CLI
|
|
123
|
+
*/
|
|
124
|
+
async function postPRComment(repo, prNumber, comment) {
|
|
125
|
+
try {
|
|
126
|
+
// Write comment to temp file to avoid shell escaping issues
|
|
127
|
+
const tempFile = resolve('.ally/pr-comment.md');
|
|
128
|
+
await writeFile(tempFile, comment);
|
|
129
|
+
// Use gh CLI to post comment
|
|
130
|
+
execSync(`gh pr comment ${prNumber} --repo ${repo.owner}/${repo.repo} --body-file "${tempFile}"`, { encoding: 'utf-8', stdio: 'pipe' });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof Error) {
|
|
135
|
+
// Check for common errors
|
|
136
|
+
if (error.message.includes('gh: command not found')) {
|
|
137
|
+
printError('GitHub CLI (gh) not found. Install it from https://cli.github.com/');
|
|
138
|
+
}
|
|
139
|
+
else if (error.message.includes('not logged in')) {
|
|
140
|
+
printError('Not logged in to GitHub CLI. Run: gh auth login');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
printError(`Failed to post comment: ${error.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create GitHub check annotations for violations
|
|
151
|
+
*/
|
|
152
|
+
function generateAnnotations(report) {
|
|
153
|
+
const annotations = [];
|
|
154
|
+
for (const result of report.results) {
|
|
155
|
+
const file = result.file || 'unknown';
|
|
156
|
+
for (const violation of result.violations) {
|
|
157
|
+
for (const node of violation.nodes) {
|
|
158
|
+
// Try to extract line number from HTML context
|
|
159
|
+
const annotation = {
|
|
160
|
+
path: file,
|
|
161
|
+
start_line: 1, // Would need source map for actual line
|
|
162
|
+
end_line: 1,
|
|
163
|
+
annotation_level: violation.impact === 'critical' || violation.impact === 'serious' ? 'failure' : 'warning',
|
|
164
|
+
message: `${violation.id}: ${violation.help}\n\nElement: ${node.html.substring(0, 100)}...`,
|
|
165
|
+
title: violation.description,
|
|
166
|
+
};
|
|
167
|
+
annotations.push(JSON.stringify(annotation));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return annotations.join('\n');
|
|
172
|
+
}
|
|
173
|
+
export async function prCheckCommand(options = {}) {
|
|
174
|
+
printBanner();
|
|
175
|
+
const { input = '.ally/scan.json', pr, comment = true, failOn } = options;
|
|
176
|
+
// Load scan results
|
|
177
|
+
const spinner = createSpinner('Loading scan results...');
|
|
178
|
+
spinner.start();
|
|
179
|
+
const reportPath = resolve(input);
|
|
180
|
+
if (!existsSync(reportPath)) {
|
|
181
|
+
spinner.stop();
|
|
182
|
+
suggestInit(reportPath);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let report;
|
|
186
|
+
try {
|
|
187
|
+
const content = await readFile(reportPath, 'utf-8');
|
|
188
|
+
report = JSON.parse(content);
|
|
189
|
+
spinner.succeed('Loaded scan results');
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
spinner.fail('Failed to load scan results');
|
|
193
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Get GitHub repo info
|
|
197
|
+
const repo = getGitHubRepo();
|
|
198
|
+
if (!repo) {
|
|
199
|
+
printWarning('Not a GitHub repository. Skipping PR integration.');
|
|
200
|
+
console.log();
|
|
201
|
+
printInfo('This command works best in a GitHub repository with a PR open.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Get PR number
|
|
205
|
+
const prNumber = pr || getCurrentPR();
|
|
206
|
+
if (!prNumber) {
|
|
207
|
+
printWarning('No PR number detected. Use --pr <number> to specify.');
|
|
208
|
+
console.log();
|
|
209
|
+
printInfo('Run this command from a GitHub Actions PR workflow or specify --pr manually.');
|
|
210
|
+
// Still show the report summary
|
|
211
|
+
console.log();
|
|
212
|
+
console.log(boxen(`${chalk.bold('Accessibility Report')}\n\n` +
|
|
213
|
+
`Score: ${report.summary.score}/100\n` +
|
|
214
|
+
`Violations: ${report.summary.totalViolations}`, {
|
|
215
|
+
padding: 1,
|
|
216
|
+
borderStyle: 'round',
|
|
217
|
+
borderColor: report.summary.score >= 75 ? 'green' : 'yellow',
|
|
218
|
+
}));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
console.log();
|
|
222
|
+
printInfo(`Posting results to ${repo.owner}/${repo.repo}#${prNumber}`);
|
|
223
|
+
// Post PR comment
|
|
224
|
+
if (comment) {
|
|
225
|
+
const commentSpinner = createSpinner('Posting PR comment...');
|
|
226
|
+
commentSpinner.start();
|
|
227
|
+
const prComment = generatePRComment(report);
|
|
228
|
+
const success = await postPRComment(repo, prNumber, prComment);
|
|
229
|
+
if (success) {
|
|
230
|
+
commentSpinner.succeed('Posted accessibility report to PR');
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
commentSpinner.fail('Failed to post PR comment');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Print summary
|
|
237
|
+
console.log();
|
|
238
|
+
const scoreColor = report.summary.score >= 90 ? chalk.green
|
|
239
|
+
: report.summary.score >= 75 ? chalk.yellow
|
|
240
|
+
: chalk.red;
|
|
241
|
+
console.log(boxen(`${chalk.bold('Accessibility Check Complete')}\n\n` +
|
|
242
|
+
`Score: ${scoreColor(`${report.summary.score}/100`)}\n\n` +
|
|
243
|
+
`Critical: ${report.summary.bySeverity.critical || 0}\n` +
|
|
244
|
+
`Serious: ${report.summary.bySeverity.serious || 0}\n` +
|
|
245
|
+
`Moderate: ${report.summary.bySeverity.moderate || 0}\n` +
|
|
246
|
+
`Minor: ${report.summary.bySeverity.minor || 0}`, {
|
|
247
|
+
padding: 1,
|
|
248
|
+
borderStyle: 'round',
|
|
249
|
+
borderColor: report.summary.score >= 75 ? 'green' : 'yellow',
|
|
250
|
+
}));
|
|
251
|
+
// Check fail conditions
|
|
252
|
+
if (failOn) {
|
|
253
|
+
const severities = failOn.split(',').map((s) => s.trim().toLowerCase());
|
|
254
|
+
let shouldFail = false;
|
|
255
|
+
for (const severity of severities) {
|
|
256
|
+
if (report.summary.bySeverity[severity] && report.summary.bySeverity[severity] > 0) {
|
|
257
|
+
shouldFail = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (shouldFail) {
|
|
262
|
+
console.log();
|
|
263
|
+
printError(`Failing due to ${failOn} violations`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
console.log();
|
|
268
|
+
printSuccess('PR check complete!');
|
|
269
|
+
}
|
|
270
|
+
export default prCheckCommand;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally report command - Generates ACCESSIBILITY.md report
|
|
3
|
+
*/
|
|
4
|
+
type ReportFormat = 'markdown' | 'html' | 'json' | 'sarif' | 'junit' | 'csv' | 'all';
|
|
5
|
+
interface ReportOptions {
|
|
6
|
+
input?: string;
|
|
7
|
+
output?: string;
|
|
8
|
+
format?: ReportFormat;
|
|
9
|
+
}
|
|
10
|
+
export declare function reportCommand(options?: ReportOptions): Promise<void>;
|
|
11
|
+
export default reportCommand;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally report command - Generates ACCESSIBILITY.md report
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { resolve, dirname, basename, relative } from 'path';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { printBanner, createSpinner, printError, printInfo, printSuccess, } from '../utils/ui.js';
|
|
9
|
+
import { suggestInit, suggestRescan } from '../utils/errors.js';
|
|
10
|
+
import { convertToSarif, convertToJunit, convertToCsv } from '../utils/converters.js';
|
|
11
|
+
export async function reportCommand(options = {}) {
|
|
12
|
+
printBanner();
|
|
13
|
+
const { input = '.ally/scan.json', output = 'ACCESSIBILITY.md', format = 'markdown', } = options;
|
|
14
|
+
// Load scan results
|
|
15
|
+
const spinner = createSpinner('Loading scan results...');
|
|
16
|
+
spinner.start();
|
|
17
|
+
const reportPath = resolve(input);
|
|
18
|
+
if (!existsSync(reportPath)) {
|
|
19
|
+
spinner.stop();
|
|
20
|
+
suggestInit(reportPath);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let report;
|
|
24
|
+
try {
|
|
25
|
+
const content = await readFile(reportPath, 'utf-8');
|
|
26
|
+
report = JSON.parse(content);
|
|
27
|
+
spinner.succeed('Loaded scan results');
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
spinner.fail('Failed to load scan results');
|
|
31
|
+
if (error instanceof SyntaxError) {
|
|
32
|
+
suggestRescan(reportPath);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Generate report
|
|
40
|
+
const generateSpinner = createSpinner('Generating report...');
|
|
41
|
+
generateSpinner.start();
|
|
42
|
+
// Handle batch export (--format all)
|
|
43
|
+
if (format === 'all') {
|
|
44
|
+
const outputDir = dirname(resolve(output));
|
|
45
|
+
const generatedFiles = [];
|
|
46
|
+
try {
|
|
47
|
+
// Generate all formats
|
|
48
|
+
const formats = [
|
|
49
|
+
{ name: 'ACCESSIBILITY.md', ext: 'md', content: generateMarkdownReport(report) },
|
|
50
|
+
{ name: 'accessibility.html', ext: 'html', content: generateHtmlReport(report) },
|
|
51
|
+
{ name: 'accessibility.json', ext: 'json', content: JSON.stringify(report, null, 2) },
|
|
52
|
+
{ name: 'accessibility.sarif', ext: 'sarif', content: JSON.stringify(convertToSarif(report), null, 2) },
|
|
53
|
+
{ name: 'accessibility.junit.xml', ext: 'xml', content: convertToJunit(report) },
|
|
54
|
+
{ name: 'accessibility.csv', ext: 'csv', content: convertToCsv(report) },
|
|
55
|
+
];
|
|
56
|
+
for (const { name, content } of formats) {
|
|
57
|
+
const filePath = resolve(outputDir, name);
|
|
58
|
+
await writeFile(filePath, content);
|
|
59
|
+
generatedFiles.push(name);
|
|
60
|
+
}
|
|
61
|
+
generateSpinner.succeed('Generated all report formats');
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(chalk.green(`\u2713 Generated ${generatedFiles.length} report files:`));
|
|
64
|
+
for (const file of generatedFiles) {
|
|
65
|
+
console.log(chalk.dim(` \u2022 ${file}`));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
generateSpinner.fail('Failed to write reports');
|
|
70
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Single format export
|
|
76
|
+
let reportContent;
|
|
77
|
+
switch (format) {
|
|
78
|
+
case 'json':
|
|
79
|
+
reportContent = JSON.stringify(report, null, 2);
|
|
80
|
+
break;
|
|
81
|
+
case 'html':
|
|
82
|
+
reportContent = generateHtmlReport(report);
|
|
83
|
+
break;
|
|
84
|
+
case 'sarif':
|
|
85
|
+
reportContent = JSON.stringify(convertToSarif(report), null, 2);
|
|
86
|
+
break;
|
|
87
|
+
case 'junit':
|
|
88
|
+
reportContent = convertToJunit(report);
|
|
89
|
+
break;
|
|
90
|
+
case 'csv':
|
|
91
|
+
reportContent = convertToCsv(report);
|
|
92
|
+
break;
|
|
93
|
+
case 'markdown':
|
|
94
|
+
default:
|
|
95
|
+
reportContent = generateMarkdownReport(report);
|
|
96
|
+
}
|
|
97
|
+
// Adjust output filename based on format
|
|
98
|
+
let outputPath = resolve(output);
|
|
99
|
+
if (format === 'sarif' && !output.endsWith('.sarif')) {
|
|
100
|
+
outputPath = resolve(dirname(output), 'accessibility.sarif');
|
|
101
|
+
}
|
|
102
|
+
else if (format === 'junit' && !output.endsWith('.xml')) {
|
|
103
|
+
outputPath = resolve(dirname(output), 'accessibility.junit.xml');
|
|
104
|
+
}
|
|
105
|
+
else if (format === 'csv' && !output.endsWith('.csv')) {
|
|
106
|
+
outputPath = resolve(dirname(output), 'accessibility.csv');
|
|
107
|
+
}
|
|
108
|
+
else if (format === 'json' && !output.endsWith('.json')) {
|
|
109
|
+
outputPath = resolve(dirname(output), 'accessibility.json');
|
|
110
|
+
}
|
|
111
|
+
else if (format === 'html' && !output.endsWith('.html')) {
|
|
112
|
+
outputPath = resolve(dirname(output), 'accessibility.html');
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await writeFile(outputPath, reportContent);
|
|
116
|
+
generateSpinner.succeed(`Report generated: ${outputPath}`);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
generateSpinner.fail('Failed to write report');
|
|
120
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
console.log();
|
|
124
|
+
printSuccess('Accessibility report ready!');
|
|
125
|
+
printInfo(`Add ${basename(outputPath)} to your repository to document your a11y compliance`);
|
|
126
|
+
}
|
|
127
|
+
// Generate badge URL
|
|
128
|
+
const badgeColor = report.summary.score >= 90 ? 'brightgreen'
|
|
129
|
+
: report.summary.score >= 75 ? 'green'
|
|
130
|
+
: report.summary.score >= 50 ? 'yellow'
|
|
131
|
+
: 'red';
|
|
132
|
+
console.log();
|
|
133
|
+
console.log(chalk.bold('Badge for README:'));
|
|
134
|
+
console.log(chalk.dim(``));
|
|
135
|
+
}
|
|
136
|
+
function generateMarkdownReport(report) {
|
|
137
|
+
const { summary } = report;
|
|
138
|
+
const scanDate = new Date(report.scanDate).toLocaleDateString();
|
|
139
|
+
// Score emoji
|
|
140
|
+
const scoreEmoji = summary.score >= 90 ? '🌟'
|
|
141
|
+
: summary.score >= 75 ? '✅'
|
|
142
|
+
: summary.score >= 50 ? '⚠️'
|
|
143
|
+
: '❌';
|
|
144
|
+
let md = `# Accessibility Report ${scoreEmoji}
|
|
145
|
+
|
|
146
|
+
> Generated by [ally](https://github.com/forbiddenlink/ally) on ${scanDate}
|
|
147
|
+
|
|
148
|
+
## Score: ${summary.score}/100
|
|
149
|
+
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
## Summary
|
|
153
|
+
|
|
154
|
+
| Severity | Count |
|
|
155
|
+
|----------|-------|
|
|
156
|
+
| 🔴 Critical | ${summary.bySeverity.critical || 0} |
|
|
157
|
+
| 🟠 Serious | ${summary.bySeverity.serious || 0} |
|
|
158
|
+
| 🟡 Moderate | ${summary.bySeverity.moderate || 0} |
|
|
159
|
+
| 🔵 Minor | ${summary.bySeverity.minor || 0} |
|
|
160
|
+
| **Total** | **${summary.totalViolations}** |
|
|
161
|
+
|
|
162
|
+
## Top Issues
|
|
163
|
+
|
|
164
|
+
`;
|
|
165
|
+
if (summary.topIssues.length > 0) {
|
|
166
|
+
for (const issue of summary.topIssues) {
|
|
167
|
+
const severityIcon = getSeverityIcon(issue.severity);
|
|
168
|
+
md += `### ${severityIcon} ${issue.description}\n\n`;
|
|
169
|
+
md += `- **Occurrences:** ${issue.count}\n`;
|
|
170
|
+
md += `- **Rule ID:** \`${issue.id}\`\n\n`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
md += `No issues found! 🎉\n\n`;
|
|
175
|
+
}
|
|
176
|
+
// Files scanned
|
|
177
|
+
md += `## Files Scanned\n\n`;
|
|
178
|
+
md += `Total: ${report.totalFiles} files\n\n`;
|
|
179
|
+
if (report.results.length > 0) {
|
|
180
|
+
md += `| File | Issues |\n`;
|
|
181
|
+
md += `|------|--------|\n`;
|
|
182
|
+
for (const result of report.results) {
|
|
183
|
+
// Convert absolute paths to relative for cleaner reports
|
|
184
|
+
let fileName;
|
|
185
|
+
if (result.file) {
|
|
186
|
+
fileName = relative(process.cwd(), result.file) || result.file;
|
|
187
|
+
}
|
|
188
|
+
else if (result.url) {
|
|
189
|
+
fileName = result.url;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
fileName = 'unknown';
|
|
193
|
+
}
|
|
194
|
+
const issueCount = result.violations.length;
|
|
195
|
+
const icon = issueCount === 0 ? '✅' : issueCount < 5 ? '⚠️' : '❌';
|
|
196
|
+
md += `| ${icon} ${fileName} | ${issueCount} |\n`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// WCAG Compliance
|
|
200
|
+
md += `\n## WCAG 2.1 Compliance\n\n`;
|
|
201
|
+
const wcagViolations = new Set();
|
|
202
|
+
for (const result of report.results) {
|
|
203
|
+
for (const violation of result.violations) {
|
|
204
|
+
for (const tag of violation.tags) {
|
|
205
|
+
if (tag.startsWith('wcag')) {
|
|
206
|
+
wcagViolations.add(tag);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (wcagViolations.size > 0) {
|
|
212
|
+
md += `The following WCAG criteria have violations:\n\n`;
|
|
213
|
+
for (const criterion of Array.from(wcagViolations).sort()) {
|
|
214
|
+
md += `- \`${criterion}\`\n`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
md += `No WCAG violations detected! ✅\n`;
|
|
219
|
+
}
|
|
220
|
+
// Next steps
|
|
221
|
+
md += `\n## Next Steps\n\n`;
|
|
222
|
+
if (summary.totalViolations > 0) {
|
|
223
|
+
md += `1. Run \`ally explain\` to understand each issue\n`;
|
|
224
|
+
md += `2. Run \`ally fix\` to apply automated fixes\n`;
|
|
225
|
+
md += `3. Re-scan with \`ally scan\` to verify fixes\n`;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
md += `Your site is looking great! Consider:\n`;
|
|
229
|
+
md += `- Testing with real screen readers (NVDA, VoiceOver)\n`;
|
|
230
|
+
md += `- Running manual keyboard navigation tests\n`;
|
|
231
|
+
md += `- Getting user feedback from people with disabilities\n`;
|
|
232
|
+
}
|
|
233
|
+
// Footer
|
|
234
|
+
md += `\n---\n\n`;
|
|
235
|
+
md += `*This report was automatically generated. Automated testing can only catch ~50% of accessibility issues. Manual testing is recommended.*\n`;
|
|
236
|
+
return md;
|
|
237
|
+
}
|
|
238
|
+
function generateHtmlReport(report) {
|
|
239
|
+
const { summary } = report;
|
|
240
|
+
return `<!DOCTYPE html>
|
|
241
|
+
<html lang="en">
|
|
242
|
+
<head>
|
|
243
|
+
<meta charset="UTF-8">
|
|
244
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
245
|
+
<title>Accessibility Report</title>
|
|
246
|
+
<style>
|
|
247
|
+
:root {
|
|
248
|
+
--critical: #dc2626;
|
|
249
|
+
--serious: #ea580c;
|
|
250
|
+
--moderate: #ca8a04;
|
|
251
|
+
--minor: #2563eb;
|
|
252
|
+
--pass: #16a34a;
|
|
253
|
+
}
|
|
254
|
+
body {
|
|
255
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
256
|
+
max-width: 800px;
|
|
257
|
+
margin: 0 auto;
|
|
258
|
+
padding: 2rem;
|
|
259
|
+
line-height: 1.6;
|
|
260
|
+
}
|
|
261
|
+
.score {
|
|
262
|
+
font-size: 4rem;
|
|
263
|
+
font-weight: bold;
|
|
264
|
+
text-align: center;
|
|
265
|
+
padding: 2rem;
|
|
266
|
+
border-radius: 1rem;
|
|
267
|
+
background: ${getScoreGradient(summary.score)};
|
|
268
|
+
color: white;
|
|
269
|
+
}
|
|
270
|
+
.severity-grid {
|
|
271
|
+
display: grid;
|
|
272
|
+
grid-template-columns: repeat(4, 1fr);
|
|
273
|
+
gap: 1rem;
|
|
274
|
+
margin: 2rem 0;
|
|
275
|
+
}
|
|
276
|
+
.severity-card {
|
|
277
|
+
padding: 1rem;
|
|
278
|
+
border-radius: 0.5rem;
|
|
279
|
+
text-align: center;
|
|
280
|
+
}
|
|
281
|
+
.severity-card.critical { background: var(--critical); color: white; }
|
|
282
|
+
.severity-card.serious { background: var(--serious); color: white; }
|
|
283
|
+
.severity-card.moderate { background: var(--moderate); color: white; }
|
|
284
|
+
.severity-card.minor { background: var(--minor); color: white; }
|
|
285
|
+
table {
|
|
286
|
+
width: 100%;
|
|
287
|
+
border-collapse: collapse;
|
|
288
|
+
margin: 1rem 0;
|
|
289
|
+
}
|
|
290
|
+
th, td {
|
|
291
|
+
padding: 0.75rem;
|
|
292
|
+
text-align: left;
|
|
293
|
+
border-bottom: 1px solid #e5e7eb;
|
|
294
|
+
}
|
|
295
|
+
th { background: #f9fafb; }
|
|
296
|
+
</style>
|
|
297
|
+
</head>
|
|
298
|
+
<body>
|
|
299
|
+
<h1>Accessibility Report</h1>
|
|
300
|
+
<div class="score">${summary.score}/100</div>
|
|
301
|
+
|
|
302
|
+
<h2>Violations by Severity</h2>
|
|
303
|
+
<div class="severity-grid">
|
|
304
|
+
<div class="severity-card critical">
|
|
305
|
+
<div style="font-size: 2rem;">${summary.bySeverity.critical || 0}</div>
|
|
306
|
+
<div>Critical</div>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="severity-card serious">
|
|
309
|
+
<div style="font-size: 2rem;">${summary.bySeverity.serious || 0}</div>
|
|
310
|
+
<div>Serious</div>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="severity-card moderate">
|
|
313
|
+
<div style="font-size: 2rem;">${summary.bySeverity.moderate || 0}</div>
|
|
314
|
+
<div>Moderate</div>
|
|
315
|
+
</div>
|
|
316
|
+
<div class="severity-card minor">
|
|
317
|
+
<div style="font-size: 2rem;">${summary.bySeverity.minor || 0}</div>
|
|
318
|
+
<div>Minor</div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<h2>Top Issues</h2>
|
|
323
|
+
<table>
|
|
324
|
+
<thead>
|
|
325
|
+
<tr>
|
|
326
|
+
<th>Issue</th>
|
|
327
|
+
<th>Severity</th>
|
|
328
|
+
<th>Count</th>
|
|
329
|
+
</tr>
|
|
330
|
+
</thead>
|
|
331
|
+
<tbody>
|
|
332
|
+
${summary.topIssues.map(issue => `
|
|
333
|
+
<tr>
|
|
334
|
+
<td>${issue.description}</td>
|
|
335
|
+
<td>${issue.severity}</td>
|
|
336
|
+
<td>${issue.count}</td>
|
|
337
|
+
</tr>
|
|
338
|
+
`).join('')}
|
|
339
|
+
</tbody>
|
|
340
|
+
</table>
|
|
341
|
+
|
|
342
|
+
<footer>
|
|
343
|
+
<p><em>Generated by ally on ${new Date(report.scanDate).toLocaleDateString()}</em></p>
|
|
344
|
+
</footer>
|
|
345
|
+
</body>
|
|
346
|
+
</html>`;
|
|
347
|
+
}
|
|
348
|
+
function getScoreColor(score) {
|
|
349
|
+
if (score >= 90)
|
|
350
|
+
return 'brightgreen';
|
|
351
|
+
if (score >= 75)
|
|
352
|
+
return 'green';
|
|
353
|
+
if (score >= 50)
|
|
354
|
+
return 'yellow';
|
|
355
|
+
return 'red';
|
|
356
|
+
}
|
|
357
|
+
function getScoreGradient(score) {
|
|
358
|
+
if (score >= 90)
|
|
359
|
+
return 'linear-gradient(135deg, #16a34a, #22c55e)';
|
|
360
|
+
if (score >= 75)
|
|
361
|
+
return 'linear-gradient(135deg, #65a30d, #84cc16)';
|
|
362
|
+
if (score >= 50)
|
|
363
|
+
return 'linear-gradient(135deg, #ca8a04, #eab308)';
|
|
364
|
+
return 'linear-gradient(135deg, #dc2626, #ef4444)';
|
|
365
|
+
}
|
|
366
|
+
function getSeverityIcon(severity) {
|
|
367
|
+
const icons = {
|
|
368
|
+
critical: '🔴',
|
|
369
|
+
serious: '🟠',
|
|
370
|
+
moderate: '🟡',
|
|
371
|
+
minor: '🔵',
|
|
372
|
+
};
|
|
373
|
+
return icons[severity] || '⚪';
|
|
374
|
+
}
|
|
375
|
+
export default reportCommand;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally scan-storybook command - Scans Storybook stories for accessibility issues
|
|
3
|
+
*/
|
|
4
|
+
import { type WcagStandard } from '../utils/scanner.js';
|
|
5
|
+
import type { AllyReport } from '../types/index.js';
|
|
6
|
+
interface ScanStorybookOptions {
|
|
7
|
+
url?: string;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
filter?: string;
|
|
10
|
+
format?: 'default' | 'json' | 'csv';
|
|
11
|
+
output?: string;
|
|
12
|
+
standard?: WcagStandard;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Main scan-storybook command
|
|
16
|
+
*/
|
|
17
|
+
export declare function scanStorybookCommand(options?: ScanStorybookOptions): Promise<AllyReport | null>;
|
|
18
|
+
export default scanStorybookCommand;
|