@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/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ DEFAULT_SEVERITY_THRESHOLD,
3
+ SEVERITY_ENV_VAR,
4
+ SEVERITY_LEVELS,
5
+ describeSeverityThreshold,
6
+ parseSeverityThreshold,
7
+ shouldFailOnSeverity
8
+ } from "./chunk-AMA6KCPL.js";
9
+ import {
10
+ validateChecklist,
11
+ validateRecord,
12
+ validateReport
13
+ } from "./chunk-KLSGW6PX.js";
14
+ export {
15
+ DEFAULT_SEVERITY_THRESHOLD,
16
+ SEVERITY_ENV_VAR,
17
+ SEVERITY_LEVELS,
18
+ describeSeverityThreshold,
19
+ parseSeverityThreshold,
20
+ shouldFailOnSeverity,
21
+ validateChecklist,
22
+ validateRecord,
23
+ validateReport
24
+ };
@@ -0,0 +1,587 @@
1
+ import {
2
+ validateReport
3
+ } from "./chunk-KLSGW6PX.js";
4
+
5
+ // src/cli/report.ts
6
+ import * as fs3 from "fs";
7
+ import * as path3 from "path";
8
+
9
+ // src/lib/aggregator.ts
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ function loadAuditRecords(recordsDir) {
13
+ const records = /* @__PURE__ */ new Map();
14
+ if (!fs.existsSync(recordsDir)) {
15
+ return records;
16
+ }
17
+ const components = fs.readdirSync(recordsDir, { withFileTypes: true });
18
+ for (const component of components) {
19
+ if (!component.isDirectory()) continue;
20
+ const componentPath = path.join(recordsDir, component.name);
21
+ const files = fs.readdirSync(componentPath).filter((f) => f.endsWith(".json") && !f.includes(".backup"));
22
+ const componentRecords = [];
23
+ for (const file of files) {
24
+ try {
25
+ const content = fs.readFileSync(path.join(componentPath, file), "utf-8");
26
+ const record = JSON.parse(content);
27
+ componentRecords.push(record);
28
+ } catch {
29
+ console.warn(`Warning: Could not parse ${path.join(componentPath, file)}`);
30
+ }
31
+ }
32
+ if (componentRecords.length > 0) {
33
+ records.set(component.name, componentRecords);
34
+ }
35
+ }
36
+ return records;
37
+ }
38
+ function getLatestRecord(records, version) {
39
+ if (version) {
40
+ return records.find((r) => r.version === version);
41
+ }
42
+ return records.sort(
43
+ (a, b) => new Date(b.auditDate).getTime() - new Date(a.auditDate).getTime()
44
+ )[0];
45
+ }
46
+ function calculateConformanceStatus(data) {
47
+ const hasAutomated = data.automatedResult !== void 0;
48
+ const hasManual = data.manualAudit !== void 0;
49
+ if (!hasAutomated && !hasManual) {
50
+ return "pending";
51
+ }
52
+ if (hasAutomated && !hasManual) {
53
+ return "pending";
54
+ }
55
+ if (hasAutomated && data.automatedResult?.passed === false) {
56
+ return "non-conformant";
57
+ }
58
+ if (hasManual && data.manualAudit) {
59
+ switch (data.manualAudit.overallStatus) {
60
+ case "conformant":
61
+ return "conformant";
62
+ case "partial":
63
+ return "partial";
64
+ case "non-conformant":
65
+ return "non-conformant";
66
+ }
67
+ }
68
+ return "pending";
69
+ }
70
+ function createManualAuditSummary(record) {
71
+ const passCount = record.items.filter((i) => i.status === "pass").length;
72
+ const failCount = record.items.filter((i) => i.status === "fail").length;
73
+ const exceptionCount = record.exceptions?.length ?? 0;
74
+ return {
75
+ status: record.overallStatus,
76
+ auditId: record.id,
77
+ auditor: record.auditor,
78
+ auditDate: record.auditDate,
79
+ passCount,
80
+ failCount,
81
+ exceptionCount
82
+ };
83
+ }
84
+ function createComponentStatus(data) {
85
+ const status = calculateConformanceStatus(data);
86
+ const automatedResult = data.automatedResult ?? {
87
+ passed: true,
88
+ violationCount: 0,
89
+ runId: "pending"
90
+ };
91
+ const lastUpdated = data.manualAudit?.auditDate ?? (/* @__PURE__ */ new Date()).toISOString();
92
+ return {
93
+ component: data.component,
94
+ version: data.version,
95
+ status,
96
+ automatedResult,
97
+ manualAudit: data.manualAudit ? createManualAuditSummary(data.manualAudit) : void 0,
98
+ lastUpdated
99
+ };
100
+ }
101
+ function aggregateForRelease(components, recordsDir, version) {
102
+ const allRecords = loadAuditRecords(recordsDir);
103
+ const statuses = [];
104
+ for (const component of components) {
105
+ const records = allRecords.get(component) ?? [];
106
+ const latestRecord = getLatestRecord(records, version) ?? getLatestRecord(records);
107
+ const data = {
108
+ component,
109
+ version,
110
+ manualAudit: latestRecord
111
+ };
112
+ statuses.push(createComponentStatus(data));
113
+ }
114
+ return statuses;
115
+ }
116
+
117
+ // src/lib/manifest-loader.ts
118
+ import * as fs2 from "fs";
119
+ import * as path2 from "path";
120
+ var DEFAULT_COMPONENTS = [
121
+ "ds-button",
122
+ "ds-checkbox",
123
+ "ds-dialog",
124
+ "ds-field",
125
+ "ds-icon",
126
+ "ds-input",
127
+ "ds-link",
128
+ "ds-menu",
129
+ "ds-popover",
130
+ "ds-radio",
131
+ "ds-spinner",
132
+ "ds-switch",
133
+ "ds-text",
134
+ "ds-textarea",
135
+ "ds-tooltip",
136
+ "ds-visually-hidden"
137
+ ];
138
+ function loadComponentList(manifestPath) {
139
+ if (manifestPath && fs2.existsSync(manifestPath)) {
140
+ try {
141
+ const content = fs2.readFileSync(manifestPath, "utf-8");
142
+ const manifest = JSON.parse(content);
143
+ if (Array.isArray(manifest.components)) {
144
+ return manifest.components.map(
145
+ (c) => typeof c === "string" ? c : c.id
146
+ );
147
+ }
148
+ if (Array.isArray(manifest)) {
149
+ return manifest.map((c) => typeof c === "string" ? c : c.id);
150
+ }
151
+ } catch {
152
+ console.warn(`Warning: Could not parse manifest at ${manifestPath}`);
153
+ }
154
+ }
155
+ const wcComponentsPath = path2.resolve(process.cwd(), "packages/wc/src/components");
156
+ if (fs2.existsSync(wcComponentsPath)) {
157
+ const dirs = fs2.readdirSync(wcComponentsPath, { withFileTypes: true });
158
+ const components = dirs.filter((d) => d.isDirectory()).map((d) => `ds-${d.name}`);
159
+ if (components.length > 0) {
160
+ return components;
161
+ }
162
+ }
163
+ return DEFAULT_COMPONENTS;
164
+ }
165
+
166
+ // src/lib/report-html.ts
167
+ import Handlebars from "handlebars";
168
+ Handlebars.registerHelper("statusIcon", (status) => {
169
+ switch (status) {
170
+ case "conformant":
171
+ return "\u2705";
172
+ case "partial":
173
+ return "\u26A0\uFE0F";
174
+ case "non-conformant":
175
+ return "\u274C";
176
+ case "pending":
177
+ return "\u23F3";
178
+ default:
179
+ return "\u2753";
180
+ }
181
+ });
182
+ Handlebars.registerHelper("statusClass", (status) => {
183
+ switch (status) {
184
+ case "conformant":
185
+ return "status-conformant";
186
+ case "partial":
187
+ return "status-partial";
188
+ case "non-conformant":
189
+ return "status-non-conformant";
190
+ case "pending":
191
+ return "status-pending";
192
+ default:
193
+ return "";
194
+ }
195
+ });
196
+ Handlebars.registerHelper(
197
+ "formatDate",
198
+ (dateString) => new Date(dateString).toLocaleDateString("en-US", {
199
+ year: "numeric",
200
+ month: "long",
201
+ day: "numeric",
202
+ hour: "2-digit",
203
+ minute: "2-digit"
204
+ })
205
+ );
206
+ var HTML_TEMPLATE = `<!DOCTYPE html>
207
+ <html lang="en">
208
+ <head>
209
+ <meta charset="UTF-8">
210
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
211
+ <title>Accessibility Conformance Report - v{{version}}</title>
212
+ <style>
213
+ :root {
214
+ --color-conformant: #22c55e;
215
+ --color-partial: #f59e0b;
216
+ --color-non-conformant: #ef4444;
217
+ --color-pending: #6b7280;
218
+ }
219
+
220
+ * {
221
+ box-sizing: border-box;
222
+ margin: 0;
223
+ padding: 0;
224
+ }
225
+
226
+ body {
227
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
228
+ line-height: 1.6;
229
+ color: #1f2937;
230
+ max-width: 1200px;
231
+ margin: 0 auto;
232
+ padding: 2rem;
233
+ }
234
+
235
+ header {
236
+ border-bottom: 2px solid #e5e7eb;
237
+ padding-bottom: 1.5rem;
238
+ margin-bottom: 2rem;
239
+ }
240
+
241
+ h1 {
242
+ font-size: 2rem;
243
+ margin-bottom: 0.5rem;
244
+ }
245
+
246
+ .meta {
247
+ color: #6b7280;
248
+ font-size: 0.875rem;
249
+ }
250
+
251
+ .summary {
252
+ display: grid;
253
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
254
+ gap: 1rem;
255
+ margin-bottom: 2rem;
256
+ }
257
+
258
+ .summary-card {
259
+ background: #f9fafb;
260
+ border-radius: 8px;
261
+ padding: 1.5rem;
262
+ text-align: center;
263
+ }
264
+
265
+ .summary-card .number {
266
+ font-size: 2.5rem;
267
+ font-weight: bold;
268
+ }
269
+
270
+ .summary-card .label {
271
+ color: #6b7280;
272
+ font-size: 0.875rem;
273
+ text-transform: uppercase;
274
+ letter-spacing: 0.05em;
275
+ }
276
+
277
+ .status-conformant .number { color: var(--color-conformant); }
278
+ .status-partial .number { color: var(--color-partial); }
279
+ .status-non-conformant .number { color: var(--color-non-conformant); }
280
+ .status-pending .number { color: var(--color-pending); }
281
+
282
+ table {
283
+ width: 100%;
284
+ border-collapse: collapse;
285
+ margin-bottom: 2rem;
286
+ }
287
+
288
+ th, td {
289
+ padding: 1rem;
290
+ text-align: left;
291
+ border-bottom: 1px solid #e5e7eb;
292
+ }
293
+
294
+ th {
295
+ background: #f9fafb;
296
+ font-weight: 600;
297
+ }
298
+
299
+ tr:hover {
300
+ background: #f9fafb;
301
+ }
302
+
303
+ .status-badge {
304
+ display: inline-flex;
305
+ align-items: center;
306
+ gap: 0.5rem;
307
+ padding: 0.25rem 0.75rem;
308
+ border-radius: 9999px;
309
+ font-size: 0.875rem;
310
+ font-weight: 500;
311
+ }
312
+
313
+ .status-badge.status-conformant {
314
+ background: #dcfce7;
315
+ color: #166534;
316
+ }
317
+
318
+ .status-badge.status-partial {
319
+ background: #fef3c7;
320
+ color: #92400e;
321
+ }
322
+
323
+ .status-badge.status-non-conformant {
324
+ background: #fee2e2;
325
+ color: #991b1b;
326
+ }
327
+
328
+ .status-badge.status-pending {
329
+ background: #f3f4f6;
330
+ color: #374151;
331
+ }
332
+
333
+ .progress-bar {
334
+ height: 8px;
335
+ background: #e5e7eb;
336
+ border-radius: 4px;
337
+ overflow: hidden;
338
+ margin-top: 0.5rem;
339
+ }
340
+
341
+ .progress-fill {
342
+ height: 100%;
343
+ background: var(--color-conformant);
344
+ transition: width 0.3s ease;
345
+ }
346
+
347
+ footer {
348
+ border-top: 1px solid #e5e7eb;
349
+ padding-top: 1rem;
350
+ margin-top: 2rem;
351
+ color: #6b7280;
352
+ font-size: 0.875rem;
353
+ }
354
+ </style>
355
+ </head>
356
+ <body>
357
+ <header>
358
+ <h1>Accessibility Conformance Report</h1>
359
+ <p class="meta">
360
+ Version {{version}} \u2022 Generated {{formatDate generatedAt}} \u2022 WCAG {{wcagVersion}} Level {{conformanceLevel}}
361
+ </p>
362
+ </header>
363
+
364
+ <section class="summary">
365
+ <div class="summary-card">
366
+ <div class="number">{{summary.totalComponents}}</div>
367
+ <div class="label">Total Components</div>
368
+ </div>
369
+ <div class="summary-card status-conformant">
370
+ <div class="number">{{summary.conformant}}</div>
371
+ <div class="label">Conformant</div>
372
+ </div>
373
+ <div class="summary-card status-partial">
374
+ <div class="number">{{summary.partial}}</div>
375
+ <div class="label">Partial</div>
376
+ </div>
377
+ <div class="summary-card status-non-conformant">
378
+ <div class="number">{{summary.nonConformant}}</div>
379
+ <div class="label">Non-Conformant</div>
380
+ </div>
381
+ <div class="summary-card status-pending">
382
+ <div class="number">{{summary.pending}}</div>
383
+ <div class="label">Pending Audit</div>
384
+ </div>
385
+ </section>
386
+
387
+ <section>
388
+ <h2>Conformance Progress</h2>
389
+ <p>{{summary.conformancePercentage}}% of components are fully conformant</p>
390
+ <div class="progress-bar">
391
+ <div class="progress-fill" style="width: {{summary.conformancePercentage}}%"></div>
392
+ </div>
393
+ </section>
394
+
395
+ <section style="margin-top: 2rem;">
396
+ <h2>Component Status</h2>
397
+ <table>
398
+ <thead>
399
+ <tr>
400
+ <th>Component</th>
401
+ <th>Version</th>
402
+ <th>Status</th>
403
+ <th>Automated</th>
404
+ <th>Manual Audit</th>
405
+ <th>Last Updated</th>
406
+ </tr>
407
+ </thead>
408
+ <tbody>
409
+ {{#each components}}
410
+ <tr>
411
+ <td><strong>{{component}}</strong></td>
412
+ <td>{{version}}</td>
413
+ <td>
414
+ <span class="status-badge {{statusClass status}}">
415
+ {{statusIcon status}} {{status}}
416
+ </span>
417
+ </td>
418
+ <td>
419
+ {{#if automatedResult.passed}}
420
+ \u2705 Passed
421
+ {{else}}
422
+ \u274C {{automatedResult.violationCount}} violations
423
+ {{/if}}
424
+ </td>
425
+ <td>
426
+ {{#if manualAudit}}
427
+ {{manualAudit.passCount}}/{{manualAudit.passCount}} passed
428
+ {{else}}
429
+ <em>Not audited</em>
430
+ {{/if}}
431
+ </td>
432
+ <td>{{formatDate lastUpdated}}</td>
433
+ </tr>
434
+ {{/each}}
435
+ </tbody>
436
+ </table>
437
+ </section>
438
+
439
+ <footer>
440
+ <p>Generated by @hypoth-ui/a11y-audit v{{metadata.toolVersion}}</p>
441
+ {{#if metadata.gitCommit}}
442
+ <p>Git commit: {{metadata.gitCommit}}</p>
443
+ {{/if}}
444
+ {{#if metadata.ciRunUrl}}
445
+ <p>CI Run: <a href="{{metadata.ciRunUrl}}">View details</a></p>
446
+ {{/if}}
447
+ </footer>
448
+ </body>
449
+ </html>`;
450
+ var template = Handlebars.compile(HTML_TEMPLATE);
451
+ function generateHTMLReport(report2) {
452
+ return template(report2);
453
+ }
454
+
455
+ // src/lib/report-json.ts
456
+ import { randomUUID } from "crypto";
457
+ function calculateSummary(components) {
458
+ const total = components.length;
459
+ const conformant = components.filter((c) => c.status === "conformant").length;
460
+ const partial = components.filter((c) => c.status === "partial").length;
461
+ const nonConformant = components.filter((c) => c.status === "non-conformant").length;
462
+ const pending = components.filter((c) => c.status === "pending").length;
463
+ const conformancePercentage = total > 0 ? Math.round(conformant / total * 100) : 0;
464
+ return {
465
+ totalComponents: total,
466
+ conformant,
467
+ partial,
468
+ nonConformant,
469
+ pending,
470
+ conformancePercentage
471
+ };
472
+ }
473
+ function generateJSONReport(options) {
474
+ const metadata = {
475
+ schemaVersion: "1.0.0",
476
+ toolVersion: "0.0.0",
477
+ axeCoreVersion: options.axeCoreVersion,
478
+ gitCommit: options.gitCommit,
479
+ ciRunUrl: options.ciRunUrl
480
+ };
481
+ const report2 = {
482
+ id: randomUUID(),
483
+ version: options.version,
484
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
485
+ generatedBy: options.generatedBy,
486
+ wcagVersion: options.wcagVersion ?? "2.1",
487
+ conformanceLevel: options.conformanceLevel ?? "AA",
488
+ components: options.components,
489
+ summary: calculateSummary(options.components),
490
+ metadata
491
+ };
492
+ return report2;
493
+ }
494
+
495
+ // src/cli/report.ts
496
+ async function report(options) {
497
+ const { version, output, format } = options;
498
+ if (!version.match(/^\d+\.\d+\.\d+$/)) {
499
+ console.error(`\u274C Invalid version format: ${version}`);
500
+ console.error(" Use semver format: 1.0.0, 2.1.0, etc.");
501
+ process.exit(1);
502
+ }
503
+ const formats = format.split(",").map((f) => f.trim().toLowerCase());
504
+ const validFormats = ["json", "html"];
505
+ for (const fmt of formats) {
506
+ if (!validFormats.includes(fmt)) {
507
+ console.error(`\u274C Invalid format: ${fmt}`);
508
+ console.error(` Valid formats: ${validFormats.join(", ")}`);
509
+ process.exit(1);
510
+ }
511
+ }
512
+ console.info(`
513
+ \u{1F50D} Generating conformance report
514
+ Version: ${version}
515
+ Output: ${output}
516
+ Formats: ${formats.join(", ")}
517
+ `);
518
+ const components = loadComponentList();
519
+ console.info(`Found ${components.length} components`);
520
+ const recordsDir = path3.resolve(process.cwd(), "a11y-audits/records");
521
+ const componentStatuses = aggregateForRelease(components, recordsDir, version);
522
+ const jsonReport = generateJSONReport({
523
+ version,
524
+ generatedBy: process.env.GITHUB_RUN_ID ?? "manual",
525
+ components: componentStatuses,
526
+ gitCommit: process.env.GITHUB_SHA,
527
+ ciRunUrl: process.env.GITHUB_RUN_ID ? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` : void 0
528
+ });
529
+ const validation = validateReport(jsonReport);
530
+ if (!validation.valid) {
531
+ console.error("\u274C Generated report failed validation:");
532
+ for (const error of validation.errors) {
533
+ console.error(` - ${error}`);
534
+ }
535
+ process.exit(1);
536
+ }
537
+ const outputDir = path3.resolve(process.cwd(), output, version);
538
+ if (!fs3.existsSync(outputDir)) {
539
+ fs3.mkdirSync(outputDir, { recursive: true });
540
+ }
541
+ const outputs = [];
542
+ if (formats.includes("json")) {
543
+ const jsonPath = path3.join(outputDir, "report.json");
544
+ fs3.writeFileSync(jsonPath, JSON.stringify(jsonReport, null, 2));
545
+ outputs.push(jsonPath);
546
+ console.info(` \u2705 JSON: ${jsonPath}`);
547
+ }
548
+ if (formats.includes("html")) {
549
+ const htmlContent = generateHTMLReport(jsonReport);
550
+ const htmlPath = path3.join(outputDir, "report.html");
551
+ fs3.writeFileSync(htmlPath, htmlContent);
552
+ outputs.push(htmlPath);
553
+ console.info(` \u2705 HTML: ${htmlPath}`);
554
+ }
555
+ const { summary } = jsonReport;
556
+ console.info(`
557
+ \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
558
+ \u2551 CONFORMANCE REPORT GENERATED \u2551
559
+ \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
560
+ \u2551 Version: ${version.padEnd(51)}\u2551
561
+ \u2551 WCAG: ${jsonReport.wcagVersion} Level ${jsonReport.conformanceLevel.padEnd(43)}\u2551
562
+ \u2551 \u2551
563
+ \u2551 Summary: \u2551
564
+ \u2551 Total Components: ${String(summary.totalComponents).padEnd(42)}\u2551
565
+ \u2551 \u2705 Conformant: ${String(summary.conformant).padEnd(42)}\u2551
566
+ \u2551 \u26A0\uFE0F Partial: ${String(summary.partial).padEnd(42)}\u2551
567
+ \u2551 \u274C Non-Conformant: ${String(summary.nonConformant).padEnd(42)}\u2551
568
+ \u2551 \u23F3 Pending: ${String(summary.pending).padEnd(42)}\u2551
569
+ \u2551 \u2551
570
+ \u2551 Conformance: ${String(`${summary.conformancePercentage}%`).padEnd(48)}\u2551
571
+ \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
572
+ `);
573
+ if (summary.nonConformant > 0 || summary.pending > 0) {
574
+ console.info("\u26A0\uFE0F Release gate check:");
575
+ if (summary.nonConformant > 0) {
576
+ console.info(` \u274C ${summary.nonConformant} component(s) are non-conformant`);
577
+ }
578
+ if (summary.pending > 0) {
579
+ console.info(` \u23F3 ${summary.pending} component(s) pending manual audit`);
580
+ }
581
+ console.info("");
582
+ console.info(" Complete manual audits before release to achieve full conformance.");
583
+ }
584
+ }
585
+ export {
586
+ report
587
+ };