equall-cli 0.1.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/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/chunk-UA3BFAGG.js +690 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +305 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +8 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bureau61
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Equall
|
|
2
|
+
|
|
3
|
+
Open-source accessibility scoring for dev teams. Aggregates axe-core, eslint-plugin-jsx-a11y, and more into a unified score.
|
|
4
|
+
|
|
5
|
+
**One command. Real score. No config.**
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
equall scan .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
◆ EQUALL — Accessibility Score
|
|
13
|
+
|
|
14
|
+
58 ~A WCAG 2.2
|
|
15
|
+
|
|
16
|
+
POUR Breakdown
|
|
17
|
+
P Perceivable ███████████████████░ 97
|
|
18
|
+
O Operable █████████░░░░░░░░░░░ 46
|
|
19
|
+
U Understandable ░░░░░░░░░░░░░░░░░░░░ n/a
|
|
20
|
+
R Robust ██████████████████░░ 91
|
|
21
|
+
|
|
22
|
+
Summary
|
|
23
|
+
33 files scanned · 18 WCAG violations · 5 best-practice issues
|
|
24
|
+
2 critical 2 serious 19 moderate 0 minor
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why Equall?
|
|
28
|
+
|
|
29
|
+
Accessibility tools today fall into two camps: dev tools that show violations without context (axe, Lighthouse), and enterprise platforms that cost $75K+/year (Deque, Siteimprove). Nothing in between.
|
|
30
|
+
|
|
31
|
+
Equall aggregates existing open-source scanners and adds what's missing: a **score**, a **trend**, and a **POUR breakdown** that tells you where to focus.
|
|
32
|
+
|
|
33
|
+
- **Aggregator, not reinventor** — wraps axe-core, eslint-plugin-jsx-a11y, and more. We don't rewrite rules, we unify results.
|
|
34
|
+
- **Score 0-100** — weighted by severity, grouped by WCAG criterion, with a conformance level (A / AA / AAA).
|
|
35
|
+
- **POUR breakdown** — see which accessibility principle (Perceivable, Operable, Understandable, Robust) is your weakest.
|
|
36
|
+
- **Zero config** — point it at a folder, get a score. No setup, no account, no signup.
|
|
37
|
+
- **Honest about coverage** — we tell you exactly which criteria we test and which need manual review. No "100% compliant" bullshit.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Scan a project
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
equall scan . # current directory
|
|
45
|
+
equall scan ./my-app # specific path
|
|
46
|
+
equall scan . --level A # target Level A only
|
|
47
|
+
equall scan . --level AAA # include Level AAA criteria
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### JSON output
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
equall scan . --json # pipe to other tools
|
|
54
|
+
equall scan . --json > report.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Filter files
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
equall scan . --include "src/**/*.tsx"
|
|
61
|
+
equall scan . --exclude "**/*.stories.*"
|
|
62
|
+
equall scan . --no-color # disable colored output
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## What gets scanned
|
|
66
|
+
|
|
67
|
+
Equall discovers and scans: `.html`, `.htm`, `.jsx`, `.tsx`, `.vue`, `.svelte`, `.astro`
|
|
68
|
+
|
|
69
|
+
It automatically skips: `node_modules`, `dist`, `build`, `.next`, test files, stories, and respects `.gitignore`.
|
|
70
|
+
|
|
71
|
+
## Scanners
|
|
72
|
+
|
|
73
|
+
| Scanner | What it checks | WCAG criteria covered |
|
|
74
|
+
|---------|---------------|----------------------|
|
|
75
|
+
| **axe-core** | HTML structure, ARIA, landmarks, forms, media | 24 |
|
|
76
|
+
| **eslint-plugin-jsx-a11y** | JSX/React-specific a11y patterns | 17 |
|
|
77
|
+
|
|
78
|
+
## Scoring
|
|
79
|
+
|
|
80
|
+
The score (0-100) is designed to be fair and scalable, whether you are scanning 5 files or 5,000. It is calculated using a **density-based asymptotic algorithm**:
|
|
81
|
+
|
|
82
|
+
- **Severity Weights**: Violations add a penalty based on severity (critical: 10, serious: 5, moderate: 2, minor: 1).
|
|
83
|
+
- **Criterion Caps**: Penalties are capped at 15 points per WCAG criterion to prevent a single recurring issue from entirely destroying the score.
|
|
84
|
+
- **Density Scaling**: Total penalties are scaled down logarithmically based on the number of files scanned (`1 / (1 + log10(files))`). A large repository is allowed more absolute errors than a tiny project for the same score.
|
|
85
|
+
- **Exponential Curve**: The final score follows an asymptotic decay curve (`100 * exp(-k * penalty)`). It drops quickly for the first few errors but smoothly approaches `0` without ever hitting it brutally, leaving room to measure progression.
|
|
86
|
+
|
|
87
|
+
### POUR Breakdown
|
|
88
|
+
The POUR metrics (Perceivable, Operable, Understandable, Robust) strictly follow the same scoring logic, isolated by principle, providing an independent 0-100 score for each accessibility pillar.
|
|
89
|
+
|
|
90
|
+
### Conformance Level
|
|
91
|
+
Conformance (A / AA / AAA) is evaluated strictly against your `--level` target. If you target `AA`, any `AAA` rules incidentally flagged by the scanners will not downgrade your conformance status.
|
|
92
|
+
|
|
93
|
+
## Programmatic API
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { runScan } from 'equall'
|
|
97
|
+
|
|
98
|
+
const result = await runScan({
|
|
99
|
+
path: './my-project',
|
|
100
|
+
level: 'AA',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
console.log(result.score) // 73
|
|
104
|
+
console.log(result.conformance_level) // 'A'
|
|
105
|
+
console.log(result.pour_scores) // { perceivable: 90, operable: 65, ... }
|
|
106
|
+
console.log(result.issues.length) // 12
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Exit codes
|
|
110
|
+
|
|
111
|
+
- `0` — score >= 50
|
|
112
|
+
- `1` — score < 50 (useful for CI gates)
|
|
113
|
+
- `2` — scan error
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// src/scoring/score.ts
|
|
2
|
+
var SEVERITY_WEIGHT = {
|
|
3
|
+
critical: 10,
|
|
4
|
+
serious: 5,
|
|
5
|
+
moderate: 2,
|
|
6
|
+
minor: 1
|
|
7
|
+
};
|
|
8
|
+
var MAX_PENALTY_PER_CRITERION = 15;
|
|
9
|
+
function computeScanResult(issues, filesScanned, scannersUsed, durationMs, targetLevel = "AA", criteriaCovered = [], criteriaTotal = 0) {
|
|
10
|
+
const summary = computeSummary(issues, filesScanned);
|
|
11
|
+
const score = computeScore(issues, filesScanned);
|
|
12
|
+
const pourScores = computePourScores(issues, filesScanned);
|
|
13
|
+
const conformanceLevel = computeConformanceLevel(issues, summary, targetLevel);
|
|
14
|
+
return {
|
|
15
|
+
score,
|
|
16
|
+
conformance_level: conformanceLevel,
|
|
17
|
+
pour_scores: pourScores,
|
|
18
|
+
issues,
|
|
19
|
+
summary,
|
|
20
|
+
scanners_used: scannersUsed,
|
|
21
|
+
criteria_covered: criteriaCovered,
|
|
22
|
+
criteria_total: criteriaTotal,
|
|
23
|
+
scanned_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
24
|
+
duration_ms: durationMs
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function computeSummary(issues, filesScanned) {
|
|
28
|
+
const bySeverity = {
|
|
29
|
+
critical: 0,
|
|
30
|
+
serious: 0,
|
|
31
|
+
moderate: 0,
|
|
32
|
+
minor: 0
|
|
33
|
+
};
|
|
34
|
+
const byScanner = {};
|
|
35
|
+
const criteriaSet = /* @__PURE__ */ new Set();
|
|
36
|
+
const failedCriteriaSet = /* @__PURE__ */ new Set();
|
|
37
|
+
for (const issue of issues) {
|
|
38
|
+
bySeverity[issue.severity]++;
|
|
39
|
+
byScanner[issue.scanner] = (byScanner[issue.scanner] ?? 0) + 1;
|
|
40
|
+
for (const c of issue.wcag_criteria) {
|
|
41
|
+
criteriaSet.add(c);
|
|
42
|
+
failedCriteriaSet.add(c);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
files_scanned: filesScanned,
|
|
47
|
+
total_issues: issues.length,
|
|
48
|
+
by_severity: bySeverity,
|
|
49
|
+
by_scanner: byScanner,
|
|
50
|
+
criteria_tested: [...criteriaSet].sort(),
|
|
51
|
+
criteria_failed: [...failedCriteriaSet].sort()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function computeScore(issues, filesScanned) {
|
|
55
|
+
if (issues.length === 0) return 100;
|
|
56
|
+
const penaltyByCriterion = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const issue of issues) {
|
|
58
|
+
const weight = SEVERITY_WEIGHT[issue.severity];
|
|
59
|
+
for (const criterion of issue.wcag_criteria) {
|
|
60
|
+
const current = penaltyByCriterion.get(criterion) ?? 0;
|
|
61
|
+
penaltyByCriterion.set(criterion, Math.min(current + weight, MAX_PENALTY_PER_CRITERION));
|
|
62
|
+
}
|
|
63
|
+
if (issue.wcag_criteria.length === 0) {
|
|
64
|
+
const key = `_${issue.scanner}:${issue.scanner_rule_id}`;
|
|
65
|
+
const current = penaltyByCriterion.get(key) ?? 0;
|
|
66
|
+
penaltyByCriterion.set(key, Math.min(current + weight, MAX_PENALTY_PER_CRITERION));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const totalRawPenalty = [...penaltyByCriterion.values()].reduce((a, b) => a + b, 0);
|
|
70
|
+
const scaleFactor = 1 / (1 + Math.log10(Math.max(1, filesScanned)));
|
|
71
|
+
const scaledPenalty = totalRawPenalty * scaleFactor;
|
|
72
|
+
const k = 0.02;
|
|
73
|
+
const score = 100 * Math.exp(-k * scaledPenalty);
|
|
74
|
+
return Math.max(0, Math.round(score));
|
|
75
|
+
}
|
|
76
|
+
function computePourScores(issues, filesScanned) {
|
|
77
|
+
const pourIssues = {
|
|
78
|
+
perceivable: [],
|
|
79
|
+
operable: [],
|
|
80
|
+
understandable: [],
|
|
81
|
+
robust: []
|
|
82
|
+
};
|
|
83
|
+
const pourCriteria = {
|
|
84
|
+
perceivable: /* @__PURE__ */ new Set(),
|
|
85
|
+
operable: /* @__PURE__ */ new Set(),
|
|
86
|
+
understandable: /* @__PURE__ */ new Set(),
|
|
87
|
+
robust: /* @__PURE__ */ new Set()
|
|
88
|
+
};
|
|
89
|
+
for (const issue of issues) {
|
|
90
|
+
if (!issue.pour) continue;
|
|
91
|
+
pourIssues[issue.pour].push(issue);
|
|
92
|
+
for (const c of issue.wcag_criteria) {
|
|
93
|
+
pourCriteria[issue.pour].add(c);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const scaleFactor = 1 / (1 + Math.log10(Math.max(1, filesScanned)));
|
|
97
|
+
const k = 0.02;
|
|
98
|
+
function pourScore(principle) {
|
|
99
|
+
const principleIssues = pourIssues[principle];
|
|
100
|
+
const criteriaCount = pourCriteria[principle].size;
|
|
101
|
+
if (criteriaCount === 0 && principleIssues.length === 0) return null;
|
|
102
|
+
if (principleIssues.length === 0) return 100;
|
|
103
|
+
const penaltyByCriterion = /* @__PURE__ */ new Map();
|
|
104
|
+
for (const issue of principleIssues) {
|
|
105
|
+
const weight = SEVERITY_WEIGHT[issue.severity];
|
|
106
|
+
for (const criterion of issue.wcag_criteria) {
|
|
107
|
+
const current = penaltyByCriterion.get(criterion) ?? 0;
|
|
108
|
+
penaltyByCriterion.set(criterion, Math.min(current + weight, MAX_PENALTY_PER_CRITERION));
|
|
109
|
+
}
|
|
110
|
+
if (issue.wcag_criteria.length === 0) {
|
|
111
|
+
const key = `_${issue.scanner}:${issue.scanner_rule_id}`;
|
|
112
|
+
const current = penaltyByCriterion.get(key) ?? 0;
|
|
113
|
+
penaltyByCriterion.set(key, Math.min(current + weight, MAX_PENALTY_PER_CRITERION));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const totalRawPenalty = [...penaltyByCriterion.values()].reduce((a, b) => a + b, 0);
|
|
117
|
+
const scaledPenalty = totalRawPenalty * scaleFactor;
|
|
118
|
+
return Math.max(0, Math.round(100 * Math.exp(-k * scaledPenalty)));
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
perceivable: pourScore("perceivable"),
|
|
122
|
+
operable: pourScore("operable"),
|
|
123
|
+
understandable: pourScore("understandable"),
|
|
124
|
+
robust: pourScore("robust")
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function computeConformanceLevel(issues, summary, targetLevel) {
|
|
128
|
+
const failedByLevel = {
|
|
129
|
+
A: /* @__PURE__ */ new Set(),
|
|
130
|
+
AA: /* @__PURE__ */ new Set(),
|
|
131
|
+
AAA: /* @__PURE__ */ new Set()
|
|
132
|
+
};
|
|
133
|
+
for (const issue of issues) {
|
|
134
|
+
if (!issue.wcag_level) continue;
|
|
135
|
+
for (const c of issue.wcag_criteria) {
|
|
136
|
+
failedByLevel[issue.wcag_level].add(c);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const hasA = failedByLevel.A.size > 0;
|
|
140
|
+
const hasAA = failedByLevel.AA.size > 0;
|
|
141
|
+
const hasAAA = failedByLevel.AAA.size > 0;
|
|
142
|
+
if (targetLevel === "A") {
|
|
143
|
+
if (!hasA) return summary.criteria_tested.length > 0 ? "A" : "None";
|
|
144
|
+
return "Partial A";
|
|
145
|
+
}
|
|
146
|
+
if (targetLevel === "AA") {
|
|
147
|
+
if (!hasA && !hasAA) return summary.criteria_tested.length > 0 ? "AA" : "None";
|
|
148
|
+
if (!hasA) return "A";
|
|
149
|
+
return "Partial A";
|
|
150
|
+
}
|
|
151
|
+
if (targetLevel === "AAA") {
|
|
152
|
+
if (!hasA && !hasAA && !hasAAA) return summary.criteria_tested.length > 0 ? "AAA" : "None";
|
|
153
|
+
if (!hasA && !hasAA) return "AA";
|
|
154
|
+
if (!hasA) return "A";
|
|
155
|
+
return "Partial A";
|
|
156
|
+
}
|
|
157
|
+
return "None";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/scan.ts
|
|
161
|
+
import { resolve as resolve2 } from "path";
|
|
162
|
+
|
|
163
|
+
// src/discover.ts
|
|
164
|
+
import { readFile } from "fs/promises";
|
|
165
|
+
import { resolve, extname } from "path";
|
|
166
|
+
var EXT_MAP = {
|
|
167
|
+
".html": "html",
|
|
168
|
+
".htm": "html",
|
|
169
|
+
".jsx": "jsx",
|
|
170
|
+
".tsx": "tsx",
|
|
171
|
+
".vue": "vue",
|
|
172
|
+
".svelte": "svelte",
|
|
173
|
+
".astro": "astro"
|
|
174
|
+
};
|
|
175
|
+
var DEFAULT_INCLUDE = [
|
|
176
|
+
"**/*.html",
|
|
177
|
+
"**/*.htm",
|
|
178
|
+
"**/*.jsx",
|
|
179
|
+
"**/*.tsx",
|
|
180
|
+
"**/*.vue",
|
|
181
|
+
"**/*.svelte",
|
|
182
|
+
"**/*.astro"
|
|
183
|
+
];
|
|
184
|
+
var DEFAULT_EXCLUDE = [
|
|
185
|
+
"**/node_modules/**",
|
|
186
|
+
"**/dist/**",
|
|
187
|
+
"**/build/**",
|
|
188
|
+
"**/.next/**",
|
|
189
|
+
"**/.nuxt/**",
|
|
190
|
+
"**/coverage/**",
|
|
191
|
+
"**/*.min.*",
|
|
192
|
+
"**/*.test.*",
|
|
193
|
+
"**/*.spec.*",
|
|
194
|
+
"**/__tests__/**",
|
|
195
|
+
"**/*.stories.*",
|
|
196
|
+
"**/storybook-static/**"
|
|
197
|
+
];
|
|
198
|
+
async function discoverFiles(rootPath, options) {
|
|
199
|
+
const { globby } = await import("globby");
|
|
200
|
+
const includePatterns = options.include_patterns.length > 0 ? options.include_patterns : DEFAULT_INCLUDE;
|
|
201
|
+
const excludePatterns = [
|
|
202
|
+
...DEFAULT_EXCLUDE,
|
|
203
|
+
...options.exclude_patterns
|
|
204
|
+
];
|
|
205
|
+
const paths = await globby(includePatterns, {
|
|
206
|
+
cwd: rootPath,
|
|
207
|
+
ignore: excludePatterns,
|
|
208
|
+
absolute: false,
|
|
209
|
+
gitignore: true
|
|
210
|
+
});
|
|
211
|
+
const files = [];
|
|
212
|
+
for (const relativePath of paths) {
|
|
213
|
+
const absolutePath = resolve(rootPath, relativePath);
|
|
214
|
+
try {
|
|
215
|
+
const content = await readFile(absolutePath, "utf-8");
|
|
216
|
+
const ext = extname(relativePath).toLowerCase();
|
|
217
|
+
const type = EXT_MAP[ext] ?? "other";
|
|
218
|
+
files.push({
|
|
219
|
+
path: relativePath,
|
|
220
|
+
absolute_path: absolutePath,
|
|
221
|
+
content,
|
|
222
|
+
type
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return files;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/scanners/axe-scanner.ts
|
|
231
|
+
function parseWcagTags(tags) {
|
|
232
|
+
const criteria = [];
|
|
233
|
+
let level = null;
|
|
234
|
+
let pour = null;
|
|
235
|
+
for (const tag of tags) {
|
|
236
|
+
const criterionMatch = tag.match(/^wcag([1-4])(\d)(\d+)$/);
|
|
237
|
+
if (criterionMatch) {
|
|
238
|
+
criteria.push(`${criterionMatch[1]}.${criterionMatch[2]}.${criterionMatch[3]}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (tag === "wcag2a" || tag === "wcag21a" || tag === "wcag22a") level = "A";
|
|
242
|
+
else if (tag === "wcag2aa" || tag === "wcag21aa" || tag === "wcag22aa") level = "AA";
|
|
243
|
+
else if (tag === "wcag2aaa" || tag === "wcag21aaa" || tag === "wcag22aaa") level = "AAA";
|
|
244
|
+
if (tag === "cat.text-alternatives" || tag === "cat.color" || tag === "cat.sensory-and-visual-cues" || tag === "cat.time-and-media" || tag === "cat.tables" || tag === "cat.forms") {
|
|
245
|
+
pour = pour ?? "perceivable";
|
|
246
|
+
}
|
|
247
|
+
if (tag === "cat.keyboard" || tag === "cat.navigation" || tag === "cat.time-and-media") {
|
|
248
|
+
pour = pour ?? "operable";
|
|
249
|
+
}
|
|
250
|
+
if (tag === "cat.language" || tag === "cat.parsing" || tag === "cat.forms") {
|
|
251
|
+
pour = pour ?? "understandable";
|
|
252
|
+
}
|
|
253
|
+
if (tag === "cat.name-role-value" || tag === "cat.structure" || tag === "cat.aria") {
|
|
254
|
+
pour = pour ?? "robust";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { criteria, level, pour };
|
|
258
|
+
}
|
|
259
|
+
function mapSeverity(impact) {
|
|
260
|
+
switch (impact) {
|
|
261
|
+
case "critical":
|
|
262
|
+
return "critical";
|
|
263
|
+
case "serious":
|
|
264
|
+
return "serious";
|
|
265
|
+
case "moderate":
|
|
266
|
+
return "moderate";
|
|
267
|
+
case "minor":
|
|
268
|
+
return "minor";
|
|
269
|
+
default:
|
|
270
|
+
return "moderate";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function pourFromCriterion(criterion) {
|
|
274
|
+
const principle = criterion.charAt(0);
|
|
275
|
+
switch (principle) {
|
|
276
|
+
case "1":
|
|
277
|
+
return "perceivable";
|
|
278
|
+
case "2":
|
|
279
|
+
return "operable";
|
|
280
|
+
case "3":
|
|
281
|
+
return "understandable";
|
|
282
|
+
case "4":
|
|
283
|
+
return "robust";
|
|
284
|
+
default:
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
var AxeScanner = class {
|
|
289
|
+
name = "axe-core";
|
|
290
|
+
version = "";
|
|
291
|
+
coveredCriteria = [
|
|
292
|
+
"1.1.1",
|
|
293
|
+
"1.2.1",
|
|
294
|
+
"1.2.2",
|
|
295
|
+
"1.3.1",
|
|
296
|
+
"1.3.4",
|
|
297
|
+
"1.3.5",
|
|
298
|
+
"1.4.1",
|
|
299
|
+
"1.4.2",
|
|
300
|
+
"1.4.3",
|
|
301
|
+
"1.4.4",
|
|
302
|
+
"1.4.12",
|
|
303
|
+
"2.1.1",
|
|
304
|
+
"2.1.3",
|
|
305
|
+
"2.2.1",
|
|
306
|
+
"2.2.2",
|
|
307
|
+
"2.4.1",
|
|
308
|
+
"2.4.2",
|
|
309
|
+
"2.4.4",
|
|
310
|
+
"2.5.3",
|
|
311
|
+
"2.5.8",
|
|
312
|
+
"3.1.1",
|
|
313
|
+
"3.1.2",
|
|
314
|
+
"3.3.2",
|
|
315
|
+
"4.1.2"
|
|
316
|
+
];
|
|
317
|
+
async isAvailable() {
|
|
318
|
+
try {
|
|
319
|
+
await import("axe-core");
|
|
320
|
+
await import("jsdom");
|
|
321
|
+
return true;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async scan(context) {
|
|
327
|
+
const axeModule = await import("axe-core");
|
|
328
|
+
const axe = axeModule.default ?? axeModule;
|
|
329
|
+
const { JSDOM } = await import("jsdom");
|
|
330
|
+
this.version = axe.version ?? "unknown";
|
|
331
|
+
const htmlFiles = context.files.filter(
|
|
332
|
+
(f) => f.type === "html" || f.type === "jsx" || f.type === "tsx" || f.type === "vue"
|
|
333
|
+
);
|
|
334
|
+
const scannableFiles = htmlFiles.filter((f) => {
|
|
335
|
+
if (f.type === "html") return true;
|
|
336
|
+
return f.content.includes("<") && (f.content.includes("return") || f.content.includes("<template"));
|
|
337
|
+
});
|
|
338
|
+
const allIssues = [];
|
|
339
|
+
const runTags = buildRunTags(context.options.wcag_level);
|
|
340
|
+
for (const file of scannableFiles) {
|
|
341
|
+
try {
|
|
342
|
+
const html = extractHtml(file.content, file.type);
|
|
343
|
+
if (!html.trim()) continue;
|
|
344
|
+
const issues = await this.scanHtml(axe, JSDOM, html, file.path, runTags);
|
|
345
|
+
allIssues.push(...issues);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
348
|
+
console.warn(` [axe-core] Skipped ${file.path}: ${msg.slice(0, 80)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return allIssues;
|
|
352
|
+
}
|
|
353
|
+
async scanHtml(axe, JSDOMClass, html, filePath, runTags) {
|
|
354
|
+
const fullHtml = html.includes("<html") ? html : `
|
|
355
|
+
<!DOCTYPE html>
|
|
356
|
+
<html lang="en">
|
|
357
|
+
<head><title>Scan</title></head>
|
|
358
|
+
<body>${html}</body>
|
|
359
|
+
</html>
|
|
360
|
+
`;
|
|
361
|
+
const originalConsoleError = console.error;
|
|
362
|
+
console.error = (...args) => {
|
|
363
|
+
const msg = String(args[0] ?? "");
|
|
364
|
+
if (msg.includes("Not implemented") || msg.includes("HTMLCanvasElement")) return;
|
|
365
|
+
originalConsoleError(...args);
|
|
366
|
+
};
|
|
367
|
+
const dom = new JSDOMClass(fullHtml, {
|
|
368
|
+
runScripts: "outside-only",
|
|
369
|
+
pretendToBeVisual: true,
|
|
370
|
+
virtualConsole: new (await import("jsdom")).VirtualConsole()
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const document = dom.window.document;
|
|
374
|
+
axe.configure({
|
|
375
|
+
rules: [
|
|
376
|
+
{ id: "color-contrast", enabled: false },
|
|
377
|
+
{ id: "color-contrast-enhanced", enabled: false }
|
|
378
|
+
]
|
|
379
|
+
});
|
|
380
|
+
const results = await axe.run(document.documentElement, {
|
|
381
|
+
runOnly: {
|
|
382
|
+
type: "tag",
|
|
383
|
+
values: runTags
|
|
384
|
+
},
|
|
385
|
+
resultTypes: ["violations"]
|
|
386
|
+
});
|
|
387
|
+
const issues = [];
|
|
388
|
+
for (const violation of results.violations) {
|
|
389
|
+
const { criteria, level, pour } = parseWcagTags(violation.tags);
|
|
390
|
+
const derivedPour = pour ?? (criteria[0] ? pourFromCriterion(criteria[0]) : null);
|
|
391
|
+
for (const node of violation.nodes) {
|
|
392
|
+
issues.push({
|
|
393
|
+
scanner: "axe-core",
|
|
394
|
+
scanner_rule_id: violation.id,
|
|
395
|
+
wcag_criteria: criteria,
|
|
396
|
+
wcag_level: level,
|
|
397
|
+
pour: derivedPour,
|
|
398
|
+
file_path: filePath,
|
|
399
|
+
line: null,
|
|
400
|
+
// axe-core doesn't provide line numbers on static HTML
|
|
401
|
+
column: null,
|
|
402
|
+
html_snippet: node.html?.slice(0, 200) ?? null,
|
|
403
|
+
severity: mapSeverity(violation.impact),
|
|
404
|
+
message: `${violation.help} (${violation.id})`,
|
|
405
|
+
help_url: violation.helpUrl ?? null,
|
|
406
|
+
suggestion: node.failureSummary ?? null
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return issues;
|
|
411
|
+
} finally {
|
|
412
|
+
dom.window.close();
|
|
413
|
+
console.error = originalConsoleError;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
function buildRunTags(level) {
|
|
418
|
+
const tags = ["wcag2a", "wcag21a", "wcag22a"];
|
|
419
|
+
if (level === "AA" || level === "AAA") {
|
|
420
|
+
tags.push("wcag2aa", "wcag21aa", "wcag22aa");
|
|
421
|
+
}
|
|
422
|
+
if (level === "AAA") {
|
|
423
|
+
tags.push("wcag2aaa", "wcag21aaa", "wcag22aaa");
|
|
424
|
+
}
|
|
425
|
+
tags.push("best-practice");
|
|
426
|
+
return tags;
|
|
427
|
+
}
|
|
428
|
+
function extractHtml(content, type) {
|
|
429
|
+
if (type === "html") return content;
|
|
430
|
+
if (type === "vue") {
|
|
431
|
+
const templateMatch = content.match(/<template[^>]*>([\s\S]*?)<\/template>/);
|
|
432
|
+
return templateMatch?.[1] ?? "";
|
|
433
|
+
}
|
|
434
|
+
if (type === "jsx" || type === "tsx") {
|
|
435
|
+
const returnMatch = content.match(/return\s*\(\s*([\s\S]*?)\s*\)\s*[;\n}]/);
|
|
436
|
+
if (returnMatch) return returnMatch[1];
|
|
437
|
+
const singleReturn = content.match(/return\s+(<[\s\S]*?>[\s\S]*?<\/[\s\S]*?>)/);
|
|
438
|
+
return singleReturn?.[1] ?? "";
|
|
439
|
+
}
|
|
440
|
+
return content;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/scanners/eslint-jsx-a11y-scanner.ts
|
|
444
|
+
var RULE_WCAG_MAP = {
|
|
445
|
+
"jsx-a11y/alt-text": { criteria: ["1.1.1"], pour: "perceivable" },
|
|
446
|
+
"jsx-a11y/anchor-has-content": { criteria: ["2.4.4", "4.1.2"], pour: "operable" },
|
|
447
|
+
"jsx-a11y/anchor-is-valid": { criteria: ["2.4.4"], pour: "operable" },
|
|
448
|
+
"jsx-a11y/aria-activedescendant-has-tabindex": { criteria: ["4.1.2"], pour: "robust" },
|
|
449
|
+
"jsx-a11y/aria-props": { criteria: ["4.1.2"], pour: "robust" },
|
|
450
|
+
"jsx-a11y/aria-proptypes": { criteria: ["4.1.2"], pour: "robust" },
|
|
451
|
+
"jsx-a11y/aria-role": { criteria: ["4.1.2"], pour: "robust" },
|
|
452
|
+
"jsx-a11y/aria-unsupported-elements": { criteria: ["4.1.2"], pour: "robust" },
|
|
453
|
+
"jsx-a11y/autocomplete-valid": { criteria: ["1.3.5"], pour: "perceivable" },
|
|
454
|
+
"jsx-a11y/click-events-have-key-events": { criteria: ["2.1.1"], pour: "operable" },
|
|
455
|
+
"jsx-a11y/heading-has-content": { criteria: ["2.4.6"], pour: "operable" },
|
|
456
|
+
"jsx-a11y/html-has-lang": { criteria: ["3.1.1"], pour: "understandable" },
|
|
457
|
+
"jsx-a11y/iframe-has-title": { criteria: ["2.4.1", "4.1.2"], pour: "operable" },
|
|
458
|
+
"jsx-a11y/img-redundant-alt": { criteria: ["1.1.1"], pour: "perceivable" },
|
|
459
|
+
"jsx-a11y/interactive-supports-focus": { criteria: ["2.1.1", "2.4.7"], pour: "operable" },
|
|
460
|
+
"jsx-a11y/label-has-associated-control": { criteria: ["1.3.1", "3.3.2"], pour: "perceivable" },
|
|
461
|
+
"jsx-a11y/lang": { criteria: ["3.1.2"], pour: "understandable" },
|
|
462
|
+
"jsx-a11y/media-has-caption": { criteria: ["1.2.2", "1.2.3"], pour: "perceivable" },
|
|
463
|
+
"jsx-a11y/mouse-events-have-key-events": { criteria: ["2.1.1"], pour: "operable" },
|
|
464
|
+
"jsx-a11y/no-access-key": { criteria: ["2.1.1"], pour: "operable" },
|
|
465
|
+
"jsx-a11y/no-autofocus": { criteria: ["2.4.3"], pour: "operable" },
|
|
466
|
+
"jsx-a11y/no-distracting-elements": { criteria: ["2.3.1"], pour: "operable" },
|
|
467
|
+
"jsx-a11y/no-interactive-element-to-noninteractive-role": { criteria: ["4.1.2"], pour: "robust" },
|
|
468
|
+
"jsx-a11y/no-noninteractive-element-interactions": { criteria: ["2.1.1"], pour: "operable" },
|
|
469
|
+
"jsx-a11y/no-noninteractive-element-to-interactive-role": { criteria: ["4.1.2"], pour: "robust" },
|
|
470
|
+
"jsx-a11y/no-noninteractive-tabindex": { criteria: ["2.4.3"], pour: "operable" },
|
|
471
|
+
"jsx-a11y/no-redundant-roles": { criteria: ["4.1.2"], pour: "robust" },
|
|
472
|
+
"jsx-a11y/no-static-element-interactions": { criteria: ["2.1.1"], pour: "operable" },
|
|
473
|
+
"jsx-a11y/prefer-tag-over-role": { criteria: ["4.1.2"], pour: "robust" },
|
|
474
|
+
"jsx-a11y/role-has-required-aria-props": { criteria: ["4.1.2"], pour: "robust" },
|
|
475
|
+
"jsx-a11y/role-supports-aria-props": { criteria: ["4.1.2"], pour: "robust" },
|
|
476
|
+
"jsx-a11y/scope": { criteria: ["1.3.1"], pour: "perceivable" },
|
|
477
|
+
"jsx-a11y/tabindex-no-positive": { criteria: ["2.4.3"], pour: "operable" }
|
|
478
|
+
};
|
|
479
|
+
function mapSeverity2(eslintSeverity) {
|
|
480
|
+
switch (eslintSeverity) {
|
|
481
|
+
case 2:
|
|
482
|
+
return "serious";
|
|
483
|
+
// error
|
|
484
|
+
case 1:
|
|
485
|
+
return "moderate";
|
|
486
|
+
// warning
|
|
487
|
+
default:
|
|
488
|
+
return "minor";
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
var AA_CRITERIA = /* @__PURE__ */ new Set(["1.3.5", "2.4.6", "2.4.7", "3.1.2", "3.3.2"]);
|
|
492
|
+
var EslintJsxA11yScanner = class {
|
|
493
|
+
name = "eslint-jsx-a11y";
|
|
494
|
+
version = "";
|
|
495
|
+
coveredCriteria = [
|
|
496
|
+
"1.1.1",
|
|
497
|
+
"1.2.2",
|
|
498
|
+
"1.2.3",
|
|
499
|
+
"1.3.1",
|
|
500
|
+
"1.3.5",
|
|
501
|
+
"2.1.1",
|
|
502
|
+
"2.3.1",
|
|
503
|
+
"2.4.1",
|
|
504
|
+
"2.4.3",
|
|
505
|
+
"2.4.4",
|
|
506
|
+
"2.4.6",
|
|
507
|
+
"2.4.7",
|
|
508
|
+
"3.1.1",
|
|
509
|
+
"3.1.2",
|
|
510
|
+
"3.3.2",
|
|
511
|
+
"4.1.2"
|
|
512
|
+
];
|
|
513
|
+
async isAvailable() {
|
|
514
|
+
try {
|
|
515
|
+
await import("eslint");
|
|
516
|
+
await import("eslint-plugin-jsx-a11y");
|
|
517
|
+
return true;
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async scan(context) {
|
|
523
|
+
const { ESLint } = await import("eslint");
|
|
524
|
+
const jsxA11yModule = await import("eslint-plugin-jsx-a11y");
|
|
525
|
+
const jsxA11y = jsxA11yModule.default ?? jsxA11yModule;
|
|
526
|
+
try {
|
|
527
|
+
const { createRequire } = await import("module");
|
|
528
|
+
const require2 = createRequire(import.meta.url);
|
|
529
|
+
const pluginPkg = require2("eslint-plugin-jsx-a11y/package.json");
|
|
530
|
+
this.version = pluginPkg.version ?? "unknown";
|
|
531
|
+
} catch {
|
|
532
|
+
this.version = jsxA11y?.meta?.version ?? "unknown";
|
|
533
|
+
}
|
|
534
|
+
const jsxFiles = context.files.filter((f) => f.type === "jsx" || f.type === "tsx");
|
|
535
|
+
if (jsxFiles.length === 0) return [];
|
|
536
|
+
const tsParser = await import("@typescript-eslint/parser");
|
|
537
|
+
const eslint = new ESLint({
|
|
538
|
+
overrideConfigFile: true,
|
|
539
|
+
overrideConfig: [
|
|
540
|
+
{
|
|
541
|
+
files: ["**/*.{jsx,tsx,js,ts}"],
|
|
542
|
+
plugins: {
|
|
543
|
+
"jsx-a11y": jsxA11y
|
|
544
|
+
},
|
|
545
|
+
rules: Object.fromEntries(
|
|
546
|
+
Object.keys(RULE_WCAG_MAP).map((rule) => [rule, "error"])
|
|
547
|
+
),
|
|
548
|
+
languageOptions: {
|
|
549
|
+
parser: tsParser.default ?? tsParser,
|
|
550
|
+
parserOptions: {
|
|
551
|
+
ecmaFeatures: { jsx: true }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
],
|
|
556
|
+
cwd: context.root_path
|
|
557
|
+
});
|
|
558
|
+
const allIssues = [];
|
|
559
|
+
const filePaths = jsxFiles.map((f) => f.absolute_path);
|
|
560
|
+
try {
|
|
561
|
+
const results = await eslint.lintFiles(filePaths);
|
|
562
|
+
for (const result of results) {
|
|
563
|
+
const relativePath = jsxFiles.find(
|
|
564
|
+
(f) => f.absolute_path === result.filePath
|
|
565
|
+
)?.path ?? result.filePath;
|
|
566
|
+
for (const msg of result.messages) {
|
|
567
|
+
if (!msg.ruleId || !msg.ruleId.startsWith("jsx-a11y/")) continue;
|
|
568
|
+
const wcagMapping = RULE_WCAG_MAP[msg.ruleId];
|
|
569
|
+
const criteria = wcagMapping?.criteria ?? [];
|
|
570
|
+
const pour = wcagMapping?.pour ?? null;
|
|
571
|
+
const aCriteria = criteria.filter((c) => !AA_CRITERIA.has(c));
|
|
572
|
+
const aaCriteria = criteria.filter((c) => AA_CRITERIA.has(c));
|
|
573
|
+
const groups = [];
|
|
574
|
+
if (aCriteria.length > 0) groups.push({ criteria: aCriteria, level: "A" });
|
|
575
|
+
if (aaCriteria.length > 0) groups.push({ criteria: aaCriteria, level: "AA" });
|
|
576
|
+
if (groups.length === 0) groups.push({ criteria, level: "A" });
|
|
577
|
+
for (const group of groups) {
|
|
578
|
+
allIssues.push({
|
|
579
|
+
scanner: "eslint-jsx-a11y",
|
|
580
|
+
scanner_rule_id: msg.ruleId,
|
|
581
|
+
wcag_criteria: group.criteria,
|
|
582
|
+
wcag_level: group.level,
|
|
583
|
+
pour,
|
|
584
|
+
file_path: relativePath,
|
|
585
|
+
line: msg.line ?? null,
|
|
586
|
+
column: msg.column ?? null,
|
|
587
|
+
html_snippet: null,
|
|
588
|
+
severity: mapSeverity2(msg.severity),
|
|
589
|
+
message: `${msg.message} (${msg.ruleId})`,
|
|
590
|
+
help_url: `https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/${msg.ruleId.replace("jsx-a11y/", "")}.md`,
|
|
591
|
+
suggestion: msg.fix ? "Auto-fixable" : null
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
598
|
+
console.warn(` [eslint-jsx-a11y] Scan failed: ${errMsg.slice(0, 120)}`);
|
|
599
|
+
}
|
|
600
|
+
return allIssues;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
// src/scanners/index.ts
|
|
605
|
+
var ALL_SCANNERS = [
|
|
606
|
+
new AxeScanner(),
|
|
607
|
+
new EslintJsxA11yScanner()
|
|
608
|
+
];
|
|
609
|
+
async function getAvailableScanners() {
|
|
610
|
+
const checks = await Promise.all(
|
|
611
|
+
ALL_SCANNERS.map(async (scanner) => ({
|
|
612
|
+
scanner,
|
|
613
|
+
available: await scanner.isAvailable()
|
|
614
|
+
}))
|
|
615
|
+
);
|
|
616
|
+
return checks.filter((c) => c.available).map((c) => c.scanner);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/scan.ts
|
|
620
|
+
async function runScan(options = {}) {
|
|
621
|
+
const rootPath = resolve2(options.path ?? process.cwd());
|
|
622
|
+
const startTime = Date.now();
|
|
623
|
+
const scanOptions = {
|
|
624
|
+
wcag_level: options.level ?? "AA",
|
|
625
|
+
include_patterns: options.include ?? [],
|
|
626
|
+
exclude_patterns: options.exclude ?? []
|
|
627
|
+
};
|
|
628
|
+
const files = await discoverFiles(rootPath, scanOptions);
|
|
629
|
+
if (files.length === 0) {
|
|
630
|
+
return computeScanResult([], 0, [], Date.now() - startTime, scanOptions.wcag_level);
|
|
631
|
+
}
|
|
632
|
+
const scanners = await getAvailableScanners();
|
|
633
|
+
if (scanners.length === 0) {
|
|
634
|
+
console.warn("No scanners available. Install axe-core and jsdom for HTML scanning.");
|
|
635
|
+
return computeScanResult([], files.length, [], Date.now() - startTime, scanOptions.wcag_level);
|
|
636
|
+
}
|
|
637
|
+
const scanContext = { root_path: rootPath, files, options: scanOptions };
|
|
638
|
+
const scannerResults = await Promise.allSettled(
|
|
639
|
+
scanners.map(async (scanner) => {
|
|
640
|
+
const issues = await scanner.scan(scanContext);
|
|
641
|
+
return {
|
|
642
|
+
scanner,
|
|
643
|
+
issues
|
|
644
|
+
};
|
|
645
|
+
})
|
|
646
|
+
);
|
|
647
|
+
const allIssues = [];
|
|
648
|
+
const scannersUsed = [];
|
|
649
|
+
for (const result of scannerResults) {
|
|
650
|
+
if (result.status === "fulfilled") {
|
|
651
|
+
const { scanner, issues } = result.value;
|
|
652
|
+
allIssues.push(...issues);
|
|
653
|
+
scannersUsed.push({
|
|
654
|
+
name: scanner.name,
|
|
655
|
+
version: scanner.version,
|
|
656
|
+
rules_count: 0,
|
|
657
|
+
// Could be enhanced per scanner
|
|
658
|
+
issues_found: issues.length
|
|
659
|
+
});
|
|
660
|
+
} else {
|
|
661
|
+
const err = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
662
|
+
console.warn(` [scanner] Failed: ${err.slice(0, 120)}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const deduped = deduplicateIssues(allIssues);
|
|
666
|
+
const criteriaCovered = [...new Set(scanners.flatMap((s) => s.coveredCriteria))].sort();
|
|
667
|
+
const WCAG_TOTAL = { A: 30, AA: 57, AAA: 78 };
|
|
668
|
+
const criteriaTotal = WCAG_TOTAL[scanOptions.wcag_level] ?? 57;
|
|
669
|
+
const durationMs = Date.now() - startTime;
|
|
670
|
+
return computeScanResult(deduped, files.length, scannersUsed, durationMs, scanOptions.wcag_level, criteriaCovered, criteriaTotal);
|
|
671
|
+
}
|
|
672
|
+
function deduplicateIssues(issues) {
|
|
673
|
+
const seen = /* @__PURE__ */ new Set();
|
|
674
|
+
const result = [];
|
|
675
|
+
for (const issue of issues) {
|
|
676
|
+
const sortedCriteria = [...issue.wcag_criteria].sort().join(",");
|
|
677
|
+
const location = issue.line != null ? `L${issue.line}:${issue.column ?? 0}` : issue.html_snippet?.slice(0, 80) ?? "no-loc";
|
|
678
|
+
const key = `${issue.file_path}|${sortedCriteria}|${location}`;
|
|
679
|
+
if (!seen.has(key)) {
|
|
680
|
+
seen.add(key);
|
|
681
|
+
result.push(issue);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return result;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export {
|
|
688
|
+
computeScanResult,
|
|
689
|
+
runScan
|
|
690
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
runScan
|
|
4
|
+
} from "./chunk-UA3BFAGG.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/output/terminal.ts
|
|
10
|
+
var WCAG_A_CRITERIA = /* @__PURE__ */ new Set([
|
|
11
|
+
"1.1.1",
|
|
12
|
+
"1.2.1",
|
|
13
|
+
"1.2.2",
|
|
14
|
+
"1.2.3",
|
|
15
|
+
"1.3.1",
|
|
16
|
+
"1.3.2",
|
|
17
|
+
"1.3.3",
|
|
18
|
+
"1.4.1",
|
|
19
|
+
"1.4.2",
|
|
20
|
+
"2.1.1",
|
|
21
|
+
"2.1.2",
|
|
22
|
+
"2.1.4",
|
|
23
|
+
"2.2.1",
|
|
24
|
+
"2.2.2",
|
|
25
|
+
"2.3.1",
|
|
26
|
+
"2.4.1",
|
|
27
|
+
"2.4.2",
|
|
28
|
+
"2.4.3",
|
|
29
|
+
"2.4.4",
|
|
30
|
+
"2.5.1",
|
|
31
|
+
"2.5.2",
|
|
32
|
+
"2.5.3",
|
|
33
|
+
"2.5.4",
|
|
34
|
+
"3.1.1",
|
|
35
|
+
"3.2.1",
|
|
36
|
+
"3.2.2",
|
|
37
|
+
"3.2.6",
|
|
38
|
+
"3.3.1",
|
|
39
|
+
"3.3.7",
|
|
40
|
+
"4.1.2"
|
|
41
|
+
]);
|
|
42
|
+
var WCAG_A_TOTAL = 30;
|
|
43
|
+
var BP_HINTS = {
|
|
44
|
+
"region": "Landmarks help screen reader users navigate page sections",
|
|
45
|
+
"landmark-main-is-top-level": "Nested landmarks confuse assistive technology",
|
|
46
|
+
"heading-order": "Skipping heading levels makes content harder to navigate",
|
|
47
|
+
"landmark-one-main": "Pages should have exactly one main landmark",
|
|
48
|
+
"landmark-unique": "Duplicate landmarks make navigation ambiguous",
|
|
49
|
+
"page-has-heading-one": "Pages should start with a top-level heading",
|
|
50
|
+
"landmark-complementary-is-top-level": "Complementary landmarks should not be nested",
|
|
51
|
+
"landmark-no-duplicate-banner": "Multiple banner landmarks confuse screen readers",
|
|
52
|
+
"landmark-no-duplicate-contentinfo": "Multiple contentinfo landmarks confuse screen readers"
|
|
53
|
+
};
|
|
54
|
+
var BOLD = "\x1B[1m";
|
|
55
|
+
var DIM = "\x1B[2m";
|
|
56
|
+
var RESET = "\x1B[0m";
|
|
57
|
+
var RED = "\x1B[31m";
|
|
58
|
+
var YELLOW = "\x1B[33m";
|
|
59
|
+
var GREEN = "\x1B[32m";
|
|
60
|
+
var CYAN = "\x1B[36m";
|
|
61
|
+
var MAGENTA = "\x1B[35m";
|
|
62
|
+
var WHITE = "\x1B[37m";
|
|
63
|
+
var BG_RED = "\x1B[41m";
|
|
64
|
+
var BG_YELLOW = "\x1B[43m";
|
|
65
|
+
var BG_GREEN = "\x1B[42m";
|
|
66
|
+
var BG_CYAN = "\x1B[46m";
|
|
67
|
+
function scoreColor(score) {
|
|
68
|
+
if (score >= 80) return GREEN;
|
|
69
|
+
if (score >= 50) return YELLOW;
|
|
70
|
+
return RED;
|
|
71
|
+
}
|
|
72
|
+
function scoreBg(score) {
|
|
73
|
+
if (score >= 80) return BG_GREEN;
|
|
74
|
+
if (score >= 50) return BG_YELLOW;
|
|
75
|
+
return BG_RED;
|
|
76
|
+
}
|
|
77
|
+
function severityIcon(s) {
|
|
78
|
+
switch (s) {
|
|
79
|
+
case "critical":
|
|
80
|
+
return `${RED}\u25CF${RESET}`;
|
|
81
|
+
case "serious":
|
|
82
|
+
return `${YELLOW}\u25CF${RESET}`;
|
|
83
|
+
case "moderate":
|
|
84
|
+
return `${CYAN}\u25CF${RESET}`;
|
|
85
|
+
case "minor":
|
|
86
|
+
return `${DIM}\u25CF${RESET}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function conformanceBadge(level) {
|
|
90
|
+
switch (level) {
|
|
91
|
+
case "AAA":
|
|
92
|
+
return `${BG_GREEN}${WHITE} AAA ${RESET}`;
|
|
93
|
+
case "AA":
|
|
94
|
+
return `${BG_GREEN}${WHITE} AA ${RESET}`;
|
|
95
|
+
case "A":
|
|
96
|
+
return `${BG_CYAN}${WHITE} A ${RESET}`;
|
|
97
|
+
case "Partial A":
|
|
98
|
+
return `${BG_YELLOW}${WHITE} ~A ${RESET}`;
|
|
99
|
+
case "None":
|
|
100
|
+
return `${BG_RED}${WHITE} \u2014 ${RESET}`;
|
|
101
|
+
default:
|
|
102
|
+
return level;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function bar(value, width = 20) {
|
|
106
|
+
if (value === null) return `${DIM}${"\u2591".repeat(width)} n/a${RESET}`;
|
|
107
|
+
const filled = Math.round(value / 100 * width);
|
|
108
|
+
const empty = width - filled;
|
|
109
|
+
const color = scoreColor(value);
|
|
110
|
+
return `${color}${"\u2588".repeat(filled)}${DIM}${"\u2591".repeat(empty)}${RESET} ${color}${value}${RESET}`;
|
|
111
|
+
}
|
|
112
|
+
function printResult(result) {
|
|
113
|
+
const { score, conformance_level, pour_scores, summary, scanners_used, duration_ms } = result;
|
|
114
|
+
console.log();
|
|
115
|
+
console.log(`${BOLD} \u25C6 EQUALL \u2014 Accessibility Score${RESET}`);
|
|
116
|
+
console.log();
|
|
117
|
+
const color = scoreColor(score);
|
|
118
|
+
console.log(` ${scoreBg(score)}${BOLD}${WHITE} ${score} ${RESET} ${conformanceBadge(conformance_level)} ${DIM}WCAG 2.2${RESET}`);
|
|
119
|
+
console.log();
|
|
120
|
+
console.log(` ${BOLD}POUR Breakdown${RESET}`);
|
|
121
|
+
console.log(` ${MAGENTA}P${RESET} Perceivable ${bar(pour_scores.perceivable)}`);
|
|
122
|
+
console.log(` ${MAGENTA}O${RESET} Operable ${bar(pour_scores.operable)}`);
|
|
123
|
+
console.log(` ${MAGENTA}U${RESET} Understandable ${bar(pour_scores.understandable)}`);
|
|
124
|
+
console.log(` ${MAGENTA}R${RESET} Robust ${bar(pour_scores.robust)}`);
|
|
125
|
+
console.log();
|
|
126
|
+
console.log(` ${BOLD}Summary${RESET}`);
|
|
127
|
+
const wcagIssuesCount = result.issues.filter((i) => i.wcag_criteria.length > 0).length;
|
|
128
|
+
const bpIssuesCount = result.issues.length - wcagIssuesCount;
|
|
129
|
+
console.log(` ${summary.files_scanned} files scanned \xB7 ${wcagIssuesCount} WCAG violations \xB7 ${bpIssuesCount} best-practice issues`);
|
|
130
|
+
console.log(` ${RED}${summary.by_severity.critical} critical${RESET} ${YELLOW}${summary.by_severity.serious} serious${RESET} ${CYAN}${summary.by_severity.moderate} moderate${RESET} ${DIM}${summary.by_severity.minor} minor${RESET}`);
|
|
131
|
+
if (result.criteria_total > 0) {
|
|
132
|
+
const covered = result.criteria_covered.length;
|
|
133
|
+
const total = result.criteria_total;
|
|
134
|
+
const failedASet = /* @__PURE__ */ new Set();
|
|
135
|
+
const failedAllSet = /* @__PURE__ */ new Set();
|
|
136
|
+
for (const issue of result.issues) {
|
|
137
|
+
for (const c of issue.wcag_criteria) {
|
|
138
|
+
failedAllSet.add(c);
|
|
139
|
+
if (WCAG_A_CRITERIA.has(c)) failedASet.add(c);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const isTargetAA = total > WCAG_A_TOTAL && total <= 57;
|
|
143
|
+
if (isTargetAA && failedASet.size > 0) {
|
|
144
|
+
const coveredA = result.criteria_covered.filter((c) => WCAG_A_CRITERIA.has(c)).length;
|
|
145
|
+
const pctA = Math.round(coveredA / WCAG_A_TOTAL * 100);
|
|
146
|
+
const pctAA = Math.round(covered / total * 100);
|
|
147
|
+
const pad = " ".repeat(` Score ${score}/100 \xB7 `.length);
|
|
148
|
+
console.log(` ${BOLD}Score ${score}/100${RESET} \xB7 ${coveredA}/${WCAG_A_TOTAL} Level A criteria checked (${pctA}%) \xB7 ${RED}${failedASet.size} failed${RESET}`);
|
|
149
|
+
console.log(`${pad}${covered}/${total} Level AA criteria checked (${pctAA}%) \xB7 ${RED}${failedAllSet.size} failed${RESET}`);
|
|
150
|
+
} else {
|
|
151
|
+
const levelLabel = total <= WCAG_A_TOTAL ? "Level A" : `Level AA`;
|
|
152
|
+
const pct = Math.round(covered / total * 100);
|
|
153
|
+
console.log(` ${BOLD}Score ${score}/100${RESET} \xB7 ${covered}/${total} ${levelLabel} criteria checked (${pct}%) \xB7 ${RED}${failedAllSet.size} failed${RESET}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.log();
|
|
157
|
+
printCoaching(result);
|
|
158
|
+
console.log();
|
|
159
|
+
const wcagIssues = result.issues.filter((i) => i.wcag_criteria.length > 0);
|
|
160
|
+
const bpIssues = result.issues.filter((i) => i.wcag_criteria.length === 0);
|
|
161
|
+
if (wcagIssues.length > 0) {
|
|
162
|
+
console.log(` ${BOLD}WCAG Violations${RESET}`);
|
|
163
|
+
const grouped = groupByCriterion(wcagIssues);
|
|
164
|
+
const sorted = [...grouped.entries()].sort((a, b) => b[1].weight - a[1].weight).slice(0, 8);
|
|
165
|
+
for (const [criterion, group] of sorted) {
|
|
166
|
+
const topSeverity = group.issues[0].severity;
|
|
167
|
+
console.log(` ${severityIcon(topSeverity)} ${BOLD}${criterion}${RESET} ${DIM}\u2014 ${group.issues.length} issue${group.issues.length > 1 ? "s" : ""}${RESET}`);
|
|
168
|
+
for (const issue of group.issues.slice(0, 2)) {
|
|
169
|
+
const location = issue.line ? `:${issue.line}` : "";
|
|
170
|
+
console.log(` ${DIM}${issue.file_path}${location}${RESET}`);
|
|
171
|
+
console.log(` ${issue.message}`);
|
|
172
|
+
if (issue.suggestion) {
|
|
173
|
+
console.log(` ${GREEN}\u2192 ${issue.suggestion}${RESET}`);
|
|
174
|
+
}
|
|
175
|
+
if (issue.help_url) {
|
|
176
|
+
console.log(` ${DIM}\u2197 ${issue.help_url}${RESET}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (group.issues.length > 2) {
|
|
180
|
+
console.log(` ${DIM}... and ${group.issues.length - 2} more${RESET}`);
|
|
181
|
+
}
|
|
182
|
+
console.log();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (bpIssues.length > 0) {
|
|
186
|
+
console.log(` ${BOLD}Best Practices${RESET}`);
|
|
187
|
+
const grouped = groupByCriterion(bpIssues);
|
|
188
|
+
const sorted = [...grouped.entries()].sort((a, b) => b[1].weight - a[1].weight);
|
|
189
|
+
for (const [criterion, group] of sorted) {
|
|
190
|
+
const topSeverity = group.issues[0].severity;
|
|
191
|
+
const hint = BP_HINTS[criterion];
|
|
192
|
+
const hintSuffix = hint ? ` ${DIM}${hint}${RESET}` : "";
|
|
193
|
+
console.log(` ${severityIcon(topSeverity)} ${BOLD}${criterion}${RESET} ${DIM}\u2014 ${group.issues.length} issue${group.issues.length > 1 ? "s" : ""}${RESET}${hintSuffix}`);
|
|
194
|
+
}
|
|
195
|
+
console.log();
|
|
196
|
+
}
|
|
197
|
+
console.log(` ${DIM}Scanners: ${scanners_used.map((s) => `${s.name}@${s.version} (${s.issues_found} issues)`).join(", ")}${RESET}`);
|
|
198
|
+
console.log(` ${DIM}Completed in ${(duration_ms / 1e3).toFixed(1)}s${RESET}`);
|
|
199
|
+
console.log();
|
|
200
|
+
}
|
|
201
|
+
function printCoaching(result) {
|
|
202
|
+
const { summary, criteria_covered, criteria_total } = result;
|
|
203
|
+
const levelAFailed = [];
|
|
204
|
+
const levelAAFailed = [];
|
|
205
|
+
for (const issue of result.issues) {
|
|
206
|
+
if (issue.wcag_criteria.length === 0) continue;
|
|
207
|
+
if (issue.wcag_level === "A") {
|
|
208
|
+
for (const c of issue.wcag_criteria) {
|
|
209
|
+
if (!levelAFailed.includes(c)) levelAFailed.push(c);
|
|
210
|
+
}
|
|
211
|
+
} else if (issue.wcag_level === "AA") {
|
|
212
|
+
for (const c of issue.wcag_criteria) {
|
|
213
|
+
if (!levelAAFailed.includes(c)) levelAAFailed.push(c);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const remaining = criteria_total - criteria_covered.length;
|
|
218
|
+
if (levelAFailed.length > 0) {
|
|
219
|
+
const list = levelAFailed.sort().join(", ");
|
|
220
|
+
console.log(` ${YELLOW}\u24D8${RESET} You're failing ${BOLD}${levelAFailed.length} Level A${RESET} criteria (${list}).`);
|
|
221
|
+
console.log(` Level A is the legal minimum \u2014 it means all users can access your core content. Fix these first.`);
|
|
222
|
+
} else if (levelAAFailed.length > 0) {
|
|
223
|
+
console.log(` ${GREEN}\u24D8${RESET} ${BOLD}Level A passed!${RESET} You're now failing ${BOLD}${levelAAFailed.length} Level AA${RESET} criteria.`);
|
|
224
|
+
console.log(` Level AA is the recommended standard \u2014 it covers usability for assistive tech users (contrast, resize, focus visible).`);
|
|
225
|
+
} else {
|
|
226
|
+
console.log(` ${GREEN}\u24D8${RESET} ${BOLD}All automated checks pass!${RESET} ${remaining} criteria still require manual testing or browser-based scanning.`);
|
|
227
|
+
}
|
|
228
|
+
if (remaining > 0) {
|
|
229
|
+
console.log(` ${DIM}${remaining} criteria can't be tested automatically \u2014 they need manual review or a real browser.${RESET}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function groupByCriterion(issues) {
|
|
233
|
+
const map = /* @__PURE__ */ new Map();
|
|
234
|
+
const severityWeight = {
|
|
235
|
+
critical: 100,
|
|
236
|
+
serious: 50,
|
|
237
|
+
moderate: 10,
|
|
238
|
+
minor: 1
|
|
239
|
+
};
|
|
240
|
+
for (const issue of issues) {
|
|
241
|
+
const keys = issue.wcag_criteria.length > 0 ? issue.wcag_criteria : [issue.scanner_rule_id];
|
|
242
|
+
for (const key of keys) {
|
|
243
|
+
if (!map.has(key)) {
|
|
244
|
+
map.set(key, { issues: [], weight: 0 });
|
|
245
|
+
}
|
|
246
|
+
const group = map.get(key);
|
|
247
|
+
group.issues.push(issue);
|
|
248
|
+
group.weight += severityWeight[issue.severity];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const group of map.values()) {
|
|
252
|
+
group.issues.sort((a, b) => severityWeight[b.severity] - severityWeight[a.severity]);
|
|
253
|
+
}
|
|
254
|
+
return map;
|
|
255
|
+
}
|
|
256
|
+
function printJson(result) {
|
|
257
|
+
console.log(JSON.stringify(result, null, 2));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/cli.ts
|
|
261
|
+
var program = new Command();
|
|
262
|
+
program.name("equall").description("Open-source accessibility scoring \u2014 aggregates axe-core, eslint-plugin-jsx-a11y, and more.").version("0.1.0");
|
|
263
|
+
program.command("scan").description("Scan a project for accessibility issues").argument("[path]", "Path to project root", ".").option("-l, --level <level>", "WCAG conformance target: A, AA, or AAA", "AA").option("--include <patterns...>", "Glob patterns to include").option("--exclude <patterns...>", "Glob patterns to exclude").option("--json", "Output results as JSON").option("--no-color", "Disable colored output").action(async (path, opts) => {
|
|
264
|
+
const level = opts.level.toUpperCase();
|
|
265
|
+
if (!["A", "AA", "AAA"].includes(level)) {
|
|
266
|
+
console.error(`Invalid level "${opts.level}". Use A, AA, or AAA.`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
const { resolve, basename } = await import("path");
|
|
270
|
+
const displayName = basename(resolve(path));
|
|
271
|
+
const ora = (await import("ora")).default;
|
|
272
|
+
const spinner = opts.json ? null : ora({ text: `Scanning ${displayName}`, indent: 2 }).start();
|
|
273
|
+
try {
|
|
274
|
+
const result = await runScan({
|
|
275
|
+
path,
|
|
276
|
+
level,
|
|
277
|
+
include: opts.include,
|
|
278
|
+
exclude: opts.exclude
|
|
279
|
+
});
|
|
280
|
+
spinner?.stop();
|
|
281
|
+
if (result.summary.files_scanned === 0) {
|
|
282
|
+
if (opts.json) {
|
|
283
|
+
printJson(result);
|
|
284
|
+
} else {
|
|
285
|
+
console.log("\n No scannable files found (.html, .jsx, .tsx, .vue, .svelte, .astro)");
|
|
286
|
+
console.log(" Check the path or use --include to specify patterns.\n");
|
|
287
|
+
}
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
if (opts.json) {
|
|
291
|
+
printJson(result);
|
|
292
|
+
} else {
|
|
293
|
+
printResult(result);
|
|
294
|
+
}
|
|
295
|
+
if (result.score < 50) process.exit(1);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
spinner?.stop();
|
|
298
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
299
|
+
console.error(`
|
|
300
|
+
Error: ${msg}
|
|
301
|
+
`);
|
|
302
|
+
process.exit(2);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
program.parse();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
interface GladosIssue {
|
|
2
|
+
scanner: string;
|
|
3
|
+
scanner_rule_id: string;
|
|
4
|
+
wcag_criteria: string[];
|
|
5
|
+
wcag_level: WcagLevel | null;
|
|
6
|
+
pour: PourPrinciple | null;
|
|
7
|
+
file_path: string;
|
|
8
|
+
line: number | null;
|
|
9
|
+
column: number | null;
|
|
10
|
+
html_snippet: string | null;
|
|
11
|
+
severity: Severity;
|
|
12
|
+
message: string;
|
|
13
|
+
help_url: string | null;
|
|
14
|
+
suggestion: string | null;
|
|
15
|
+
}
|
|
16
|
+
type WcagLevel = 'A' | 'AA' | 'AAA';
|
|
17
|
+
type PourPrinciple = 'perceivable' | 'operable' | 'understandable' | 'robust';
|
|
18
|
+
type Severity = 'critical' | 'serious' | 'moderate' | 'minor';
|
|
19
|
+
interface ScannerAdapter {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
coveredCriteria: string[];
|
|
23
|
+
scan(context: ScanContext): Promise<GladosIssue[]>;
|
|
24
|
+
isAvailable(): Promise<boolean>;
|
|
25
|
+
}
|
|
26
|
+
interface ScanContext {
|
|
27
|
+
root_path: string;
|
|
28
|
+
files: FileEntry[];
|
|
29
|
+
options: ScanOptions;
|
|
30
|
+
}
|
|
31
|
+
interface FileEntry {
|
|
32
|
+
path: string;
|
|
33
|
+
absolute_path: string;
|
|
34
|
+
content: string;
|
|
35
|
+
type: FileType;
|
|
36
|
+
}
|
|
37
|
+
type FileType = 'html' | 'jsx' | 'tsx' | 'vue' | 'svelte' | 'astro' | 'other';
|
|
38
|
+
interface ScanOptions {
|
|
39
|
+
wcag_level: WcagLevel;
|
|
40
|
+
include_patterns: string[];
|
|
41
|
+
exclude_patterns: string[];
|
|
42
|
+
}
|
|
43
|
+
interface ScanResult {
|
|
44
|
+
score: number;
|
|
45
|
+
conformance_level: ConformanceLevel;
|
|
46
|
+
pour_scores: PourScores;
|
|
47
|
+
issues: GladosIssue[];
|
|
48
|
+
summary: ScanSummary;
|
|
49
|
+
scanners_used: ScannerInfo[];
|
|
50
|
+
criteria_covered: string[];
|
|
51
|
+
criteria_total: number;
|
|
52
|
+
scanned_at: string;
|
|
53
|
+
duration_ms: number;
|
|
54
|
+
}
|
|
55
|
+
interface PourScores {
|
|
56
|
+
perceivable: number | null;
|
|
57
|
+
operable: number | null;
|
|
58
|
+
understandable: number | null;
|
|
59
|
+
robust: number | null;
|
|
60
|
+
}
|
|
61
|
+
type ConformanceLevel = 'AAA' | 'AA' | 'A' | 'Partial A' | 'None';
|
|
62
|
+
interface ScanSummary {
|
|
63
|
+
files_scanned: number;
|
|
64
|
+
total_issues: number;
|
|
65
|
+
by_severity: Record<Severity, number>;
|
|
66
|
+
by_scanner: Record<string, number>;
|
|
67
|
+
criteria_tested: string[];
|
|
68
|
+
criteria_failed: string[];
|
|
69
|
+
}
|
|
70
|
+
interface ScannerInfo {
|
|
71
|
+
name: string;
|
|
72
|
+
version: string;
|
|
73
|
+
rules_count: number;
|
|
74
|
+
issues_found: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RunScanOptions {
|
|
78
|
+
path?: string;
|
|
79
|
+
level?: WcagLevel;
|
|
80
|
+
include?: string[];
|
|
81
|
+
exclude?: string[];
|
|
82
|
+
}
|
|
83
|
+
declare function runScan(options?: RunScanOptions): Promise<ScanResult>;
|
|
84
|
+
|
|
85
|
+
declare function computeScanResult(issues: GladosIssue[], filesScanned: number, scannersUsed: ScannerInfo[], durationMs: number, targetLevel?: WcagLevel, criteriaCovered?: string[], criteriaTotal?: number): ScanResult;
|
|
86
|
+
|
|
87
|
+
export { type ConformanceLevel, type GladosIssue, type PourPrinciple, type PourScores, type RunScanOptions, type ScanContext, type ScanOptions, type ScanResult, type ScannerAdapter, type Severity, type WcagLevel, computeScanResult, runScan };
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "equall-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open-source accessibility scoring CLI — aggregates axe-core, eslint-plugin-jsx-a11y, and more into a unified score.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"accessibility",
|
|
7
|
+
"a11y",
|
|
8
|
+
"wcag",
|
|
9
|
+
"scoring",
|
|
10
|
+
"cli",
|
|
11
|
+
"axe-core",
|
|
12
|
+
"eslint",
|
|
13
|
+
"audit"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Bureau61 <kevin@bureau61.com>",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/bureau61/equall"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"equall": "./dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup src/cli.ts src/index.ts --format esm --dts --clean",
|
|
39
|
+
"dev": "tsup src/cli.ts src/index.ts --format esm --dts --watch",
|
|
40
|
+
"scan": "tsx src/cli.ts",
|
|
41
|
+
"lint": "eslint src/",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@typescript-eslint/parser": "^8.57.2",
|
|
46
|
+
"axe-core": "^4.10.0",
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"eslint": "^9.0.0",
|
|
49
|
+
"eslint-plugin-jsx-a11y": "^6.10.0",
|
|
50
|
+
"globby": "^14.0.0",
|
|
51
|
+
"jsdom": "^25.0.0",
|
|
52
|
+
"ora": "^9.3.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/jsdom": "^21.1.7",
|
|
56
|
+
"@types/node": "^22.0.0",
|
|
57
|
+
"tsup": "^8.0.0",
|
|
58
|
+
"tsx": "^4.0.0",
|
|
59
|
+
"typescript": "^5.9.3",
|
|
60
|
+
"vitest": "^2.0.0"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18.0.0"
|
|
64
|
+
}
|
|
65
|
+
}
|