@octaviaflow/accessibility-checker 1.0.0 → 1.1.2
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 +378 -52
- package/bin/achecker.js +9 -0
- package/cjs/checker/index.js +4 -43
- package/cjs/index.js +117 -0
- package/cjs/storage/reportWriter.js +190 -0
- package/cjs/utils/wcag-rules.js +65 -0
- package/mjs/checker/index.js +2 -9
- package/mjs/index.js +110 -0
- package/mjs/storage/reportWriter.js +186 -0
- package/mjs/utils/wcag-rules.js +61 -0
- package/package.json +35 -15
- package/types/checker/index.d.ts +11 -2
- package/types/index.d.ts +16 -0
- package/types/storage/reportWriter.d.ts +3 -0
- package/types/utils/wcag-rules.d.ts +10 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.saveReport = saveReport;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const XLSX = tslib_1.__importStar(require("exceljs"));
|
|
8
|
+
async function ensureDir(filePath) {
|
|
9
|
+
const dir = (0, path_1.dirname)(filePath);
|
|
10
|
+
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
async function readArray(filePath) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await fs_1.promises.readFile(filePath, 'utf8');
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function generateHtmlReport(result) {
|
|
22
|
+
const { summary, issues, meta } = result;
|
|
23
|
+
return `
|
|
24
|
+
<!DOCTYPE html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="UTF-8">
|
|
28
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
29
|
+
<title>Accessibility Report - ${meta?.source || 'Unknown'}</title>
|
|
30
|
+
<style>
|
|
31
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
|
32
|
+
.container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
33
|
+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 8px 8px 0 0; }
|
|
34
|
+
.header h1 { margin: 0; font-size: 2.5em; font-weight: 300; }
|
|
35
|
+
.header p { margin: 10px 0 0 0; opacity: 0.9; }
|
|
36
|
+
.summary { padding: 30px; border-bottom: 1px solid #eee; }
|
|
37
|
+
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
|
38
|
+
.summary-card { background: #f8f9fa; padding: 20px; border-radius: 6px; text-align: center; }
|
|
39
|
+
.summary-card h3 { margin: 0 0 10px 0; color: #495057; font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; }
|
|
40
|
+
.summary-card .number { font-size: 2.5em; font-weight: bold; margin: 0; }
|
|
41
|
+
.summary-card.issues .number { color: #dc3545; }
|
|
42
|
+
.summary-card.violations .number { color: #fd7e14; }
|
|
43
|
+
.issues { padding: 30px; }
|
|
44
|
+
.issue { background: #fff; border: 1px solid #dee2e6; border-radius: 6px; margin-bottom: 15px; overflow: hidden; }
|
|
45
|
+
.issue-header { background: #f8f9fa; padding: 15px; border-bottom: 1px solid #dee2e6; }
|
|
46
|
+
.issue-type { display: inline-block; background: #6c757d; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.8em; margin-right: 10px; }
|
|
47
|
+
.issue-type.axe { background: #007bff; }
|
|
48
|
+
.issue-type.image-missing-alt { background: #dc3545; }
|
|
49
|
+
.issue-type.link-empty { background: #fd7e14; }
|
|
50
|
+
.issue-type.heading-empty { background: #ffc107; color: #212529; }
|
|
51
|
+
.issue-message { font-weight: 500; }
|
|
52
|
+
.issue-body { padding: 15px; }
|
|
53
|
+
.code-snippet { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; padding: 10px; font-family: 'Monaco', 'Consolas', monospace; font-size: 0.9em; overflow-x: auto; }
|
|
54
|
+
.no-issues { text-align: center; padding: 60px; color: #28a745; }
|
|
55
|
+
.no-issues h2 { color: #28a745; margin-bottom: 10px; }
|
|
56
|
+
.footer { padding: 20px 30px; background: #f8f9fa; border-radius: 0 0 8px 8px; text-align: center; color: #6c757d; font-size: 0.9em; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="container">
|
|
61
|
+
<div class="header">
|
|
62
|
+
<h1>Accessibility Report</h1>
|
|
63
|
+
<p>Generated on ${new Date(meta?.timestamp || Date.now()).toLocaleString()}</p>
|
|
64
|
+
<p>Source: ${meta?.source || 'Unknown'}</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="summary">
|
|
68
|
+
<div class="summary-grid">
|
|
69
|
+
<div class="summary-card issues">
|
|
70
|
+
<h3>Total Issues</h3>
|
|
71
|
+
<p class="number">${summary.totalIssues}</p>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="summary-card violations">
|
|
74
|
+
<h3>Axe Violations</h3>
|
|
75
|
+
<p class="number">${summary.axeViolations || 0}</p>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="summary-card">
|
|
78
|
+
<h3>Issue Types</h3>
|
|
79
|
+
<p class="number">${Object.keys(summary.byType).length}</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="issues">
|
|
85
|
+
${issues.length === 0 ? `
|
|
86
|
+
<div class="no-issues">
|
|
87
|
+
<h2>✅ No Issues Found!</h2>
|
|
88
|
+
<p>This page appears to be accessible according to our analysis.</p>
|
|
89
|
+
</div>
|
|
90
|
+
` : `
|
|
91
|
+
<h2>Issues Found (${issues.length})</h2>
|
|
92
|
+
${issues.map(issue => `
|
|
93
|
+
<div class="issue">
|
|
94
|
+
<div class="issue-header">
|
|
95
|
+
<span class="issue-type ${issue.type.replace(':', '-')}">${issue.type}</span>
|
|
96
|
+
<span class="issue-message">${issue.message}</span>
|
|
97
|
+
</div>
|
|
98
|
+
${issue.snippet ? `
|
|
99
|
+
<div class="issue-body">
|
|
100
|
+
<strong>Code snippet:</strong>
|
|
101
|
+
<div class="code-snippet">${issue.snippet.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
102
|
+
${issue.helpUrl ? `<p><a href="${issue.helpUrl}" target="_blank">Learn more →</a></p>` : ''}
|
|
103
|
+
</div>
|
|
104
|
+
` : ''}
|
|
105
|
+
</div>
|
|
106
|
+
`).join('')}
|
|
107
|
+
`}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="footer">
|
|
111
|
+
Generated by OctaviaFlow Accessibility Checker v${meta?.version || '1.0.0'}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</body>
|
|
115
|
+
</html>`;
|
|
116
|
+
}
|
|
117
|
+
function generateCsvReport(result) {
|
|
118
|
+
const { issues } = result;
|
|
119
|
+
const headers = ['Type', 'Message', 'Source', 'Snippet', 'Help URL'];
|
|
120
|
+
const rows = issues.map(issue => [
|
|
121
|
+
issue.type,
|
|
122
|
+
issue.message,
|
|
123
|
+
issue.source || 'heuristic',
|
|
124
|
+
(issue.snippet || '').replace(/"/g, '""'),
|
|
125
|
+
issue.helpUrl || ''
|
|
126
|
+
]);
|
|
127
|
+
const csvContent = [headers, ...rows]
|
|
128
|
+
.map(row => row.map(cell => `"${cell}"`).join(','))
|
|
129
|
+
.join('\n');
|
|
130
|
+
return csvContent;
|
|
131
|
+
}
|
|
132
|
+
async function generateExcelReport(result, filePath) {
|
|
133
|
+
const workbook = new XLSX.Workbook();
|
|
134
|
+
const summarySheet = workbook.addWorksheet('Summary');
|
|
135
|
+
summarySheet.addRow(['OctaviaFlow Accessibility Report']);
|
|
136
|
+
summarySheet.addRow(['Generated:', new Date(result.meta?.timestamp || Date.now()).toLocaleString()]);
|
|
137
|
+
summarySheet.addRow(['Source:', result.meta?.source || 'Unknown']);
|
|
138
|
+
summarySheet.addRow([]);
|
|
139
|
+
summarySheet.addRow(['Total Issues:', result.summary.totalIssues]);
|
|
140
|
+
summarySheet.addRow(['Axe Violations:', result.summary.axeViolations || 0]);
|
|
141
|
+
summarySheet.addRow([]);
|
|
142
|
+
summarySheet.addRow(['Issue Types:']);
|
|
143
|
+
Object.entries(result.summary.byType).forEach(([type, count]) => {
|
|
144
|
+
summarySheet.addRow([type, count]);
|
|
145
|
+
});
|
|
146
|
+
const issuesSheet = workbook.addWorksheet('Issues');
|
|
147
|
+
issuesSheet.addRow(['Type', 'Message', 'Source', 'Snippet', 'Help URL']);
|
|
148
|
+
result.issues.forEach(issue => {
|
|
149
|
+
issuesSheet.addRow([
|
|
150
|
+
issue.type,
|
|
151
|
+
issue.message,
|
|
152
|
+
issue.source || 'heuristic',
|
|
153
|
+
issue.snippet || '',
|
|
154
|
+
issue.helpUrl || ''
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
summarySheet.getRow(1).font = { bold: true, size: 16 };
|
|
158
|
+
issuesSheet.getRow(1).font = { bold: true };
|
|
159
|
+
await workbook.xlsx.writeFile(filePath);
|
|
160
|
+
}
|
|
161
|
+
async function saveReport(result, filePath, format = 'json') {
|
|
162
|
+
await ensureDir(filePath);
|
|
163
|
+
switch (format) {
|
|
164
|
+
case 'json': {
|
|
165
|
+
const arr = await readArray(filePath);
|
|
166
|
+
arr.push(result);
|
|
167
|
+
await fs_1.promises.writeFile(filePath, JSON.stringify(arr, null, 2), 'utf8');
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'html': {
|
|
171
|
+
const htmlContent = generateHtmlReport(result);
|
|
172
|
+
const htmlPath = filePath.replace(/\.[^.]+$/, '.html');
|
|
173
|
+
await fs_1.promises.writeFile(htmlPath, htmlContent, 'utf8');
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'csv': {
|
|
177
|
+
const csvContent = generateCsvReport(result);
|
|
178
|
+
const csvPath = filePath.replace(/\.[^.]+$/, '.csv');
|
|
179
|
+
await fs_1.promises.writeFile(csvPath, csvContent, 'utf8');
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'excel': {
|
|
183
|
+
const excelPath = filePath.replace(/\.[^.]+$/, '.xlsx');
|
|
184
|
+
await generateExcelReport(result, excelPath);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
throw new Error(`Unsupported format: ${format}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WCAG_RULES = void 0;
|
|
4
|
+
exports.getWCAGRule = getWCAGRule;
|
|
5
|
+
exports.WCAG_RULES = {
|
|
6
|
+
'color-contrast': {
|
|
7
|
+
id: 'color-contrast',
|
|
8
|
+
level: 'AA',
|
|
9
|
+
principle: 'perceivable',
|
|
10
|
+
guideline: '1.4.3',
|
|
11
|
+
description: 'Elements must have sufficient color contrast',
|
|
12
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html'
|
|
13
|
+
},
|
|
14
|
+
'image-alt': {
|
|
15
|
+
id: 'image-alt',
|
|
16
|
+
level: 'A',
|
|
17
|
+
principle: 'perceivable',
|
|
18
|
+
guideline: '1.1.1',
|
|
19
|
+
description: 'Images must have alternative text',
|
|
20
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html'
|
|
21
|
+
},
|
|
22
|
+
'link-name': {
|
|
23
|
+
id: 'link-name',
|
|
24
|
+
level: 'A',
|
|
25
|
+
principle: 'operable',
|
|
26
|
+
guideline: '2.4.4',
|
|
27
|
+
description: 'Links must have discernible text',
|
|
28
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html'
|
|
29
|
+
},
|
|
30
|
+
'heading-order': {
|
|
31
|
+
id: 'heading-order',
|
|
32
|
+
level: 'AA',
|
|
33
|
+
principle: 'perceivable',
|
|
34
|
+
guideline: '1.3.1',
|
|
35
|
+
description: 'Heading levels should only increase by one',
|
|
36
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html'
|
|
37
|
+
},
|
|
38
|
+
'form-label': {
|
|
39
|
+
id: 'form-label',
|
|
40
|
+
level: 'A',
|
|
41
|
+
principle: 'perceivable',
|
|
42
|
+
guideline: '1.3.1',
|
|
43
|
+
description: 'Form elements must have labels',
|
|
44
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html'
|
|
45
|
+
},
|
|
46
|
+
'keyboard-navigation': {
|
|
47
|
+
id: 'keyboard-navigation',
|
|
48
|
+
level: 'A',
|
|
49
|
+
principle: 'operable',
|
|
50
|
+
guideline: '2.1.1',
|
|
51
|
+
description: 'All functionality must be available from a keyboard',
|
|
52
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html'
|
|
53
|
+
},
|
|
54
|
+
'focus-visible': {
|
|
55
|
+
id: 'focus-visible',
|
|
56
|
+
level: 'AA',
|
|
57
|
+
principle: 'operable',
|
|
58
|
+
guideline: '2.4.7',
|
|
59
|
+
description: 'Any keyboard operable interface has a mode of operation where the keyboard focus indicator is visible',
|
|
60
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html'
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
function getWCAGRule(ruleId) {
|
|
64
|
+
return exports.WCAG_RULES[ruleId];
|
|
65
|
+
}
|
package/mjs/checker/index.js
CHANGED
|
@@ -2,7 +2,6 @@ import { JSDOM } from 'jsdom';
|
|
|
2
2
|
import * as axe from 'axe-core';
|
|
3
3
|
function heuristicAnalyze(html) {
|
|
4
4
|
const issues = [];
|
|
5
|
-
// images without alt
|
|
6
5
|
const imgRegex = /<img\b[^>]*>/gi;
|
|
7
6
|
let m;
|
|
8
7
|
while ((m = imgRegex.exec(html))) {
|
|
@@ -15,7 +14,6 @@ function heuristicAnalyze(html) {
|
|
|
15
14
|
});
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
|
-
// links without descriptive text (naive)
|
|
19
17
|
const aRegex = /<a\b[^>]*>([\s\S]*?)<\/a>/gi;
|
|
20
18
|
while ((m = aRegex.exec(html))) {
|
|
21
19
|
const inner = (m[1] || '').trim();
|
|
@@ -27,7 +25,6 @@ function heuristicAnalyze(html) {
|
|
|
27
25
|
});
|
|
28
26
|
}
|
|
29
27
|
}
|
|
30
|
-
// empty headings
|
|
31
28
|
const hRegex = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
|
32
29
|
while ((m = hRegex.exec(html))) {
|
|
33
30
|
const content = (m[1] || '').replace(/<[^>]*>/g, '').trim();
|
|
@@ -47,10 +44,7 @@ async function runAxe(html) {
|
|
|
47
44
|
resources: 'usable',
|
|
48
45
|
});
|
|
49
46
|
const { window } = dom;
|
|
50
|
-
|
|
51
|
-
const result = await axe.run(window.document, {
|
|
52
|
-
// default rules; you can customize here
|
|
53
|
-
});
|
|
47
|
+
const result = await axe.run(window.document, {});
|
|
54
48
|
return result;
|
|
55
49
|
}
|
|
56
50
|
export async function analyze(html) {
|
|
@@ -60,11 +54,10 @@ export async function analyze(html) {
|
|
|
60
54
|
axeRes = await runAxe(html);
|
|
61
55
|
}
|
|
62
56
|
catch (e) {
|
|
63
|
-
// If axe fails for some HTML, continue with heuristics
|
|
64
57
|
axeRes = { error: String(e) };
|
|
65
58
|
}
|
|
66
59
|
const axeIssues = [];
|
|
67
|
-
if (axeRes && Array.isArray(axeRes.violations)) {
|
|
60
|
+
if (axeRes && 'violations' in axeRes && Array.isArray(axeRes.violations)) {
|
|
68
61
|
for (const v of axeRes.violations) {
|
|
69
62
|
for (const node of v.nodes || []) {
|
|
70
63
|
axeIssues.push({
|
package/mjs/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import minimist from 'minimist';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { analyze } from './checker/index.js';
|
|
7
|
+
import { saveReport } from './storage/reportWriter.js';
|
|
8
|
+
export { analyze } from './checker/index.js';
|
|
9
|
+
export { saveReport } from './storage/reportWriter.js';
|
|
10
|
+
function showHelp() {
|
|
11
|
+
console.log(chalk.blue.bold('OctaviaFlow Accessibility Checker'));
|
|
12
|
+
console.log('');
|
|
13
|
+
console.log(chalk.yellow('Usage:'));
|
|
14
|
+
console.log(' octaviaflow-achecker [options]');
|
|
15
|
+
console.log('');
|
|
16
|
+
console.log(chalk.yellow('Options:'));
|
|
17
|
+
console.log(' -i, --input <file> Input HTML file to analyze');
|
|
18
|
+
console.log(' -o, --output <file> Output file for results (default: ./data/results.json)');
|
|
19
|
+
console.log(' --sample Run with sample HTML for testing');
|
|
20
|
+
console.log(' --format <type> Output format: json, html, csv, excel (default: json)');
|
|
21
|
+
console.log(' -v, --verbose Verbose output');
|
|
22
|
+
console.log(' -h, --help Show this help message');
|
|
23
|
+
console.log('');
|
|
24
|
+
console.log(chalk.yellow('Examples:'));
|
|
25
|
+
console.log(' octaviaflow-achecker --input index.html');
|
|
26
|
+
console.log(' octaviaflow-achecker --sample --format html');
|
|
27
|
+
console.log(' octaviaflow-achecker -i page.html -o report.json --verbose');
|
|
28
|
+
}
|
|
29
|
+
export async function main(args) {
|
|
30
|
+
const argv = minimist(args || process.argv.slice(2));
|
|
31
|
+
if (argv.help || argv.h) {
|
|
32
|
+
showHelp();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const inputPath = argv.input || argv.i;
|
|
36
|
+
const outputPath = argv.output || argv.o || join(process.cwd(), 'data', 'results.json');
|
|
37
|
+
const format = argv.format || 'json';
|
|
38
|
+
const verbose = argv.verbose || argv.v || false;
|
|
39
|
+
let html = '';
|
|
40
|
+
let spinner = null;
|
|
41
|
+
try {
|
|
42
|
+
if (argv.sample) {
|
|
43
|
+
html = `<html>
|
|
44
|
+
<head><title>Sample Accessibility Test</title></head>
|
|
45
|
+
<body>
|
|
46
|
+
<img src="image.png">
|
|
47
|
+
<a href="/"></a>
|
|
48
|
+
<h1></h1>
|
|
49
|
+
<button></button>
|
|
50
|
+
<input type="text">
|
|
51
|
+
<div role="button"></div>
|
|
52
|
+
</body>
|
|
53
|
+
</html>`;
|
|
54
|
+
if (verbose) {
|
|
55
|
+
console.log(chalk.gray('Using sample HTML for testing'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (inputPath) {
|
|
59
|
+
if (verbose) {
|
|
60
|
+
console.log(chalk.gray(`Reading HTML from: ${inputPath}`));
|
|
61
|
+
}
|
|
62
|
+
html = readFileSync(resolve(inputPath), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.error(chalk.red('Error: No input specified'));
|
|
66
|
+
showHelp();
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
spinner = ora('Analyzing accessibility...').start();
|
|
70
|
+
const result = await analyze(html);
|
|
71
|
+
result.meta = {
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
source: inputPath || 'sample',
|
|
74
|
+
format,
|
|
75
|
+
version: '1.0.0'
|
|
76
|
+
};
|
|
77
|
+
spinner.succeed('Analysis complete');
|
|
78
|
+
if (verbose) {
|
|
79
|
+
console.log(chalk.green(`Found ${result.summary.totalIssues} accessibility issues`));
|
|
80
|
+
console.log(chalk.gray(`Axe violations: ${result.axe && 'violations' in result.axe ? result.axe.violations?.length || 0 : 0}`));
|
|
81
|
+
}
|
|
82
|
+
await saveReport(result, outputPath, format);
|
|
83
|
+
console.log(chalk.green(`✓ Results saved to ${outputPath}`));
|
|
84
|
+
if (result.summary.totalIssues > 0) {
|
|
85
|
+
console.log(chalk.yellow('\nIssue Summary:'));
|
|
86
|
+
Object.entries(result.summary.byType).forEach(([type, count]) => {
|
|
87
|
+
console.log(chalk.gray(` ${type}: ${count}`));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.log(chalk.green('\n✓ No accessibility issues found!'));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (spinner) {
|
|
96
|
+
spinner.fail('Analysis failed');
|
|
97
|
+
}
|
|
98
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error));
|
|
99
|
+
if (verbose && error instanceof Error) {
|
|
100
|
+
console.error(chalk.gray(error.stack));
|
|
101
|
+
}
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (require.main === module) {
|
|
106
|
+
main().catch(err => {
|
|
107
|
+
console.error(chalk.red('Fatal error:'), err);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import * as XLSX from 'exceljs';
|
|
4
|
+
async function ensureDir(filePath) {
|
|
5
|
+
const dir = dirname(filePath);
|
|
6
|
+
await fs.mkdir(dir, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
async function readArray(filePath) {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function generateHtmlReport(result) {
|
|
18
|
+
const { summary, issues, meta } = result;
|
|
19
|
+
return `
|
|
20
|
+
<!DOCTYPE html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="UTF-8">
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
25
|
+
<title>Accessibility Report - ${meta?.source || 'Unknown'}</title>
|
|
26
|
+
<style>
|
|
27
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
|
28
|
+
.container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
29
|
+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 8px 8px 0 0; }
|
|
30
|
+
.header h1 { margin: 0; font-size: 2.5em; font-weight: 300; }
|
|
31
|
+
.header p { margin: 10px 0 0 0; opacity: 0.9; }
|
|
32
|
+
.summary { padding: 30px; border-bottom: 1px solid #eee; }
|
|
33
|
+
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
|
34
|
+
.summary-card { background: #f8f9fa; padding: 20px; border-radius: 6px; text-align: center; }
|
|
35
|
+
.summary-card h3 { margin: 0 0 10px 0; color: #495057; font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; }
|
|
36
|
+
.summary-card .number { font-size: 2.5em; font-weight: bold; margin: 0; }
|
|
37
|
+
.summary-card.issues .number { color: #dc3545; }
|
|
38
|
+
.summary-card.violations .number { color: #fd7e14; }
|
|
39
|
+
.issues { padding: 30px; }
|
|
40
|
+
.issue { background: #fff; border: 1px solid #dee2e6; border-radius: 6px; margin-bottom: 15px; overflow: hidden; }
|
|
41
|
+
.issue-header { background: #f8f9fa; padding: 15px; border-bottom: 1px solid #dee2e6; }
|
|
42
|
+
.issue-type { display: inline-block; background: #6c757d; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.8em; margin-right: 10px; }
|
|
43
|
+
.issue-type.axe { background: #007bff; }
|
|
44
|
+
.issue-type.image-missing-alt { background: #dc3545; }
|
|
45
|
+
.issue-type.link-empty { background: #fd7e14; }
|
|
46
|
+
.issue-type.heading-empty { background: #ffc107; color: #212529; }
|
|
47
|
+
.issue-message { font-weight: 500; }
|
|
48
|
+
.issue-body { padding: 15px; }
|
|
49
|
+
.code-snippet { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; padding: 10px; font-family: 'Monaco', 'Consolas', monospace; font-size: 0.9em; overflow-x: auto; }
|
|
50
|
+
.no-issues { text-align: center; padding: 60px; color: #28a745; }
|
|
51
|
+
.no-issues h2 { color: #28a745; margin-bottom: 10px; }
|
|
52
|
+
.footer { padding: 20px 30px; background: #f8f9fa; border-radius: 0 0 8px 8px; text-align: center; color: #6c757d; font-size: 0.9em; }
|
|
53
|
+
</style>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<div class="container">
|
|
57
|
+
<div class="header">
|
|
58
|
+
<h1>Accessibility Report</h1>
|
|
59
|
+
<p>Generated on ${new Date(meta?.timestamp || Date.now()).toLocaleString()}</p>
|
|
60
|
+
<p>Source: ${meta?.source || 'Unknown'}</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="summary">
|
|
64
|
+
<div class="summary-grid">
|
|
65
|
+
<div class="summary-card issues">
|
|
66
|
+
<h3>Total Issues</h3>
|
|
67
|
+
<p class="number">${summary.totalIssues}</p>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="summary-card violations">
|
|
70
|
+
<h3>Axe Violations</h3>
|
|
71
|
+
<p class="number">${summary.axeViolations || 0}</p>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="summary-card">
|
|
74
|
+
<h3>Issue Types</h3>
|
|
75
|
+
<p class="number">${Object.keys(summary.byType).length}</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="issues">
|
|
81
|
+
${issues.length === 0 ? `
|
|
82
|
+
<div class="no-issues">
|
|
83
|
+
<h2>✅ No Issues Found!</h2>
|
|
84
|
+
<p>This page appears to be accessible according to our analysis.</p>
|
|
85
|
+
</div>
|
|
86
|
+
` : `
|
|
87
|
+
<h2>Issues Found (${issues.length})</h2>
|
|
88
|
+
${issues.map(issue => `
|
|
89
|
+
<div class="issue">
|
|
90
|
+
<div class="issue-header">
|
|
91
|
+
<span class="issue-type ${issue.type.replace(':', '-')}">${issue.type}</span>
|
|
92
|
+
<span class="issue-message">${issue.message}</span>
|
|
93
|
+
</div>
|
|
94
|
+
${issue.snippet ? `
|
|
95
|
+
<div class="issue-body">
|
|
96
|
+
<strong>Code snippet:</strong>
|
|
97
|
+
<div class="code-snippet">${issue.snippet.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
98
|
+
${issue.helpUrl ? `<p><a href="${issue.helpUrl}" target="_blank">Learn more →</a></p>` : ''}
|
|
99
|
+
</div>
|
|
100
|
+
` : ''}
|
|
101
|
+
</div>
|
|
102
|
+
`).join('')}
|
|
103
|
+
`}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div class="footer">
|
|
107
|
+
Generated by OctaviaFlow Accessibility Checker v${meta?.version || '1.0.0'}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</body>
|
|
111
|
+
</html>`;
|
|
112
|
+
}
|
|
113
|
+
function generateCsvReport(result) {
|
|
114
|
+
const { issues } = result;
|
|
115
|
+
const headers = ['Type', 'Message', 'Source', 'Snippet', 'Help URL'];
|
|
116
|
+
const rows = issues.map(issue => [
|
|
117
|
+
issue.type,
|
|
118
|
+
issue.message,
|
|
119
|
+
issue.source || 'heuristic',
|
|
120
|
+
(issue.snippet || '').replace(/"/g, '""'),
|
|
121
|
+
issue.helpUrl || ''
|
|
122
|
+
]);
|
|
123
|
+
const csvContent = [headers, ...rows]
|
|
124
|
+
.map(row => row.map(cell => `"${cell}"`).join(','))
|
|
125
|
+
.join('\n');
|
|
126
|
+
return csvContent;
|
|
127
|
+
}
|
|
128
|
+
async function generateExcelReport(result, filePath) {
|
|
129
|
+
const workbook = new XLSX.Workbook();
|
|
130
|
+
const summarySheet = workbook.addWorksheet('Summary');
|
|
131
|
+
summarySheet.addRow(['OctaviaFlow Accessibility Report']);
|
|
132
|
+
summarySheet.addRow(['Generated:', new Date(result.meta?.timestamp || Date.now()).toLocaleString()]);
|
|
133
|
+
summarySheet.addRow(['Source:', result.meta?.source || 'Unknown']);
|
|
134
|
+
summarySheet.addRow([]);
|
|
135
|
+
summarySheet.addRow(['Total Issues:', result.summary.totalIssues]);
|
|
136
|
+
summarySheet.addRow(['Axe Violations:', result.summary.axeViolations || 0]);
|
|
137
|
+
summarySheet.addRow([]);
|
|
138
|
+
summarySheet.addRow(['Issue Types:']);
|
|
139
|
+
Object.entries(result.summary.byType).forEach(([type, count]) => {
|
|
140
|
+
summarySheet.addRow([type, count]);
|
|
141
|
+
});
|
|
142
|
+
const issuesSheet = workbook.addWorksheet('Issues');
|
|
143
|
+
issuesSheet.addRow(['Type', 'Message', 'Source', 'Snippet', 'Help URL']);
|
|
144
|
+
result.issues.forEach(issue => {
|
|
145
|
+
issuesSheet.addRow([
|
|
146
|
+
issue.type,
|
|
147
|
+
issue.message,
|
|
148
|
+
issue.source || 'heuristic',
|
|
149
|
+
issue.snippet || '',
|
|
150
|
+
issue.helpUrl || ''
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
summarySheet.getRow(1).font = { bold: true, size: 16 };
|
|
154
|
+
issuesSheet.getRow(1).font = { bold: true };
|
|
155
|
+
await workbook.xlsx.writeFile(filePath);
|
|
156
|
+
}
|
|
157
|
+
export async function saveReport(result, filePath, format = 'json') {
|
|
158
|
+
await ensureDir(filePath);
|
|
159
|
+
switch (format) {
|
|
160
|
+
case 'json': {
|
|
161
|
+
const arr = await readArray(filePath);
|
|
162
|
+
arr.push(result);
|
|
163
|
+
await fs.writeFile(filePath, JSON.stringify(arr, null, 2), 'utf8');
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'html': {
|
|
167
|
+
const htmlContent = generateHtmlReport(result);
|
|
168
|
+
const htmlPath = filePath.replace(/\.[^.]+$/, '.html');
|
|
169
|
+
await fs.writeFile(htmlPath, htmlContent, 'utf8');
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case 'csv': {
|
|
173
|
+
const csvContent = generateCsvReport(result);
|
|
174
|
+
const csvPath = filePath.replace(/\.[^.]+$/, '.csv');
|
|
175
|
+
await fs.writeFile(csvPath, csvContent, 'utf8');
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'excel': {
|
|
179
|
+
const excelPath = filePath.replace(/\.[^.]+$/, '.xlsx');
|
|
180
|
+
await generateExcelReport(result, excelPath);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
throw new Error(`Unsupported format: ${format}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const WCAG_RULES = {
|
|
2
|
+
'color-contrast': {
|
|
3
|
+
id: 'color-contrast',
|
|
4
|
+
level: 'AA',
|
|
5
|
+
principle: 'perceivable',
|
|
6
|
+
guideline: '1.4.3',
|
|
7
|
+
description: 'Elements must have sufficient color contrast',
|
|
8
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html'
|
|
9
|
+
},
|
|
10
|
+
'image-alt': {
|
|
11
|
+
id: 'image-alt',
|
|
12
|
+
level: 'A',
|
|
13
|
+
principle: 'perceivable',
|
|
14
|
+
guideline: '1.1.1',
|
|
15
|
+
description: 'Images must have alternative text',
|
|
16
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html'
|
|
17
|
+
},
|
|
18
|
+
'link-name': {
|
|
19
|
+
id: 'link-name',
|
|
20
|
+
level: 'A',
|
|
21
|
+
principle: 'operable',
|
|
22
|
+
guideline: '2.4.4',
|
|
23
|
+
description: 'Links must have discernible text',
|
|
24
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html'
|
|
25
|
+
},
|
|
26
|
+
'heading-order': {
|
|
27
|
+
id: 'heading-order',
|
|
28
|
+
level: 'AA',
|
|
29
|
+
principle: 'perceivable',
|
|
30
|
+
guideline: '1.3.1',
|
|
31
|
+
description: 'Heading levels should only increase by one',
|
|
32
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html'
|
|
33
|
+
},
|
|
34
|
+
'form-label': {
|
|
35
|
+
id: 'form-label',
|
|
36
|
+
level: 'A',
|
|
37
|
+
principle: 'perceivable',
|
|
38
|
+
guideline: '1.3.1',
|
|
39
|
+
description: 'Form elements must have labels',
|
|
40
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html'
|
|
41
|
+
},
|
|
42
|
+
'keyboard-navigation': {
|
|
43
|
+
id: 'keyboard-navigation',
|
|
44
|
+
level: 'A',
|
|
45
|
+
principle: 'operable',
|
|
46
|
+
guideline: '2.1.1',
|
|
47
|
+
description: 'All functionality must be available from a keyboard',
|
|
48
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html'
|
|
49
|
+
},
|
|
50
|
+
'focus-visible': {
|
|
51
|
+
id: 'focus-visible',
|
|
52
|
+
level: 'AA',
|
|
53
|
+
principle: 'operable',
|
|
54
|
+
guideline: '2.4.7',
|
|
55
|
+
description: 'Any keyboard operable interface has a mode of operation where the keyboard focus indicator is visible',
|
|
56
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html'
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
export function getWCAGRule(ruleId) {
|
|
60
|
+
return WCAG_RULES[ruleId];
|
|
61
|
+
}
|