recker 1.0.28 → 1.0.29

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 (38) hide show
  1. package/dist/cli/tui/shell.d.ts +1 -0
  2. package/dist/cli/tui/shell.js +339 -5
  3. package/dist/scrape/index.d.ts +2 -0
  4. package/dist/scrape/index.js +1 -0
  5. package/dist/scrape/spider.d.ts +61 -0
  6. package/dist/scrape/spider.js +250 -0
  7. package/dist/seo/analyzer.js +27 -0
  8. package/dist/seo/index.d.ts +3 -1
  9. package/dist/seo/index.js +1 -0
  10. package/dist/seo/rules/accessibility.js +620 -54
  11. package/dist/seo/rules/best-practices.d.ts +2 -0
  12. package/dist/seo/rules/best-practices.js +188 -0
  13. package/dist/seo/rules/crawl.d.ts +2 -0
  14. package/dist/seo/rules/crawl.js +307 -0
  15. package/dist/seo/rules/cwv.d.ts +2 -0
  16. package/dist/seo/rules/cwv.js +337 -0
  17. package/dist/seo/rules/ecommerce.d.ts +2 -0
  18. package/dist/seo/rules/ecommerce.js +252 -0
  19. package/dist/seo/rules/i18n.d.ts +2 -0
  20. package/dist/seo/rules/i18n.js +222 -0
  21. package/dist/seo/rules/index.d.ts +32 -0
  22. package/dist/seo/rules/index.js +71 -0
  23. package/dist/seo/rules/internal-linking.d.ts +2 -0
  24. package/dist/seo/rules/internal-linking.js +375 -0
  25. package/dist/seo/rules/local.d.ts +2 -0
  26. package/dist/seo/rules/local.js +265 -0
  27. package/dist/seo/rules/pwa.d.ts +2 -0
  28. package/dist/seo/rules/pwa.js +302 -0
  29. package/dist/seo/rules/readability.d.ts +2 -0
  30. package/dist/seo/rules/readability.js +255 -0
  31. package/dist/seo/rules/security.js +406 -28
  32. package/dist/seo/rules/social.d.ts +2 -0
  33. package/dist/seo/rules/social.js +373 -0
  34. package/dist/seo/rules/types.d.ts +155 -0
  35. package/dist/seo/seo-spider.d.ts +47 -0
  36. package/dist/seo/seo-spider.js +362 -0
  37. package/dist/seo/types.d.ts +24 -0
  38. package/package.json +1 -1
@@ -1,128 +1,694 @@
1
1
  import { createResult } from './types.js';
2
2
  export const accessibilityRules = [
3
3
  {
4
- id: 'a11y-buttons-aria',
5
- name: 'Button Accessibility',
4
+ id: 'a11y-buttons-accessible-name',
5
+ name: 'Buttons Accessible Name',
6
6
  category: 'accessibility',
7
- severity: 'warning',
8
- description: 'Buttons without visible text must have aria-label',
7
+ severity: 'error',
8
+ description: 'Buttons must have an accessible name (text content, aria-label, or title)',
9
9
  check: (ctx) => {
10
- const count = ctx.buttonsWithoutAriaLabel ?? 0;
10
+ if (ctx.buttonsWithoutAriaLabel === undefined)
11
+ return null;
12
+ const count = ctx.buttonsWithoutAriaLabel;
11
13
  if (count > 0) {
12
- return createResult({ id: 'a11y-buttons-aria', name: 'Button Accessibility', category: 'accessibility', severity: 'warning' }, 'warn', `${count} button(s) without accessible text/aria-label`, { value: count, recommendation: 'Add aria-label to icon-only buttons' });
14
+ return createResult({ id: 'a11y-buttons-accessible-name', name: 'Buttons Accessible Name', category: 'accessibility', severity: 'error' }, 'fail', `${count} button(s) do not have an accessible name`, {
15
+ value: count,
16
+ recommendation: 'Add text content, aria-label, aria-labelledby, or title to all buttons',
17
+ evidence: {
18
+ found: count,
19
+ expected: 0,
20
+ impact: 'Screen reader users cannot determine the button purpose',
21
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/button-name',
22
+ },
23
+ });
13
24
  }
14
- return null;
25
+ return createResult({ id: 'a11y-buttons-accessible-name', name: 'Buttons Accessible Name', category: 'accessibility', severity: 'error' }, 'pass', 'All buttons have accessible names');
26
+ },
27
+ },
28
+ {
29
+ id: 'a11y-images-alt',
30
+ name: 'Image Alt Attributes',
31
+ category: 'accessibility',
32
+ severity: 'error',
33
+ description: 'Image elements must have [alt] attributes',
34
+ check: (ctx) => {
35
+ if (ctx.imagesWithoutAlt === undefined)
36
+ return null;
37
+ const count = ctx.imagesWithoutAlt;
38
+ if (count > 0) {
39
+ return createResult({ id: 'a11y-images-alt', name: 'Image Alt Attributes', category: 'accessibility', severity: 'error' }, 'fail', `${count} image(s) do not have [alt] attributes`, {
40
+ value: count,
41
+ recommendation: 'Add alt="" for decorative images or descriptive alt text for informative images',
42
+ evidence: {
43
+ found: count,
44
+ expected: 0,
45
+ impact: 'Screen readers cannot convey image content to blind users',
46
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/image-alt',
47
+ },
48
+ });
49
+ }
50
+ return createResult({ id: 'a11y-images-alt', name: 'Image Alt Attributes', category: 'accessibility', severity: 'error' }, 'pass', 'All images have [alt] attributes');
51
+ },
52
+ },
53
+ {
54
+ id: 'a11y-links-discernible-name',
55
+ name: 'Links Discernible Name',
56
+ category: 'accessibility',
57
+ severity: 'error',
58
+ description: 'Links must have a discernible name',
59
+ check: (ctx) => {
60
+ if (ctx.linksWithoutText === undefined && ctx.linksWithoutAriaLabel === undefined)
61
+ return null;
62
+ const linksNoText = ctx.linksWithoutText ?? 0;
63
+ const linksNoAria = ctx.linksWithoutAriaLabel ?? 0;
64
+ const count = Math.max(linksNoText, linksNoAria);
65
+ if (count > 0) {
66
+ return createResult({ id: 'a11y-links-discernible-name', name: 'Links Discernible Name', category: 'accessibility', severity: 'error' }, 'fail', `${count} link(s) do not have a discernible name`, {
67
+ value: count,
68
+ recommendation: 'Add text content, aria-label, aria-labelledby, or title to links',
69
+ evidence: {
70
+ found: count,
71
+ expected: 0,
72
+ impact: 'Screen reader users cannot understand link destinations',
73
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/link-name',
74
+ },
75
+ });
76
+ }
77
+ return createResult({ id: 'a11y-links-discernible-name', name: 'Links Discernible Name', category: 'accessibility', severity: 'error' }, 'pass', 'All links have discernible names');
78
+ },
79
+ },
80
+ {
81
+ id: 'a11y-form-labels',
82
+ name: 'Form Input Labels',
83
+ category: 'accessibility',
84
+ severity: 'error',
85
+ description: 'Form elements must have associated labels',
86
+ check: (ctx) => {
87
+ if (ctx.inputsWithoutLabel === undefined)
88
+ return null;
89
+ const count = ctx.inputsWithoutLabel;
90
+ if (count > 0) {
91
+ return createResult({ id: 'a11y-form-labels', name: 'Form Input Labels', category: 'accessibility', severity: 'error' }, 'fail', `${count} form input(s) without associated label`, {
92
+ value: count,
93
+ recommendation: 'Add <label for="id">, aria-label, or aria-labelledby to all form inputs',
94
+ evidence: {
95
+ found: count,
96
+ expected: 0,
97
+ impact: 'Users cannot identify input purpose, critical for screen reader users',
98
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/label',
99
+ },
100
+ });
101
+ }
102
+ return createResult({ id: 'a11y-form-labels', name: 'Form Input Labels', category: 'accessibility', severity: 'error' }, 'pass', 'All form inputs have associated labels');
103
+ },
104
+ },
105
+ {
106
+ id: 'a11y-heading-order',
107
+ name: 'Heading Order',
108
+ category: 'accessibility',
109
+ severity: 'warning',
110
+ description: 'Heading elements should be in sequentially-descending order',
111
+ check: (ctx) => {
112
+ if (ctx.headingHierarchyValid === undefined)
113
+ return null;
114
+ if (!ctx.headingHierarchyValid) {
115
+ return createResult({ id: 'a11y-heading-order', name: 'Heading Order', category: 'accessibility', severity: 'warning' }, 'warn', 'Heading elements are not in sequentially-descending order', {
116
+ recommendation: 'Ensure headings follow a logical order (H1 → H2 → H3) without skipping levels',
117
+ evidence: {
118
+ found: ctx.headingSkippedLevels?.join(', ') || 'Skipped levels detected',
119
+ expected: 'Sequential heading hierarchy',
120
+ impact: 'Impacts keyboard navigation for screen reader users',
121
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/heading-order',
122
+ },
123
+ });
124
+ }
125
+ return createResult({ id: 'a11y-heading-order', name: 'Heading Order', category: 'accessibility', severity: 'warning' }, 'pass', 'Headings are in sequentially-descending order');
15
126
  },
16
127
  },
17
128
  {
18
- id: 'a11y-links-aria',
19
- name: 'Link Accessibility',
129
+ id: 'a11y-tabindex',
130
+ name: 'Tabindex Values',
20
131
  category: 'accessibility',
21
132
  severity: 'warning',
22
- description: 'Links without visible text must have aria-label',
133
+ description: 'No element should have a [tabindex] value greater than 0',
23
134
  check: (ctx) => {
24
- const count = ctx.linksWithoutAriaLabel ?? 0;
135
+ if (ctx.elementsWithHighTabindex === undefined)
136
+ return null;
137
+ const count = ctx.elementsWithHighTabindex;
25
138
  if (count > 0) {
26
- return createResult({ id: 'a11y-links-aria', name: 'Link Accessibility', category: 'accessibility', severity: 'warning' }, 'warn', `${count} link(s) without accessible text/aria-label`, { value: count, recommendation: 'Add aria-label to icon-only links' });
139
+ return createResult({ id: 'a11y-tabindex', name: 'Tabindex Values', category: 'accessibility', severity: 'warning' }, 'warn', `${count} element(s) have tabindex > 0`, {
140
+ value: count,
141
+ recommendation: 'Remove positive tabindex values; use tabindex="0" or "-1" instead',
142
+ evidence: {
143
+ found: count,
144
+ expected: 0,
145
+ impact: 'Creates confusing focus order for keyboard users',
146
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/tabindex',
147
+ },
148
+ });
27
149
  }
28
- return null;
150
+ return createResult({ id: 'a11y-tabindex', name: 'Tabindex Values', category: 'accessibility', severity: 'warning' }, 'pass', 'No elements have tabindex > 0');
29
151
  },
30
152
  },
31
153
  {
32
- id: 'a11y-inputs-label',
33
- name: 'Input Labels',
154
+ id: 'a11y-aria-valid-attrs',
155
+ name: 'Valid ARIA Attributes',
34
156
  category: 'accessibility',
35
157
  severity: 'error',
36
- description: 'Form inputs must have associated labels',
158
+ description: '[aria-*] attributes must be valid and not misspelled',
37
159
  check: (ctx) => {
38
- const count = ctx.inputsWithoutLabel ?? 0;
160
+ if (ctx.invalidAriaAttributes === undefined)
161
+ return null;
162
+ const count = ctx.invalidAriaAttributes;
39
163
  if (count > 0) {
40
- return createResult({ id: 'a11y-inputs-label', name: 'Input Labels', category: 'accessibility', severity: 'error' }, 'fail', `${count} input(s) without associated label`, { value: count, recommendation: 'Add <label for="id"> or aria-label to all form inputs' });
164
+ return createResult({ id: 'a11y-aria-valid-attrs', name: 'Valid ARIA Attributes', category: 'accessibility', severity: 'error' }, 'fail', `${count} invalid or misspelled aria-* attribute(s) found`, {
165
+ value: count,
166
+ recommendation: 'Verify ARIA attributes are spelled correctly and valid',
167
+ evidence: {
168
+ found: count,
169
+ expected: 0,
170
+ impact: 'Invalid ARIA provides no accessibility benefit',
171
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-valid-attr',
172
+ },
173
+ });
41
174
  }
42
- return null;
175
+ return createResult({ id: 'a11y-aria-valid-attrs', name: 'Valid ARIA Attributes', category: 'accessibility', severity: 'error' }, 'pass', 'All ARIA attributes are valid');
176
+ },
177
+ },
178
+ {
179
+ id: 'a11y-aria-valid-values',
180
+ name: 'ARIA Attribute Values',
181
+ category: 'accessibility',
182
+ severity: 'error',
183
+ description: '[aria-*] attributes must have valid values',
184
+ check: (ctx) => {
185
+ if (ctx.invalidAriaValues === undefined)
186
+ return null;
187
+ const count = ctx.invalidAriaValues;
188
+ if (count > 0) {
189
+ return createResult({ id: 'a11y-aria-valid-values', name: 'ARIA Attribute Values', category: 'accessibility', severity: 'error' }, 'fail', `${count} aria-* attribute(s) have invalid values`, {
190
+ value: count,
191
+ recommendation: 'Use valid values for ARIA attributes (e.g., aria-live="polite")',
192
+ evidence: {
193
+ found: count,
194
+ expected: 0,
195
+ impact: 'Invalid values prevent assistive technologies from working correctly',
196
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-valid-attr-value',
197
+ },
198
+ });
199
+ }
200
+ return createResult({ id: 'a11y-aria-valid-values', name: 'ARIA Attribute Values', category: 'accessibility', severity: 'error' }, 'pass', 'All ARIA attribute values are valid');
201
+ },
202
+ },
203
+ {
204
+ id: 'a11y-aria-roles',
205
+ name: 'Valid ARIA Roles',
206
+ category: 'accessibility',
207
+ severity: 'error',
208
+ description: '[role] values must be valid',
209
+ check: (ctx) => {
210
+ if (ctx.invalidAriaRoles === undefined)
211
+ return null;
212
+ const count = ctx.invalidAriaRoles;
213
+ if (count > 0) {
214
+ return createResult({ id: 'a11y-aria-roles', name: 'Valid ARIA Roles', category: 'accessibility', severity: 'error' }, 'fail', `${count} invalid [role] value(s) found`, {
215
+ value: count,
216
+ recommendation: 'Use valid ARIA role values (e.g., button, dialog, navigation)',
217
+ evidence: {
218
+ found: count,
219
+ expected: 0,
220
+ impact: 'Invalid roles confuse assistive technologies',
221
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-roles',
222
+ },
223
+ });
224
+ }
225
+ return createResult({ id: 'a11y-aria-roles', name: 'Valid ARIA Roles', category: 'accessibility', severity: 'error' }, 'pass', 'All [role] values are valid');
226
+ },
227
+ },
228
+ {
229
+ id: 'a11y-aria-required-attrs',
230
+ name: 'Required ARIA Attributes',
231
+ category: 'accessibility',
232
+ severity: 'error',
233
+ description: '[role]s must have all required [aria-*] attributes',
234
+ check: (ctx) => {
235
+ if (ctx.missingRequiredAriaAttrs === undefined)
236
+ return null;
237
+ const count = ctx.missingRequiredAriaAttrs;
238
+ if (count > 0) {
239
+ return createResult({ id: 'a11y-aria-required-attrs', name: 'Required ARIA Attributes', category: 'accessibility', severity: 'error' }, 'fail', `${count} element(s) with roles missing required aria-* attributes`, {
240
+ value: count,
241
+ recommendation: 'Add required ARIA attributes for each role (e.g., role="slider" requires aria-valuenow)',
242
+ evidence: {
243
+ found: count,
244
+ expected: 0,
245
+ impact: 'Missing attributes break assistive technology functionality',
246
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-required-attr',
247
+ },
248
+ });
249
+ }
250
+ return createResult({ id: 'a11y-aria-required-attrs', name: 'Required ARIA Attributes', category: 'accessibility', severity: 'error' }, 'pass', 'All roles have required ARIA attributes');
43
251
  },
44
252
  },
45
253
  {
46
- id: 'a11y-iframes-title',
47
- name: 'Iframe Titles',
254
+ id: 'a11y-aria-hidden-body',
255
+ name: 'ARIA Hidden Body',
256
+ category: 'accessibility',
257
+ severity: 'error',
258
+ description: '[aria-hidden="true"] must not be present on the document <body>',
259
+ check: (ctx) => {
260
+ if (ctx.hasAriaHiddenBody === undefined)
261
+ return null;
262
+ if (ctx.hasAriaHiddenBody) {
263
+ return createResult({ id: 'a11y-aria-hidden-body', name: 'ARIA Hidden Body', category: 'accessibility', severity: 'error' }, 'fail', 'aria-hidden="true" is present on document <body>', {
264
+ recommendation: 'Remove aria-hidden from <body> element',
265
+ evidence: {
266
+ found: 'aria-hidden="true" on body',
267
+ expected: 'No aria-hidden on body',
268
+ impact: 'Entire page content hidden from assistive technologies',
269
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-hidden-body',
270
+ },
271
+ });
272
+ }
273
+ return createResult({ id: 'a11y-aria-hidden-body', name: 'ARIA Hidden Body', category: 'accessibility', severity: 'error' }, 'pass', 'aria-hidden is not present on <body>');
274
+ },
275
+ },
276
+ {
277
+ id: 'a11y-aria-hidden-focus',
278
+ name: 'ARIA Hidden Focusable',
279
+ category: 'accessibility',
280
+ severity: 'error',
281
+ description: '[aria-hidden="true"] elements must not contain focusable descendants',
282
+ check: (ctx) => {
283
+ if (ctx.ariaHiddenFocusableCount === undefined)
284
+ return null;
285
+ const count = ctx.ariaHiddenFocusableCount;
286
+ if (count > 0) {
287
+ return createResult({ id: 'a11y-aria-hidden-focus', name: 'ARIA Hidden Focusable', category: 'accessibility', severity: 'error' }, 'fail', `${count} aria-hidden element(s) contain focusable descendants`, {
288
+ value: count,
289
+ recommendation: 'Remove focusable elements from aria-hidden containers or remove aria-hidden',
290
+ evidence: {
291
+ found: count,
292
+ expected: 0,
293
+ impact: 'Focus can move to invisible elements, confusing users',
294
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-hidden-focus',
295
+ },
296
+ });
297
+ }
298
+ return createResult({ id: 'a11y-aria-hidden-focus', name: 'ARIA Hidden Focusable', category: 'accessibility', severity: 'error' }, 'pass', 'No focusable elements inside aria-hidden containers');
299
+ },
300
+ },
301
+ {
302
+ id: 'a11y-aria-deprecated',
303
+ name: 'Deprecated ARIA Roles',
48
304
  category: 'accessibility',
49
305
  severity: 'warning',
50
- description: 'Iframes must have title attribute',
306
+ description: 'Deprecated ARIA roles should not be used',
51
307
  check: (ctx) => {
52
- const count = ctx.iframesWithoutTitle ?? 0;
308
+ if (ctx.deprecatedAriaRoles === undefined)
309
+ return null;
310
+ const count = ctx.deprecatedAriaRoles;
53
311
  if (count > 0) {
54
- return createResult({ id: 'a11y-iframes-title', name: 'Iframe Titles', category: 'accessibility', severity: 'warning' }, 'warn', `${count} iframe(s) without title attribute`, { value: count, recommendation: 'Add title attribute to describe iframe content' });
312
+ return createResult({ id: 'a11y-aria-deprecated', name: 'Deprecated ARIA Roles', category: 'accessibility', severity: 'warning' }, 'warn', `${count} deprecated ARIA role(s) found`, {
313
+ value: count,
314
+ recommendation: 'Replace deprecated roles with current alternatives',
315
+ evidence: {
316
+ found: count,
317
+ expected: 0,
318
+ impact: 'Deprecated roles may not work in future browsers',
319
+ learnMore: 'https://www.w3.org/TR/wai-aria-1.2/',
320
+ },
321
+ });
55
322
  }
56
- return null;
323
+ return createResult({ id: 'a11y-aria-deprecated', name: 'Deprecated ARIA Roles', category: 'accessibility', severity: 'warning' }, 'pass', 'No deprecated ARIA roles used');
57
324
  },
58
325
  },
59
326
  {
60
- id: 'a11y-tables-caption',
61
- name: 'Table Captions',
327
+ id: 'a11y-aria-ids-unique',
328
+ name: 'ARIA IDs Unique',
329
+ category: 'accessibility',
330
+ severity: 'error',
331
+ description: 'ARIA IDs must be unique',
332
+ check: (ctx) => {
333
+ if (ctx.duplicateAriaIds === undefined)
334
+ return null;
335
+ const count = ctx.duplicateAriaIds;
336
+ if (count > 0) {
337
+ return createResult({ id: 'a11y-aria-ids-unique', name: 'ARIA IDs Unique', category: 'accessibility', severity: 'error' }, 'fail', `${count} duplicate ARIA ID(s) found`, {
338
+ value: count,
339
+ recommendation: 'Ensure all IDs referenced by aria-labelledby, aria-describedby, etc. are unique',
340
+ evidence: {
341
+ found: count,
342
+ expected: 0,
343
+ impact: 'References to duplicate IDs produce unpredictable behavior',
344
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/duplicate-id-aria',
345
+ },
346
+ });
347
+ }
348
+ return createResult({ id: 'a11y-aria-ids-unique', name: 'ARIA IDs Unique', category: 'accessibility', severity: 'error' }, 'pass', 'All ARIA IDs are unique');
349
+ },
350
+ },
351
+ {
352
+ id: 'a11y-dialog-name',
353
+ name: 'Dialog Accessible Name',
354
+ category: 'accessibility',
355
+ severity: 'error',
356
+ description: 'Elements with role="dialog" or role="alertdialog" must have accessible names',
357
+ check: (ctx) => {
358
+ if (ctx.dialogsWithoutName === undefined)
359
+ return null;
360
+ const count = ctx.dialogsWithoutName;
361
+ if (count > 0) {
362
+ return createResult({ id: 'a11y-dialog-name', name: 'Dialog Accessible Name', category: 'accessibility', severity: 'error' }, 'fail', `${count} dialog(s) without accessible name`, {
363
+ value: count,
364
+ recommendation: 'Add aria-label or aria-labelledby to dialog elements',
365
+ evidence: {
366
+ found: count,
367
+ expected: 0,
368
+ impact: 'Screen reader users cannot identify dialog purpose',
369
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/aria-dialog-name',
370
+ },
371
+ });
372
+ }
373
+ return createResult({ id: 'a11y-dialog-name', name: 'Dialog Accessible Name', category: 'accessibility', severity: 'error' }, 'pass', 'All dialogs have accessible names');
374
+ },
375
+ },
376
+ {
377
+ id: 'a11y-main-landmark',
378
+ name: 'Main Landmark',
379
+ category: 'accessibility',
380
+ severity: 'warning',
381
+ description: 'Document should have a main landmark',
382
+ check: (ctx) => {
383
+ if (ctx.hasMain === undefined)
384
+ return null;
385
+ if (!ctx.hasMain) {
386
+ return createResult({ id: 'a11y-main-landmark', name: 'Main Landmark', category: 'accessibility', severity: 'warning' }, 'warn', 'Document does not have a <main> landmark', {
387
+ recommendation: 'Add a <main> element or role="main" to identify the main content area',
388
+ evidence: {
389
+ expected: '<main> or role="main"',
390
+ impact: 'Screen reader users cannot quickly navigate to main content',
391
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/landmark-one-main',
392
+ },
393
+ });
394
+ }
395
+ return createResult({ id: 'a11y-main-landmark', name: 'Main Landmark', category: 'accessibility', severity: 'warning' }, 'pass', 'Document has a main landmark');
396
+ },
397
+ },
398
+ {
399
+ id: 'a11y-skip-link',
400
+ name: 'Skip Link',
401
+ category: 'accessibility',
402
+ severity: 'info',
403
+ description: 'Page should contain a heading, skip link, or landmark region',
404
+ check: (ctx) => {
405
+ if (ctx.hasSkipLink === undefined && ctx.hasMain === undefined && ctx.h1Count === undefined)
406
+ return null;
407
+ const hasSkip = ctx.hasSkipLink ?? false;
408
+ const hasMain = ctx.hasMain ?? false;
409
+ const hasH1 = (ctx.h1Count ?? 0) > 0;
410
+ if (!hasSkip && !hasMain && !hasH1) {
411
+ return createResult({ id: 'a11y-skip-link', name: 'Skip Link', category: 'accessibility', severity: 'info' }, 'info', 'No skip link, main landmark, or heading found', {
412
+ recommendation: 'Add a skip link, <main> landmark, or at least one heading for navigation',
413
+ evidence: {
414
+ expected: 'Skip link, <main>, or heading',
415
+ impact: 'Keyboard users must tab through all navigation to reach content',
416
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/bypass',
417
+ },
418
+ });
419
+ }
420
+ return createResult({ id: 'a11y-skip-link', name: 'Skip Link', category: 'accessibility', severity: 'info' }, 'pass', 'Page has navigation bypass mechanism');
421
+ },
422
+ },
423
+ {
424
+ id: 'a11y-table-caption',
425
+ name: 'Table Caption',
62
426
  category: 'accessibility',
63
427
  severity: 'info',
64
428
  description: 'Data tables should have caption or aria-label',
65
429
  check: (ctx) => {
66
- const count = ctx.tablesWithoutCaption ?? 0;
430
+ if (ctx.tablesWithoutCaption === undefined)
431
+ return null;
432
+ const count = ctx.tablesWithoutCaption;
67
433
  if (count > 0) {
68
- return createResult({ id: 'a11y-tables-caption', name: 'Table Captions', category: 'accessibility', severity: 'info' }, 'info', `${count} table(s) without caption/aria-label`, { value: count, recommendation: 'Add <caption> or aria-label to data tables' });
434
+ return createResult({ id: 'a11y-table-caption', name: 'Table Caption', category: 'accessibility', severity: 'info' }, 'info', `${count} table(s) without caption or aria-label`, {
435
+ value: count,
436
+ recommendation: 'Add <caption> or aria-label to data tables',
437
+ evidence: {
438
+ found: count,
439
+ expected: 0,
440
+ impact: 'Screen reader users cannot understand table purpose',
441
+ },
442
+ });
69
443
  }
70
- return null;
444
+ return createResult({ id: 'a11y-table-caption', name: 'Table Caption', category: 'accessibility', severity: 'info' }, 'pass', 'All tables have captions or labels');
445
+ },
446
+ },
447
+ {
448
+ id: 'a11y-iframe-title',
449
+ name: 'Iframe Title',
450
+ category: 'accessibility',
451
+ severity: 'warning',
452
+ description: '<frame> or <iframe> elements must have a title',
453
+ check: (ctx) => {
454
+ if (ctx.iframesWithoutTitle === undefined)
455
+ return null;
456
+ const count = ctx.iframesWithoutTitle;
457
+ if (count > 0) {
458
+ return createResult({ id: 'a11y-iframe-title', name: 'Iframe Title', category: 'accessibility', severity: 'warning' }, 'warn', `${count} iframe(s) without title attribute`, {
459
+ value: count,
460
+ recommendation: 'Add title attribute to describe iframe content',
461
+ evidence: {
462
+ found: count,
463
+ expected: 0,
464
+ impact: 'Screen reader users cannot identify iframe purpose',
465
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/frame-title',
466
+ },
467
+ });
468
+ }
469
+ return createResult({ id: 'a11y-iframe-title', name: 'Iframe Title', category: 'accessibility', severity: 'warning' }, 'pass', 'All iframes have title attributes');
71
470
  },
72
471
  },
73
472
  {
74
473
  id: 'a11y-svg-title',
75
- name: 'SVG Accessibility',
474
+ name: 'SVG Title',
76
475
  category: 'accessibility',
77
476
  severity: 'warning',
78
477
  description: 'SVGs should have <title> or aria-label for accessibility',
79
478
  check: (ctx) => {
80
- const count = ctx.svgsWithoutTitle ?? 0;
479
+ if (ctx.svgsWithoutTitle === undefined)
480
+ return null;
481
+ const count = ctx.svgsWithoutTitle;
81
482
  if (count > 0) {
82
- return createResult({ id: 'a11y-svg-title', name: 'SVG Accessibility', category: 'accessibility', severity: 'warning' }, 'warn', `${count} SVG(s) without accessible title`, { value: count, recommendation: 'Add <title> inside SVG or aria-label for decorative SVGs' });
483
+ return createResult({ id: 'a11y-svg-title', name: 'SVG Title', category: 'accessibility', severity: 'warning' }, 'warn', `${count} SVG(s) without accessible title`, {
484
+ value: count,
485
+ recommendation: 'Add <title> element inside SVG or aria-label attribute',
486
+ evidence: {
487
+ found: count,
488
+ expected: 0,
489
+ impact: 'Screen readers cannot describe SVG content',
490
+ },
491
+ });
83
492
  }
84
- return null;
493
+ return createResult({ id: 'a11y-svg-title', name: 'SVG Title', category: 'accessibility', severity: 'warning' }, 'pass', 'All SVGs have accessible titles');
85
494
  },
86
495
  },
87
496
  {
88
- id: 'a11y-images-decorative',
89
- name: 'Decorative Images',
497
+ id: 'a11y-viewport-zoom',
498
+ name: 'Viewport Zoom',
90
499
  category: 'accessibility',
91
- severity: 'info',
92
- description: 'Decorative images should have empty alt=""',
500
+ severity: 'error',
501
+ description: '[user-scalable="no"] should not be used and maximum-scale should not be less than 5',
93
502
  check: (ctx) => {
94
- const decorative = ctx.imagesDecorativeCount ?? 0;
95
- const emptyAlt = ctx.imagesWithEmptyAlt ?? 0;
96
- if (decorative > 0 && emptyAlt === 0) {
97
- return createResult({ id: 'a11y-images-decorative', name: 'Decorative Images', category: 'accessibility', severity: 'info' }, 'info', 'Some images may be decorative - use alt="" for decorative images', { recommendation: 'For decorative images, use alt="" (empty string) not missing alt' });
503
+ if (ctx.viewportContent === undefined)
504
+ return null;
505
+ const viewport = ctx.viewportContent.toLowerCase();
506
+ const hasUserScalableNo = viewport.includes('user-scalable=no') || viewport.includes('user-scalable=0');
507
+ const maxScaleMatch = viewport.match(/maximum-scale\s*=\s*([\d.]+)/);
508
+ const maxScale = maxScaleMatch ? parseFloat(maxScaleMatch[1]) : null;
509
+ if (hasUserScalableNo || (maxScale !== null && maxScale < 5)) {
510
+ return createResult({ id: 'a11y-viewport-zoom', name: 'Viewport Zoom', category: 'accessibility', severity: 'error' }, 'fail', 'Viewport prevents zooming', {
511
+ recommendation: 'Remove user-scalable=no and ensure maximum-scale is at least 5',
512
+ evidence: {
513
+ found: ctx.viewportContent,
514
+ expected: 'No user-scalable=no, maximum-scale >= 5',
515
+ impact: 'Users with low vision cannot zoom to read content',
516
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/meta-viewport',
517
+ },
518
+ });
98
519
  }
99
- return null;
520
+ return createResult({ id: 'a11y-viewport-zoom', name: 'Viewport Zoom', category: 'accessibility', severity: 'error' }, 'pass', 'Viewport allows zooming');
100
521
  },
101
522
  },
102
523
  {
103
- id: 'a11y-buttons-aria-label',
104
- name: 'Buttons with Accessible Name',
524
+ id: 'a11y-document-title',
525
+ name: 'Document Title',
105
526
  category: 'accessibility',
106
527
  severity: 'error',
107
- description: 'Buttons without text content must have an aria-label or title',
528
+ description: 'Document must have a <title> element',
108
529
  check: (ctx) => {
109
- if (ctx.buttonsWithoutAriaLabel && ctx.buttonsWithoutAriaLabel > 0) {
110
- return createResult({ id: 'a11y-buttons-aria-label', name: 'Buttons Accessible Name', category: 'accessibility', severity: 'error' }, 'fail', `${ctx.buttonsWithoutAriaLabel} button(s) without accessible name found`, { recommendation: 'Add descriptive text, aria-label, aria-labelledby, or title to buttons' });
530
+ if (ctx.title === undefined)
531
+ return null;
532
+ if (!ctx.title || ctx.title.trim().length === 0) {
533
+ return createResult({ id: 'a11y-document-title', name: 'Document Title', category: 'accessibility', severity: 'error' }, 'fail', 'Document does not have a <title> element', {
534
+ recommendation: 'Add a descriptive <title> element to the document',
535
+ evidence: {
536
+ expected: '<title>Page Title</title>',
537
+ impact: 'Screen reader users cannot identify the page',
538
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/document-title',
539
+ },
540
+ });
111
541
  }
112
- return null;
542
+ return createResult({ id: 'a11y-document-title', name: 'Document Title', category: 'accessibility', severity: 'error' }, 'pass', 'Document has a title element');
113
543
  },
114
544
  },
115
545
  {
116
- id: 'a11y-links-aria-label',
117
- name: 'Links with Accessible Name',
546
+ id: 'a11y-html-lang',
547
+ name: 'HTML Lang Attribute',
118
548
  category: 'accessibility',
119
549
  severity: 'error',
120
- description: 'Icon-only links must have an aria-label or title',
550
+ description: '<html> element must have a [lang] attribute',
121
551
  check: (ctx) => {
122
- if (ctx.linksWithoutAriaLabel && ctx.linksWithoutAriaLabel > 0) {
123
- return createResult({ id: 'a11y-links-aria-label', name: 'Links Accessible Name', category: 'accessibility', severity: 'error' }, 'fail', `${ctx.linksWithoutAriaLabel} icon-only link(s) without accessible name found`, { recommendation: 'Add descriptive text, aria-label, aria-labelledby, or title to icon-only links' });
552
+ if (ctx.hasLang === undefined)
553
+ return null;
554
+ if (!ctx.hasLang) {
555
+ return createResult({ id: 'a11y-html-lang', name: 'HTML Lang Attribute', category: 'accessibility', severity: 'error' }, 'fail', '<html> element does not have a [lang] attribute', {
556
+ recommendation: 'Add lang attribute to html element (e.g., <html lang="en">)',
557
+ evidence: {
558
+ expected: '<html lang="en">',
559
+ impact: 'Screen readers may pronounce content incorrectly',
560
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/html-has-lang',
561
+ },
562
+ });
563
+ }
564
+ return createResult({ id: 'a11y-html-lang', name: 'HTML Lang Attribute', category: 'accessibility', severity: 'error' }, 'pass', 'HTML element has lang attribute');
565
+ },
566
+ },
567
+ {
568
+ id: 'a11y-html-lang-valid',
569
+ name: 'Valid Lang Attribute',
570
+ category: 'accessibility',
571
+ severity: 'error',
572
+ description: '<html> element must have a valid value for its [lang] attribute',
573
+ check: (ctx) => {
574
+ if (!ctx.hasLang || !ctx.langValue)
575
+ return null;
576
+ const validLangPattern = /^[a-z]{2,3}(-[A-Za-z]{2,4})?(-[A-Za-z0-9]{2,})?$/i;
577
+ if (!validLangPattern.test(ctx.langValue)) {
578
+ return createResult({ id: 'a11y-html-lang-valid', name: 'Valid Lang Attribute', category: 'accessibility', severity: 'error' }, 'fail', `Invalid lang attribute value: ${ctx.langValue}`, {
579
+ value: ctx.langValue,
580
+ recommendation: 'Use a valid BCP 47 language tag (e.g., "en", "en-US", "pt-BR")',
581
+ evidence: {
582
+ found: ctx.langValue,
583
+ expected: 'Valid BCP 47 language tag',
584
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/html-lang-valid',
585
+ },
586
+ });
587
+ }
588
+ return createResult({ id: 'a11y-html-lang-valid', name: 'Valid Lang Attribute', category: 'accessibility', severity: 'error' }, 'pass', `Valid lang attribute: ${ctx.langValue}`);
589
+ },
590
+ },
591
+ {
592
+ id: 'a11y-video-captions',
593
+ name: 'Video Captions',
594
+ category: 'accessibility',
595
+ severity: 'warning',
596
+ description: '<video> elements should contain a <track> element with [kind="captions"]',
597
+ check: (ctx) => {
598
+ if (ctx.videoCount === undefined || ctx.videosWithCaptions === undefined)
599
+ return null;
600
+ const videos = ctx.videoCount;
601
+ const withCaptions = ctx.videosWithCaptions;
602
+ if (videos > 0 && withCaptions < videos) {
603
+ return createResult({ id: 'a11y-video-captions', name: 'Video Captions', category: 'accessibility', severity: 'warning' }, 'warn', `${videos - withCaptions} of ${videos} video(s) missing captions track`, {
604
+ value: videos - withCaptions,
605
+ recommendation: 'Add <track kind="captions" src="..."> to video elements',
606
+ evidence: {
607
+ found: `${withCaptions} with captions`,
608
+ expected: `${videos} with captions`,
609
+ impact: 'Deaf users cannot access video content',
610
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/video-caption',
611
+ },
612
+ });
613
+ }
614
+ if (videos > 0) {
615
+ return createResult({ id: 'a11y-video-captions', name: 'Video Captions', category: 'accessibility', severity: 'warning' }, 'pass', `All ${videos} video(s) have captions`);
124
616
  }
125
617
  return null;
126
618
  },
127
619
  },
620
+ {
621
+ id: 'a11y-list-structure',
622
+ name: 'List Structure',
623
+ category: 'accessibility',
624
+ severity: 'warning',
625
+ description: 'Lists must contain only <li> elements and script supporting elements',
626
+ check: (ctx) => {
627
+ if (ctx.invalidListStructure === undefined)
628
+ return null;
629
+ const count = ctx.invalidListStructure;
630
+ if (count > 0) {
631
+ return createResult({ id: 'a11y-list-structure', name: 'List Structure', category: 'accessibility', severity: 'warning' }, 'warn', `${count} list(s) have invalid structure`, {
632
+ value: count,
633
+ recommendation: 'Ensure lists (<ul>, <ol>) only contain <li>, <script>, or <template> children',
634
+ evidence: {
635
+ found: count,
636
+ expected: 0,
637
+ impact: 'Screen readers may not announce lists correctly',
638
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/list',
639
+ },
640
+ });
641
+ }
642
+ return createResult({ id: 'a11y-list-structure', name: 'List Structure', category: 'accessibility', severity: 'warning' }, 'pass', 'All lists have valid structure');
643
+ },
644
+ },
645
+ {
646
+ id: 'a11y-headings-content',
647
+ name: 'Heading Content',
648
+ category: 'accessibility',
649
+ severity: 'warning',
650
+ description: 'All heading elements must contain content',
651
+ check: (ctx) => {
652
+ if (ctx.emptyHeadings === undefined)
653
+ return null;
654
+ const count = ctx.emptyHeadings;
655
+ if (count > 0) {
656
+ return createResult({ id: 'a11y-headings-content', name: 'Heading Content', category: 'accessibility', severity: 'warning' }, 'warn', `${count} empty heading element(s) found`, {
657
+ value: count,
658
+ recommendation: 'Add text content to all heading elements or remove empty headings',
659
+ evidence: {
660
+ found: count,
661
+ expected: 0,
662
+ impact: 'Empty headings confuse screen reader navigation',
663
+ learnMore: 'https://dequeuniversity.com/rules/axe/4.4/empty-heading',
664
+ },
665
+ });
666
+ }
667
+ return createResult({ id: 'a11y-headings-content', name: 'Heading Content', category: 'accessibility', severity: 'warning' }, 'pass', 'All headings contain content');
668
+ },
669
+ },
670
+ {
671
+ id: 'a11y-images-redundant-alt',
672
+ name: 'Redundant Alt Text',
673
+ category: 'accessibility',
674
+ severity: 'info',
675
+ description: 'Image alt attributes should not be redundant',
676
+ check: (ctx) => {
677
+ if (ctx.imagesWithRedundantAlt === undefined)
678
+ return null;
679
+ const count = ctx.imagesWithRedundantAlt;
680
+ if (count > 0) {
681
+ return createResult({ id: 'a11y-images-redundant-alt', name: 'Redundant Alt Text', category: 'accessibility', severity: 'info' }, 'info', `${count} image(s) have redundant alt text (e.g., "image of", "picture of")`, {
682
+ value: count,
683
+ recommendation: 'Remove redundant phrases like "image of" or "picture of" from alt text',
684
+ evidence: {
685
+ found: count,
686
+ expected: 0,
687
+ impact: 'Screen readers already announce images; redundant text is repetitive',
688
+ },
689
+ });
690
+ }
691
+ return createResult({ id: 'a11y-images-redundant-alt', name: 'Redundant Alt Text', category: 'accessibility', severity: 'info' }, 'pass', 'No redundant alt text found');
692
+ },
693
+ },
128
694
  ];