@diegovelasquezweb/a11y-engine 0.1.8 → 0.2.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/scripts/index.mjs CHANGED
@@ -42,31 +42,9 @@ function getWcagReference() {
42
42
  }
43
43
 
44
44
  // ---------------------------------------------------------------------------
45
- // Assets access
45
+ // Pa11y rule canonicalization (internal)
46
46
  // ---------------------------------------------------------------------------
47
47
 
48
- /**
49
- * Returns all engine asset data. Lazy-loaded and cached.
50
- * @returns {{ intelligence: object, pa11yConfig: object, complianceConfig: object, wcagReference: object }}
51
- */
52
- function getAssets() {
53
- return {
54
- intelligence: getIntelligence(),
55
- pa11yConfig: getPa11yConfig(),
56
- complianceConfig: getComplianceConfig(),
57
- wcagReference: getWcagReference(),
58
- };
59
- }
60
-
61
- // ---------------------------------------------------------------------------
62
- // Pa11y rule canonicalization
63
- // ---------------------------------------------------------------------------
64
-
65
- /**
66
- * Normalizes a pa11y code token for comparison.
67
- * @param {string} value
68
- * @returns {string}
69
- */
70
48
  function normalizePa11yToken(value) {
71
49
  return value
72
50
  .toLowerCase()
@@ -75,13 +53,6 @@ function normalizePa11yToken(value) {
75
53
  .replace(/[^a-z0-9]/g, "");
76
54
  }
77
55
 
78
- /**
79
- * Maps a pa11y rule ID to its canonical axe-equivalent ID.
80
- * @param {string} ruleId - The current rule ID (may already be canonical or a pa11y slug).
81
- * @param {string|null} sourceRuleId - The original pa11y code if available.
82
- * @param {object|null} checkData - The check_data object which may contain a `code` field.
83
- * @returns {string} The canonical rule ID (e.g., "color-contrast").
84
- */
85
56
  function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null) {
86
57
  const equivalenceMap = getPa11yConfig().equivalenceMap || {};
87
58
 
@@ -109,63 +80,196 @@ function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null)
109
80
  return ruleId;
110
81
  }
111
82
 
83
+ // ---------------------------------------------------------------------------
84
+ // Raw finding normalization (internal)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const SEVERITY_ORDER = { Critical: 1, Serious: 2, Moderate: 3, Minor: 4 };
88
+
89
+ function str(v, fallback = "") {
90
+ return typeof v === "string" ? v : (v != null ? String(v) : fallback);
91
+ }
92
+
93
+ function strOrNull(v) {
94
+ return typeof v === "string" && v.length > 0 ? v : null;
95
+ }
96
+
97
+ function normalizeSingleFinding(item, index, screenshotUrlBuilder) {
98
+ const screenshotRaw = strOrNull(item.screenshot_path);
99
+ const screenshotPath = screenshotRaw && screenshotUrlBuilder
100
+ ? screenshotUrlBuilder(screenshotRaw)
101
+ : screenshotRaw;
102
+
103
+ return {
104
+ id: str(item.id, `A11Y-${String(index + 1).padStart(3, "0")}`),
105
+ rule_id: str(item.rule_id),
106
+ source: str(item.source, "axe"),
107
+ source_rule_id: strOrNull(item.source_rule_id),
108
+ wcag_criterion_id: strOrNull(item.wcag_criterion_id),
109
+ category: strOrNull(item.category),
110
+ title: str(item.title, "Untitled finding"),
111
+ severity: str(item.severity, "Unknown"),
112
+ wcag: str(item.wcag),
113
+ wcag_classification: strOrNull(item.wcag_classification),
114
+ area: str(item.area),
115
+ url: str(item.url),
116
+ selector: str(item.selector),
117
+ primary_selector: str(item.primary_selector || item.selector),
118
+ impacted_users: str(item.impacted_users),
119
+ actual: str(item.actual),
120
+ primary_failure_mode: strOrNull(item.primary_failure_mode),
121
+ relationship_hint: strOrNull(item.relationship_hint),
122
+ failure_checks: Array.isArray(item.failure_checks) ? item.failure_checks : [],
123
+ related_context: Array.isArray(item.related_context) ? item.related_context : [],
124
+ expected: str(item.expected),
125
+ mdn: strOrNull(item.mdn),
126
+ fix_description: strOrNull(item.fix_description),
127
+ fix_code: strOrNull(item.fix_code),
128
+ fix_code_lang: str(item.fix_code_lang, "html"),
129
+ recommended_fix: str(item.recommended_fix),
130
+ evidence: Array.isArray(item.evidence) ? item.evidence : [],
131
+ total_instances: typeof item.total_instances === "number" ? item.total_instances : null,
132
+ effort: strOrNull(item.effort), // null = will be inferred during enrichment
133
+ related_rules: Array.isArray(item.related_rules) ? item.related_rules : [],
134
+ screenshot_path: screenshotPath,
135
+ false_positive_risk: strOrNull(item.false_positive_risk),
136
+ guardrails: item.guardrails && typeof item.guardrails === "object" ? item.guardrails : null,
137
+ fix_difficulty_notes: item.fix_difficulty_notes ?? null,
138
+ framework_notes: strOrNull(item.framework_notes),
139
+ cms_notes: strOrNull(item.cms_notes),
140
+ file_search_pattern: strOrNull(item.file_search_pattern),
141
+ ownership_status: str(item.ownership_status, "unknown"),
142
+ ownership_reason: strOrNull(item.ownership_reason),
143
+ primary_source_scope: Array.isArray(item.primary_source_scope) ? item.primary_source_scope : [],
144
+ search_strategy: str(item.search_strategy, "verify_ownership_before_search"),
145
+ managed_by_library: strOrNull(item.managed_by_library),
146
+ component_hint: strOrNull(item.component_hint),
147
+ verification_command: strOrNull(item.verification_command),
148
+ verification_command_fallback: strOrNull(item.verification_command_fallback),
149
+ check_data: item.check_data && typeof item.check_data === "object" ? item.check_data : null,
150
+ pages_affected: typeof item.pages_affected === "number" ? item.pages_affected : null,
151
+ affected_urls: Array.isArray(item.affected_urls) ? item.affected_urls : null,
152
+ };
153
+ }
154
+
112
155
  // ---------------------------------------------------------------------------
113
156
  // Finding enrichment
114
157
  // ---------------------------------------------------------------------------
115
158
 
116
159
  /**
117
- * Enriches findings with intelligence data (fix descriptions, fix code, category).
118
- * Each finding should have at minimum: { ruleId, sourceRuleId?, checkData?, fixDescription?, fixCode? }
119
- * @param {object[]} findings - Array of findings with camelCase keys.
120
- * @returns {object[]} Enriched findings.
160
+ * Normalizes and enriches raw findings with intelligence data.
161
+ *
162
+ * Accepts either:
163
+ * - A full scan payload: { findings: object[], metadata?: object }
164
+ * - An array of findings directly: object[]
165
+ *
166
+ * Options:
167
+ * - screenshotUrlBuilder: (rawPath: string) => string — transforms screenshot
168
+ * paths into consumer-specific URLs.
169
+ *
170
+ * @param {object[]|{findings: object[]}} input
171
+ * @param {{ screenshotUrlBuilder?: (path: string) => string }} [options={}]
172
+ * @returns {object[]} Enriched, normalized, sorted findings.
121
173
  */
122
- export function getEnrichedFindings(findings) {
174
+ export function getEnrichedFindings(input, options = {}) {
175
+ const { screenshotUrlBuilder = null } = options;
123
176
  const rules = getIntelligence().rules || {};
124
177
 
125
- return findings.map((finding) => {
178
+ // Accept payload object or array directly
179
+ const rawFindings = Array.isArray(input) ? input : (input?.findings || []);
180
+
181
+ // Normalize raw findings
182
+ const normalized = rawFindings.map((item, index) =>
183
+ normalizeSingleFinding(item, index, screenshotUrlBuilder)
184
+ );
185
+
186
+ // Enrich with intelligence + camelCase aliases
187
+ const enriched = normalized.map((finding) => {
126
188
  const canonical = mapPa11yRuleToCanonical(
127
- finding.ruleId || finding.rule_id || "",
128
- finding.sourceRuleId || finding.source_rule_id || null,
129
- finding.checkData || finding.check_data || null,
189
+ finding.rule_id,
190
+ finding.source_rule_id,
191
+ finding.check_data,
130
192
  );
131
193
 
132
- // Always create camelCase aliases from snake_case fields
133
- const normalized = {
194
+ // Effort will be inferred after enrichment
195
+
196
+ // Always create camelCase aliases
197
+ const withAliases = {
134
198
  ...finding,
135
199
  ruleId: canonical,
136
200
  rule_id: canonical,
137
- sourceRuleId: finding.sourceRuleId || finding.source_rule_id || finding.ruleId || finding.rule_id || null,
138
- fixDescription: finding.fixDescription ?? finding.fix_description ?? null,
139
- fixCode: finding.fixCode ?? finding.fix_code ?? null,
140
- fixCodeLang: finding.fixCodeLang ?? finding.fix_code_lang ?? null,
141
- falsePositiveRisk: finding.falsePositiveRisk ?? finding.false_positive_risk ?? null,
142
- fixDifficultyNotes: finding.fixDifficultyNotes ?? finding.fix_difficulty_notes ?? null,
143
- screenshotPath: finding.screenshotPath ?? finding.screenshot_path ?? null,
144
- wcagCriterionId: finding.wcagCriterionId ?? finding.wcag_criterion_id ?? null,
145
- impactedUsers: finding.impactedUsers ?? finding.impacted_users ?? null,
201
+ sourceRuleId: finding.source_rule_id || finding.rule_id || null,
202
+ fixDescription: finding.fix_description,
203
+ fixCode: finding.fix_code,
204
+ fixCodeLang: finding.fix_code_lang,
205
+ falsePositiveRisk: finding.false_positive_risk,
206
+ fixDifficultyNotes: finding.fix_difficulty_notes,
207
+ screenshotPath: finding.screenshot_path,
208
+ wcagCriterionId: finding.wcag_criterion_id,
209
+ wcagClassification: finding.wcag_classification,
210
+ impactedUsers: finding.impacted_users,
211
+ primarySelector: finding.primary_selector,
212
+ primaryFailureMode: finding.primary_failure_mode,
213
+ relationshipHint: finding.relationship_hint,
214
+ failureChecks: finding.failure_checks,
215
+ relatedContext: finding.related_context,
216
+ recommendedFix: finding.recommended_fix,
217
+ totalInstances: finding.total_instances,
218
+ relatedRules: finding.related_rules,
219
+ ownershipStatus: finding.ownership_status,
220
+ ownershipReason: finding.ownership_reason,
221
+ primarySourceScope: finding.primary_source_scope,
222
+ searchStrategy: finding.search_strategy,
223
+ managedByLibrary: finding.managed_by_library,
224
+ componentHint: finding.component_hint,
225
+ verificationCommand: finding.verification_command,
226
+ verificationCommandFallback: finding.verification_command_fallback,
227
+ checkData: finding.check_data,
228
+ pagesAffected: finding.pages_affected,
229
+ affectedUrls: finding.affected_urls,
146
230
  };
147
231
 
148
232
  // If fix data already exists, no need to look up intelligence
149
- if (normalized.fixDescription || normalized.fixCode) {
150
- return normalized;
233
+ let result;
234
+ if (withAliases.fixDescription || withAliases.fixCode) {
235
+ result = withAliases;
236
+ } else {
237
+ const info = rules[canonical];
238
+ if (!info) {
239
+ result = withAliases;
240
+ } else {
241
+ result = {
242
+ ...withAliases,
243
+ category: withAliases.category ?? info.category ?? null,
244
+ fixDescription: info.fix?.description ?? null,
245
+ fix_description: info.fix?.description ?? null,
246
+ fixCode: info.fix?.code ?? null,
247
+ fix_code: info.fix?.code ?? withAliases.fix_code ?? null,
248
+ falsePositiveRisk: withAliases.falsePositiveRisk ?? info.false_positive_risk ?? null,
249
+ false_positive_risk: withAliases.false_positive_risk ?? info.false_positive_risk ?? null,
250
+ fixDifficultyNotes: withAliases.fixDifficultyNotes ?? info.fix_difficulty_notes ?? null,
251
+ fix_difficulty_notes: withAliases.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
252
+ };
253
+ }
151
254
  }
152
255
 
153
- const info = rules[canonical];
154
- if (!info) return normalized;
256
+ // Infer effort AFTER enrichment so intelligence-provided fixCode is considered
257
+ if (!result.effort || result.effort === "null") {
258
+ result.effort = (result.fixCode || result.fix_code) ? "low" : "high";
259
+ }
155
260
 
156
- return {
157
- ...normalized,
158
- category: normalized.category ?? info.category ?? null,
159
- fixDescription: info.fix?.description ?? null,
160
- fix_description: info.fix?.description ?? normalized.fix_description ?? null,
161
- fixCode: info.fix?.code ?? null,
162
- fix_code: info.fix?.code ?? normalized.fix_code ?? null,
163
- falsePositiveRisk: normalized.falsePositiveRisk ?? info.false_positive_risk ?? null,
164
- false_positive_risk: normalized.false_positive_risk ?? info.false_positive_risk ?? null,
165
- fixDifficultyNotes: normalized.fixDifficultyNotes ?? info.fix_difficulty_notes ?? null,
166
- fix_difficulty_notes: normalized.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
167
- };
261
+ return result;
168
262
  });
263
+
264
+ // Sort by severity then by ID
265
+ enriched.sort((a, b) => {
266
+ const sa = SEVERITY_ORDER[a.severity] ?? 99;
267
+ const sb = SEVERITY_ORDER[b.severity] ?? 99;
268
+ if (sa !== sb) return sa - sb;
269
+ return a.id.localeCompare(b.id);
270
+ });
271
+
272
+ return enriched;
169
273
  }
170
274
 
171
275
  // ---------------------------------------------------------------------------
@@ -266,21 +370,169 @@ function getPersonaGroups(findings) {
266
370
 
267
371
  /**
268
372
  * Computes a complete audit summary from enriched findings: severity totals,
269
- * compliance score, grade label, WCAG status, and persona impact groups.
373
+ * compliance score, grade label, WCAG status, persona groups, quick wins,
374
+ * target URL, and detected stack.
375
+ *
270
376
  * @param {object[]} findings - Array of enriched findings.
271
- * @returns {{ totals, score, label, wcagStatus, personaGroups }}
377
+ * @param {{ findings: object[], metadata?: object }|null} [payload=null] - Original scan payload for metadata extraction.
378
+ * @returns {object} Full audit summary.
272
379
  */
273
- export function getAuditSummary(findings) {
380
+ export function getAuditSummary(findings, payload = null) {
274
381
  const totals = { Critical: 0, Serious: 0, Moderate: 0, Minor: 0 };
275
382
  for (const f of findings) {
276
- const severity = f.severity || f.Severity || "";
383
+ const severity = f.severity || "";
277
384
  if (severity in totals) totals[severity] += 1;
278
385
  }
279
386
 
280
387
  const { score, label, wcagStatus } = getComplianceScore(totals);
281
388
  const personaGroups = getPersonaGroups(findings);
282
389
 
283
- return { totals, score, label, wcagStatus, personaGroups };
390
+ const quickWins = findings
391
+ .filter((f) =>
392
+ (f.severity === "Critical" || f.severity === "Serious") &&
393
+ (f.fixCode || f.fix_code)
394
+ )
395
+ .slice(0, 3);
396
+
397
+ // Extract metadata from payload if provided
398
+ let targetUrl = "";
399
+ let detectedStack = { framework: null, cms: null, uiLibraries: [] };
400
+
401
+ if (payload && payload.metadata) {
402
+ const meta = payload.metadata;
403
+ const firstUrl = findings.length > 0 ? str(findings[0].url) : "";
404
+ targetUrl = str(meta.target_url || meta.targetUrl || meta.base_url || firstUrl);
405
+
406
+ const ctx = meta.projectContext || {};
407
+ detectedStack = {
408
+ framework: strOrNull(ctx.framework),
409
+ cms: strOrNull(ctx.cms),
410
+ uiLibraries: Array.isArray(ctx.uiLibraries) ? ctx.uiLibraries : [],
411
+ };
412
+ }
413
+
414
+ return {
415
+ totals,
416
+ score,
417
+ label,
418
+ wcagStatus,
419
+ personaGroups,
420
+ quickWins,
421
+ targetUrl,
422
+ detectedStack,
423
+ totalFindings: findings.length,
424
+ };
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Full audit pipeline
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * Runs a complete accessibility audit: crawl + scan (axe + CDP + pa11y) + analyze.
433
+ * Returns the enriched scan payload ready for getEnrichedFindings().
434
+ *
435
+ * @param {{
436
+ * baseUrl: string,
437
+ * maxRoutes?: number,
438
+ * crawlDepth?: number,
439
+ * routes?: string,
440
+ * waitMs?: number,
441
+ * timeoutMs?: number,
442
+ * headless?: boolean,
443
+ * waitUntil?: string,
444
+ * colorScheme?: string,
445
+ * viewport?: { width: number, height: number },
446
+ * axeTags?: string[],
447
+ * onlyRule?: string,
448
+ * excludeSelectors?: string[],
449
+ * ignoreFindings?: string[],
450
+ * framework?: string,
451
+ * projectDir?: string,
452
+ * skipPatterns?: boolean,
453
+ * onProgress?: (step: string, status: string, extra?: object) => void,
454
+ * }} options
455
+ * @returns {Promise<{ findings: object[], metadata: object, incomplete_findings?: object[] }>}
456
+ */
457
+ export async function runAudit(options) {
458
+ if (!options.baseUrl) throw new Error("runAudit requires baseUrl");
459
+
460
+ const { runDomScanner } = await import("./engine/dom-scanner.mjs");
461
+ const { runAnalyzer } = await import("./engine/analyzer.mjs");
462
+
463
+ const onProgress = options.onProgress || null;
464
+
465
+ // Step 1: DOM scan (axe + CDP + pa11y)
466
+ if (onProgress) onProgress("page", "running");
467
+
468
+ const scanPayload = await runDomScanner(
469
+ {
470
+ baseUrl: options.baseUrl,
471
+ maxRoutes: options.maxRoutes,
472
+ crawlDepth: options.crawlDepth,
473
+ routes: options.routes,
474
+ waitMs: options.waitMs,
475
+ timeoutMs: options.timeoutMs,
476
+ headless: options.headless,
477
+ waitUntil: options.waitUntil,
478
+ colorScheme: options.colorScheme,
479
+ viewport: options.viewport,
480
+ axeTags: options.axeTags,
481
+ onlyRule: options.onlyRule,
482
+ excludeSelectors: options.excludeSelectors,
483
+ },
484
+ { onProgress },
485
+ );
486
+
487
+ // Step 2: Analyze + enrich
488
+ if (onProgress) onProgress("intelligence", "running");
489
+
490
+ const findingsPayload = runAnalyzer(scanPayload, {
491
+ ignoreFindings: options.ignoreFindings,
492
+ framework: options.framework,
493
+ });
494
+
495
+ // Step 3: Source patterns (optional)
496
+ if (options.projectDir && !options.skipPatterns) {
497
+ try {
498
+ const { resolveScanDirs, scanPattern } = await import("./engine/source-scanner.mjs");
499
+ const { patterns } = loadAssetJson(ASSET_PATHS.remediation.codePatterns, "code-patterns.json");
500
+
501
+ let resolvedFramework = options.framework;
502
+ if (!resolvedFramework && findingsPayload.metadata?.projectContext?.framework) {
503
+ resolvedFramework = findingsPayload.metadata.projectContext.framework;
504
+ }
505
+
506
+ const scanDirs = resolveScanDirs(resolvedFramework || null, options.projectDir);
507
+ const allPatternFindings = [];
508
+ for (const pattern of patterns) {
509
+ for (const scanDir of scanDirs) {
510
+ allPatternFindings.push(...scanPattern(pattern, scanDir, options.projectDir));
511
+ }
512
+ }
513
+
514
+ if (allPatternFindings.length > 0) {
515
+ findingsPayload.patternFindings = {
516
+ generated_at: new Date().toISOString(),
517
+ project_dir: options.projectDir,
518
+ findings: allPatternFindings,
519
+ summary: {
520
+ total: allPatternFindings.length,
521
+ confirmed: allPatternFindings.filter((f) => f.status === "confirmed").length,
522
+ potential: allPatternFindings.filter((f) => f.status === "potential").length,
523
+ },
524
+ };
525
+ }
526
+ } catch (err) {
527
+ // Non-fatal: source scanning is optional
528
+ const msg = err instanceof Error ? err.message : String(err);
529
+ console.warn(`Source pattern scan failed (non-fatal): ${msg}`);
530
+ }
531
+ }
532
+
533
+ if (onProgress) onProgress("intelligence", "done");
534
+
535
+ return findingsPayload;
284
536
  }
285
537
 
286
538
  // ---------------------------------------------------------------------------
@@ -298,10 +550,9 @@ import {
298
550
 
299
551
  /**
300
552
  * Generates a PDF report buffer from raw scan findings.
301
- * Requires Playwright (chromium) to render the PDF.
302
- * @param {{ findings: object[], metadata?: object }} payload - Raw scan output (snake_case keys).
553
+ * @param {{ findings: object[], metadata?: object }} payload
303
554
  * @param {{ baseUrl?: string, target?: string }} [options={}]
304
- * @returns {Promise<Buffer>} The PDF as a Node.js Buffer.
555
+ * @returns {Promise<{ buffer: Buffer, contentType: "application/pdf" }>}
305
556
  */
306
557
  export async function getPDFReport(payload, options = {}) {
307
558
  const { chromium } = await import("playwright");
@@ -367,9 +618,8 @@ ${buildPdfAuditLimitations()}
367
618
 
368
619
  /**
369
620
  * Generates a standalone manual accessibility checklist HTML string.
370
- * Does not depend on scan results — reads from manual-checks.json asset.
371
621
  * @param {{ baseUrl?: string }} [options={}]
372
- * @returns {Promise<string>} The complete checklist HTML document.
622
+ * @returns {Promise<{ html: string, contentType: "text/html" }>}
373
623
  */
374
624
  export async function getChecklist(options = {}) {
375
625
  const { buildManualCheckCard } = await import("./reports/renderers/html.mjs");
@@ -387,9 +637,6 @@ export async function getChecklist(options = {}) {
387
637
  const selectClasses =
388
638
  "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";
389
639
 
390
- // Import the full checklist builder to reuse its buildHtml
391
- // The checklist builder module has a main() that auto-runs, so we dynamically
392
- // construct the same output using the renderer functions directly.
393
640
  const html = `<!doctype html>
394
641
  <html lang="en">
395
642
  <head>
@@ -472,3 +719,204 @@ export async function getChecklist(options = {}) {
472
719
  contentType: "text/html",
473
720
  };
474
721
  }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // HTML Report
725
+ // ---------------------------------------------------------------------------
726
+
727
+ /**
728
+ * Generates an interactive HTML audit dashboard from raw scan findings.
729
+ * Embeds screenshots as base64 data URIs when available.
730
+ * @param {{ findings: object[], metadata?: object }} payload - Raw scan output.
731
+ * @param {{ baseUrl?: string, target?: string, screenshotsDir?: string }} [options={}]
732
+ * @returns {Promise<{ html: string, contentType: "text/html" }>}
733
+ */
734
+ export async function getHTMLReport(payload, options = {}) {
735
+ const fs = await import("node:fs");
736
+ const path = await import("node:path");
737
+ const { buildIssueCard, buildPageGroupedSection } = await import("./reports/renderers/html.mjs");
738
+ const { escapeHtml } = await import("./reports/renderers/utils.mjs");
739
+
740
+ const args = { baseUrl: options.baseUrl || "", target: options.target || "WCAG 2.2 AA" };
741
+ const findings = normalizeForReports(payload).filter(
742
+ (f) => f.wcagClassification !== "AAA" && f.wcagClassification !== "Best Practice",
743
+ );
744
+
745
+ // Embed screenshots as base64 if screenshotsDir is provided
746
+ if (options.screenshotsDir) {
747
+ for (const finding of findings) {
748
+ if (finding.screenshotPath) {
749
+ const filename = path.basename(finding.screenshotPath);
750
+ const absolutePath = path.join(options.screenshotsDir, filename);
751
+ try {
752
+ if (fs.existsSync(absolutePath)) {
753
+ const data = fs.readFileSync(absolutePath);
754
+ finding.screenshotPath = `data:image/png;base64,${data.toString("base64")}`;
755
+ } else {
756
+ finding.screenshotPath = null;
757
+ }
758
+ } catch {
759
+ finding.screenshotPath = null;
760
+ }
761
+ }
762
+ }
763
+ }
764
+
765
+ // Dynamically import the html builder's buildHtml — it auto-executes main() on import,
766
+ // so we replicate its logic here using the renderers directly.
767
+ const {
768
+ buildSummary: buildSummaryLocal,
769
+ computeComplianceScore: computeScoreLocal,
770
+ scoreLabel: scoreLabelLocal,
771
+ buildPersonaSummary: buildPersonaSummaryLocal,
772
+ wcagOverallStatus: wcagOverallStatusLocal,
773
+ } = await import("./reports/renderers/findings.mjs");
774
+
775
+ // Use the builder's internal buildHtml by re-importing it
776
+ // Since html.mjs auto-runs main() on import, we cannot import it directly.
777
+ // Instead, we construct the HTML using the same renderers.
778
+ const totals = buildSummaryLocal(findings);
779
+ const score = computeScoreLocal(totals);
780
+ const label = scoreLabelLocal(score);
781
+ const wcagStatus = wcagOverallStatusLocal(totals);
782
+ const personaCounts = buildPersonaSummaryLocal(findings);
783
+
784
+ let siteHostname = args.baseUrl;
785
+ try {
786
+ siteHostname = new URL(args.baseUrl.startsWith("http") ? args.baseUrl : `https://${args.baseUrl}`).hostname;
787
+ } catch {}
788
+
789
+ const pageGroups = {};
790
+ for (const f of findings) {
791
+ const area = f.area || "Unknown";
792
+ if (!pageGroups[area]) pageGroups[area] = [];
793
+ pageGroups[area].push(f);
794
+ }
795
+
796
+ const issueCards = findings.map((f) => buildIssueCard(f)).join("\n");
797
+ const pageGroupedSections = Object.entries(pageGroups)
798
+ .map(([area, group]) => buildPageGroupedSection(area, group))
799
+ .join("\n");
800
+
801
+ const quickWins = findings
802
+ .filter((f) => (f.severity === "Critical" || f.severity === "Serious") && f.fixCode)
803
+ .slice(0, 3);
804
+
805
+ // Build a self-contained HTML report
806
+ const html = `<!doctype html>
807
+ <html lang="en">
808
+ <head>
809
+ <meta charset="utf-8">
810
+ <meta name="viewport" content="width=device-width, initial-scale=1">
811
+ <title>Accessibility Audit — ${escapeHtml(siteHostname)}</title>
812
+ <script src="https://cdn.tailwindcss.com"><\/script>
813
+ <link rel="preconnect" href="https://fonts.googleapis.com">
814
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
815
+ <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">
816
+ <style>
817
+ body { font-family: 'Inter', sans-serif; background: #f8fafc; }
818
+ </style>
819
+ </head>
820
+ <body>
821
+ <main class="max-w-5xl mx-auto px-4 py-12">
822
+ <h1 class="text-3xl font-extrabold mb-2">Accessibility Audit Dashboard</h1>
823
+ <p class="text-slate-500 mb-8">${escapeHtml(siteHostname)} — ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</p>
824
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
825
+ <div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
826
+ <div class="text-3xl font-black">${score}</div>
827
+ <div class="text-xs font-bold text-slate-500 uppercase">${label}</div>
828
+ </div>
829
+ <div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
830
+ <div class="text-3xl font-black">${findings.length}</div>
831
+ <div class="text-xs font-bold text-slate-500 uppercase">Issues</div>
832
+ </div>
833
+ <div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
834
+ <div class="text-3xl font-black ${wcagStatus === 'Pass' ? 'text-emerald-600' : 'text-rose-600'}">${wcagStatus}</div>
835
+ <div class="text-xs font-bold text-slate-500 uppercase">WCAG 2.2 AA</div>
836
+ </div>
837
+ <div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
838
+ <div class="text-3xl font-black">${Object.keys(pageGroups).length}</div>
839
+ <div class="text-xs font-bold text-slate-500 uppercase">Pages</div>
840
+ </div>
841
+ </div>
842
+ <div class="space-y-4">
843
+ ${pageGroupedSections}
844
+ </div>
845
+ </main>
846
+ </body>
847
+ </html>`;
848
+
849
+ return {
850
+ html,
851
+ contentType: "text/html",
852
+ };
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // Remediation Guide (Markdown)
857
+ // ---------------------------------------------------------------------------
858
+
859
+ /**
860
+ * Generates a Markdown remediation guide from raw scan findings.
861
+ * @param {{ findings: object[], metadata?: object, incomplete_findings?: object[] }} payload
862
+ * @param {{ baseUrl?: string, target?: string, patternFindings?: object }} [options={}]
863
+ * @returns {Promise<{ markdown: string, contentType: "text/markdown" }>}
864
+ */
865
+ export async function getRemediationGuide(payload, options = {}) {
866
+ const { buildMarkdownSummary } = await import("./reports/renderers/md.mjs");
867
+
868
+ const args = { baseUrl: options.baseUrl || "", target: options.target || "WCAG 2.2 AA" };
869
+ const findings = normalizeForReports(payload);
870
+
871
+ const markdown = buildMarkdownSummary(args, findings, {
872
+ ...payload.metadata,
873
+ incomplete_findings: payload.incomplete_findings,
874
+ pattern_findings: options.patternFindings || null,
875
+ });
876
+
877
+ return {
878
+ markdown,
879
+ contentType: "text/markdown",
880
+ };
881
+ }
882
+
883
+ // ---------------------------------------------------------------------------
884
+ // Source Pattern Scanner
885
+ // ---------------------------------------------------------------------------
886
+
887
+ /**
888
+ * Scans a project's source code for accessibility patterns not detectable by axe-core.
889
+ * @param {string} projectDir - Absolute path to the project root.
890
+ * @param {{ framework?: string, onlyPattern?: string }} [options={}]
891
+ * @returns {Promise<{ findings: object[], summary: { total: number, confirmed: number, potential: number } }>}
892
+ */
893
+ export async function getSourcePatterns(projectDir, options = {}) {
894
+ const { scanPattern, resolveScanDirs } = await import("./engine/source-scanner.mjs");
895
+
896
+ const { patterns } = loadAssetJson(ASSET_PATHS.remediation.codePatterns, "code-patterns.json");
897
+
898
+ const activePatterns = options.onlyPattern
899
+ ? patterns.filter((p) => p.id === options.onlyPattern)
900
+ : patterns;
901
+
902
+ if (activePatterns.length === 0) {
903
+ return { findings: [], summary: { total: 0, confirmed: 0, potential: 0 } };
904
+ }
905
+
906
+ const scanDirs = resolveScanDirs(options.framework || null, projectDir);
907
+ const allFindings = [];
908
+
909
+ for (const pattern of activePatterns) {
910
+ for (const scanDir of scanDirs) {
911
+ allFindings.push(...scanPattern(pattern, scanDir, projectDir));
912
+ }
913
+ }
914
+
915
+ const confirmed = allFindings.filter((f) => f.status === "confirmed").length;
916
+ const potential = allFindings.filter((f) => f.status === "potential").length;
917
+
918
+ return {
919
+ findings: allFindings,
920
+ summary: { total: allFindings.length, confirmed, potential },
921
+ };
922
+ }