@mindfiredigital/ignix-lite-mcp 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/CHANGELOG.md +7 -0
  3. package/dist/manifests/accordion.json +61 -0
  4. package/dist/manifests/alert.json +69 -0
  5. package/dist/manifests/avatar.json +75 -0
  6. package/dist/manifests/badge.json +74 -0
  7. package/dist/manifests/breadcrumb.json +87 -0
  8. package/dist/manifests/button.json +85 -0
  9. package/dist/manifests/card.json +91 -0
  10. package/dist/manifests/checkbox.json +122 -0
  11. package/dist/manifests/codeblock.json +63 -0
  12. package/dist/manifests/combobox.json +33 -0
  13. package/dist/manifests/dialog.json +64 -0
  14. package/dist/manifests/divider.json +47 -0
  15. package/dist/manifests/dropdown.json +105 -0
  16. package/dist/manifests/form.json +81 -0
  17. package/dist/manifests/grid.json +143 -0
  18. package/dist/manifests/input.json +99 -0
  19. package/dist/manifests/meter.json +103 -0
  20. package/dist/manifests/navigation.json +70 -0
  21. package/dist/manifests/progress.json +88 -0
  22. package/dist/manifests/radio.json +121 -0
  23. package/dist/manifests/select.json +109 -0
  24. package/dist/manifests/skeleton.json +101 -0
  25. package/dist/manifests/tab.json +88 -0
  26. package/dist/manifests/table.json +92 -0
  27. package/dist/manifests/textarea.json +117 -0
  28. package/dist/manifests/toast.json +157 -0
  29. package/dist/manifests/tooltip.json +115 -0
  30. package/dist/server.d.ts +1 -2
  31. package/dist/server.js +2102 -1
  32. package/dist/server.js.map +1 -1
  33. package/dist/utils/check-api.d.ts +2 -0
  34. package/dist/utils/check-api.js +11 -0
  35. package/dist/utils/check-api.js.map +1 -0
  36. package/dist/vector-index.json +14015 -0
  37. package/package.json +25 -11
  38. package/src/context/api-context.ts +14 -0
  39. package/src/global.d.ts +15 -0
  40. package/src/manifests/accordion.json +61 -0
  41. package/src/manifests/alert.json +69 -0
  42. package/src/manifests/avatar.json +75 -0
  43. package/src/manifests/badge.json +74 -0
  44. package/src/manifests/breadcrumb.json +87 -0
  45. package/src/manifests/button.json +85 -0
  46. package/src/manifests/card.json +91 -0
  47. package/src/manifests/checkbox.json +122 -0
  48. package/src/manifests/codeblock.json +63 -0
  49. package/src/manifests/combobox.json +33 -0
  50. package/src/manifests/dialog.json +64 -0
  51. package/src/manifests/divider.json +47 -0
  52. package/src/manifests/dropdown.json +105 -0
  53. package/src/manifests/form.json +81 -0
  54. package/src/manifests/grid.json +143 -0
  55. package/src/manifests/index.ts +45 -0
  56. package/src/manifests/input.json +99 -0
  57. package/src/manifests/meter.json +103 -0
  58. package/src/manifests/navigation.json +70 -0
  59. package/src/manifests/progress.json +88 -0
  60. package/src/manifests/radio.json +121 -0
  61. package/src/manifests/select.json +109 -0
  62. package/src/manifests/skeleton.json +101 -0
  63. package/src/manifests/tab.json +88 -0
  64. package/src/manifests/table.json +92 -0
  65. package/src/manifests/textarea.json +117 -0
  66. package/src/manifests/toast.json +157 -0
  67. package/src/manifests/tooltip.json +115 -0
  68. package/src/server.ts +200 -2
  69. package/src/tools/build-index.ts +55 -0
  70. package/src/tools/check-a11y.ts +106 -0
  71. package/src/tools/embedder.ts +18 -0
  72. package/src/tools/generate-theme.ts +42 -0
  73. package/src/tools/get-emmet.ts +64 -0
  74. package/src/tools/get-manifests.ts +55 -0
  75. package/src/tools/intent-engine.ts +197 -0
  76. package/src/tools/list-components.ts +20 -0
  77. package/src/tools/search-index.ts +66 -0
  78. package/src/tools/theme-palette.ts +65 -0
  79. package/src/tools/theme-tokens.ts +176 -0
  80. package/src/tools/validator.ts +367 -0
  81. package/src/types.ts +63 -0
  82. package/src/utils/a11y-rules.ts +873 -0
  83. package/src/utils/a11y-types.ts +15 -0
  84. package/src/utils/check-api.ts +13 -0
  85. package/src/utils/cosine.ts +15 -0
  86. package/src/utils/emmet-helpers.ts +171 -0
  87. package/src/utils/intent-helpers.ts +66 -0
  88. package/src/utils/intent-parser.ts +186 -0
  89. package/src/utils/tokenizer.ts +7 -0
  90. package/tsconfig.json +9 -2
  91. package/tsup.config.ts +6 -3
  92. package/LICENSE +0 -21
  93. package/dist/server.cjs +0 -2
  94. package/dist/server.cjs.map +0 -1
  95. package/dist/server.d.cts +0 -3
  96. package/test/basic.test.ts +0 -8
@@ -0,0 +1,873 @@
1
+ import type { HTMLElement } from 'node-html-parser'
2
+ import type { A11yIssue, RuleResult } from './a11y-types.js'
3
+
4
+ function injectAttr(outerHTML: string, attr: string, value: string): string {
5
+ return outerHTML.replace(/^(<[a-zA-Z][a-zA-Z0-9-]*)/, `$1 ${attr}="${value}"`)
6
+ }
7
+
8
+ function clip(str: string, max = 200): string {
9
+ return str.length > max ? str.slice(0, max) + '...' : str
10
+ }
11
+
12
+ function isDecorative(img: HTMLElement): boolean {
13
+ const role = img.getAttribute('role')
14
+ const ariaHidden = img.getAttribute('aria-hidden')
15
+ return role === 'presentation' || role === 'none' || ariaHidden === 'true'
16
+ }
17
+
18
+ // Returns IDs from an ARIA reference attribute that do not exist in the DOM
19
+ function findBrokenAriaRefs(
20
+ el: HTMLElement,
21
+ attr: string,
22
+ root: HTMLElement
23
+ ): string[] {
24
+ const val = el.getAttribute(attr)
25
+ if (!val?.trim()) return []
26
+ return val
27
+ .trim()
28
+ .split(/\s+/)
29
+ .filter((id) => !root.querySelector(`[id="${id}"]`))
30
+ }
31
+
32
+ // Walks child nodes skipping any subtree rooted at aria-hidden="true"
33
+ function getVisibleText(el: HTMLElement): string {
34
+ if (el.getAttribute('aria-hidden') === 'true') return ''
35
+ let result = ''
36
+ for (const child of el.childNodes) {
37
+ if (child.nodeType === 1) {
38
+ result += getVisibleText(child as HTMLElement)
39
+ } else if (child.nodeType === 3) {
40
+ result += child.text ?? ''
41
+ }
42
+ }
43
+ return result
44
+ }
45
+
46
+ function getAccessibleName(el: HTMLElement, root: HTMLElement): string {
47
+ const text = getVisibleText(el).trim()
48
+ if (text) return text
49
+
50
+ const ariaLabel = el.getAttribute('aria-label')?.trim()
51
+ if (ariaLabel) return ariaLabel
52
+
53
+ const title = el.getAttribute('title')?.trim()
54
+ if (title) return title
55
+
56
+ const childImg = el.querySelector('img')
57
+ const childAlt = childImg?.getAttribute('alt')?.trim()
58
+ if (childAlt) return childAlt
59
+
60
+ const labelledBy = el.getAttribute('aria-labelledby')
61
+ if (labelledBy) {
62
+ const name = labelledBy
63
+ .trim()
64
+ .split(/\s+/)
65
+ .map((id) => root.querySelector(`[id="${id}"]`)?.text.trim() ?? '')
66
+ .join(' ')
67
+ .trim()
68
+ if (name) return name
69
+ }
70
+
71
+ return ''
72
+ }
73
+
74
+ export function checkImages(root: HTMLElement): RuleResult {
75
+ const ruleName = 'WCAG 1.1.1 Non-text Content'
76
+ const issues: A11yIssue[] = []
77
+
78
+ root.querySelectorAll('img').forEach((img) => {
79
+ const alt = img.getAttribute('alt')
80
+ const ariaLabel = img.getAttribute('aria-label')?.trim()
81
+ const labelledBy = img.getAttribute('aria-labelledby')
82
+ const hasAriaName = !!(ariaLabel || labelledBy)
83
+
84
+ if (alt === undefined) {
85
+ if (hasAriaName) {
86
+ issues.push({
87
+ type: 'warning',
88
+ rule: ruleName,
89
+ element: clip(img.outerHTML),
90
+ message:
91
+ '<img> uses aria-label/aria-labelledby but the alt attribute is recommended for broader screen reader compatibility',
92
+ fix: injectAttr(
93
+ img.outerHTML,
94
+ 'alt',
95
+ ariaLabel ?? '[Describe the image]'
96
+ )
97
+ })
98
+ } else {
99
+ issues.push({
100
+ type: 'error',
101
+ rule: ruleName,
102
+ element: clip(img.outerHTML),
103
+ message: '<img> is missing the alt attribute',
104
+ fix: injectAttr(img.outerHTML, 'alt', '[Describe the image]')
105
+ })
106
+ }
107
+ return
108
+ }
109
+
110
+ if (alt.trim() === '' && !isDecorative(img)) {
111
+ issues.push({
112
+ type: 'warning',
113
+ rule: ruleName,
114
+ element: clip(img.outerHTML),
115
+ message:
116
+ '<img> has an empty alt but is not marked as decorative - add role="presentation" or provide a description',
117
+ fix: injectAttr(img.outerHTML, 'role', 'presentation')
118
+ })
119
+ }
120
+ })
121
+
122
+ return { ruleName, issues }
123
+ }
124
+
125
+ export function checkFormLabels(root: HTMLElement): RuleResult {
126
+ const ruleName = 'WCAG 1.3.1 Form Labels'
127
+ const issues: A11yIssue[] = []
128
+ const UNLABELED_SKIP = new Set([
129
+ 'hidden',
130
+ 'submit',
131
+ 'reset',
132
+ 'button',
133
+ 'image'
134
+ ])
135
+
136
+ for (const tag of ['input', 'select', 'textarea'] as const) {
137
+ root.querySelectorAll(tag).forEach((el) => {
138
+ if (tag === 'input') {
139
+ const type = (el.getAttribute('type') ?? 'text').toLowerCase()
140
+ if (UNLABELED_SKIP.has(type)) return
141
+ }
142
+
143
+ const id = el.getAttribute('id')
144
+ const hasExplicit = id
145
+ ? !!root.querySelector(`label[for="${id}"]`)
146
+ : false
147
+ const hasImplicit = !!el.closest('label')
148
+ const ariaLabel = el.getAttribute('aria-label')?.trim()
149
+ const ariaLabelledBy = el.getAttribute('aria-labelledby')
150
+ const hasTitle = !!el.getAttribute('title')?.trim()
151
+
152
+ if (
153
+ !hasExplicit &&
154
+ !hasImplicit &&
155
+ !ariaLabel &&
156
+ !ariaLabelledBy &&
157
+ !hasTitle
158
+ ) {
159
+ issues.push({
160
+ type: 'error',
161
+ rule: ruleName,
162
+ element: clip(el.outerHTML),
163
+ message: `<${tag}> is not associated with a <label> - use for/id, wrapping label, or aria-label`,
164
+ fix: id
165
+ ? `<label for="${id}">[Label text]</label>\n${el.outerHTML}`
166
+ : `<label>[Label text] ${el.outerHTML}</label>`
167
+ })
168
+ return
169
+ }
170
+
171
+ // Verify aria-labelledby targets actually exist in the DOM
172
+ if (ariaLabelledBy) {
173
+ const broken = findBrokenAriaRefs(el, 'aria-labelledby', root)
174
+ if (broken.length > 0) {
175
+ issues.push({
176
+ type: 'error',
177
+ rule: ruleName,
178
+ element: clip(el.outerHTML),
179
+ message: `<${tag}> aria-labelledby references non-existent element(s): ${broken.map((id) => `#${id}`).join(', ')}`,
180
+ fix: `Ensure element(s) with id="${broken.join('", "')}" exist in the DOM, or use aria-label instead`
181
+ })
182
+ }
183
+ }
184
+ })
185
+ }
186
+
187
+ root.querySelectorAll('fieldset').forEach((fieldset) => {
188
+ const legend = fieldset.querySelector('legend')
189
+ if (!legend || !legend.text.trim()) {
190
+ issues.push({
191
+ type: 'error',
192
+ rule: ruleName,
193
+ element: '<fieldset>',
194
+ message:
195
+ '<fieldset> must have a non-empty <legend> to group related form controls',
196
+ fix: `<fieldset>\n <legend>[Group label]</legend>\n ...\n</fieldset>`
197
+ })
198
+ }
199
+ })
200
+
201
+ return { ruleName, issues }
202
+ }
203
+
204
+ export function checkEmptyLabels(root: HTMLElement): RuleResult {
205
+ const ruleName = 'WCAG 2.4.6 Empty Labels'
206
+ const issues: A11yIssue[] = []
207
+
208
+ root.querySelectorAll('label').forEach((label) => {
209
+ const hasText = !!label.text.trim()
210
+ const hasAriaLabel = !!label.getAttribute('aria-label')?.trim()
211
+
212
+ if (!hasText && !hasAriaLabel) {
213
+ issues.push({
214
+ type: 'warning',
215
+ rule: ruleName,
216
+ element: clip(label.outerHTML),
217
+ message:
218
+ '<label> is empty and provides no accessible name for its control',
219
+ fix: label.outerHTML.replace('</label>', '[Label text]</label>')
220
+ })
221
+ }
222
+
223
+ // Check broken label[for] association
224
+ const forAttr = label.getAttribute('for')
225
+ if (forAttr && !root.querySelector(`[id="${forAttr}"]`)) {
226
+ issues.push({
227
+ type: 'error',
228
+ rule: ruleName,
229
+ element: clip(label.outerHTML),
230
+ message: `<label for="${forAttr}"> references id="${forAttr}" which does not exist in the DOM`,
231
+ fix: `Add id="${forAttr}" to the target form element, or correct the for attribute`
232
+ })
233
+ }
234
+ })
235
+
236
+ return { ruleName, issues }
237
+ }
238
+
239
+ export function checkButtons(root: HTMLElement): RuleResult {
240
+ const ruleName = 'WCAG 4.1.2 Button Names'
241
+ const issues: A11yIssue[] = []
242
+
243
+ root.querySelectorAll('button').forEach((button) => {
244
+ if (!getAccessibleName(button, root)) {
245
+ issues.push({
246
+ type: 'error',
247
+ rule: ruleName,
248
+ element: clip(button.outerHTML),
249
+ message:
250
+ '<button> has no accessible name - add text content, aria-label, or a child <img> with alt',
251
+ fix: injectAttr(button.outerHTML, 'aria-label', '[Action description]')
252
+ })
253
+ }
254
+ })
255
+
256
+ return { ruleName, issues }
257
+ }
258
+
259
+ export function checkLinks(root: HTMLElement): RuleResult {
260
+ const ruleName = 'WCAG 2.4.4 Link Purpose'
261
+ const issues: A11yIssue[] = []
262
+ const VAGUE = new Set([
263
+ 'click here',
264
+ 'here',
265
+ 'read more',
266
+ 'more',
267
+ 'link',
268
+ 'click',
269
+ 'learn more',
270
+ 'details',
271
+ 'info'
272
+ ])
273
+
274
+ root.querySelectorAll('a').forEach((link) => {
275
+ const name = getAccessibleName(link, root)
276
+ const href = link.getAttribute('href')
277
+ const isButtonRole = link.getAttribute('role') === 'button'
278
+
279
+ if (!name) {
280
+ issues.push({
281
+ type: 'error',
282
+ rule: ruleName,
283
+ element: clip(link.outerHTML),
284
+ message:
285
+ '<a> has no accessible name - add text, aria-label, or a child <img> with alt',
286
+ fix: injectAttr(
287
+ link.outerHTML,
288
+ 'aria-label',
289
+ '[Describe the link destination]'
290
+ )
291
+ })
292
+ return
293
+ }
294
+
295
+ if (VAGUE.has(name.toLowerCase())) {
296
+ issues.push({
297
+ type: 'warning',
298
+ rule: ruleName,
299
+ element: clip(link.outerHTML),
300
+ message: `<a> has non-descriptive text "${name}" - use aria-label to clarify the destination`,
301
+ fix: injectAttr(
302
+ link.outerHTML,
303
+ 'aria-label',
304
+ '[Describe where this link goes]'
305
+ )
306
+ })
307
+ }
308
+
309
+ if (!href && !isButtonRole) {
310
+ issues.push({
311
+ type: 'warning',
312
+ rule: ruleName,
313
+ element: clip(link.outerHTML),
314
+ message:
315
+ '<a> has no href - use <button> for actions, or add a valid href',
316
+ fix: link.outerHTML.replace(/^<a\b/, '<a href="#"')
317
+ })
318
+ }
319
+ })
320
+
321
+ return { ruleName, issues }
322
+ }
323
+
324
+ export function checkAriaStates(root: HTMLElement): RuleResult {
325
+ const ruleName = 'WCAG 3.3.1 Error Identification'
326
+ const issues: A11yIssue[] = []
327
+
328
+ // aria-invalid="true" must have aria-describedby pointing to an existing element
329
+ root.querySelectorAll('[aria-invalid]').forEach((el) => {
330
+ const invalidVal = el.getAttribute('aria-invalid')
331
+ const VALID_INVALID_VALUES = new Set([
332
+ 'false',
333
+ 'true',
334
+ 'grammar',
335
+ 'spelling'
336
+ ])
337
+
338
+ if (!VALID_INVALID_VALUES.has(invalidVal ?? '')) {
339
+ issues.push({
340
+ type: 'warning',
341
+ rule: 'WCAG 4.1.2 ARIA State Values',
342
+ element: clip(el.outerHTML),
343
+ message: `aria-invalid="${invalidVal}" is not valid - use "true", "false", "grammar", or "spelling"`,
344
+ fix: el.outerHTML.replace(
345
+ `aria-invalid="${invalidVal}"`,
346
+ 'aria-invalid="true"'
347
+ )
348
+ })
349
+ return
350
+ }
351
+
352
+ if (invalidVal === 'true') {
353
+ const describedBy = el.getAttribute('aria-describedby')
354
+ if (!describedBy) {
355
+ issues.push({
356
+ type: 'error',
357
+ rule: ruleName,
358
+ element: clip(el.outerHTML),
359
+ message:
360
+ 'Element with aria-invalid="true" must have aria-describedby pointing to the error message element',
361
+ fix: injectAttr(el.outerHTML, 'aria-describedby', 'error-message-id')
362
+ })
363
+ } else {
364
+ // Verify the describedby target exists
365
+ const broken = findBrokenAriaRefs(el, 'aria-describedby', root)
366
+ if (broken.length > 0) {
367
+ issues.push({
368
+ type: 'error',
369
+ rule: ruleName,
370
+ element: clip(el.outerHTML),
371
+ message: `aria-describedby references non-existent element(s): ${broken.map((id) => `#${id}`).join(', ')}`,
372
+ fix: `Ensure element(s) with id="${broken.join('", "')}" exist in the DOM`
373
+ })
374
+ }
375
+ }
376
+ }
377
+ })
378
+
379
+ // Strictly boolean ARIA attributes (only "true" or "false" valid)
380
+ const BOOLEAN_ARIA_ATTRS = [
381
+ 'aria-busy',
382
+ 'aria-expanded',
383
+ 'aria-selected',
384
+ 'aria-pressed'
385
+ ]
386
+ for (const attr of BOOLEAN_ARIA_ATTRS) {
387
+ root.querySelectorAll(`[${attr}]`).forEach((el) => {
388
+ const val = el.getAttribute(attr)
389
+ if (val !== 'true' && val !== 'false') {
390
+ issues.push({
391
+ type: 'warning',
392
+ rule: 'WCAG 4.1.2 ARIA State Values',
393
+ element: clip(el.outerHTML),
394
+ message: `${attr} must be "true" or "false", got "${val}"`,
395
+ fix: el.outerHTML.replace(`${attr}="${val}"`, `${attr}="true"`)
396
+ })
397
+ }
398
+ })
399
+ }
400
+
401
+ // aria-checked also allows "mixed" for tri-state checkboxes (WAI-ARIA spec)
402
+ root.querySelectorAll('[aria-checked]').forEach((el) => {
403
+ const val = el.getAttribute('aria-checked')
404
+ if (val !== 'true' && val !== 'false' && val !== 'mixed') {
405
+ issues.push({
406
+ type: 'warning',
407
+ rule: 'WCAG 4.1.2 ARIA State Values',
408
+ element: clip(el.outerHTML),
409
+ message: `aria-checked must be "true", "false", or "mixed" (for tri-state), got "${val}"`,
410
+ fix: el.outerHTML.replace(
411
+ `aria-checked="${val}"`,
412
+ 'aria-checked="false"'
413
+ )
414
+ })
415
+ }
416
+ })
417
+
418
+ // aria-live with invalid value
419
+ const VALID_ARIA_LIVE = new Set(['off', 'polite', 'assertive'])
420
+ root.querySelectorAll('[aria-live]').forEach((el) => {
421
+ const val = el.getAttribute('aria-live') ?? ''
422
+ if (!VALID_ARIA_LIVE.has(val)) {
423
+ issues.push({
424
+ type: 'warning',
425
+ rule: 'WCAG 4.1.2 ARIA State Values',
426
+ element: clip(el.outerHTML),
427
+ message: `aria-live="${val}" is not valid - use "off", "polite", or "assertive"`,
428
+ fix: el.outerHTML.replace(`aria-live="${val}"`, 'aria-live="polite"')
429
+ })
430
+ }
431
+ })
432
+
433
+ return { ruleName, issues }
434
+ }
435
+
436
+ export function checkDuplicateIds(root: HTMLElement): RuleResult {
437
+ const ruleName = 'WCAG 4.1.1 Parsing'
438
+ const issues: A11yIssue[] = []
439
+ const seen = new Map<string, number>()
440
+
441
+ root.querySelectorAll('[id]').forEach((el) => {
442
+ const id = el.getAttribute('id')
443
+
444
+ // Empty id is invalid HTML and breaks all ARIA references
445
+ if (id !== undefined && id.trim() === '') {
446
+ issues.push({
447
+ type: 'error',
448
+ rule: ruleName,
449
+ element: clip(el.outerHTML),
450
+ message: 'id="" is an empty ID - IDs must have a non-empty value',
451
+ fix: el.outerHTML.replace('id=""', 'id="[unique-id]"')
452
+ })
453
+ return
454
+ }
455
+
456
+ if (id) seen.set(id, (seen.get(id) ?? 0) + 1)
457
+ })
458
+
459
+ seen.forEach((count, id) => {
460
+ if (count > 1) {
461
+ issues.push({
462
+ type: 'error',
463
+ rule: ruleName,
464
+ element: `id="${id}"`,
465
+ message: `id="${id}" is used ${count} times - IDs must be unique within a document`,
466
+ fix: `Keep one element with id="${id}" and rename all duplicates to unique IDs`
467
+ })
468
+ }
469
+ })
470
+
471
+ return { ruleName, issues }
472
+ }
473
+
474
+ export function checkTabIndex(root: HTMLElement): RuleResult {
475
+ const ruleName = 'WCAG 2.1.1 Keyboard'
476
+ const issues: A11yIssue[] = []
477
+
478
+ root.querySelectorAll('[tabindex]').forEach((el) => {
479
+ const raw = el.getAttribute('tabindex') ?? ''
480
+ const val = parseInt(raw, 10)
481
+
482
+ if (isNaN(val)) {
483
+ issues.push({
484
+ type: 'warning',
485
+ rule: ruleName,
486
+ element: clip(el.outerHTML),
487
+ message: `tabindex="${raw}" is not a valid integer`,
488
+ fix: el.outerHTML.replace(`tabindex="${raw}"`, 'tabindex="0"')
489
+ })
490
+ return
491
+ }
492
+
493
+ if (val > 0) {
494
+ issues.push({
495
+ type: 'warning',
496
+ rule: ruleName,
497
+ element: clip(el.outerHTML),
498
+ message: `tabindex="${val}" disrupts natural tab order - use tabindex="0" or rely on DOM order`,
499
+ fix: el.outerHTML.replace(`tabindex="${val}"`, 'tabindex="0"')
500
+ })
501
+ }
502
+ })
503
+
504
+ return { ruleName, issues }
505
+ }
506
+
507
+ export function checkHeadings(root: HTMLElement): RuleResult {
508
+ const ruleName = 'WCAG 2.4.6 Heading Hierarchy'
509
+ const issues: A11yIssue[] = []
510
+ const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
511
+
512
+ let lastLevel = 0
513
+ let h1Count = 0
514
+
515
+ headings.forEach((h) => {
516
+ const tag = h.tagName.toLowerCase()
517
+ const level = parseInt(h.tagName.slice(1), 10)
518
+
519
+ if (!h.text.trim()) {
520
+ issues.push({
521
+ type: 'error',
522
+ rule: ruleName,
523
+ element: clip(h.outerHTML),
524
+ message: `<${tag}> is empty - headings must have descriptive text`,
525
+ fix: `<${tag}>[Heading text]</${tag}>`
526
+ })
527
+ }
528
+
529
+ if (level === 1) h1Count++
530
+
531
+ if (lastLevel > 0 && level > lastLevel + 1) {
532
+ issues.push({
533
+ type: 'warning',
534
+ rule: ruleName,
535
+ element: clip(h.outerHTML),
536
+ message: `Heading skips from h${lastLevel} to h${level} - use h${lastLevel + 1} to maintain document outline`,
537
+ fix: `<h${lastLevel + 1}>${h.text.trim()}</h${lastLevel + 1}>`
538
+ })
539
+ }
540
+
541
+ lastLevel = level
542
+ })
543
+
544
+ if (h1Count > 1) {
545
+ issues.push({
546
+ type: 'warning',
547
+ rule: ruleName,
548
+ element: 'h1',
549
+ message: `${h1Count} <h1> elements found - a page should have exactly one <h1>`,
550
+ fix: 'Demote additional <h1> elements to <h2> or lower'
551
+ })
552
+ }
553
+
554
+ return { ruleName, issues }
555
+ }
556
+
557
+ export function checkTables(root: HTMLElement): RuleResult {
558
+ const ruleName = 'WCAG 1.3.1 Table Structure'
559
+ const issues: A11yIssue[] = []
560
+
561
+ root.querySelectorAll('table').forEach((table) => {
562
+ // Layout/presentational tables do not need captions or headers
563
+ const role = table.getAttribute('role')
564
+ if (role === 'presentation' || role === 'none') return
565
+
566
+ const caption = table.querySelector('caption')
567
+ const ariaLabel = table.getAttribute('aria-label')
568
+ const ariaLabelledBy = table.getAttribute('aria-labelledby')
569
+
570
+ if (!caption && !ariaLabel && !ariaLabelledBy) {
571
+ issues.push({
572
+ type: 'warning',
573
+ rule: ruleName,
574
+ element: '<table>',
575
+ message:
576
+ '<table> has no caption, aria-label, or aria-labelledby - screen readers cannot identify its purpose',
577
+ fix: injectAttr('<table>', 'aria-label', '[Describe the table]')
578
+ })
579
+ }
580
+
581
+ // Empty caption is as bad as no caption
582
+ if (caption && !caption.text.trim()) {
583
+ issues.push({
584
+ type: 'warning',
585
+ rule: ruleName,
586
+ element: clip(caption.outerHTML),
587
+ message:
588
+ '<caption> is empty - provide a meaningful description of the table',
589
+ fix: caption.outerHTML.replace(
590
+ '</caption>',
591
+ '[Table description]</caption>'
592
+ )
593
+ })
594
+ }
595
+
596
+ table.querySelectorAll('th').forEach((th) => {
597
+ if (!th.getAttribute('scope')) {
598
+ issues.push({
599
+ type: 'warning',
600
+ rule: ruleName,
601
+ element: clip(th.outerHTML),
602
+ message:
603
+ '<th> is missing the scope attribute - use scope="col" for column headers or scope="row" for row headers',
604
+ fix: injectAttr(th.outerHTML, 'scope', 'col')
605
+ })
606
+ }
607
+ })
608
+ })
609
+
610
+ return { ruleName, issues }
611
+ }
612
+
613
+ export function checkDialogs(root: HTMLElement): RuleResult {
614
+ const ruleName = 'WCAG 4.1.2 Dialog Accessibility'
615
+ const issues: A11yIssue[] = []
616
+
617
+ root.querySelectorAll('dialog').forEach((dialog) => {
618
+ const id = dialog.getAttribute('id')
619
+ const ariaLabel = dialog.getAttribute('aria-label')
620
+ const ariaLabelledBy = dialog.getAttribute('aria-labelledby')
621
+
622
+ if (!id) {
623
+ issues.push({
624
+ type: 'warning',
625
+ rule: ruleName,
626
+ element: '<dialog>',
627
+ message:
628
+ '<dialog> has no id - required by the ignix-lite button[onclick="dialogId.showModal()"] pattern',
629
+ fix: injectAttr('<dialog>', 'id', 'dialog-id')
630
+ })
631
+ }
632
+
633
+ if (!ariaLabel && !ariaLabelledBy) {
634
+ issues.push({
635
+ type: 'warning',
636
+ rule: ruleName,
637
+ element: '<dialog>',
638
+ message:
639
+ '<dialog> has no accessible name - add aria-labelledby pointing to a heading inside, or aria-label',
640
+ fix: id
641
+ ? `<dialog id="${id}" aria-labelledby="dialog-title">...</dialog>`
642
+ : `<dialog aria-label="[Dialog purpose]">...</dialog>`
643
+ })
644
+ } else if (ariaLabelledBy) {
645
+ // Verify the labelledby target exists
646
+ const broken = findBrokenAriaRefs(dialog, 'aria-labelledby', root)
647
+ if (broken.length > 0) {
648
+ issues.push({
649
+ type: 'error',
650
+ rule: ruleName,
651
+ element: '<dialog>',
652
+ message: `dialog aria-labelledby references non-existent element(s): ${broken.map((id) => `#${id}`).join(', ')}`,
653
+ fix: `Ensure element(s) with id="${broken.join('", "')}" exist inside the dialog`
654
+ })
655
+ }
656
+ }
657
+ })
658
+
659
+ return { ruleName, issues }
660
+ }
661
+
662
+ export function checkRoles(root: HTMLElement): RuleResult {
663
+ const ruleName = 'WCAG 4.1.2 ARIA Role Requirements'
664
+ const issues: A11yIssue[] = []
665
+ const NATIVELY_INTERACTIVE = new Set([
666
+ 'a',
667
+ 'button',
668
+ 'input',
669
+ 'select',
670
+ 'textarea',
671
+ 'details',
672
+ 'summary'
673
+ ])
674
+
675
+ root.querySelectorAll('[role="button"]').forEach((el) => {
676
+ const tag = el.tagName.toLowerCase()
677
+ const tabIndex = el.getAttribute('tabindex')
678
+ if (!NATIVELY_INTERACTIVE.has(tag) && tabIndex !== '0') {
679
+ issues.push({
680
+ type: 'error',
681
+ rule: ruleName,
682
+ element: clip(el.outerHTML),
683
+ message:
684
+ 'Element with role="button" must have tabindex="0" to be keyboard-accessible',
685
+ fix: injectAttr(el.outerHTML, 'tabindex', '0')
686
+ })
687
+ }
688
+ })
689
+
690
+ // Roles requiring aria-checked
691
+ for (const role of [
692
+ 'checkbox',
693
+ 'radio',
694
+ 'menuitemcheckbox',
695
+ 'menuitemradio'
696
+ ] as const) {
697
+ root.querySelectorAll(`[role="${role}"]`).forEach((el) => {
698
+ if (!el.getAttribute('aria-checked')) {
699
+ issues.push({
700
+ type: 'error',
701
+ rule: ruleName,
702
+ element: clip(el.outerHTML),
703
+ message: `role="${role}" requires aria-checked attribute (values: "true", "false"${role === 'checkbox' ? ', "mixed"' : ''})`,
704
+ fix: injectAttr(el.outerHTML, 'aria-checked', 'false')
705
+ })
706
+ }
707
+ })
708
+ }
709
+
710
+ root.querySelectorAll('[role="combobox"]').forEach((el) => {
711
+ if (!el.getAttribute('aria-expanded')) {
712
+ issues.push({
713
+ type: 'error',
714
+ rule: ruleName,
715
+ element: clip(el.outerHTML),
716
+ message: 'role="combobox" requires aria-expanded attribute',
717
+ fix: injectAttr(el.outerHTML, 'aria-expanded', 'false')
718
+ })
719
+ }
720
+ })
721
+
722
+ // Roles requiring aria-selected
723
+ for (const role of ['tab', 'option', 'treeitem', 'gridcell'] as const) {
724
+ root.querySelectorAll(`[role="${role}"]`).forEach((el) => {
725
+ if (!el.getAttribute('aria-selected')) {
726
+ issues.push({
727
+ type: 'error',
728
+ rule: ruleName,
729
+ element: clip(el.outerHTML),
730
+ message: `role="${role}" requires aria-selected attribute`,
731
+ fix: injectAttr(el.outerHTML, 'aria-selected', 'false')
732
+ })
733
+ }
734
+ })
735
+ }
736
+
737
+ root.querySelectorAll('[role="slider"]').forEach((el) => {
738
+ const required = ['aria-valuenow', 'aria-valuemin', 'aria-valuemax']
739
+ const missing = required.filter((attr) => !el.getAttribute(attr))
740
+ if (missing.length > 0) {
741
+ issues.push({
742
+ type: 'error',
743
+ rule: ruleName,
744
+ element: clip(el.outerHTML),
745
+ message: `role="slider" is missing required attributes: ${missing.join(', ')}`,
746
+ fix: 'Add aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" to the element'
747
+ })
748
+ }
749
+ })
750
+
751
+ root.querySelectorAll('[role="progressbar"]').forEach((el) => {
752
+ if (
753
+ !el.getAttribute('aria-valuenow') &&
754
+ !el.getAttribute('aria-valuetext')
755
+ ) {
756
+ issues.push({
757
+ type: 'warning',
758
+ rule: ruleName,
759
+ element: clip(el.outerHTML),
760
+ message:
761
+ 'role="progressbar" should have aria-valuenow or aria-valuetext to communicate current progress',
762
+ fix: injectAttr(el.outerHTML, 'aria-valuenow', '0')
763
+ })
764
+ }
765
+ })
766
+
767
+ root.querySelectorAll('[role="listbox"]').forEach((el) => {
768
+ if (!getAccessibleName(el, root)) {
769
+ issues.push({
770
+ type: 'warning',
771
+ rule: ruleName,
772
+ element: clip(el.outerHTML),
773
+ message:
774
+ 'role="listbox" should have an accessible name via aria-label or aria-labelledby',
775
+ fix: injectAttr(
776
+ el.outerHTML,
777
+ 'aria-label',
778
+ '[Describe the list options]'
779
+ )
780
+ })
781
+ }
782
+ })
783
+
784
+ return { ruleName, issues }
785
+ }
786
+
787
+ export function checkAutocomplete(root: HTMLElement): RuleResult {
788
+ const ruleName = 'WCAG 1.3.5 Input Purpose'
789
+ const issues: A11yIssue[] = []
790
+
791
+ const TYPE_AUTOCOMPLETE: Record<string, string> = {
792
+ email: 'email',
793
+ tel: 'tel',
794
+ url: 'url'
795
+ }
796
+
797
+ root.querySelectorAll('input').forEach((input) => {
798
+ const type = (input.getAttribute('type') ?? 'text').toLowerCase()
799
+ const expected = TYPE_AUTOCOMPLETE[type]
800
+
801
+ if (expected && !input.getAttribute('autocomplete')) {
802
+ issues.push({
803
+ type: 'warning',
804
+ rule: ruleName,
805
+ element: clip(input.outerHTML),
806
+ message: `<input type="${type}"> should have autocomplete="${expected}" to assist users with autofill`,
807
+ fix: injectAttr(input.outerHTML, 'autocomplete', expected)
808
+ })
809
+ }
810
+
811
+ if (type === 'password' && !input.getAttribute('autocomplete')) {
812
+ issues.push({
813
+ type: 'warning',
814
+ rule: ruleName,
815
+ element: clip(input.outerHTML),
816
+ message:
817
+ '<input type="password"> should have autocomplete="current-password" or autocomplete="new-password"',
818
+ fix: injectAttr(input.outerHTML, 'autocomplete', 'current-password')
819
+ })
820
+ }
821
+ })
822
+
823
+ return { ruleName, issues }
824
+ }
825
+
826
+ export function checkFocusStyle(root: HTMLElement): RuleResult {
827
+ const ruleName = 'WCAG 2.4.7 Focus Visible'
828
+ const issues: A11yIssue[] = []
829
+ const KILLS_FOCUS = [
830
+ /outline\s*:\s*none/i,
831
+ /outline\s*:\s*0(?:px)?/i,
832
+ /outline-width\s*:\s*0/i
833
+ ]
834
+
835
+ root.querySelectorAll('[style]').forEach((el) => {
836
+ const style = el.getAttribute('style') ?? ''
837
+ if (KILLS_FOCUS.some((rx) => rx.test(style))) {
838
+ issues.push({
839
+ type: 'warning',
840
+ rule: ruleName,
841
+ element: clip(el.outerHTML),
842
+ message:
843
+ 'Inline style removes the focus outline - keyboard users cannot see the focus indicator',
844
+ fix: el.outerHTML
845
+ .replace(/outline\s*:\s*(none|0(?:px)?)\s*;?/gi, '')
846
+ .replace(/outline-width\s*:\s*0\s*;?/gi, '')
847
+ })
848
+ }
849
+ })
850
+
851
+ return { ruleName, issues }
852
+ }
853
+
854
+ export function checkLang(root: HTMLElement): RuleResult {
855
+ const ruleName = 'WCAG 3.1.1 Language of Page'
856
+ const issues: A11yIssue[] = []
857
+
858
+ const htmlEl = root.querySelector('html')
859
+ if (htmlEl && !htmlEl.getAttribute('lang')?.trim()) {
860
+ issues.push({
861
+ type: 'error',
862
+ rule: ruleName,
863
+ element: '<html>',
864
+ message:
865
+ '<html> is missing the lang attribute - screen readers need this to select the correct voice/language',
866
+ fix: '<html lang="en">'
867
+ })
868
+ }
869
+
870
+ return { ruleName, issues }
871
+ }
872
+
873
+