ally-a11y 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.
- package/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-fix patterns for common accessibility violations
|
|
3
|
+
*
|
|
4
|
+
* Each pattern takes the violation node HTML and returns a fixed version,
|
|
5
|
+
* or null if it can't be auto-fixed.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Helper to extract attribute value from HTML string
|
|
9
|
+
*/
|
|
10
|
+
export function getAttr(html, attr) {
|
|
11
|
+
const match = html.match(new RegExp(`${attr}=["']([^"']*)["']`, 'i'));
|
|
12
|
+
return match ? match[1] : null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Helper to check if element has an attribute
|
|
16
|
+
*/
|
|
17
|
+
export function hasAttr(html, attr) {
|
|
18
|
+
return new RegExp(`\\s${attr}(=|\\s|>|/>)`, 'i').test(html);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Helper to add attribute to opening tag
|
|
22
|
+
*/
|
|
23
|
+
export function addAttr(html, tag, attr, value) {
|
|
24
|
+
const tagRegex = new RegExp(`<${tag}(\\s|>)`, 'i');
|
|
25
|
+
return html.replace(tagRegex, `<${tag} ${attr}="${value}"$1`);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Helper to extract tag name from HTML
|
|
29
|
+
*/
|
|
30
|
+
export function getTagName(html) {
|
|
31
|
+
const match = html.match(/<([a-z][a-z0-9-]*)/i);
|
|
32
|
+
return match ? match[1].toLowerCase() : null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Helper to infer a description from existing attributes or content
|
|
36
|
+
*/
|
|
37
|
+
export function inferDescription(html, fallback) {
|
|
38
|
+
// Try to get description from common attributes
|
|
39
|
+
const title = getAttr(html, 'title');
|
|
40
|
+
if (title)
|
|
41
|
+
return title;
|
|
42
|
+
const name = getAttr(html, 'name');
|
|
43
|
+
if (name)
|
|
44
|
+
return name.replace(/[-_]/g, ' ');
|
|
45
|
+
const id = getAttr(html, 'id');
|
|
46
|
+
if (id)
|
|
47
|
+
return id.replace(/[-_]/g, ' ');
|
|
48
|
+
const placeholder = getAttr(html, 'placeholder');
|
|
49
|
+
if (placeholder)
|
|
50
|
+
return placeholder;
|
|
51
|
+
// Try to extract text content
|
|
52
|
+
const textMatch = html.match(/>([^<]+)</);
|
|
53
|
+
if (textMatch && textMatch[1].trim())
|
|
54
|
+
return textMatch[1].trim();
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Confidence scores for each fix pattern (0.0 to 1.0)
|
|
59
|
+
*
|
|
60
|
+
* High confidence (0.9+): Simple attribute additions (alt, aria-label, lang)
|
|
61
|
+
* Medium confidence (0.7-0.9): Structural changes, role assignments
|
|
62
|
+
* Lower confidence (0.5-0.7): Complex fixes requiring context
|
|
63
|
+
*/
|
|
64
|
+
export const FIX_CONFIDENCE = {
|
|
65
|
+
// === IMAGE AND MEDIA ===
|
|
66
|
+
'image-alt': 0.95, // Simple, reliable - just adds alt attribute
|
|
67
|
+
'image-redundant-alt': 0.85, // Usually correct but may need human review
|
|
68
|
+
// === INTERACTIVE ELEMENTS ===
|
|
69
|
+
'button-name': 0.85, // Usually correct but may need human review
|
|
70
|
+
'link-name': 0.80, // Infers from href, may need adjustment
|
|
71
|
+
'input-button-name': 0.90, // Simple value addition
|
|
72
|
+
'aria-command-name': 0.75, // Role-based inference, needs context
|
|
73
|
+
// === FORM ELEMENTS ===
|
|
74
|
+
'label': 0.85, // Infers from name attribute
|
|
75
|
+
'select-name': 0.85, // Infers from name attribute
|
|
76
|
+
'autocomplete-valid': 0.80, // Map-based, may not match all cases
|
|
77
|
+
'form-field-multiple-labels': 0.50, // Just a comment, needs manual work
|
|
78
|
+
// === DOCUMENT STRUCTURE ===
|
|
79
|
+
'html-has-lang': 0.99, // Trivial fix, very reliable
|
|
80
|
+
'document-title': 0.90, // Simple addition but needs real title
|
|
81
|
+
'meta-viewport': 0.95, // Regex replacement is reliable
|
|
82
|
+
// === HEADING STRUCTURE ===
|
|
83
|
+
'heading-order': 0.65, // Suggests h2, but context-dependent
|
|
84
|
+
'empty-heading': 0.70, // Adds placeholder, needs real content
|
|
85
|
+
// === LANDMARKS ===
|
|
86
|
+
'landmark-one-main': 0.75, // Wrapping is correct but may affect layout
|
|
87
|
+
'region': 0.80, // Simple role addition
|
|
88
|
+
'bypass': 0.60, // Adds skip link template, needs integration
|
|
89
|
+
// === TABLES ===
|
|
90
|
+
'td-headers-attr': 0.70, // Adds placeholder, needs real header IDs
|
|
91
|
+
'th-has-data-cells': 0.85, // Scope addition is usually correct
|
|
92
|
+
// === ARIA ===
|
|
93
|
+
'aria-required-children': 0.65, // Complex, depends on content structure
|
|
94
|
+
'aria-required-parent': 0.70, // Wrapping may affect layout
|
|
95
|
+
'aria-hidden-focus': 0.90, // Tabindex fix is straightforward
|
|
96
|
+
'aria-valid-attr-value': 0.85, // Regex replacement is reliable
|
|
97
|
+
// === KEYBOARD NAVIGATION ===
|
|
98
|
+
'tabindex': 0.95, // Simple value change
|
|
99
|
+
'focus-visible': 0.55, // Just a CSS comment, needs manual work
|
|
100
|
+
'focus-order-semantics': 0.95, // Simple tabindex fix
|
|
101
|
+
// === LISTS ===
|
|
102
|
+
'list': 0.70, // Wrapping may affect layout
|
|
103
|
+
'listitem': 0.70, // Wrapping may affect layout
|
|
104
|
+
// === IFRAMES ===
|
|
105
|
+
'frame-title': 0.90, // Infers from src, usually good
|
|
106
|
+
'frame-focusable-content': 0.85, // Simple tabindex addition
|
|
107
|
+
// === COLOR AND CONTRAST ===
|
|
108
|
+
'color-contrast': 0.50, // Just a comment, needs manual work
|
|
109
|
+
'link-in-text-block': 0.80, // Adds underline style
|
|
110
|
+
// === IDS ===
|
|
111
|
+
'duplicate-id': 0.75, // Generates unique ID, but may break references
|
|
112
|
+
'duplicate-id-active': 0.75, // Same as above
|
|
113
|
+
'duplicate-id-aria': 0.75, // Same as above
|
|
114
|
+
// === SVG ===
|
|
115
|
+
'svg-img-alt': 0.85, // Adds role and aria-label
|
|
116
|
+
// === SCROLLABLE REGIONS ===
|
|
117
|
+
'scrollable-region-focusable': 0.90, // Simple tabindex addition
|
|
118
|
+
// === VIDEO/AUDIO ===
|
|
119
|
+
'video-caption': 0.60, // Adds template, needs real captions file
|
|
120
|
+
'audio-caption': 0.50, // Just a comment, needs transcript
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Get confidence score for a fix pattern
|
|
124
|
+
* @returns Confidence score between 0 and 1, or null if pattern not found
|
|
125
|
+
*/
|
|
126
|
+
export function getFixConfidence(patternId) {
|
|
127
|
+
return FIX_CONFIDENCE[patternId] ?? null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get confidence level category
|
|
131
|
+
*/
|
|
132
|
+
export function getConfidenceLevel(confidence) {
|
|
133
|
+
if (confidence >= 0.9)
|
|
134
|
+
return 'high';
|
|
135
|
+
if (confidence >= 0.7)
|
|
136
|
+
return 'medium';
|
|
137
|
+
return 'low';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Auto-fix patterns for common accessibility violations
|
|
141
|
+
*/
|
|
142
|
+
export const FIX_PATTERNS = {
|
|
143
|
+
// === IMAGE AND MEDIA ===
|
|
144
|
+
'image-alt': (html) => {
|
|
145
|
+
if (hasAttr(html, 'alt'))
|
|
146
|
+
return null;
|
|
147
|
+
const src = getAttr(html, 'src');
|
|
148
|
+
const inferredAlt = src
|
|
149
|
+
? src.split('/').pop()?.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ') || '[describe image]'
|
|
150
|
+
: '[describe image]';
|
|
151
|
+
return addAttr(html, 'img', 'alt', inferredAlt);
|
|
152
|
+
},
|
|
153
|
+
'image-redundant-alt': (html) => {
|
|
154
|
+
// Remove redundant words like "image of", "picture of" from alt text
|
|
155
|
+
const alt = getAttr(html, 'alt');
|
|
156
|
+
if (!alt)
|
|
157
|
+
return null;
|
|
158
|
+
const cleaned = alt.replace(/^(image|picture|photo|graphic|icon)\s+(of|showing)\s+/i, '');
|
|
159
|
+
if (cleaned === alt)
|
|
160
|
+
return null;
|
|
161
|
+
return html.replace(/alt=["'][^"']*["']/, `alt="${cleaned}"`);
|
|
162
|
+
},
|
|
163
|
+
// === INTERACTIVE ELEMENTS ===
|
|
164
|
+
'button-name': (html) => {
|
|
165
|
+
if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
|
|
166
|
+
return null;
|
|
167
|
+
const desc = inferDescription(html, '[action description]');
|
|
168
|
+
return addAttr(html, 'button', 'aria-label', desc);
|
|
169
|
+
},
|
|
170
|
+
'link-name': (html) => {
|
|
171
|
+
if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
|
|
172
|
+
return null;
|
|
173
|
+
const href = getAttr(html, 'href');
|
|
174
|
+
const desc = href && href !== '#'
|
|
175
|
+
? href.split('/').pop()?.replace(/[-_]/g, ' ') || '[link description]'
|
|
176
|
+
: '[link description]';
|
|
177
|
+
return addAttr(html, 'a', 'aria-label', desc);
|
|
178
|
+
},
|
|
179
|
+
'input-button-name': (html) => {
|
|
180
|
+
// Add value to submit/button inputs
|
|
181
|
+
if (hasAttr(html, 'value') || hasAttr(html, 'aria-label'))
|
|
182
|
+
return null;
|
|
183
|
+
const type = getAttr(html, 'type');
|
|
184
|
+
const defaultValue = type === 'submit' ? 'Submit' : type === 'reset' ? 'Reset' : 'Button';
|
|
185
|
+
return addAttr(html, 'input', 'value', defaultValue);
|
|
186
|
+
},
|
|
187
|
+
'aria-command-name': (html) => {
|
|
188
|
+
// Add accessible name to ARIA buttons/links/menuitems
|
|
189
|
+
if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
|
|
190
|
+
return null;
|
|
191
|
+
const role = getAttr(html, 'role');
|
|
192
|
+
const desc = role ? `[${role} description]` : '[command description]';
|
|
193
|
+
const tag = getTagName(html);
|
|
194
|
+
return tag ? addAttr(html, tag, 'aria-label', desc) : null;
|
|
195
|
+
},
|
|
196
|
+
// === FORM ELEMENTS ===
|
|
197
|
+
'label': (html) => {
|
|
198
|
+
// Add aria-label to inputs without labels
|
|
199
|
+
if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby') || hasAttr(html, 'id'))
|
|
200
|
+
return null;
|
|
201
|
+
const type = getAttr(html, 'type') || 'text';
|
|
202
|
+
const name = getAttr(html, 'name');
|
|
203
|
+
const desc = name ? name.replace(/[-_]/g, ' ') : `${type} input`;
|
|
204
|
+
return addAttr(html, 'input', 'aria-label', desc);
|
|
205
|
+
},
|
|
206
|
+
'select-name': (html) => {
|
|
207
|
+
if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
|
|
208
|
+
return null;
|
|
209
|
+
const name = getAttr(html, 'name');
|
|
210
|
+
const desc = name ? name.replace(/[-_]/g, ' ') : 'Select option';
|
|
211
|
+
return addAttr(html, 'select', 'aria-label', desc);
|
|
212
|
+
},
|
|
213
|
+
'autocomplete-valid': (html) => {
|
|
214
|
+
// Add valid autocomplete attribute based on common input names/types
|
|
215
|
+
if (hasAttr(html, 'autocomplete'))
|
|
216
|
+
return null;
|
|
217
|
+
const name = getAttr(html, 'name')?.toLowerCase() || '';
|
|
218
|
+
const type = getAttr(html, 'type')?.toLowerCase() || '';
|
|
219
|
+
const autocompleteMap = {
|
|
220
|
+
'email': 'email',
|
|
221
|
+
'phone': 'tel',
|
|
222
|
+
'tel': 'tel',
|
|
223
|
+
'name': 'name',
|
|
224
|
+
'fname': 'given-name',
|
|
225
|
+
'firstname': 'given-name',
|
|
226
|
+
'lname': 'family-name',
|
|
227
|
+
'lastname': 'family-name',
|
|
228
|
+
'address': 'street-address',
|
|
229
|
+
'city': 'address-level2',
|
|
230
|
+
'state': 'address-level1',
|
|
231
|
+
'zip': 'postal-code',
|
|
232
|
+
'postal': 'postal-code',
|
|
233
|
+
'country': 'country-name',
|
|
234
|
+
'username': 'username',
|
|
235
|
+
'password': 'current-password',
|
|
236
|
+
};
|
|
237
|
+
const autocomplete = autocompleteMap[name] || autocompleteMap[type] || 'off';
|
|
238
|
+
return addAttr(html, 'input', 'autocomplete', autocomplete);
|
|
239
|
+
},
|
|
240
|
+
'form-field-multiple-labels': (html) => {
|
|
241
|
+
// Suggest removing duplicate labels by adding aria-labelledby
|
|
242
|
+
// This is complex - just add a comment suggestion
|
|
243
|
+
return `<!-- FIX: Remove duplicate labels or use aria-labelledby -->\n${html}`;
|
|
244
|
+
},
|
|
245
|
+
// === DOCUMENT STRUCTURE ===
|
|
246
|
+
'html-has-lang': (html) => {
|
|
247
|
+
if (hasAttr(html, 'lang'))
|
|
248
|
+
return null;
|
|
249
|
+
return addAttr(html, 'html', 'lang', 'en');
|
|
250
|
+
},
|
|
251
|
+
'document-title': (html) => {
|
|
252
|
+
// Add title element to head
|
|
253
|
+
if (html.includes('<title>'))
|
|
254
|
+
return null;
|
|
255
|
+
if (html.includes('</head>')) {
|
|
256
|
+
return html.replace('</head>', ' <title>[Page Title]</title>\n</head>');
|
|
257
|
+
}
|
|
258
|
+
return `<title>[Page Title]</title>`;
|
|
259
|
+
},
|
|
260
|
+
'meta-viewport': (html) => {
|
|
261
|
+
// Fix viewport to allow user scaling
|
|
262
|
+
if (!html.includes('user-scalable=no') && !html.includes('maximum-scale=1')) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return html
|
|
266
|
+
.replace(/,?\s*user-scalable\s*=\s*no/gi, '')
|
|
267
|
+
.replace(/,?\s*maximum-scale\s*=\s*1(\.0)?/gi, '')
|
|
268
|
+
.replace(/content="([^"]*),\s*,/g, 'content="$1,')
|
|
269
|
+
.replace(/,\s*"/g, '"');
|
|
270
|
+
},
|
|
271
|
+
// === HEADING STRUCTURE ===
|
|
272
|
+
'heading-order': (html) => {
|
|
273
|
+
// Fix heading hierarchy - suggest the correct level
|
|
274
|
+
const match = html.match(/<h([1-6])/i);
|
|
275
|
+
if (!match)
|
|
276
|
+
return null;
|
|
277
|
+
const currentLevel = parseInt(match[1], 10);
|
|
278
|
+
// Suggest h2 as a safe default (most common fix)
|
|
279
|
+
const suggestedLevel = currentLevel > 2 ? currentLevel - 1 : 2;
|
|
280
|
+
return html
|
|
281
|
+
.replace(/<h[1-6]/gi, `<h${suggestedLevel}`)
|
|
282
|
+
.replace(/<\/h[1-6]>/gi, `</h${suggestedLevel}>`);
|
|
283
|
+
},
|
|
284
|
+
'empty-heading': (html) => {
|
|
285
|
+
// Add placeholder text to empty headings
|
|
286
|
+
const match = html.match(/<(h[1-6])([^>]*)>(\s*)<\/h[1-6]>/i);
|
|
287
|
+
if (!match)
|
|
288
|
+
return null;
|
|
289
|
+
return html.replace(/<(h[1-6])([^>]*)>(\s*)<\/h[1-6]>/i, '<$1$2>[Heading text]</$1>');
|
|
290
|
+
},
|
|
291
|
+
// === LANDMARKS ===
|
|
292
|
+
'landmark-one-main': (html) => {
|
|
293
|
+
// Wrap content in main landmark
|
|
294
|
+
return `<main>\n${html}\n</main>`;
|
|
295
|
+
},
|
|
296
|
+
'region': (html) => {
|
|
297
|
+
// Add role="region" with aria-label
|
|
298
|
+
const tag = getTagName(html);
|
|
299
|
+
if (!tag)
|
|
300
|
+
return null;
|
|
301
|
+
if (hasAttr(html, 'role'))
|
|
302
|
+
return null;
|
|
303
|
+
return addAttr(html, tag, 'role', 'region');
|
|
304
|
+
},
|
|
305
|
+
'bypass': (html) => {
|
|
306
|
+
// Suggest skip link
|
|
307
|
+
return `<!-- Add skip link at start of body -->\n<a href="#main-content" class="skip-link">Skip to main content</a>\n${html}`;
|
|
308
|
+
},
|
|
309
|
+
// === TABLES ===
|
|
310
|
+
'td-headers-attr': (html) => {
|
|
311
|
+
// Add headers attribute to td
|
|
312
|
+
if (hasAttr(html, 'headers'))
|
|
313
|
+
return null;
|
|
314
|
+
return addAttr(html, 'td', 'headers', '[header-id]');
|
|
315
|
+
},
|
|
316
|
+
'th-has-data-cells': (html) => {
|
|
317
|
+
// Add scope to th
|
|
318
|
+
if (hasAttr(html, 'scope'))
|
|
319
|
+
return null;
|
|
320
|
+
return addAttr(html, 'th', 'scope', 'col');
|
|
321
|
+
},
|
|
322
|
+
// === ARIA ===
|
|
323
|
+
'aria-required-children': (html, violation) => {
|
|
324
|
+
// Add required ARIA children based on role
|
|
325
|
+
const role = getAttr(html, 'role');
|
|
326
|
+
const childRoles = {
|
|
327
|
+
'menu': 'menuitem',
|
|
328
|
+
'menubar': 'menuitem',
|
|
329
|
+
'list': 'listitem',
|
|
330
|
+
'listbox': 'option',
|
|
331
|
+
'grid': 'row',
|
|
332
|
+
'table': 'row',
|
|
333
|
+
'tree': 'treeitem',
|
|
334
|
+
'tablist': 'tab',
|
|
335
|
+
'radiogroup': 'radio',
|
|
336
|
+
};
|
|
337
|
+
if (!role || !childRoles[role])
|
|
338
|
+
return null;
|
|
339
|
+
const childRole = childRoles[role];
|
|
340
|
+
// Check if it's a self-closing or empty element
|
|
341
|
+
if (html.includes('/>') || html.match(/<[^>]+>\s*<\/[^>]+>/)) {
|
|
342
|
+
const tag = getTagName(html);
|
|
343
|
+
return html.replace(/(\/>|>\s*<\/[^>]+>)/, `>\n <div role="${childRole}">[content]</div>\n</${tag}>`);
|
|
344
|
+
}
|
|
345
|
+
return `<!-- Add role="${childRole}" to child elements -->\n${html}`;
|
|
346
|
+
},
|
|
347
|
+
'aria-required-parent': (html) => {
|
|
348
|
+
// Wrap in required parent role
|
|
349
|
+
const role = getAttr(html, 'role');
|
|
350
|
+
const parentRoles = {
|
|
351
|
+
'menuitem': 'menu',
|
|
352
|
+
'option': 'listbox',
|
|
353
|
+
'row': 'table',
|
|
354
|
+
'tab': 'tablist',
|
|
355
|
+
'treeitem': 'tree',
|
|
356
|
+
'listitem': 'list',
|
|
357
|
+
};
|
|
358
|
+
if (!role || !parentRoles[role])
|
|
359
|
+
return null;
|
|
360
|
+
const parentRole = parentRoles[role];
|
|
361
|
+
return `<div role="${parentRole}">\n ${html}\n</div>`;
|
|
362
|
+
},
|
|
363
|
+
'aria-hidden-focus': (html) => {
|
|
364
|
+
// Remove tabindex from aria-hidden elements or add tabindex="-1"
|
|
365
|
+
if (hasAttr(html, 'aria-hidden')) {
|
|
366
|
+
// Add tabindex="-1" to remove from tab order
|
|
367
|
+
if (hasAttr(html, 'tabindex')) {
|
|
368
|
+
return html.replace(/tabindex=["'][^"']*["']/, 'tabindex="-1"');
|
|
369
|
+
}
|
|
370
|
+
const tag = getTagName(html);
|
|
371
|
+
return tag ? addAttr(html, tag, 'tabindex', '-1') : null;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
},
|
|
375
|
+
'aria-valid-attr-value': (html) => {
|
|
376
|
+
// Fix common invalid ARIA values
|
|
377
|
+
// aria-expanded, aria-checked, etc. should be "true" or "false"
|
|
378
|
+
return html
|
|
379
|
+
.replace(/aria-expanded=["'](?!true|false)[^"']*["']/gi, 'aria-expanded="false"')
|
|
380
|
+
.replace(/aria-checked=["'](?!true|false|mixed)[^"']*["']/gi, 'aria-checked="false"')
|
|
381
|
+
.replace(/aria-selected=["'](?!true|false)[^"']*["']/gi, 'aria-selected="false"')
|
|
382
|
+
.replace(/aria-pressed=["'](?!true|false|mixed)[^"']*["']/gi, 'aria-pressed="false"')
|
|
383
|
+
.replace(/aria-hidden=["'](?!true|false)[^"']*["']/gi, 'aria-hidden="true"');
|
|
384
|
+
},
|
|
385
|
+
// === KEYBOARD NAVIGATION ===
|
|
386
|
+
'tabindex': (html) => {
|
|
387
|
+
// Fix positive tabindex values
|
|
388
|
+
const tabindex = getAttr(html, 'tabindex');
|
|
389
|
+
if (!tabindex)
|
|
390
|
+
return null;
|
|
391
|
+
const value = parseInt(tabindex, 10);
|
|
392
|
+
if (isNaN(value) || value <= 0)
|
|
393
|
+
return null;
|
|
394
|
+
// Replace positive tabindex with 0
|
|
395
|
+
return html.replace(/tabindex=["']\d+["']/, 'tabindex="0"');
|
|
396
|
+
},
|
|
397
|
+
'focus-visible': (html) => {
|
|
398
|
+
// Add CSS comment for focus styles (can't fix inline, but can suggest)
|
|
399
|
+
return `<!-- Add CSS: ${getTagName(html)}:focus-visible { outline: 2px solid #005fcc; } -->\n${html}`;
|
|
400
|
+
},
|
|
401
|
+
'focus-order-semantics': (html) => {
|
|
402
|
+
// Similar to tabindex fix
|
|
403
|
+
const tabindex = getAttr(html, 'tabindex');
|
|
404
|
+
if (tabindex && parseInt(tabindex, 10) > 0) {
|
|
405
|
+
return html.replace(/tabindex=["']\d+["']/, 'tabindex="0"');
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
},
|
|
409
|
+
// === LISTS ===
|
|
410
|
+
'list': (html) => {
|
|
411
|
+
// Fix list structure - ensure proper nesting
|
|
412
|
+
const tag = getTagName(html);
|
|
413
|
+
if (tag === 'li') {
|
|
414
|
+
return `<ul>\n ${html}\n</ul>`;
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
},
|
|
418
|
+
'listitem': (html) => {
|
|
419
|
+
// Ensure li is in ul/ol
|
|
420
|
+
if (html.includes('<li')) {
|
|
421
|
+
return `<ul>\n${html}\n</ul>`;
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
},
|
|
425
|
+
// === IFRAMES ===
|
|
426
|
+
'frame-title': (html) => {
|
|
427
|
+
if (hasAttr(html, 'title'))
|
|
428
|
+
return null;
|
|
429
|
+
const src = getAttr(html, 'src');
|
|
430
|
+
const title = src
|
|
431
|
+
? src.split('/').pop()?.replace(/[-_]/g, ' ').replace(/\.[^.]+$/, '') || 'Embedded content'
|
|
432
|
+
: 'Embedded content';
|
|
433
|
+
return addAttr(html, 'iframe', 'title', title);
|
|
434
|
+
},
|
|
435
|
+
'frame-focusable-content': (html) => {
|
|
436
|
+
// Add tabindex to iframe if needed
|
|
437
|
+
if (hasAttr(html, 'tabindex'))
|
|
438
|
+
return null;
|
|
439
|
+
return addAttr(html, 'iframe', 'tabindex', '0');
|
|
440
|
+
},
|
|
441
|
+
// === COLOR AND CONTRAST ===
|
|
442
|
+
'color-contrast': (html) => {
|
|
443
|
+
// Can't auto-fix colors, but add helpful comment
|
|
444
|
+
return `<!-- CONTRAST FIX: Increase color contrast ratio to at least 4.5:1 for normal text, 3:1 for large text -->\n<!-- Suggested: Use darker text (#333) on light backgrounds or lighter text (#fff) on dark backgrounds -->\n${html}`;
|
|
445
|
+
},
|
|
446
|
+
'link-in-text-block': (html) => {
|
|
447
|
+
// Add underline to links for non-color differentiation
|
|
448
|
+
const tag = getTagName(html);
|
|
449
|
+
if (tag !== 'a')
|
|
450
|
+
return null;
|
|
451
|
+
if (html.includes('style=')) {
|
|
452
|
+
return html.replace(/style=["']/, 'style="text-decoration: underline; ');
|
|
453
|
+
}
|
|
454
|
+
return addAttr(html, 'a', 'style', 'text-decoration: underline');
|
|
455
|
+
},
|
|
456
|
+
// === IDS ===
|
|
457
|
+
'duplicate-id': (html) => {
|
|
458
|
+
// Suggest unique ID
|
|
459
|
+
const id = getAttr(html, 'id');
|
|
460
|
+
if (!id)
|
|
461
|
+
return null;
|
|
462
|
+
const uniqueId = `${id}-${Math.random().toString(36).substr(2, 4)}`;
|
|
463
|
+
return html.replace(new RegExp(`id=["']${id}["']`), `id="${uniqueId}"`);
|
|
464
|
+
},
|
|
465
|
+
'duplicate-id-active': (html) => {
|
|
466
|
+
const id = getAttr(html, 'id');
|
|
467
|
+
if (!id)
|
|
468
|
+
return null;
|
|
469
|
+
const uniqueId = `${id}-${Math.random().toString(36).substr(2, 4)}`;
|
|
470
|
+
return html.replace(new RegExp(`id=["']${id}["']`), `id="${uniqueId}"`);
|
|
471
|
+
},
|
|
472
|
+
'duplicate-id-aria': (html) => {
|
|
473
|
+
const id = getAttr(html, 'id');
|
|
474
|
+
if (!id)
|
|
475
|
+
return null;
|
|
476
|
+
const uniqueId = `${id}-${Math.random().toString(36).substr(2, 4)}`;
|
|
477
|
+
return html.replace(new RegExp(`id=["']${id}["']`), `id="${uniqueId}"`);
|
|
478
|
+
},
|
|
479
|
+
// === SVG ===
|
|
480
|
+
'svg-img-alt': (html) => {
|
|
481
|
+
// Add accessible name to SVG
|
|
482
|
+
if (hasAttr(html, 'aria-label') || hasAttr(html, 'aria-labelledby'))
|
|
483
|
+
return null;
|
|
484
|
+
if (hasAttr(html, 'role') && getAttr(html, 'role') === 'img') {
|
|
485
|
+
return addAttr(html, 'svg', 'aria-label', '[SVG description]');
|
|
486
|
+
}
|
|
487
|
+
// Add role="img" and aria-label
|
|
488
|
+
let fixed = addAttr(html, 'svg', 'role', 'img');
|
|
489
|
+
fixed = addAttr(fixed, 'svg', 'aria-label', '[SVG description]');
|
|
490
|
+
return fixed;
|
|
491
|
+
},
|
|
492
|
+
// === SCROLLABLE REGIONS ===
|
|
493
|
+
'scrollable-region-focusable': (html) => {
|
|
494
|
+
// Add tabindex to scrollable regions
|
|
495
|
+
if (hasAttr(html, 'tabindex'))
|
|
496
|
+
return null;
|
|
497
|
+
const tag = getTagName(html);
|
|
498
|
+
return tag ? addAttr(html, tag, 'tabindex', '0') : null;
|
|
499
|
+
},
|
|
500
|
+
// === VIDEO/AUDIO ===
|
|
501
|
+
'video-caption': (html) => {
|
|
502
|
+
// Suggest adding captions track
|
|
503
|
+
if (html.includes('<track'))
|
|
504
|
+
return null;
|
|
505
|
+
if (html.includes('</video>')) {
|
|
506
|
+
return html.replace('</video>', ' <track kind="captions" src="[captions.vtt]" srclang="en" label="English">\n</video>');
|
|
507
|
+
}
|
|
508
|
+
return `<!-- Add captions track: <track kind="captions" src="captions.vtt" srclang="en"> -->\n${html}`;
|
|
509
|
+
},
|
|
510
|
+
'audio-caption': (html) => {
|
|
511
|
+
// Suggest transcript for audio
|
|
512
|
+
return `<!-- Provide a transcript for this audio content -->\n${html}`;
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
/**
|
|
516
|
+
* Generate a suggested fix for a violation
|
|
517
|
+
*/
|
|
518
|
+
export function generateSuggestedFix(violation, html) {
|
|
519
|
+
const fixer = FIX_PATTERNS[violation.id];
|
|
520
|
+
if (!fixer)
|
|
521
|
+
return null;
|
|
522
|
+
try {
|
|
523
|
+
return fixer(html, violation);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// If pattern matching fails, return null to let Copilot handle it
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Historical Tracking System
|
|
3
|
+
*
|
|
4
|
+
* Tracks scan history over time to show progress and trends.
|
|
5
|
+
* Quick win: 3 days effort, HIGH ROI - shows teams their improvement.
|
|
6
|
+
*/
|
|
7
|
+
import type { AllyReport } from '../types/index.js';
|
|
8
|
+
export interface HistoryEntry {
|
|
9
|
+
timestamp: string;
|
|
10
|
+
score: number;
|
|
11
|
+
totalViolations: number;
|
|
12
|
+
bySeverity: {
|
|
13
|
+
critical: number;
|
|
14
|
+
serious: number;
|
|
15
|
+
moderate: number;
|
|
16
|
+
minor: number;
|
|
17
|
+
};
|
|
18
|
+
filesScanned: number;
|
|
19
|
+
command: string;
|
|
20
|
+
branch?: string;
|
|
21
|
+
commit?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface HistoryData {
|
|
24
|
+
entries: HistoryEntry[];
|
|
25
|
+
firstScan: string;
|
|
26
|
+
lastScan: string;
|
|
27
|
+
totalScans: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load history from file
|
|
31
|
+
*/
|
|
32
|
+
export declare function loadHistory(): Promise<HistoryData>;
|
|
33
|
+
/**
|
|
34
|
+
* Save history entry
|
|
35
|
+
*/
|
|
36
|
+
export declare function saveHistoryEntry(report: AllyReport, command?: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Get trend direction (improving, declining, stable)
|
|
39
|
+
*/
|
|
40
|
+
export declare function getTrend(history: HistoryData, lookback?: number): 'improving' | 'declining' | 'stable';
|
|
41
|
+
/**
|
|
42
|
+
* Get score change from previous scan
|
|
43
|
+
*/
|
|
44
|
+
export declare function getScoreChange(history: HistoryData): number | null;
|
|
45
|
+
/**
|
|
46
|
+
* Get best score ever achieved
|
|
47
|
+
*/
|
|
48
|
+
export declare function getBestScore(history: HistoryData): number | null;
|
|
49
|
+
/**
|
|
50
|
+
* Get worst score ever recorded
|
|
51
|
+
*/
|
|
52
|
+
export declare function getWorstScore(history: HistoryData): number | null;
|
|
53
|
+
/**
|
|
54
|
+
* Get average score over time
|
|
55
|
+
*/
|
|
56
|
+
export declare function getAverageScore(history: HistoryData): number | null;
|
|
57
|
+
/**
|
|
58
|
+
* Get total violations fixed since first scan
|
|
59
|
+
*/
|
|
60
|
+
export declare function getTotalFixed(history: HistoryData): number | null;
|
|
61
|
+
/**
|
|
62
|
+
* Get streak (consecutive scans with improving or stable scores)
|
|
63
|
+
*/
|
|
64
|
+
export declare function getStreak(history: HistoryData): number;
|
|
65
|
+
/**
|
|
66
|
+
* Format time ago (e.g., "2 hours ago", "3 days ago")
|
|
67
|
+
*/
|
|
68
|
+
export declare function timeAgo(timestamp: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* Get recent entries (last N scans)
|
|
71
|
+
*/
|
|
72
|
+
export declare function getRecentEntries(history: HistoryData, count?: number): HistoryEntry[];
|
|
73
|
+
/**
|
|
74
|
+
* Get entries by branch
|
|
75
|
+
*/
|
|
76
|
+
export declare function getEntriesByBranch(history: HistoryData, branch: string): HistoryEntry[];
|
|
77
|
+
/**
|
|
78
|
+
* Get summary stats for display
|
|
79
|
+
*/
|
|
80
|
+
export interface HistoryStats {
|
|
81
|
+
currentScore: number;
|
|
82
|
+
previousScore: number | null;
|
|
83
|
+
scoreChange: number | null;
|
|
84
|
+
trend: 'improving' | 'declining' | 'stable';
|
|
85
|
+
bestScore: number | null;
|
|
86
|
+
worstScore: number | null;
|
|
87
|
+
averageScore: number | null;
|
|
88
|
+
totalScans: number;
|
|
89
|
+
totalFixed: number | null;
|
|
90
|
+
streak: number;
|
|
91
|
+
lastScan: string;
|
|
92
|
+
firstScan: string;
|
|
93
|
+
}
|
|
94
|
+
export declare function getStats(history: HistoryData): HistoryStats | null;
|