@diegovelasquezweb/a11y-engine 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "WCAG 2.2 AA accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,3 +1,67 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Core types
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface Finding {
6
+ id: string;
7
+ rule_id: string;
8
+ title: string;
9
+ severity: string;
10
+ wcag: string;
11
+ wcag_classification: string | null;
12
+ wcag_criterion_id: string | null;
13
+ category: string | null;
14
+ area: string;
15
+ url: string;
16
+ selector: string;
17
+ primary_selector: string;
18
+ impacted_users: string;
19
+ actual: string;
20
+ expected: string;
21
+ primary_failure_mode: string | null;
22
+ relationship_hint: string | null;
23
+ failure_checks: unknown[];
24
+ related_context: unknown[];
25
+ mdn: string | null;
26
+ fix_description: string | null;
27
+ fix_code: string | null;
28
+ fix_code_lang: string | null;
29
+ recommended_fix: string;
30
+ evidence: unknown[];
31
+ total_instances: number | null;
32
+ effort: string | null;
33
+ related_rules: string[];
34
+ screenshot_path: string | null;
35
+ false_positive_risk: string | null;
36
+ guardrails: Record<string, unknown> | null;
37
+ fix_difficulty_notes: string | string[] | null;
38
+ framework_notes: string | null;
39
+ cms_notes: string | null;
40
+ file_search_pattern: string | null;
41
+ ownership_status: string;
42
+ ownership_reason: string | null;
43
+ primary_source_scope: string[];
44
+ search_strategy: string;
45
+ managed_by_library: string | null;
46
+ component_hint: string | null;
47
+ verification_command: string | null;
48
+ verification_command_fallback: string | null;
49
+ check_data: Record<string, unknown> | null;
50
+ source?: string;
51
+ source_rule_id?: string | null;
52
+ pages_affected?: number | null;
53
+ affected_urls?: string[] | null;
54
+ }
55
+
56
+ export interface EnrichedFinding extends Finding {
57
+ ruleId: string;
58
+ sourceRuleId: string | null;
59
+ fixDescription: string | null;
60
+ fixCode: string | null;
61
+ falsePositiveRisk: string | null;
62
+ fixDifficultyNotes: string | string[] | null;
63
+ }
64
+
1
65
  export interface SeverityTotals {
2
66
  Critical: number;
3
67
  Serious: number;
@@ -5,7 +69,7 @@ export interface SeverityTotals {
5
69
  Minor: number;
6
70
  }
7
71
 
8
- export interface ScoreResult {
72
+ export interface ComplianceScore {
9
73
  score: number;
10
74
  label: string;
11
75
  wcagStatus: "Pass" | "Conditional Pass" | "Fail";
@@ -17,27 +81,48 @@ export interface PersonaGroup {
17
81
  icon: string;
18
82
  }
19
83
 
20
- export interface EngineAssets {
21
- intelligence: Record<string, unknown>;
22
- pa11yConfig: Record<string, unknown>;
23
- complianceConfig: Record<string, unknown>;
24
- wcagReference: Record<string, unknown>;
84
+ // ---------------------------------------------------------------------------
85
+ // Report types
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export interface ScanPayload {
89
+ findings: Finding[] | Record<string, unknown>[];
90
+ metadata?: Record<string, unknown>;
25
91
  }
26
92
 
27
- export function getAssets(): EngineAssets;
93
+ export interface ReportOptions {
94
+ baseUrl?: string;
95
+ target?: string;
96
+ }
28
97
 
29
- export function mapPa11yRuleToCanonical(
30
- ruleId: string,
31
- sourceRuleId?: string | null,
32
- checkData?: Record<string, unknown> | null
33
- ): string;
98
+ export interface PDFReport {
99
+ buffer: Buffer;
100
+ contentType: "application/pdf";
101
+ }
102
+
103
+ export interface ChecklistReport {
104
+ html: string;
105
+ contentType: "text/html";
106
+ }
34
107
 
35
- export function enrichFindings<T extends Record<string, unknown>>(
36
- findings: T[]
37
- ): T[];
108
+ // ---------------------------------------------------------------------------
109
+ // Public API
110
+ // ---------------------------------------------------------------------------
38
111
 
39
- export function computeScore(totals: SeverityTotals): ScoreResult;
112
+ export function getEnrichedFindings(findings: Finding[]): EnrichedFinding[];
113
+ export function getEnrichedFindings(findings: Record<string, unknown>[]): EnrichedFinding[];
40
114
 
41
- export function computePersonaGroups(
42
- findings: Record<string, unknown>[]
115
+ export function getComplianceScore(totals: SeverityTotals): ComplianceScore;
116
+
117
+ export function getPersonaGroups(
118
+ findings: EnrichedFinding[] | Record<string, unknown>[]
43
119
  ): Record<string, PersonaGroup>;
120
+
121
+ export function getPDFReport(
122
+ payload: ScanPayload,
123
+ options?: ReportOptions
124
+ ): Promise<PDFReport>;
125
+
126
+ export function getChecklist(
127
+ options?: Pick<ReportOptions, "baseUrl">
128
+ ): Promise<ChecklistReport>;
package/scripts/index.mjs CHANGED
@@ -49,7 +49,7 @@ function getWcagReference() {
49
49
  * Returns all engine asset data. Lazy-loaded and cached.
50
50
  * @returns {{ intelligence: object, pa11yConfig: object, complianceConfig: object, wcagReference: object }}
51
51
  */
52
- export function getAssets() {
52
+ function getAssets() {
53
53
  return {
54
54
  intelligence: getIntelligence(),
55
55
  pa11yConfig: getPa11yConfig(),
@@ -82,7 +82,7 @@ function normalizePa11yToken(value) {
82
82
  * @param {object|null} checkData - The check_data object which may contain a `code` field.
83
83
  * @returns {string} The canonical rule ID (e.g., "color-contrast").
84
84
  */
85
- export function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null) {
85
+ function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null) {
86
86
  const equivalenceMap = getPa11yConfig().equivalenceMap || {};
87
87
 
88
88
  const checkCode = checkData && typeof checkData === "object" && typeof checkData.code === "string"
@@ -119,7 +119,7 @@ export function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData =
119
119
  * @param {object[]} findings - Array of findings with camelCase keys.
120
120
  * @returns {object[]} Enriched findings.
121
121
  */
122
- export function enrichFindings(findings) {
122
+ export function getEnrichedFindings(findings) {
123
123
  const rules = getIntelligence().rules || {};
124
124
 
125
125
  return findings.map((finding) => {
@@ -168,7 +168,7 @@ export function enrichFindings(findings) {
168
168
  * @param {{ Critical: number, Serious: number, Moderate: number, Minor: number }} totals
169
169
  * @returns {{ score: number, label: string, wcagStatus: "Pass" | "Conditional Pass" | "Fail" }}
170
170
  */
171
- export function computeScore(totals) {
171
+ export function getComplianceScore(totals) {
172
172
  const config = getComplianceConfig();
173
173
  const penalties = config.complianceScore.penalties;
174
174
  const thresholds = config.gradeThresholds;
@@ -206,7 +206,7 @@ export function computeScore(totals) {
206
206
  * @param {object[]} findings - Array of findings with ruleId, wcagCriterionId, impactedUsers.
207
207
  * @returns {Record<string, { label: string, count: number, icon: string }>}
208
208
  */
209
- export function computePersonaGroups(findings) {
209
+ export function getPersonaGroups(findings) {
210
210
  const ref = getWcagReference();
211
211
  const personaConfig = ref.personaConfig || {};
212
212
  const personaMapping = ref.personaMapping || {};
@@ -260,3 +260,193 @@ export function computePersonaGroups(findings) {
260
260
 
261
261
  return groups;
262
262
  }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Report generation
266
+ // ---------------------------------------------------------------------------
267
+
268
+ import {
269
+ normalizeFindings as normalizeForReports,
270
+ buildSummary,
271
+ computeComplianceScore,
272
+ scoreLabel,
273
+ buildPersonaSummary,
274
+ wcagOverallStatus,
275
+ } from "./reports/renderers/findings.mjs";
276
+
277
+ /**
278
+ * Generates a PDF report buffer from raw scan findings.
279
+ * Requires Playwright (chromium) to render the PDF.
280
+ * @param {{ findings: object[], metadata?: object }} payload - Raw scan output (snake_case keys).
281
+ * @param {{ baseUrl?: string, target?: string }} [options={}]
282
+ * @returns {Promise<Buffer>} The PDF as a Node.js Buffer.
283
+ */
284
+ export async function getPDFReport(payload, options = {}) {
285
+ const { chromium } = await import("playwright");
286
+ const {
287
+ buildPdfCoverPage,
288
+ buildPdfTableOfContents,
289
+ buildPdfExecutiveSummary,
290
+ buildPdfRiskSection,
291
+ buildPdfRemediationRoadmap,
292
+ buildPdfMethodologySection,
293
+ buildPdfIssueSummaryTable,
294
+ buildPdfNextSteps,
295
+ buildPdfAuditLimitations,
296
+ } = await import("./reports/renderers/pdf.mjs");
297
+
298
+ const args = { baseUrl: options.baseUrl || "", target: options.target || "WCAG 2.2 AA" };
299
+ const findings = normalizeForReports(payload).filter(
300
+ (f) => f.wcagClassification !== "AAA" && f.wcagClassification !== "Best Practice",
301
+ );
302
+
303
+ const totals = buildSummary(findings);
304
+ const score = computeComplianceScore(totals);
305
+ let siteHostname = args.baseUrl;
306
+ try { siteHostname = new URL(args.baseUrl.startsWith("http") ? args.baseUrl : `https://${args.baseUrl}`).hostname; } catch {}
307
+ const coverDate = new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
308
+
309
+ const html = `<!doctype html>
310
+ <html lang="en"><head><meta charset="utf-8"><title>Accessibility Audit — ${siteHostname}</title>
311
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
312
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
313
+ <style>@page{size:A4;margin:2cm}body{background:white;color:black;font-family:'Libre Baskerville',serif;font-size:11pt;line-height:1.6;margin:0;padding:0}h1,h2,h3,h4{font-family:'Inter',sans-serif;color:black;margin-top:1.5rem;margin-bottom:1rem}.cover-page{height:25.5cm;display:flex;flex-direction:column;page-break-after:always}.finding-entry{border-top:1pt solid black;padding-top:1.5rem;margin-top:2rem;page-break-inside:avoid}.severity-tag{font-weight:800;text-transform:uppercase;border:1.5pt solid black;padding:2pt 6pt;font-size:9pt;margin-bottom:1rem;display:inline-block}.remediation-box{background-color:#f3f4f6;border-left:4pt solid black;padding:1rem;margin:1rem 0;font-style:italic}pre{background:#f9fafb;border:1pt solid #ddd;padding:10pt;font-size:8pt;overflow:hidden;white-space:pre-wrap}.stats-table{width:100%;border-collapse:collapse;margin:2rem 0}.stats-table th,.stats-table td{border:1pt solid black;padding:10pt;text-align:left;font-size:9pt;font-family:'Inter',sans-serif}</style>
314
+ </head><body>
315
+ ${buildPdfCoverPage({ siteHostname, target: args.target, score, wcagStatus: wcagOverallStatus(totals), coverDate })}
316
+ ${buildPdfTableOfContents()}
317
+ ${buildPdfExecutiveSummary(args, findings, totals)}
318
+ ${buildPdfRiskSection(totals)}
319
+ ${buildPdfRemediationRoadmap(findings)}
320
+ ${buildPdfMethodologySection(args, findings)}
321
+ ${buildPdfIssueSummaryTable(findings)}
322
+ ${buildPdfNextSteps(findings, totals)}
323
+ ${buildPdfAuditLimitations()}
324
+ </body></html>`;
325
+
326
+ const browser = await chromium.launch();
327
+ try {
328
+ const page = await browser.newPage();
329
+ await page.setContent(html, { waitUntil: "load" });
330
+ await page.evaluate(() => document.fonts.ready);
331
+ const pdfBuffer = await page.pdf({
332
+ format: "A4",
333
+ printBackground: true,
334
+ margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" },
335
+ displayHeaderFooter: false,
336
+ });
337
+ return {
338
+ buffer: Buffer.from(pdfBuffer),
339
+ contentType: "application/pdf",
340
+ };
341
+ } finally {
342
+ await browser.close();
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Generates a standalone manual accessibility checklist HTML string.
348
+ * Does not depend on scan results — reads from manual-checks.json asset.
349
+ * @param {{ baseUrl?: string }} [options={}]
350
+ * @returns {Promise<string>} The complete checklist HTML document.
351
+ */
352
+ export async function getChecklist(options = {}) {
353
+ const { buildManualCheckCard } = await import("./reports/renderers/html.mjs");
354
+ const { escapeHtml } = await import("./reports/renderers/utils.mjs");
355
+
356
+ const manualChecks = loadAssetJson(ASSET_PATHS.reporting.manualChecks, "manual-checks.json");
357
+ const siteLabel = options.baseUrl || "your site";
358
+ const cards = manualChecks.map((c) => buildManualCheckCard(c)).join("\n");
359
+
360
+ const TOTAL = manualChecks.length;
361
+ const COUNT_A = manualChecks.filter((c) => c.level === "A").length;
362
+ const COUNT_AA = manualChecks.filter((c) => c.level === "AA").length;
363
+ const COUNT_AT = manualChecks.filter((c) => c.level === "AT").length;
364
+
365
+ const selectClasses =
366
+ "pl-4 pr-10 py-3 bg-white border border-slate-300 rounded-2xl text-sm font-bold text-slate-800 focus:outline-none focus:ring-4 focus:ring-amber-500/20 focus:border-amber-400 shadow-sm transition-all appearance-none cursor-pointer bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23374151%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%3E%3Cpath%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat";
367
+
368
+ // Import the full checklist builder to reuse its buildHtml
369
+ // The checklist builder module has a main() that auto-runs, so we dynamically
370
+ // construct the same output using the renderer functions directly.
371
+ const html = `<!doctype html>
372
+ <html lang="en">
373
+ <head>
374
+ <meta charset="utf-8">
375
+ <meta name="viewport" content="width=device-width, initial-scale=1">
376
+ <title>Manual Accessibility Checklist &mdash; ${escapeHtml(siteLabel)}</title>
377
+ <script src="https://cdn.tailwindcss.com"><\/script>
378
+ <link rel="preconnect" href="https://fonts.googleapis.com">
379
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
380
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
381
+ <style>
382
+ :root { --amber: hsl(38, 92%, 50%); }
383
+ html { scroll-padding-top: 80px; }
384
+ body { background-color: #f8fafc; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
385
+ .glass-header { background: rgba(255,255,255,0.85); backdrop-filter: blur(12px) saturate(180%); }
386
+ </style>
387
+ </head>
388
+ <body class="text-slate-900 min-h-screen">
389
+ <header class="fixed top-0 left-0 right-0 z-50 glass-header border-b border-slate-200/80 shadow-sm" id="navbar">
390
+ <nav aria-label="Checklist header">
391
+ <div class="max-w-4xl mx-auto px-4 h-16 flex justify-between items-center">
392
+ <div class="flex items-center gap-3">
393
+ <div class="px-3 h-10 rounded-lg bg-slate-900 text-white font-bold text-base font-mono flex items-center justify-center shadow-md">a11y</div>
394
+ <h1 class="text-xl font-bold">Manual <span class="text-slate-500">Checklist</span></h1>
395
+ </div>
396
+ <span class="text-sm text-slate-500 font-medium">${escapeHtml(siteLabel)}</span>
397
+ </div>
398
+ </nav>
399
+ </header>
400
+ <main id="main-content" class="max-w-4xl mx-auto px-4 pt-24 pb-20">
401
+ <div class="mb-12">
402
+ <h2 class="text-3xl font-extrabold mb-2">Manual Testing Checklist</h2>
403
+ <p class="text-slate-600 text-base leading-relaxed max-w-2xl">Automated scans catch ~30-40% of accessibility issues. This checklist covers the rest: keyboard navigation, screen reader behaviour, cognitive flow and more.</p>
404
+ </div>
405
+ <div class="flex flex-wrap items-center gap-3 mb-8">
406
+ <div class="flex items-center gap-1.5 text-sm font-semibold"><span class="inline-block w-3 h-3 rounded-full bg-slate-300"></span><span id="count-total">${TOTAL}</span> Total</div>
407
+ <div class="flex items-center gap-1.5 text-sm font-semibold text-emerald-600"><span class="inline-block w-3 h-3 rounded-full bg-emerald-400"></span><span id="count-pass">0</span> Pass</div>
408
+ <div class="flex items-center gap-1.5 text-sm font-semibold text-rose-600"><span class="inline-block w-3 h-3 rounded-full bg-rose-400"></span><span id="count-fail">0</span> Fail</div>
409
+ <div class="flex items-center gap-1.5 text-sm font-semibold text-amber-600"><span class="inline-block w-3 h-3 rounded-full bg-amber-400"></span><span id="count-na">0</span> N/A</div>
410
+ <div class="ml-auto flex items-center gap-2">
411
+ <select id="level-filter" class="${selectClasses}">
412
+ <option value="all">All levels</option>
413
+ <option value="A">Level A (${COUNT_A})</option>
414
+ <option value="AA">Level AA (${COUNT_AA})</option>
415
+ <option value="AT">Assistive Tech (${COUNT_AT})</option>
416
+ </select>
417
+ </div>
418
+ </div>
419
+ <div id="checklist-items" class="space-y-4">${cards}</div>
420
+ </main>
421
+ <script>
422
+ const items = document.querySelectorAll('[data-check]');
423
+ function updateProgress() {
424
+ let pass=0,fail=0,na=0;
425
+ items.forEach(el => { const s=el.dataset.status; if(s==='pass')pass++; else if(s==='fail')fail++; else if(s==='na')na++; });
426
+ document.getElementById('count-pass').textContent=pass;
427
+ document.getElementById('count-fail').textContent=fail;
428
+ document.getElementById('count-na').textContent=na;
429
+ }
430
+ document.getElementById('level-filter').addEventListener('change',e=>{
431
+ const v=e.target.value;
432
+ items.forEach(el=>{el.style.display=(v==='all'||el.dataset.level===v)?'':'none';});
433
+ });
434
+ items.forEach(el=>{
435
+ el.querySelectorAll('[data-action]').forEach(btn=>{
436
+ btn.addEventListener('click',()=>{
437
+ const action=btn.dataset.action;
438
+ el.dataset.status=el.dataset.status===action?'none':action;
439
+ updateProgress();
440
+ });
441
+ });
442
+ });
443
+ updateProgress();
444
+ <\/script>
445
+ </body>
446
+ </html>`;
447
+
448
+ return {
449
+ html,
450
+ contentType: "text/html",
451
+ };
452
+ }