@dallask/a11y-mcp-srv 1.0.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +9 -0
  3. package/README.md +1328 -0
  4. package/bin/server.js +8 -0
  5. package/dist/core/accessibility-runner.d.ts +123 -0
  6. package/dist/core/accessibility-runner.d.ts.map +1 -0
  7. package/dist/core/accessibility-runner.js +465 -0
  8. package/dist/core/accessibility-runner.js.map +1 -0
  9. package/dist/core/basic-auth.d.ts +35 -0
  10. package/dist/core/basic-auth.d.ts.map +1 -0
  11. package/dist/core/basic-auth.js +52 -0
  12. package/dist/core/basic-auth.js.map +1 -0
  13. package/dist/core/config.d.ts +44 -0
  14. package/dist/core/config.d.ts.map +1 -0
  15. package/dist/core/config.js +163 -0
  16. package/dist/core/config.js.map +1 -0
  17. package/dist/core/error-handler.d.ts +66 -0
  18. package/dist/core/error-handler.d.ts.map +1 -0
  19. package/dist/core/error-handler.js +305 -0
  20. package/dist/core/error-handler.js.map +1 -0
  21. package/dist/core/normalize-audit-result.d.ts +18 -0
  22. package/dist/core/normalize-audit-result.d.ts.map +1 -0
  23. package/dist/core/normalize-audit-result.js +118 -0
  24. package/dist/core/normalize-audit-result.js.map +1 -0
  25. package/dist/core/playwright-bootstrap.d.ts +21 -0
  26. package/dist/core/playwright-bootstrap.d.ts.map +1 -0
  27. package/dist/core/playwright-bootstrap.js +144 -0
  28. package/dist/core/playwright-bootstrap.js.map +1 -0
  29. package/dist/core/progress-streamer.d.ts +44 -0
  30. package/dist/core/progress-streamer.d.ts.map +1 -0
  31. package/dist/core/progress-streamer.js +160 -0
  32. package/dist/core/progress-streamer.js.map +1 -0
  33. package/dist/core/result-processor.d.ts +86 -0
  34. package/dist/core/result-processor.d.ts.map +1 -0
  35. package/dist/core/result-processor.js +475 -0
  36. package/dist/core/result-processor.js.map +1 -0
  37. package/dist/core/session-manager.d.ts +73 -0
  38. package/dist/core/session-manager.d.ts.map +1 -0
  39. package/dist/core/session-manager.js +243 -0
  40. package/dist/core/session-manager.js.map +1 -0
  41. package/dist/server.d.ts +10 -0
  42. package/dist/server.d.ts.map +1 -0
  43. package/dist/server.js +1439 -0
  44. package/dist/server.js.map +1 -0
  45. package/dist/tools/aggregate.d.ts +26 -0
  46. package/dist/tools/aggregate.d.ts.map +1 -0
  47. package/dist/tools/aggregate.js +340 -0
  48. package/dist/tools/aggregate.js.map +1 -0
  49. package/dist/tools/analysis.d.ts +68 -0
  50. package/dist/tools/analysis.d.ts.map +1 -0
  51. package/dist/tools/analysis.js +1199 -0
  52. package/dist/tools/analysis.js.map +1 -0
  53. package/dist/tools/audit.d.ts +38 -0
  54. package/dist/tools/audit.d.ts.map +1 -0
  55. package/dist/tools/audit.js +472 -0
  56. package/dist/tools/audit.js.map +1 -0
  57. package/dist/tools/comparison.d.ts +27 -0
  58. package/dist/tools/comparison.d.ts.map +1 -0
  59. package/dist/tools/comparison.js +499 -0
  60. package/dist/tools/comparison.js.map +1 -0
  61. package/dist/tools/export.d.ts +43 -0
  62. package/dist/tools/export.d.ts.map +1 -0
  63. package/dist/tools/export.js +746 -0
  64. package/dist/tools/export.js.map +1 -0
  65. package/dist/tools/filter.d.ts +26 -0
  66. package/dist/tools/filter.d.ts.map +1 -0
  67. package/dist/tools/filter.js +244 -0
  68. package/dist/tools/filter.js.map +1 -0
  69. package/dist/tools/session.d.ts +26 -0
  70. package/dist/tools/session.d.ts.map +1 -0
  71. package/dist/tools/session.js +228 -0
  72. package/dist/tools/session.js.map +1 -0
  73. package/dist/tools/visualize.d.ts +26 -0
  74. package/dist/tools/visualize.d.ts.map +1 -0
  75. package/dist/tools/visualize.js +942 -0
  76. package/dist/tools/visualize.js.map +1 -0
  77. package/dist/types/index.d.ts +792 -0
  78. package/dist/types/index.d.ts.map +1 -0
  79. package/dist/types/index.js +24 -0
  80. package/dist/types/index.js.map +1 -0
  81. package/package.json +69 -0
@@ -0,0 +1,1199 @@
1
+ /**
2
+ * Analysis & reporting tools
3
+ * Implements: get_accessibility_score, prioritize_issues, explain_issue, get_quick_fixes
4
+ */
5
+ import { resolveBasicAuth } from '../core/basic-auth.js';
6
+ import { normalizeAuditResult } from '../core/normalize-audit-result.js';
7
+ import { auditUrl } from './audit.js';
8
+ import { IMPACT_ORDER } from '../types/index.js';
9
+ import { wcagLevelMatches, wcagLevelOrder } from '../core/result-processor.js';
10
+ function impactRank(impact) {
11
+ return IMPACT_ORDER[impact.toLowerCase()] ?? 2;
12
+ }
13
+ /**
14
+ * Default weights for different issue types when calculating scores (axe + ACE native levels)
15
+ */
16
+ const DEFAULT_WEIGHTS = {
17
+ critical: 5.0,
18
+ serious: 3.0,
19
+ moderate: 1.0,
20
+ minor: 0.5,
21
+ violation: 5.0,
22
+ potentialviolation: 4.0,
23
+ potentialrecommendation: 3.0,
24
+ recommendation: 2.0,
25
+ manual: 3.0,
26
+ pass: 0.5,
27
+ ignored: 0,
28
+ // Category-based weights
29
+ error: 5.0,
30
+ contrast: 3.0,
31
+ alert: 1.0,
32
+ feature: 0.5,
33
+ structure: 1.0,
34
+ aria: 2.0,
35
+ };
36
+ /**
37
+ * Calculate breakdown by category
38
+ */
39
+ function calculateCategoryBreakdown(issues, weights) {
40
+ const breakdown = {};
41
+ const categoryScores = {};
42
+ issues.forEach((issue) => {
43
+ const category = issue.category || 'unknown';
44
+ const impactWeight = weights[issue.impact] || DEFAULT_WEIGHTS[issue.impact] || 1.0;
45
+ const categoryWeight = weights[category] || DEFAULT_WEIGHTS[category] || 1.0;
46
+ const combinedWeight = (impactWeight + categoryWeight) / 2;
47
+ if (!categoryScores[category]) {
48
+ categoryScores[category] = { total: 0, count: 0 };
49
+ }
50
+ categoryScores[category].total += combinedWeight;
51
+ categoryScores[category].count += 1;
52
+ });
53
+ // Calculate average score per category (lower is better, so we subtract from 100)
54
+ Object.entries(categoryScores).forEach(([category, data]) => {
55
+ const avgPenalty = data.total / Math.max(data.count, 1);
56
+ breakdown[category] = Math.max(0, Math.round(100 - avgPenalty * 10));
57
+ });
58
+ return breakdown;
59
+ }
60
+ /**
61
+ * Calculate WCAG compliance percentages
62
+ */
63
+ function calculateWCAGCompliance(issues) {
64
+ if (issues.length === 0) {
65
+ return { A: 100, AA: 100, AAA: 100 };
66
+ }
67
+ // Count issues by WCAG level
68
+ const levelA = issues.filter((i) => wcagLevelMatches(i.wcagLevel, 'A'));
69
+ const levelAA = issues.filter((i) => wcagLevelMatches(i.wcagLevel, 'AA'));
70
+ const levelAAA = issues.filter((i) => wcagLevelMatches(i.wcagLevel, 'AAA'));
71
+ // Calculate compliance as percentage
72
+ // This is a simplified calculation - assumes each issue represents a criterion violation
73
+ const totalIssues = issues.length;
74
+ const complianceA = totalIssues > 0
75
+ ? Math.max(0, Math.round(100 - (levelA.length / totalIssues) * 100))
76
+ : 100;
77
+ const complianceAA = totalIssues > 0
78
+ ? Math.max(0, Math.round(100 - (levelAA.length / totalIssues) * 100))
79
+ : 100;
80
+ const complianceAAA = totalIssues > 0
81
+ ? Math.max(0, Math.round(100 - (levelAAA.length / totalIssues) * 100))
82
+ : 100;
83
+ return {
84
+ A: complianceA,
85
+ AA: complianceAA,
86
+ AAA: complianceAAA,
87
+ };
88
+ }
89
+ /**
90
+ * Calculate overall accessibility score (0-100)
91
+ * Higher score = better accessibility
92
+ */
93
+ function calculateOverallScore(issues, weights) {
94
+ if (issues.length === 0) {
95
+ return 100;
96
+ }
97
+ // Start with perfect score
98
+ let score = 100;
99
+ // Deduct points based on issues and their weights
100
+ issues.forEach((issue) => {
101
+ const impactWeight = weights[issue.impact] || DEFAULT_WEIGHTS[issue.impact] || 1.0;
102
+ const categoryWeight = weights[issue.category || 'unknown'] || DEFAULT_WEIGHTS[issue.category || 'unknown'] || 1.0;
103
+ const combinedWeight = (impactWeight + categoryWeight) / 2;
104
+ // Deduct points proportional to the weight
105
+ score -= combinedWeight * 0.5;
106
+ });
107
+ // Ensure score stays within bounds
108
+ return Math.max(0, Math.min(100, Math.round(score)));
109
+ }
110
+ /**
111
+ * get_accessibility_score - Calculate accessibility score (0-100) with breakdowns
112
+ *
113
+ * Calculates an overall accessibility score from audit results, with detailed
114
+ * breakdowns by category and WCAG compliance levels. Supports custom weights
115
+ * for different issue types.
116
+ *
117
+ * @param input - Score calculation input (results or URL, optional weights)
118
+ * @returns Score result with overall score, breakdowns, and WCAG compliance
119
+ */
120
+ export async function getAccessibilityScore(input) {
121
+ let auditResult;
122
+ // If input is a URL string, run an audit first (with optional Basic Auth, same as audit_url)
123
+ if (typeof input.results === 'string') {
124
+ const { urlWithoutAuth, basicAuthUsername: u, basicAuthPassword: p } = resolveBasicAuth(input.results, input.basicAuthUsername, input.basicAuthPassword);
125
+ auditResult = await auditUrl({ url: urlWithoutAuth, basicAuthUsername: u, basicAuthPassword: p });
126
+ }
127
+ else {
128
+ const normalized = normalizeAuditResult(input.results);
129
+ auditResult = normalized ?? input.results;
130
+ }
131
+ const issues = Array.isArray(auditResult?.prioritizedIssues) ? auditResult.prioritizedIssues : [];
132
+ // Merge custom weights with defaults
133
+ const weights = {
134
+ ...DEFAULT_WEIGHTS,
135
+ ...(input.weights || {}),
136
+ };
137
+ // Calculate overall score
138
+ const overallScore = calculateOverallScore(issues, weights);
139
+ // Calculate breakdown by category
140
+ const breakdown = calculateCategoryBreakdown(issues, weights);
141
+ // Calculate WCAG compliance
142
+ const wcagCompliance = calculateWCAGCompliance(issues);
143
+ // Note: Trend data would require historical tracking, which is not implemented yet
144
+ // This is a placeholder for future enhancement
145
+ return {
146
+ overallScore,
147
+ breakdown,
148
+ wcagCompliance,
149
+ };
150
+ }
151
+ /**
152
+ * Prioritize issues by impact, WCAG level, or fixability
153
+ */
154
+ function prioritizeByCriteria(issues, criteria) {
155
+ const sorted = [...issues];
156
+ switch (criteria) {
157
+ case 'impact': {
158
+ // Sort by impact level (axe: critical/serious/moderate/minor; ACE: violation/...)
159
+ sorted.sort((a, b) => {
160
+ const impactDiff = impactRank(b.impact) - impactRank(a.impact);
161
+ if (impactDiff !== 0)
162
+ return impactDiff;
163
+ return b.priority - a.priority;
164
+ });
165
+ break;
166
+ }
167
+ case 'wcag': {
168
+ // Sort by WCAG level (A > AA > AAA) - Level A violations are legal requirements
169
+ sorted.sort((a, b) => {
170
+ const levelDiff = wcagLevelOrder(b.wcagLevel) - wcagLevelOrder(a.wcagLevel);
171
+ if (levelDiff !== 0)
172
+ return levelDiff;
173
+ return impactRank(b.impact) - impactRank(a.impact);
174
+ });
175
+ break;
176
+ }
177
+ case 'fixability': {
178
+ // Sort by fixability (issues with clear fixes first)
179
+ // Estimate fixability based on whether fix suggestion has actual code changes
180
+ sorted.sort((a, b) => {
181
+ const aHasFix = a.fix.suggested !== a.fix.current && a.fix.suggested.length > 0;
182
+ const bHasFix = b.fix.suggested !== b.fix.current && b.fix.suggested.length > 0;
183
+ if (aHasFix && !bHasFix)
184
+ return -1;
185
+ if (!aHasFix && bHasFix)
186
+ return 1;
187
+ // If both have fixes or both don't, sort by priority
188
+ return b.priority - a.priority;
189
+ });
190
+ break;
191
+ }
192
+ case 'user-impact': {
193
+ // Sort by user impact (axe/ACE native levels)
194
+ sorted.sort((a, b) => {
195
+ const impactDiff = impactRank(b.impact) - impactRank(a.impact);
196
+ if (impactDiff !== 0)
197
+ return impactDiff;
198
+ return wcagLevelOrder(b.wcagLevel) - wcagLevelOrder(a.wcagLevel);
199
+ });
200
+ break;
201
+ }
202
+ }
203
+ return sorted;
204
+ }
205
+ /**
206
+ * Identify quick wins - easy fixes with high impact
207
+ */
208
+ function identifyQuickWins(issues) {
209
+ const quickWins = [];
210
+ // Group issues by rule ID
211
+ const ruleGroups = new Map();
212
+ issues.forEach((issue) => {
213
+ if (!ruleGroups.has(issue.ruleId)) {
214
+ ruleGroups.set(issue.ruleId, []);
215
+ }
216
+ ruleGroups.get(issue.ruleId).push(issue);
217
+ });
218
+ ruleGroups.forEach((groupIssues, ruleId) => {
219
+ // Quick wins are issues that:
220
+ // 1. Have high impact (critical or serious)
221
+ // 2. Have clear fix suggestions (suggested code differs from current)
222
+ // 3. Affect multiple elements (batch fix opportunity)
223
+ const highImpactIssues = groupIssues.filter((i) => impactRank(i.impact) >= 5);
224
+ if (highImpactIssues.length > 0) {
225
+ const firstIssue = groupIssues[0];
226
+ const hasClearFix = firstIssue.fix.suggested !== firstIssue.fix.current &&
227
+ firstIssue.fix.suggested.length > 0 &&
228
+ firstIssue.fix.explanation.length > 0;
229
+ // Consider it a quick win if:
230
+ // - High impact AND (has clear fix OR affects multiple elements)
231
+ if (hasClearFix || groupIssues.length > 1) {
232
+ quickWins.push({
233
+ ruleId,
234
+ description: firstIssue.description,
235
+ impact: firstIssue.impact,
236
+ fix: firstIssue.fix,
237
+ estimatedTime: `${Math.ceil(groupIssues.length * 2)} minutes`,
238
+ affectedElements: groupIssues.length,
239
+ });
240
+ }
241
+ }
242
+ });
243
+ // Sort by impact and number of affected elements
244
+ quickWins.sort((a, b) => {
245
+ const impactDiff = impactRank(b.impact) - impactRank(a.impact);
246
+ if (impactDiff !== 0)
247
+ return impactDiff;
248
+ return b.affectedElements - a.affectedElements;
249
+ });
250
+ return quickWins.slice(0, 10); // Top 10 quick wins
251
+ }
252
+ /**
253
+ * Identify critical blockers - must fix before launch
254
+ */
255
+ function identifyCriticalBlockers(issues) {
256
+ const blockers = [];
257
+ // Group by rule ID
258
+ const ruleGroups = new Map();
259
+ issues.forEach((issue) => {
260
+ if (!ruleGroups.has(issue.ruleId)) {
261
+ ruleGroups.set(issue.ruleId, []);
262
+ }
263
+ ruleGroups.get(issue.ruleId).push(issue);
264
+ });
265
+ ruleGroups.forEach((groupIssues, ruleId) => {
266
+ // Critical blockers are:
267
+ // 1. High impact (axe: critical/serious; ACE: violation/potentialviolation)
268
+ // 2. WCAG Level A violations (legal requirement)
269
+ const criticalIssues = groupIssues.filter((i) => impactRank(i.impact) >= 5);
270
+ const levelAIssues = groupIssues.filter((i) => wcagLevelMatches(i.wcagLevel, 'A'));
271
+ if (criticalIssues.length > 0 || levelAIssues.length > 0) {
272
+ const firstIssue = groupIssues[0];
273
+ blockers.push({
274
+ ruleId,
275
+ description: firstIssue.description,
276
+ impact: firstIssue.impact,
277
+ userImpact: firstIssue.userImpact,
278
+ affectedElements: groupIssues.length,
279
+ wcagLevel: (wcagLevelMatches(firstIssue.wcagLevel, 'A') || wcagLevelMatches(firstIssue.wcagLevel, 'AA') || wcagLevelMatches(firstIssue.wcagLevel, 'AAA'))
280
+ ? firstIssue.wcagLevel
281
+ : 'N/A',
282
+ });
283
+ }
284
+ });
285
+ // Sort by WCAG level (A first) and impact
286
+ blockers.sort((a, b) => {
287
+ const levelDiff = wcagLevelOrder(b.wcagLevel) - wcagLevelOrder(a.wcagLevel);
288
+ if (levelDiff !== 0)
289
+ return levelDiff;
290
+ return impactRank(b.impact) - impactRank(a.impact);
291
+ });
292
+ return blockers;
293
+ }
294
+ /**
295
+ * Generate reasoning for prioritization
296
+ */
297
+ function generatePrioritizationReasoning(criteria, prioritized, quickWins, blockers) {
298
+ const parts = [];
299
+ parts.push(`Prioritized ${prioritized.length} issues using "${criteria}" criteria.`);
300
+ if (blockers.length > 0) {
301
+ parts.push(`\n🚨 Found ${blockers.length} critical blocker(s) that must be fixed before launch.`);
302
+ parts.push(`These are WCAG Level A violations or high-impact issues (e.g. axe critical/serious, ACE violation) that prevent users with disabilities from accessing content.`);
303
+ }
304
+ if (quickWins.length > 0) {
305
+ parts.push(`\n✨ Identified ${quickWins.length} quick win(s) - easy fixes with high impact.`);
306
+ parts.push(`These issues can be fixed quickly and will significantly improve accessibility.`);
307
+ }
308
+ // Breakdown by impact (axe/ACE native levels)
309
+ const impactCounts = {};
310
+ prioritized.forEach((issue) => {
311
+ impactCounts[issue.impact] = (impactCounts[issue.impact] || 0) + 1;
312
+ });
313
+ const impactEntries = Object.entries(impactCounts).sort((a, b) => impactRank(b[0]) - impactRank(a[0]));
314
+ impactEntries.forEach(([level, count]) => {
315
+ const icon = impactRank(level) >= 5 ? '🚫' : impactRank(level) >= 4 ? '⚠️' : 'ℹ️';
316
+ parts.push(`\n${icon} ${level}: ${count}`);
317
+ });
318
+ // Explain the criteria used
319
+ switch (criteria) {
320
+ case 'impact':
321
+ parts.push(`\nIssues are sorted by impact level (axe: critical/serious/moderate/minor; ACE: violation/potentialviolation/...).`);
322
+ break;
323
+ case 'wcag':
324
+ parts.push(`\nIssues are sorted by WCAG compliance level (Level A violations first, as they are legal requirements).`);
325
+ break;
326
+ case 'fixability':
327
+ parts.push(`\nIssues are sorted by fixability (issues with clear fix suggestions first).`);
328
+ break;
329
+ case 'user-impact':
330
+ parts.push(`\nIssues are sorted by user impact, prioritizing issues that most affect users with disabilities.`);
331
+ break;
332
+ }
333
+ return parts.join('\n');
334
+ }
335
+ /**
336
+ * prioritize_issues - Smart prioritization with quick wins and critical blockers
337
+ *
338
+ * Intelligently prioritizes accessibility issues based on specified criteria,
339
+ * identifying quick wins (easy fixes with high impact) and critical blockers
340
+ * (must fix before launch).
341
+ *
342
+ * @param input - Prioritization input (results, criteria, limit)
343
+ * @returns Prioritized issues with quick wins, critical blockers, and reasoning
344
+ */
345
+ export function prioritizeIssues(input) {
346
+ const { results, criteria = 'impact', limit, } = input;
347
+ // Normalize: accept single result, array, JSON string, or MCP wrapper
348
+ const raw = Array.isArray(results) ? results[0] : results;
349
+ const normalized = normalizeAuditResult(raw);
350
+ const auditResult = normalized ?? raw;
351
+ const prioritizedIssues = Array.isArray(auditResult?.prioritizedIssues)
352
+ ? auditResult.prioritizedIssues
353
+ : [];
354
+ // Prioritize issues by criteria
355
+ let prioritized = prioritizeByCriteria(prioritizedIssues, criteria);
356
+ // Apply limit if specified
357
+ if (limit && limit > 0) {
358
+ prioritized = prioritized.slice(0, limit);
359
+ }
360
+ // Identify quick wins
361
+ const quickWins = identifyQuickWins(prioritizedIssues);
362
+ // Identify critical blockers
363
+ const criticalBlockers = identifyCriticalBlockers(prioritizedIssues);
364
+ // Generate reasoning
365
+ const reasoning = generatePrioritizationReasoning(criteria, prioritized, quickWins, criticalBlockers);
366
+ return {
367
+ prioritized,
368
+ quickWins,
369
+ criticalBlockers,
370
+ reasoning,
371
+ };
372
+ }
373
+ /**
374
+ * Knowledge base for accessibility rule explanations
375
+ * Maps rule IDs to comprehensive explanations with code examples
376
+ */
377
+ const RULE_EXPLANATIONS = {
378
+ alt_missing: {
379
+ explanation: 'Images without alternative text cannot be understood by screen readers. When an image is decorative or informational, it needs an alt attribute that describes its content or purpose.',
380
+ userImpact: 'Users who are blind or have low vision rely on screen readers to understand images. Without alt text, they miss important visual information, making the content inaccessible.',
381
+ howToFix: 'Add an alt attribute to all <img> elements. For informative images, describe what the image shows. For decorative images, use an empty alt attribute (alt="").',
382
+ wcagReference: 'WCAG 2.1 Success Criterion 1.1.1 (Level A): Non-text Content',
383
+ commonMistakes: [
384
+ 'Using generic alt text like "image" or "photo"',
385
+ 'Including "image of" or "picture of" in alt text (redundant)',
386
+ 'Using alt text for decorative images instead of alt=""',
387
+ 'Missing alt attribute entirely',
388
+ 'Using the filename as alt text',
389
+ ],
390
+ codeExample: {
391
+ before: '<img src="logo.png">',
392
+ after: '<img src="logo.png" alt="Company Logo">',
393
+ },
394
+ },
395
+ alt_link_missing: {
396
+ explanation: 'Linked images without alternative text create navigation barriers. Screen reader users cannot determine where the link goes without descriptive alt text.',
397
+ userImpact: 'Screen reader users hear "link" without context, making it impossible to understand the link purpose or destination. This prevents effective navigation.',
398
+ howToFix: 'Add descriptive alt text to images that are links. The alt text should describe the link destination or action, not just the image content.',
399
+ wcagReference: 'WCAG 2.1 Success Criterion 1.1.1 (Level A): Non-text Content',
400
+ commonMistakes: [
401
+ 'Using alt text that describes the image instead of the link purpose',
402
+ 'Leaving alt text empty for linked images',
403
+ 'Using the same alt text for multiple links',
404
+ 'Including "link to" in alt text (redundant)',
405
+ ],
406
+ codeExample: {
407
+ before: '<a href="/products"><img src="products-icon.png"></a>',
408
+ after: '<a href="/products"><img src="products-icon.png" alt="View our products">',
409
+ },
410
+ },
411
+ label_missing: {
412
+ explanation: 'Form inputs without labels cannot be properly identified by assistive technologies. Labels provide essential context about what information is expected.',
413
+ userImpact: 'Screen reader users cannot determine what information to enter in form fields. This makes forms completely unusable for users with disabilities.',
414
+ howToFix: 'Associate a <label> element with each form input using the "for" attribute matching the input "id", or wrap the input inside the label element.',
415
+ wcagReference: 'WCAG 2.1 Success Criterion 1.3.1 (Level A): Info and Relationships',
416
+ commonMistakes: [
417
+ 'Using placeholder text instead of labels',
418
+ 'Using only visual labels without proper label association',
419
+ 'Mismatching label "for" and input "id" attributes',
420
+ 'Using aria-label instead of proper label elements',
421
+ ],
422
+ codeExample: {
423
+ before: '<input type="text" id="email">',
424
+ after: '<label for="email">Email Address</label><input type="text" id="email" name="email">',
425
+ },
426
+ },
427
+ label_empty: {
428
+ explanation: 'Empty label elements provide no information to assistive technology users. Labels must contain descriptive text that explains the form field purpose.',
429
+ userImpact: 'Screen reader users hear "label" but receive no information about what to enter, making the form field unusable.',
430
+ howToFix: 'Add descriptive text content to label elements that clearly indicates what information is expected in the associated form field.',
431
+ wcagReference: 'WCAG 2.1 Success Criterion 1.3.1 (Level A): Info and Relationships',
432
+ commonMistakes: [
433
+ 'Leaving label text empty',
434
+ 'Using only symbols or icons without text',
435
+ 'Using placeholder text as a substitute for label text',
436
+ ],
437
+ codeExample: {
438
+ before: '<label for="phone"></label><input type="tel" id="phone">',
439
+ after: '<label for="phone">Phone Number</label><input type="tel" id="phone" name="phone">',
440
+ },
441
+ },
442
+ contrast: {
443
+ explanation: 'Insufficient color contrast between text and background makes content difficult or impossible to read for users with low vision or color blindness.',
444
+ userImpact: 'Users with low vision, color blindness, or those viewing content in bright sunlight cannot read text with poor contrast. This affects readability and usability.',
445
+ howToFix: 'Ensure text has a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18pt+ or 14pt+ bold) against the background. Use contrast checking tools to verify.',
446
+ wcagReference: 'WCAG 2.1 Success Criterion 1.4.3 (Level AA): Contrast (Minimum)',
447
+ commonMistakes: [
448
+ 'Using light gray text on white backgrounds',
449
+ 'Using colored text without sufficient contrast',
450
+ 'Assuming color alone conveys information',
451
+ 'Not testing contrast in different lighting conditions',
452
+ ],
453
+ codeExample: {
454
+ before: '<p style="color: #cccccc; background: white;">Low contrast text</p>',
455
+ after: '<p style="color: #333333; background: white;">High contrast text</p>',
456
+ },
457
+ },
458
+ heading_empty: {
459
+ explanation: 'Empty heading elements break document structure and confuse screen reader users. Headings should contain meaningful text that describes the section content.',
460
+ userImpact: 'Screen reader users rely on headings to navigate and understand page structure. Empty headings create confusion and make navigation difficult.',
461
+ howToFix: 'Add descriptive text content to all heading elements (h1-h6). Ensure headings follow a logical hierarchy without skipping levels.',
462
+ wcagReference: 'WCAG 2.1 Success Criterion 1.3.1 (Level A): Info and Relationships',
463
+ commonMistakes: [
464
+ 'Using empty headings for spacing',
465
+ 'Skipping heading levels (e.g., h1 to h3)',
466
+ 'Using headings purely for styling instead of structure',
467
+ 'Hiding heading text with CSS instead of removing empty headings',
468
+ ],
469
+ codeExample: {
470
+ before: '<h2></h2><p>Content here</p>',
471
+ after: '<h2>Section Title</h2><p>Content here</p>',
472
+ },
473
+ },
474
+ link_empty: {
475
+ explanation: 'Links without text content cannot be understood by screen reader users. Empty links or links with only images without alt text provide no context.',
476
+ userImpact: 'Screen reader users hear "link" without any information about where it goes or what it does, making navigation impossible.',
477
+ howToFix: 'Add descriptive text content to all links. If a link contains only an image, ensure the image has descriptive alt text that explains the link purpose.',
478
+ wcagReference: 'WCAG 2.1 Success Criterion 2.4.4 (Level A): Link Purpose (In Context)',
479
+ commonMistakes: [
480
+ 'Using empty links for JavaScript actions',
481
+ 'Using only icons without text or alt text',
482
+ 'Using generic text like "click here" or "read more"',
483
+ 'Using the URL as link text',
484
+ ],
485
+ codeExample: {
486
+ before: '<a href="/about"></a>',
487
+ after: '<a href="/about">Learn more about us</a>',
488
+ },
489
+ },
490
+ language_missing: {
491
+ explanation: 'Pages without a declared language make it difficult for screen readers to pronounce content correctly. The language should be specified in the HTML lang attribute.',
492
+ userImpact: 'Screen readers may mispronounce words, making content difficult to understand. Users who rely on translation tools cannot properly translate the page.',
493
+ howToFix: 'Add a lang attribute to the <html> element specifying the primary language of the page (e.g., lang="en" for English).',
494
+ wcagReference: 'WCAG 2.1 Success Criterion 3.1.1 (Level A): Language of Page',
495
+ commonMistakes: [
496
+ 'Missing lang attribute on html element',
497
+ 'Using incorrect language codes',
498
+ 'Not updating lang attribute for pages in different languages',
499
+ 'Using lang attribute only on body instead of html',
500
+ ],
501
+ codeExample: {
502
+ before: '<html><head>...</head><body>...</body></html>',
503
+ after: '<html lang="en"><head>...</head><body>...</body></html>',
504
+ },
505
+ },
506
+ aria_reference_broken: {
507
+ explanation: 'ARIA attributes that reference other elements (like aria-labelledby or aria-describedby) point to non-existent elements, breaking the accessibility relationship.',
508
+ userImpact: 'Screen reader users miss important information because ARIA relationships are broken. This can make interactive elements unusable.',
509
+ howToFix: 'Ensure all ARIA reference attributes (aria-labelledby, aria-describedby, aria-controls, etc.) point to existing element IDs on the page.',
510
+ wcagReference: 'WCAG 2.1 Success Criterion 4.1.2 (Level A): Name, Role, Value',
511
+ commonMistakes: [
512
+ 'Referencing IDs that do not exist',
513
+ 'Using duplicate IDs on the page',
514
+ 'Referencing elements that are hidden or removed',
515
+ 'Typos in ID references',
516
+ ],
517
+ codeExample: {
518
+ before: '<button aria-labelledby="nonexistent">Submit</button>',
519
+ after: '<span id="submit-label">Submit Form</span><button aria-labelledby="submit-label">Submit</button>',
520
+ },
521
+ },
522
+ keyboard: {
523
+ explanation: 'Interactive elements that cannot be accessed via keyboard exclude users who cannot use a mouse. All functionality must be keyboard accessible.',
524
+ userImpact: 'Users who rely on keyboard navigation cannot access interactive elements, making parts of the website completely unusable for them.',
525
+ howToFix: 'Ensure all interactive elements (buttons, links, form controls) are keyboard accessible. Add proper focus indicators and ensure keyboard event handlers work correctly.',
526
+ wcagReference: 'WCAG 2.1 Success Criterion 2.1.1 (Level A): Keyboard',
527
+ commonMistakes: [
528
+ 'Using div or span with click handlers instead of buttons',
529
+ 'Removing default keyboard functionality',
530
+ 'Not providing visible focus indicators',
531
+ 'Creating keyboard traps that prevent navigation',
532
+ ],
533
+ codeExample: {
534
+ before: '<div onclick="submitForm()">Submit</div>',
535
+ after: '<button type="button" onclick="submitForm()">Submit</button>',
536
+ },
537
+ },
538
+ focus_order: {
539
+ explanation: 'Focus order that does not follow a logical sequence confuses keyboard users. Focus should move in an order that preserves meaning and operability.',
540
+ userImpact: 'Keyboard users expect focus to move logically through the page. Illogical focus order makes navigation confusing and frustrating.',
541
+ howToFix: 'Ensure focus order follows the visual reading order. Use tabindex sparingly and only when necessary to fix focus order issues.',
542
+ wcagReference: 'WCAG 2.1 Success Criterion 2.4.3 (Level A): Focus Order',
543
+ commonMistakes: [
544
+ 'Using positive tabindex values that disrupt natural order',
545
+ 'Focus jumping to off-screen or hidden elements',
546
+ 'Focus order not matching visual layout',
547
+ 'Focus moving to elements that are not interactive',
548
+ ],
549
+ codeExample: {
550
+ before: '<input tabindex="3"><input tabindex="1"><input tabindex="2">',
551
+ after: '<input><input><input>',
552
+ },
553
+ },
554
+ };
555
+ /**
556
+ * Get explanation for an accessibility rule, with fallback for unknown rules
557
+ */
558
+ function getRuleExplanation(ruleId, context) {
559
+ const explanation = RULE_EXPLANATIONS[ruleId];
560
+ if (explanation) {
561
+ // Enhance explanation with context if provided
562
+ if (context) {
563
+ return {
564
+ ...explanation,
565
+ explanation: `${explanation.explanation}\n\nAdditional context: ${context}`,
566
+ };
567
+ }
568
+ return explanation;
569
+ }
570
+ // Fallback for unknown rules
571
+ return {
572
+ explanation: `This accessibility issue (${ruleId}) indicates a violation that may impact users with disabilities. While we don't have detailed information about this specific rule, it's important to address it to ensure accessibility.`,
573
+ userImpact: 'This issue may prevent users with disabilities from accessing or understanding content. The specific impact depends on the nature of the violation.',
574
+ howToFix: `Review the accessibility documentation for ${ruleId} to understand the specific requirements and how to fix this issue.`,
575
+ wcagReference: 'WCAG 2.1 Guidelines - See accessibility documentation for specific criterion',
576
+ commonMistakes: [
577
+ 'Not addressing the issue',
578
+ 'Implementing a partial fix',
579
+ 'Not testing the fix with assistive technologies',
580
+ ],
581
+ codeExample: context
582
+ ? {
583
+ before: context,
584
+ after: 'Review accessibility documentation for the correct implementation',
585
+ }
586
+ : undefined,
587
+ };
588
+ }
589
+ /**
590
+ * explain_issue - Educational tool that explains accessibility issues
591
+ *
592
+ * Provides comprehensive explanations of accessibility rules in plain language,
593
+ * including why they matter, how to fix them, and common mistakes to avoid.
594
+ *
595
+ * @param input - Explanation input (ruleId, optional context)
596
+ * @returns Detailed explanation with code examples and WCAG references
597
+ */
598
+ export function explainIssue(input) {
599
+ const { ruleId, context } = input;
600
+ if (!ruleId || ruleId.trim().length === 0) {
601
+ throw new Error('ruleId is required');
602
+ }
603
+ const explanation = getRuleExplanation(ruleId, context);
604
+ return {
605
+ ruleId,
606
+ ...explanation,
607
+ };
608
+ }
609
+ /**
610
+ * Format quick fix as markdown
611
+ */
612
+ function formatFixAsMarkdown(fix) {
613
+ const parts = [];
614
+ parts.push(`## ${fix.description}`);
615
+ parts.push(`\n**Rule ID:** ${fix.ruleId}`);
616
+ parts.push(`\n**Impact:** ${fix.impactEstimate}`);
617
+ parts.push(`\n**Affected Elements:** ${fix.affectedElements}`);
618
+ parts.push(`\n\n### Explanation`);
619
+ parts.push(`\n${fix.explanation}`);
620
+ if (fix.currentCode && fix.fixedCode) {
621
+ parts.push(`\n\n### Code Fix`);
622
+ parts.push(`\n**Before:**`);
623
+ parts.push(`\n\`\`\`html`);
624
+ parts.push(`\n${fix.currentCode}`);
625
+ parts.push(`\n\`\`\``);
626
+ parts.push(`\n\n**After:**`);
627
+ parts.push(`\n\`\`\`html`);
628
+ parts.push(`\n${fix.fixedCode}`);
629
+ parts.push(`\n\`\`\``);
630
+ }
631
+ return parts.join('');
632
+ }
633
+ /**
634
+ * Format quick fix as HTML
635
+ */
636
+ function formatFixAsHTML(fix) {
637
+ const parts = [];
638
+ parts.push(`<div class="quick-fix">`);
639
+ parts.push(`<h2>${fix.description}</h2>`);
640
+ parts.push(`<p><strong>Rule ID:</strong> ${fix.ruleId}</p>`);
641
+ parts.push(`<p><strong>Impact:</strong> ${fix.impactEstimate}</p>`);
642
+ parts.push(`<p><strong>Affected Elements:</strong> ${fix.affectedElements}</p>`);
643
+ parts.push(`<h3>Explanation</h3>`);
644
+ parts.push(`<p>${fix.explanation}</p>`);
645
+ if (fix.currentCode && fix.fixedCode) {
646
+ parts.push(`<h3>Code Fix</h3>`);
647
+ parts.push(`<h4>Before:</h4>`);
648
+ parts.push(`<pre><code>${escapeHtml(fix.currentCode)}</code></pre>`);
649
+ parts.push(`<h4>After:</h4>`);
650
+ parts.push(`<pre><code>${escapeHtml(fix.fixedCode)}</code></pre>`);
651
+ }
652
+ parts.push(`</div>`);
653
+ return parts.join('');
654
+ }
655
+ /**
656
+ * Escape HTML special characters
657
+ */
658
+ function escapeHtml(text) {
659
+ return text
660
+ .replace(/&/g, '&amp;')
661
+ .replace(/</g, '&lt;')
662
+ .replace(/>/g, '&gt;')
663
+ .replace(/"/g, '&quot;')
664
+ .replace(/'/g, '&#039;');
665
+ }
666
+ /**
667
+ * Convert prioritized issues to quick fix items
668
+ */
669
+ function issuesToQuickFixes(issues, includeCode) {
670
+ // Group issues by rule ID to show fixes once per rule type
671
+ const ruleGroups = new Map();
672
+ issues.forEach((issue) => {
673
+ if (!ruleGroups.has(issue.ruleId)) {
674
+ ruleGroups.set(issue.ruleId, []);
675
+ }
676
+ ruleGroups.get(issue.ruleId).push(issue);
677
+ });
678
+ const quickFixes = [];
679
+ ruleGroups.forEach((groupIssues, ruleId) => {
680
+ const firstIssue = groupIssues[0];
681
+ const r = impactRank(firstIssue.impact);
682
+ const impactEstimate = r >= 5
683
+ ? 'Critical - Must fix immediately'
684
+ : r >= 4
685
+ ? 'Serious - High priority'
686
+ : r >= 3
687
+ ? 'Moderate - Should fix soon'
688
+ : 'Minor - Consider fixing';
689
+ quickFixes.push({
690
+ ruleId,
691
+ description: firstIssue.description,
692
+ currentCode: includeCode ? firstIssue.fix.current : undefined,
693
+ fixedCode: includeCode ? firstIssue.fix.suggested : undefined,
694
+ explanation: firstIssue.fix.explanation || firstIssue.userImpact,
695
+ impactEstimate,
696
+ affectedElements: groupIssues.length,
697
+ });
698
+ });
699
+ // Sort by impact (critical first)
700
+ const estimateRank = (e) => e === 'Critical - Must fix immediately' ? 4 : e === 'Serious - High priority' ? 3 : e === 'Moderate - Should fix soon' ? 2 : 1;
701
+ quickFixes.sort((a, b) => estimateRank(b.impactEstimate) - estimateRank(a.impactEstimate));
702
+ return quickFixes;
703
+ }
704
+ /**
705
+ * get_quick_fixes - Generate actionable fixes with before/after code examples
706
+ *
707
+ * Extracts actionable fix suggestions from audit results and formats them
708
+ * in the requested format (markdown, HTML, or JSON) with before/after code examples.
709
+ *
710
+ * @param input - Quick fixes input (results or URL, format, includeCode)
711
+ * @returns Formatted quick fixes with code examples
712
+ */
713
+ export async function getQuickFixes(input) {
714
+ const { results, format = 'json', includeCode = true, basicAuthUsername, basicAuthPassword, } = input;
715
+ let auditResult;
716
+ // If input is a URL string, run an audit first (with optional Basic Auth, same as audit_url)
717
+ if (typeof results === 'string') {
718
+ const { urlWithoutAuth, basicAuthUsername: u, basicAuthPassword: p } = resolveBasicAuth(results, basicAuthUsername, basicAuthPassword);
719
+ auditResult = await auditUrl({ url: urlWithoutAuth, basicAuthUsername: u, basicAuthPassword: p });
720
+ }
721
+ else {
722
+ const normalized = normalizeAuditResult(results);
723
+ auditResult = normalized ?? results;
724
+ }
725
+ const issues = Array.isArray(auditResult?.prioritizedIssues) ? auditResult.prioritizedIssues : [];
726
+ // Convert issues to quick fixes
727
+ const fixes = issuesToQuickFixes(issues, includeCode);
728
+ // Format fixes based on requested format
729
+ if (format === 'markdown') {
730
+ const markdownContent = fixes.map(formatFixAsMarkdown).join('\n\n---\n\n');
731
+ return {
732
+ fixes,
733
+ format,
734
+ totalFixes: fixes.length,
735
+ formatted: markdownContent,
736
+ };
737
+ }
738
+ else if (format === 'html') {
739
+ const htmlContent = fixes.map(formatFixAsHTML).join('\n\n');
740
+ return {
741
+ fixes,
742
+ format,
743
+ totalFixes: fixes.length,
744
+ formatted: `<div class="quick-fixes">\n${htmlContent}\n</div>`,
745
+ };
746
+ }
747
+ // Return structured data (JSON format)
748
+ return {
749
+ fixes,
750
+ format,
751
+ totalFixes: fixes.length,
752
+ };
753
+ }
754
+ /**
755
+ * WCAG 2.1 Success Criteria mapping
756
+ * Maps accessibility rule IDs to WCAG 2.1 success criteria
757
+ */
758
+ const WCAG_CRITERIA_MAPPING = {
759
+ // Level A criteria
760
+ alt_missing: { criterion: '1.1.1', title: 'Non-text Content', level: 'A' },
761
+ alt_link_missing: { criterion: '1.1.1', title: 'Non-text Content', level: 'A' },
762
+ label_missing: { criterion: '1.3.1', title: 'Info and Relationships', level: 'A' },
763
+ label_empty: { criterion: '1.3.1', title: 'Info and Relationships', level: 'A' },
764
+ heading_empty: { criterion: '1.3.1', title: 'Info and Relationships', level: 'A' },
765
+ language_missing: { criterion: '3.1.1', title: 'Language of Page', level: 'A' },
766
+ link_empty: { criterion: '2.4.4', title: 'Link Purpose (In Context)', level: 'A' },
767
+ link_skip_broken: { criterion: '2.4.1', title: 'Bypass Blocks', level: 'A' },
768
+ aria_reference_broken: { criterion: '4.1.2', title: 'Name, Role, Value', level: 'A' },
769
+ aria_hidden: { criterion: '4.1.2', title: 'Name, Role, Value', level: 'A' },
770
+ keyboard: { criterion: '2.1.1', title: 'Keyboard', level: 'A' },
771
+ focus_order: { criterion: '2.4.3', title: 'Focus Order', level: 'A' },
772
+ focus_visible: { criterion: '2.4.7', title: 'Focus Visible', level: 'AA' },
773
+ // Level AA criteria
774
+ contrast: { criterion: '1.4.3', title: 'Contrast (Minimum)', level: 'AA' },
775
+ resize_text: { criterion: '1.4.4', title: 'Resize Text', level: 'AA' },
776
+ // Level AAA criteria (less common)
777
+ contrast_enhanced: { criterion: '1.4.6', title: 'Contrast (Enhanced)', level: 'AAA' },
778
+ };
779
+ /**
780
+ * Get WCAG criterion for a rule ID
781
+ */
782
+ function getWCAGCriterion(ruleId) {
783
+ return WCAG_CRITERIA_MAPPING[ruleId] || null;
784
+ }
785
+ /**
786
+ * Group issues by WCAG criterion
787
+ */
788
+ function groupIssuesByCriterion(issues) {
789
+ const criterionMap = new Map();
790
+ issues.forEach((issue) => {
791
+ const criterionInfo = getWCAGCriterion(issue.ruleId);
792
+ if (criterionInfo) {
793
+ const key = `${criterionInfo.criterion}-${criterionInfo.level}`;
794
+ if (!criterionMap.has(key)) {
795
+ criterionMap.set(key, []);
796
+ }
797
+ criterionMap.get(key).push(issue);
798
+ }
799
+ else {
800
+ // If no mapping found, use rule ID as fallback
801
+ const fallbackKey = `unknown-${issue.ruleId}`;
802
+ if (!criterionMap.has(fallbackKey)) {
803
+ criterionMap.set(fallbackKey, []);
804
+ }
805
+ criterionMap.get(fallbackKey).push(issue);
806
+ }
807
+ });
808
+ return criterionMap;
809
+ }
810
+ /**
811
+ * Calculate compliance status for a criterion
812
+ */
813
+ function calculateCriterionStatus(violations) {
814
+ if (violations.length === 0) {
815
+ return 'pass';
816
+ }
817
+ // If all violations are low impact, consider it partial compliance
818
+ const allLowImpact = violations.every((v) => impactRank(v.impact) <= 2);
819
+ if (allLowImpact) {
820
+ return 'partial';
821
+ }
822
+ // If there are high-impact violations (axe: critical/serious; ACE: violation/potentialviolation), it's a fail
823
+ const hasCriticalOrSerious = violations.some((v) => impactRank(v.impact) >= 5);
824
+ if (hasCriticalOrSerious) {
825
+ return 'fail';
826
+ }
827
+ return 'partial';
828
+ }
829
+ /**
830
+ * Format compliance report as VPAT
831
+ */
832
+ function formatVPATReport(level, wcagMapping, compliancePercentage, remediationPlan) {
833
+ const parts = [];
834
+ parts.push('# Voluntary Product Accessibility Template (VPAT)');
835
+ parts.push(`\n## WCAG ${level} Compliance Report`);
836
+ parts.push(`\n**Compliance Percentage:** ${compliancePercentage}%`);
837
+ parts.push(`\n**Report Date:** ${new Date().toISOString().split('T')[0]}`);
838
+ parts.push(`\n## Summary Table`);
839
+ parts.push(`\n| Criterion | Level | Status | Notes |`);
840
+ parts.push(`|-----------|-------|--------|-------|`);
841
+ Object.entries(wcagMapping)
842
+ .sort(([a], [b]) => {
843
+ const aNum = parseFloat(a.split('.')[0] + '.' + a.split('.')[1]);
844
+ const bNum = parseFloat(b.split('.')[0] + '.' + b.split('.')[1]);
845
+ return aNum - bNum;
846
+ })
847
+ .forEach(([criterion, status]) => {
848
+ const statusEmoji = status === 'pass' ? '✅' : status === 'partial' ? '⚠️' : '❌';
849
+ parts.push(`| ${criterion} | ${level} | ${statusEmoji} ${status} | ${status === 'pass' ? 'Meets requirement' : 'Needs remediation'} |`);
850
+ });
851
+ if (remediationPlan && remediationPlan.length > 0) {
852
+ parts.push(`\n## Remediation Plan`);
853
+ remediationPlan.forEach((fix, index) => {
854
+ parts.push(`\n### ${index + 1}. ${fix.description}`);
855
+ parts.push(`- **Rule ID:** ${fix.ruleId}`);
856
+ parts.push(`- **Impact:** ${fix.impactEstimate}`);
857
+ parts.push(`- **Affected Elements:** ${fix.affectedElements}`);
858
+ parts.push(`- **Explanation:** ${fix.explanation}`);
859
+ if (fix.fixedCode) {
860
+ parts.push(`- **Fix:** See code example below`);
861
+ }
862
+ });
863
+ }
864
+ return parts.join('\n');
865
+ }
866
+ /**
867
+ * Format compliance report as WCAG
868
+ */
869
+ function formatWCAGReport(level, wcagMapping, compliancePercentage, remediationPlan) {
870
+ const parts = [];
871
+ parts.push('# WCAG 2.1 Compliance Report');
872
+ parts.push(`\n## Level ${level} Compliance Assessment`);
873
+ parts.push(`\n**Overall Compliance:** ${compliancePercentage}%`);
874
+ parts.push(`\n**Assessment Date:** ${new Date().toISOString().split('T')[0]}`);
875
+ parts.push(`\n## Success Criteria Status`);
876
+ parts.push(`\n| Criterion | Title | Status |`);
877
+ parts.push(`|----------|-------|--------|`);
878
+ Object.entries(wcagMapping)
879
+ .sort(([a], [b]) => {
880
+ const aNum = parseFloat(a.split('.')[0] + '.' + a.split('.')[1]);
881
+ const bNum = parseFloat(b.split('.')[0] + '.' + b.split('.')[1]);
882
+ return aNum - bNum;
883
+ })
884
+ .forEach(([criterion, status]) => {
885
+ const criterionInfo = Object.values(WCAG_CRITERIA_MAPPING).find((c) => c.criterion === criterion && c.level === level);
886
+ const title = criterionInfo?.title || 'Unknown';
887
+ const statusEmoji = status === 'pass' ? '✅' : status === 'partial' ? '⚠️' : '❌';
888
+ parts.push(`| ${criterion} | ${title} | ${statusEmoji} ${status} |`);
889
+ });
890
+ if (remediationPlan && remediationPlan.length > 0) {
891
+ parts.push(`\n## Recommended Remediation`);
892
+ remediationPlan.forEach((fix, index) => {
893
+ parts.push(`\n### ${index + 1}. ${fix.description}`);
894
+ parts.push(`- **Impact:** ${fix.impactEstimate}`);
895
+ parts.push(`- **Affected Elements:** ${fix.affectedElements}`);
896
+ parts.push(`- **Remediation:** ${fix.explanation}`);
897
+ });
898
+ }
899
+ return parts.join('\n');
900
+ }
901
+ /**
902
+ * Format compliance report as ADA
903
+ */
904
+ function formatADAReport(level, wcagMapping, compliancePercentage, remediationPlan) {
905
+ const parts = [];
906
+ parts.push('# Americans with Disabilities Act (ADA) Compliance Report');
907
+ parts.push(`\n## Web Content Accessibility Assessment`);
908
+ parts.push(`\n**Compliance Level:** WCAG ${level}`);
909
+ parts.push(`\n**Compliance Percentage:** ${compliancePercentage}%`);
910
+ parts.push(`\n**Assessment Date:** ${new Date().toISOString().split('T')[0]}`);
911
+ parts.push(`\n## Compliance Status`);
912
+ const passCount = Object.values(wcagMapping).filter((s) => s === 'pass').length;
913
+ const partialCount = Object.values(wcagMapping).filter((s) => s === 'partial').length;
914
+ const failCount = Object.values(wcagMapping).filter((s) => s === 'fail').length;
915
+ const totalCriteria = Object.keys(wcagMapping).length;
916
+ parts.push(`\n- **Total Criteria Assessed:** ${totalCriteria}`);
917
+ parts.push(`- **Fully Compliant:** ${passCount} (${Math.round((passCount / totalCriteria) * 100)}%)`);
918
+ parts.push(`- **Partially Compliant:** ${partialCount} (${Math.round((partialCount / totalCriteria) * 100)}%)`);
919
+ parts.push(`- **Non-Compliant:** ${failCount} (${Math.round((failCount / totalCriteria) * 100)}%)`);
920
+ parts.push(`\n## Detailed Findings`);
921
+ Object.entries(wcagMapping)
922
+ .sort(([a], [b]) => {
923
+ const aNum = parseFloat(a.split('.')[0] + '.' + a.split('.')[1]);
924
+ const bNum = parseFloat(b.split('.')[0] + '.' + b.split('.')[1]);
925
+ return aNum - bNum;
926
+ })
927
+ .forEach(([criterion, status]) => {
928
+ const criterionInfo = Object.values(WCAG_CRITERIA_MAPPING).find((c) => c.criterion === criterion && c.level === level);
929
+ const title = criterionInfo?.title || 'Unknown';
930
+ const statusText = status === 'pass' ? 'Compliant' : status === 'partial' ? 'Partially Compliant' : 'Non-Compliant';
931
+ parts.push(`\n### ${criterion} - ${title}`);
932
+ parts.push(`**Status:** ${statusText}`);
933
+ });
934
+ if (remediationPlan && remediationPlan.length > 0) {
935
+ parts.push(`\n## Remediation Recommendations`);
936
+ remediationPlan.forEach((fix, index) => {
937
+ parts.push(`\n${index + 1}. **${fix.description}**`);
938
+ parts.push(` - Impact: ${fix.impactEstimate}`);
939
+ parts.push(` - Affected Elements: ${fix.affectedElements}`);
940
+ parts.push(` - Recommendation: ${fix.explanation}`);
941
+ });
942
+ }
943
+ return parts.join('\n');
944
+ }
945
+ /**
946
+ * Format compliance report as Section 508
947
+ */
948
+ function formatSection508Report(level, wcagMapping, compliancePercentage, remediationPlan) {
949
+ const parts = [];
950
+ parts.push('# Section 508 Compliance Report');
951
+ parts.push(`\n## Information and Communication Technology (ICT) Accessibility Assessment`);
952
+ parts.push(`\n**WCAG Level:** ${level}`);
953
+ parts.push(`\n**Compliance Percentage:** ${compliancePercentage}%`);
954
+ parts.push(`\n**Assessment Date:** ${new Date().toISOString().split('T')[0]}`);
955
+ parts.push(`\n## Section 508 Standards Compliance`);
956
+ parts.push(`\nSection 508 requires compliance with WCAG ${level} Level standards.`);
957
+ parts.push(`\n**Overall Compliance:** ${compliancePercentage}%`);
958
+ parts.push(`\n## WCAG Success Criteria Status`);
959
+ parts.push(`\n| Criterion | Status |`);
960
+ parts.push(`|-----------|--------|`);
961
+ Object.entries(wcagMapping)
962
+ .sort(([a], [b]) => {
963
+ const aNum = parseFloat(a.split('.')[0] + '.' + a.split('.')[1]);
964
+ const bNum = parseFloat(b.split('.')[0] + '.' + b.split('.')[1]);
965
+ return aNum - bNum;
966
+ })
967
+ .forEach(([criterion, status]) => {
968
+ const statusText = status === 'pass' ? 'Compliant' : status === 'partial' ? 'Partially Compliant' : 'Non-Compliant';
969
+ parts.push(`| ${criterion} | ${statusText} |`);
970
+ });
971
+ if (remediationPlan && remediationPlan.length > 0) {
972
+ parts.push(`\n## Corrective Action Plan`);
973
+ remediationPlan.forEach((fix, index) => {
974
+ parts.push(`\n### Corrective Action ${index + 1}`);
975
+ parts.push(`- **Issue:** ${fix.description}`);
976
+ parts.push(`- **Severity:** ${fix.impactEstimate}`);
977
+ parts.push(`- **Affected Elements:** ${fix.affectedElements}`);
978
+ parts.push(`- **Corrective Action:** ${fix.explanation}`);
979
+ });
980
+ }
981
+ return parts.join('\n');
982
+ }
983
+ /**
984
+ * Generate executive summary for compliance report
985
+ */
986
+ function generateExecutiveSummary(format, level, compliancePercentage, totalIssues, wcagMapping) {
987
+ const passCount = Object.values(wcagMapping).filter((s) => s === 'pass').length;
988
+ const failCount = Object.values(wcagMapping).filter((s) => s === 'fail').length;
989
+ const partialCount = Object.values(wcagMapping).filter((s) => s === 'partial').length;
990
+ const totalCriteria = Object.keys(wcagMapping).length;
991
+ const parts = [];
992
+ parts.push(`This ${format} compliance report assesses the accessibility of the web content against WCAG 2.1 Level ${level} standards.`);
993
+ parts.push(`\n\n**Key Findings:**`);
994
+ parts.push(`- Overall compliance: ${compliancePercentage}%`);
995
+ parts.push(`- Total accessibility issues found: ${totalIssues}`);
996
+ parts.push(`- Criteria assessed: ${totalCriteria}`);
997
+ parts.push(`- Fully compliant criteria: ${passCount}`);
998
+ parts.push(`- Partially compliant criteria: ${partialCount}`);
999
+ parts.push(`- Non-compliant criteria: ${failCount}`);
1000
+ if (compliancePercentage >= 95) {
1001
+ parts.push(`\n\nThe content demonstrates strong compliance with WCAG ${level} standards.`);
1002
+ }
1003
+ else if (compliancePercentage >= 80) {
1004
+ parts.push(`\n\nThe content shows good compliance but requires some improvements to fully meet WCAG ${level} standards.`);
1005
+ }
1006
+ else if (compliancePercentage >= 60) {
1007
+ parts.push(`\n\nThe content needs significant improvements to meet WCAG ${level} compliance requirements.`);
1008
+ }
1009
+ else {
1010
+ parts.push(`\n\nThe content requires substantial remediation to achieve WCAG ${level} compliance.`);
1011
+ }
1012
+ return parts.join('');
1013
+ }
1014
+ /**
1015
+ * generate_compliance_report - Generate VPAT/WCAG/ADA compliance reports
1016
+ *
1017
+ * Generates compliance reports in various formats (VPAT, WCAG, ADA, Section 508)
1018
+ * with WCAG criterion mapping, compliance percentages, and optional remediation plans.
1019
+ *
1020
+ * @param input - Compliance report input (results, format, level, includeRemediation)
1021
+ * @returns Formatted compliance report with executive summary and WCAG mapping
1022
+ */
1023
+ export function generateComplianceReport(input) {
1024
+ const { results, format = 'WCAG', level = 'AA', includeRemediation = false, } = input;
1025
+ // Normalize: accept single result, array, JSON string, or MCP wrapper
1026
+ const raw = Array.isArray(results) ? results[0] : results;
1027
+ const normalized = normalizeAuditResult(raw);
1028
+ const auditResult = normalized ?? raw;
1029
+ if (auditResult == null || typeof auditResult !== 'object') {
1030
+ throw new Error('Invalid results: expected an audit result object (or array with one result).');
1031
+ }
1032
+ const prioritizedIssues = Array.isArray(auditResult.prioritizedIssues)
1033
+ ? auditResult.prioritizedIssues
1034
+ : [];
1035
+ // Group issues by WCAG criterion
1036
+ const criterionMap = groupIssuesByCriterion(prioritizedIssues);
1037
+ // Build WCAG mapping
1038
+ const wcagMapping = {};
1039
+ const criteria = [];
1040
+ criterionMap.forEach((violations, key) => {
1041
+ const [criterion, criterionLevel] = key.split('-');
1042
+ // Only include criteria for the requested level and below
1043
+ const levelOrder = { A: 1, AA: 2, AAA: 3 };
1044
+ const requestedLevelOrder = levelOrder[level];
1045
+ const violationLevelOrder = levelOrder[criterionLevel] || 0;
1046
+ if (violationLevelOrder <= requestedLevelOrder && criterionLevel === level) {
1047
+ const status = calculateCriterionStatus(violations);
1048
+ wcagMapping[criterion] = status;
1049
+ const criterionInfo = Object.values(WCAG_CRITERIA_MAPPING).find((c) => c.criterion === criterion && c.level === level);
1050
+ criteria.push({
1051
+ criterion,
1052
+ title: criterionInfo?.title || 'Unknown',
1053
+ level: level,
1054
+ status,
1055
+ violations,
1056
+ });
1057
+ }
1058
+ });
1059
+ // Calculate compliance percentage
1060
+ const totalCriteria = Object.keys(wcagMapping).length;
1061
+ const passCount = Object.values(wcagMapping).filter((s) => s === 'pass').length;
1062
+ const compliancePercentage = totalCriteria > 0
1063
+ ? Math.round((passCount / totalCriteria) * 100)
1064
+ : 100;
1065
+ // Generate remediation plan if requested
1066
+ let remediationPlan;
1067
+ if (includeRemediation) {
1068
+ const quickFixesResult = issuesToQuickFixes(prioritizedIssues, true);
1069
+ remediationPlan = quickFixesResult.slice(0, 20); // Top 20 fixes
1070
+ }
1071
+ // Generate executive summary (safe when summary is missing)
1072
+ const totalIssues = auditResult.summary?.totalIssues ?? 0;
1073
+ const executiveSummary = generateExecutiveSummary(format, level, compliancePercentage, totalIssues, wcagMapping);
1074
+ // Format report content based on format
1075
+ let reportContent;
1076
+ switch (format) {
1077
+ case 'VPAT':
1078
+ reportContent = formatVPATReport(level, wcagMapping, compliancePercentage, remediationPlan);
1079
+ break;
1080
+ case 'ADA':
1081
+ reportContent = formatADAReport(level, wcagMapping, compliancePercentage, remediationPlan);
1082
+ break;
1083
+ case 'Section508':
1084
+ reportContent = formatSection508Report(level, wcagMapping, compliancePercentage, remediationPlan);
1085
+ break;
1086
+ case 'WCAG':
1087
+ default:
1088
+ reportContent = formatWCAGReport(level, wcagMapping, compliancePercentage, remediationPlan);
1089
+ break;
1090
+ }
1091
+ return {
1092
+ format,
1093
+ level,
1094
+ executiveSummary,
1095
+ wcagMapping,
1096
+ remediationPlan,
1097
+ compliancePercentage,
1098
+ reportContent,
1099
+ };
1100
+ }
1101
+ /**
1102
+ * get_wcag_compliance - Check WCAG compliance status with per-criterion breakdown
1103
+ *
1104
+ * Analyzes audit results to determine WCAG compliance status at the specified level,
1105
+ * providing a detailed per-criterion breakdown with violations and missing requirements.
1106
+ *
1107
+ * @param input - WCAG compliance input (results or URL, level)
1108
+ * @returns WCAG compliance result with per-criterion breakdown
1109
+ */
1110
+ export async function getWCAGCompliance(input) {
1111
+ let auditResult;
1112
+ // If input is a URL string, run an audit first (with optional Basic Auth, same as audit_url)
1113
+ if (typeof input.results === 'string') {
1114
+ const { urlWithoutAuth, basicAuthUsername: u, basicAuthPassword: p } = resolveBasicAuth(input.results, input.basicAuthUsername, input.basicAuthPassword);
1115
+ auditResult = await auditUrl({ url: urlWithoutAuth, basicAuthUsername: u, basicAuthPassword: p });
1116
+ }
1117
+ else {
1118
+ const normalized = normalizeAuditResult(input.results);
1119
+ auditResult = normalized ?? input.results;
1120
+ }
1121
+ const level = input.level || 'AA';
1122
+ const issues = Array.isArray(auditResult?.prioritizedIssues) ? auditResult.prioritizedIssues : [];
1123
+ // Group issues by WCAG criterion
1124
+ const criterionMap = groupIssuesByCriterion(issues);
1125
+ // Build criteria breakdown
1126
+ const criteria = [];
1127
+ const missingRequirements = [];
1128
+ // Get all WCAG criteria for the requested level
1129
+ const levelCriteria = Object.values(WCAG_CRITERIA_MAPPING).filter((c) => {
1130
+ const levelOrder = { A: 1, AA: 2, AAA: 3 };
1131
+ const requestedLevelOrder = levelOrder[level];
1132
+ const criterionLevelOrder = levelOrder[c.level];
1133
+ return criterionLevelOrder <= requestedLevelOrder && c.level === level;
1134
+ });
1135
+ // Check each criterion
1136
+ levelCriteria.forEach((criterionInfo) => {
1137
+ const key = `${criterionInfo.criterion}-${criterionInfo.level}`;
1138
+ const violations = criterionMap.get(key) || [];
1139
+ const status = calculateCriterionStatus(violations);
1140
+ criteria.push({
1141
+ criterion: criterionInfo.criterion,
1142
+ title: criterionInfo.title,
1143
+ level: criterionInfo.level,
1144
+ status,
1145
+ violations,
1146
+ });
1147
+ if (status === 'fail') {
1148
+ missingRequirements.push(`${criterionInfo.criterion} - ${criterionInfo.title}: ${violations.length} violation(s) found`);
1149
+ }
1150
+ else if (status === 'partial') {
1151
+ missingRequirements.push(`${criterionInfo.criterion} - ${criterionInfo.title}: Partial compliance with ${violations.length} minor issue(s)`);
1152
+ }
1153
+ });
1154
+ // Also check for violations that don't map to known criteria
1155
+ criterionMap.forEach((violations, key) => {
1156
+ if (key.startsWith('unknown-')) {
1157
+ violations.forEach((violation) => {
1158
+ missingRequirements.push(`Unknown criterion - ${violation.ruleId}: ${violation.description}`);
1159
+ });
1160
+ }
1161
+ });
1162
+ // Calculate overall compliance status
1163
+ const totalCriteria = criteria.length;
1164
+ const passCount = criteria.filter((c) => c.status === 'pass').length;
1165
+ const compliancePercentage = totalCriteria > 0
1166
+ ? Math.round((passCount / totalCriteria) * 100)
1167
+ : 100;
1168
+ // Determine overall status
1169
+ let status;
1170
+ if (compliancePercentage === 100) {
1171
+ status = 'pass';
1172
+ }
1173
+ else if (compliancePercentage >= 80) {
1174
+ status = 'partial';
1175
+ }
1176
+ else {
1177
+ status = 'fail';
1178
+ }
1179
+ // Generate summary
1180
+ const summaryParts = [];
1181
+ summaryParts.push(`WCAG 2.1 Level ${level} Compliance: ${compliancePercentage}%`);
1182
+ summaryParts.push(`\n- Total criteria assessed: ${totalCriteria}`);
1183
+ summaryParts.push(`- Fully compliant: ${passCount}`);
1184
+ summaryParts.push(`- Partially compliant: ${criteria.filter((c) => c.status === 'partial').length}`);
1185
+ summaryParts.push(`- Non-compliant: ${criteria.filter((c) => c.status === 'fail').length}`);
1186
+ if (missingRequirements.length > 0) {
1187
+ summaryParts.push(`\nMissing or incomplete requirements: ${missingRequirements.length}`);
1188
+ }
1189
+ const summary = summaryParts.join('\n');
1190
+ return {
1191
+ level,
1192
+ status,
1193
+ compliancePercentage,
1194
+ criteria,
1195
+ missingRequirements,
1196
+ summary,
1197
+ };
1198
+ }
1199
+ //# sourceMappingURL=analysis.js.map