@hypoth-ui/a11y-audit 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 +47 -0
- package/dist/audit-Q3UXBYIW.js +266 -0
- package/dist/chunk-AMA6KCPL.js +39 -0
- package/dist/chunk-KLSGW6PX.js +515 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +36 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +24 -0
- package/dist/report-6GBLBDLV.js +587 -0
- package/dist/validate-MQQMU57I.js +112 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 hypoth-org
|
|
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,47 @@
|
|
|
1
|
+
# @hypoth-ui/a11y-audit
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Accessibility audit and conformance reporting tools for the hypoth-ui design system. Provides automated CI checks, manual audit checklists, and WCAG conformance report generation.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @hypoth-ui/a11y-audit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Run Automated Accessibility Tests
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm test:a11y
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Start a Manual Audit
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm a11y:audit -- --component ds-button --category form-controls
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Available categories: `form-controls`, `overlays`, `navigation`, `data-display`, `feedback`.
|
|
28
|
+
|
|
29
|
+
### Generate a Conformance Report
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm a11y:report -- --version 1.0.0
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Validate Audit Records
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pnpm a11y:validate
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
See the [main README](../../README.md) for full documentation and architecture overview.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateRecord
|
|
3
|
+
} from "./chunk-KLSGW6PX.js";
|
|
4
|
+
|
|
5
|
+
// src/cli/audit.ts
|
|
6
|
+
import * as os from "os";
|
|
7
|
+
import * as path3 from "path";
|
|
8
|
+
|
|
9
|
+
// src/lib/artifact.ts
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
function calculateOverallStatus(items) {
|
|
14
|
+
const hasBlocked = items.some((i) => i.status === "blocked");
|
|
15
|
+
const hasFail = items.some((i) => i.status === "fail");
|
|
16
|
+
const allPass = items.every((i) => i.status === "pass" || i.status === "na");
|
|
17
|
+
if (hasBlocked) {
|
|
18
|
+
return "partial";
|
|
19
|
+
}
|
|
20
|
+
if (hasFail) {
|
|
21
|
+
const failCount = items.filter((i) => i.status === "fail").length;
|
|
22
|
+
const passCount = items.filter((i) => i.status === "pass").length;
|
|
23
|
+
if (failCount > passCount) {
|
|
24
|
+
return "non-conformant";
|
|
25
|
+
}
|
|
26
|
+
return "partial";
|
|
27
|
+
}
|
|
28
|
+
if (allPass) {
|
|
29
|
+
return "conformant";
|
|
30
|
+
}
|
|
31
|
+
return "partial";
|
|
32
|
+
}
|
|
33
|
+
function validateCompleteness(checklist, items) {
|
|
34
|
+
const completedIds = new Set(items.map((i) => i.itemId));
|
|
35
|
+
const missing = checklist.items.filter((item) => !completedIds.has(item.id)).map((item) => item.id);
|
|
36
|
+
return {
|
|
37
|
+
complete: missing.length === 0,
|
|
38
|
+
missing
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function generateArtifact(options) {
|
|
42
|
+
const completeness = validateCompleteness(options.checklist, options.items);
|
|
43
|
+
if (!completeness.complete) {
|
|
44
|
+
throw new Error(`Incomplete audit: missing items ${completeness.missing.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
const record = {
|
|
47
|
+
id: randomUUID(),
|
|
48
|
+
component: options.component,
|
|
49
|
+
version: options.version,
|
|
50
|
+
checklistId: options.checklist.id,
|
|
51
|
+
checklistVersion: options.checklist.version,
|
|
52
|
+
auditor: options.auditor,
|
|
53
|
+
auditDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
54
|
+
items: options.items,
|
|
55
|
+
overallStatus: calculateOverallStatus(options.items),
|
|
56
|
+
notes: options.notes
|
|
57
|
+
};
|
|
58
|
+
const validation = validateRecord(record);
|
|
59
|
+
if (!validation.valid) {
|
|
60
|
+
throw new Error(`Invalid audit record: ${validation.errors.join(", ")}`);
|
|
61
|
+
}
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
64
|
+
function saveArtifact(record, outputDir) {
|
|
65
|
+
const componentDir = path.join(outputDir, record.component);
|
|
66
|
+
if (!fs.existsSync(componentDir)) {
|
|
67
|
+
fs.mkdirSync(componentDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
const filename = `${record.version}.json`;
|
|
70
|
+
const filepath = path.join(componentDir, filename);
|
|
71
|
+
if (fs.existsSync(filepath)) {
|
|
72
|
+
const backupPath = filepath.replace(".json", `.backup-${Date.now()}.json`);
|
|
73
|
+
fs.renameSync(filepath, backupPath);
|
|
74
|
+
console.info(`Existing record backed up to: ${backupPath}`);
|
|
75
|
+
}
|
|
76
|
+
fs.writeFileSync(filepath, JSON.stringify(record, null, 2));
|
|
77
|
+
return filepath;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/lib/checklist-runner.ts
|
|
81
|
+
import * as fs2 from "fs";
|
|
82
|
+
import * as path2 from "path";
|
|
83
|
+
import * as readline from "readline";
|
|
84
|
+
function loadChecklist(category, templatesDir) {
|
|
85
|
+
const templatePath = path2.join(templatesDir, `${category}.json`);
|
|
86
|
+
if (!fs2.existsSync(templatePath)) {
|
|
87
|
+
throw new Error(`Checklist template not found: ${category}`);
|
|
88
|
+
}
|
|
89
|
+
const content = fs2.readFileSync(templatePath, "utf-8");
|
|
90
|
+
return JSON.parse(content);
|
|
91
|
+
}
|
|
92
|
+
function getAvailableCategories(templatesDir) {
|
|
93
|
+
if (!fs2.existsSync(templatesDir)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return fs2.readdirSync(templatesDir).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
|
|
97
|
+
}
|
|
98
|
+
function createSession(options) {
|
|
99
|
+
const checklist = loadChecklist(options.category, options.templatesDir);
|
|
100
|
+
return {
|
|
101
|
+
checklist,
|
|
102
|
+
component: options.component,
|
|
103
|
+
version: options.version ?? "0.0.0",
|
|
104
|
+
items: [],
|
|
105
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseStatus(input) {
|
|
109
|
+
const normalized = input.toLowerCase().trim();
|
|
110
|
+
switch (normalized) {
|
|
111
|
+
case "p":
|
|
112
|
+
case "pass":
|
|
113
|
+
return "pass";
|
|
114
|
+
case "f":
|
|
115
|
+
case "fail":
|
|
116
|
+
return "fail";
|
|
117
|
+
case "n":
|
|
118
|
+
case "na":
|
|
119
|
+
case "n/a":
|
|
120
|
+
return "na";
|
|
121
|
+
case "b":
|
|
122
|
+
case "blocked":
|
|
123
|
+
return "blocked";
|
|
124
|
+
case "s":
|
|
125
|
+
case "skip":
|
|
126
|
+
return "skip";
|
|
127
|
+
default:
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function runInteractiveSession(session) {
|
|
132
|
+
const rl = readline.createInterface({
|
|
133
|
+
input: process.stdin,
|
|
134
|
+
output: process.stdout
|
|
135
|
+
});
|
|
136
|
+
const question = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const item of session.checklist.items) {
|
|
139
|
+
let status = null;
|
|
140
|
+
while (!status) {
|
|
141
|
+
const input = await question("Enter status: ");
|
|
142
|
+
const parsed = parseStatus(input);
|
|
143
|
+
if (parsed === "skip") {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (parsed === null) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
status = parsed;
|
|
150
|
+
}
|
|
151
|
+
if (status) {
|
|
152
|
+
let notes = "";
|
|
153
|
+
if (status === "fail" || status === "blocked") {
|
|
154
|
+
notes = await question("Enter notes (required for fail/blocked): ");
|
|
155
|
+
while (!notes.trim()) {
|
|
156
|
+
notes = await question("Notes are required. Please enter notes: ");
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
const notesInput = await question("Enter notes (optional, press Enter to skip): ");
|
|
160
|
+
notes = notesInput.trim();
|
|
161
|
+
}
|
|
162
|
+
results.push({
|
|
163
|
+
itemId: item.id,
|
|
164
|
+
status,
|
|
165
|
+
notes: notes || void 0
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
rl.close();
|
|
170
|
+
return results;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/cli/audit.ts
|
|
174
|
+
var VALID_CATEGORIES = ["form-controls", "overlays", "navigation", "data-display", "feedback"];
|
|
175
|
+
async function audit(options) {
|
|
176
|
+
const { component, category, version = "0.0.0" } = options;
|
|
177
|
+
if (!component.match(/^ds-[a-z][a-z0-9-]*$/)) {
|
|
178
|
+
console.error(`\u274C Invalid component ID: ${component}`);
|
|
179
|
+
console.error(" Component ID must match pattern: ds-<name> (e.g., ds-button)");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
if (!VALID_CATEGORIES.includes(category)) {
|
|
183
|
+
console.error(`\u274C Invalid category: ${category}`);
|
|
184
|
+
console.error(` Valid categories: ${VALID_CATEGORIES.join(", ")}`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
const templatesDir = path3.resolve(process.cwd(), "packages/a11y-audit/src/templates");
|
|
188
|
+
const outputDir = path3.resolve(process.cwd(), "a11y-audits/records");
|
|
189
|
+
const categories = getAvailableCategories(templatesDir);
|
|
190
|
+
if (categories.length === 0) {
|
|
191
|
+
console.error(`\u274C No checklist templates found in: ${templatesDir}`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
if (!categories.includes(category)) {
|
|
195
|
+
console.error(`\u274C Checklist template not found: ${category}`);
|
|
196
|
+
console.error(` Available: ${categories.join(", ")}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
console.info(`
|
|
200
|
+
\u{1F50D} Starting accessibility audit
|
|
201
|
+
Component: ${component}
|
|
202
|
+
Category: ${category}
|
|
203
|
+
Version: ${version}
|
|
204
|
+
`);
|
|
205
|
+
try {
|
|
206
|
+
const session = createSession({
|
|
207
|
+
component,
|
|
208
|
+
category,
|
|
209
|
+
version,
|
|
210
|
+
templatesDir
|
|
211
|
+
});
|
|
212
|
+
const items = await runInteractiveSession(session);
|
|
213
|
+
if (items.length === 0) {
|
|
214
|
+
console.info("\n\u26A0\uFE0F No items completed. Audit cancelled.");
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
if (items.length < session.checklist.items.length) {
|
|
218
|
+
console.info(
|
|
219
|
+
`
|
|
220
|
+
\u26A0\uFE0F Audit incomplete: ${items.length}/${session.checklist.items.length} items completed`
|
|
221
|
+
);
|
|
222
|
+
console.info(" Run the audit again to complete remaining items.");
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
const auditor = process.env.USER || os.userInfo().username || "unknown@example.com";
|
|
226
|
+
const record = generateArtifact({
|
|
227
|
+
component,
|
|
228
|
+
version,
|
|
229
|
+
checklist: session.checklist,
|
|
230
|
+
items,
|
|
231
|
+
auditor: `${auditor}@example.com`,
|
|
232
|
+
outputDir
|
|
233
|
+
});
|
|
234
|
+
const filepath = saveArtifact(record, outputDir);
|
|
235
|
+
const passCount = items.filter((i) => i.status === "pass").length;
|
|
236
|
+
const failCount = items.filter((i) => i.status === "fail").length;
|
|
237
|
+
const naCount = items.filter((i) => i.status === "na").length;
|
|
238
|
+
const blockedCount = items.filter((i) => i.status === "blocked").length;
|
|
239
|
+
console.info(`
|
|
240
|
+
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
241
|
+
\u2551 AUDIT COMPLETE \u2551
|
|
242
|
+
\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
|
|
243
|
+
\u2551 Overall Status: ${record.overallStatus.toUpperCase().padEnd(44)}\u2551
|
|
244
|
+
\u2551 \u2551
|
|
245
|
+
\u2551 Results: \u2551
|
|
246
|
+
\u2551 \u2705 Pass: ${String(passCount).padEnd(48)}\u2551
|
|
247
|
+
\u2551 \u274C Fail: ${String(failCount).padEnd(48)}\u2551
|
|
248
|
+
\u2551 \u2796 N/A: ${String(naCount).padEnd(48)}\u2551
|
|
249
|
+
\u2551 \u{1F6AB} Blocked: ${String(blockedCount).padEnd(48)}\u2551
|
|
250
|
+
\u2551 \u2551
|
|
251
|
+
\u2551 Artifact saved to: \u2551
|
|
252
|
+
\u2551 ${filepath.slice(0, 59).padEnd(60)}\u2551
|
|
253
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
254
|
+
`);
|
|
255
|
+
console.info("\u{1F4DD} Remember to commit this audit record to version control:");
|
|
256
|
+
console.info(` git add ${filepath}`);
|
|
257
|
+
console.info(` git commit -m "Add a11y audit for ${component} v${version}"`);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(`
|
|
260
|
+
\u274C Audit failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export {
|
|
265
|
+
audit
|
|
266
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/lib/config.ts
|
|
2
|
+
var DEFAULT_SEVERITY_THRESHOLD = {
|
|
3
|
+
failOn: ["critical", "serious"]
|
|
4
|
+
};
|
|
5
|
+
var SEVERITY_LEVELS = ["critical", "serious", "moderate", "minor"];
|
|
6
|
+
var SEVERITY_ENV_VAR = "A11Y_SEVERITY";
|
|
7
|
+
function parseSeverityThreshold(value) {
|
|
8
|
+
const input = value ?? process.env[SEVERITY_ENV_VAR];
|
|
9
|
+
if (!input) {
|
|
10
|
+
return DEFAULT_SEVERITY_THRESHOLD;
|
|
11
|
+
}
|
|
12
|
+
if (input.toLowerCase() === "all") {
|
|
13
|
+
return { failOn: [...SEVERITY_LEVELS] };
|
|
14
|
+
}
|
|
15
|
+
const levels = input.toLowerCase().split(",").map((s) => s.trim()).filter((s) => SEVERITY_LEVELS.includes(s));
|
|
16
|
+
if (levels.length === 0) {
|
|
17
|
+
console.warn(`Invalid severity threshold: "${input}". Using default.`);
|
|
18
|
+
return DEFAULT_SEVERITY_THRESHOLD;
|
|
19
|
+
}
|
|
20
|
+
return { failOn: levels };
|
|
21
|
+
}
|
|
22
|
+
function shouldFailOnSeverity(severity, threshold = DEFAULT_SEVERITY_THRESHOLD) {
|
|
23
|
+
return threshold.failOn.includes(severity);
|
|
24
|
+
}
|
|
25
|
+
function describeSeverityThreshold(threshold) {
|
|
26
|
+
if (threshold.failOn.length === SEVERITY_LEVELS.length) {
|
|
27
|
+
return "all violations";
|
|
28
|
+
}
|
|
29
|
+
return `${threshold.failOn.join(" or ")} violations`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_SEVERITY_THRESHOLD,
|
|
34
|
+
SEVERITY_LEVELS,
|
|
35
|
+
SEVERITY_ENV_VAR,
|
|
36
|
+
parseSeverityThreshold,
|
|
37
|
+
shouldFailOnSeverity,
|
|
38
|
+
describeSeverityThreshold
|
|
39
|
+
};
|