@herb-tools/linter 0.5.0 → 0.6.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 (139) hide show
  1. package/dist/herb-lint.js +5131 -1647
  2. package/dist/herb-lint.js.map +1 -1
  3. package/dist/index.cjs +662 -145
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +654 -147
  6. package/dist/index.js.map +1 -1
  7. package/dist/package.json +4 -4
  8. package/dist/src/cli/argument-parser.js +0 -4
  9. package/dist/src/cli/argument-parser.js.map +1 -1
  10. package/dist/src/default-rules.js +20 -0
  11. package/dist/src/default-rules.js.map +1 -1
  12. package/dist/src/linter.js +29 -4
  13. package/dist/src/linter.js.map +1 -1
  14. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
  15. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
  16. package/dist/src/rules/erb-prefer-image-tag-helper.js +0 -4
  17. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  18. package/dist/src/rules/html-aria-attribute-must-be-valid.js +11 -10
  19. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  20. package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
  21. package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
  22. package/dist/src/rules/html-aria-level-must-be-valid.js +26 -4
  23. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
  24. package/dist/src/rules/html-aria-role-heading-requires-level.js +7 -13
  25. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  26. package/dist/src/rules/html-aria-role-must-be-valid.js +3 -3
  27. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  28. package/dist/src/rules/html-attribute-double-quotes.js +14 -4
  29. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  30. package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
  31. package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
  32. package/dist/src/rules/html-attribute-values-require-quotes.js +19 -8
  33. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  34. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
  35. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
  36. package/dist/src/rules/html-boolean-attributes-no-value.js +9 -2
  37. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  38. package/dist/src/rules/html-iframe-has-title.js +39 -0
  39. package/dist/src/rules/html-iframe-has-title.js.map +1 -0
  40. package/dist/src/rules/html-img-require-alt.js +0 -4
  41. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  42. package/dist/src/rules/html-navigation-has-label.js +43 -0
  43. package/dist/src/rules/html-navigation-has-label.js.map +1 -0
  44. package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
  45. package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
  46. package/dist/src/rules/html-no-duplicate-attributes.js +22 -25
  47. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  48. package/dist/src/rules/html-no-duplicate-ids.js +2 -2
  49. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  50. package/dist/src/rules/html-no-empty-headings.js +0 -21
  51. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  52. package/dist/src/rules/html-no-positive-tab-index.js +21 -0
  53. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
  54. package/dist/src/rules/html-no-self-closing.js +22 -0
  55. package/dist/src/rules/html-no-self-closing.js.map +1 -0
  56. package/dist/src/rules/html-no-title-attribute.js +27 -0
  57. package/dist/src/rules/html-no-title-attribute.js.map +1 -0
  58. package/dist/src/rules/html-tag-name-lowercase.js +35 -23
  59. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  60. package/dist/src/rules/index.js +10 -0
  61. package/dist/src/rules/index.js.map +1 -1
  62. package/dist/src/rules/rule-utils.js +176 -22
  63. package/dist/src/rules/rule-utils.js.map +1 -1
  64. package/dist/src/rules/svg-tag-name-capitalization.js +0 -8
  65. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/dist/types/cli/index.d.ts +4 -0
  69. package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  70. package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  71. package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
  72. package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  73. package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
  74. package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
  75. package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  76. package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
  77. package/dist/types/rules/html-no-self-closing.d.ts +7 -0
  78. package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
  79. package/dist/types/rules/html-tag-name-lowercase.d.ts +2 -1
  80. package/dist/types/rules/index.d.ts +10 -0
  81. package/dist/types/rules/rule-utils.d.ts +107 -13
  82. package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  83. package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  84. package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
  85. package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  86. package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
  87. package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
  88. package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  89. package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
  90. package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
  91. package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
  92. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +2 -1
  93. package/dist/types/src/rules/index.d.ts +10 -0
  94. package/dist/types/src/rules/rule-utils.d.ts +107 -13
  95. package/dist/types/src/types.d.ts +24 -0
  96. package/dist/types/types.d.ts +24 -0
  97. package/docs/rules/README.md +12 -2
  98. package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
  99. package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
  100. package/docs/rules/html-attribute-equals-spacing.md +35 -0
  101. package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
  102. package/docs/rules/html-iframe-has-title.md +43 -0
  103. package/docs/rules/html-navigation-has-label.md +61 -0
  104. package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
  105. package/docs/rules/html-no-positive-tab-index.md +55 -0
  106. package/docs/rules/html-no-self-closing.md +65 -0
  107. package/docs/rules/html-no-title-attribute.md +69 -0
  108. package/docs/rules/html-tag-name-lowercase.md +16 -3
  109. package/package.json +4 -4
  110. package/src/cli/argument-parser.ts +0 -5
  111. package/src/default-rules.ts +20 -0
  112. package/src/linter.ts +30 -4
  113. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
  114. package/src/rules/erb-prefer-image-tag-helper.ts +2 -7
  115. package/src/rules/html-aria-attribute-must-be-valid.ts +28 -32
  116. package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
  117. package/src/rules/html-aria-level-must-be-valid.ts +38 -5
  118. package/src/rules/html-aria-role-heading-requires-level.ts +16 -28
  119. package/src/rules/html-aria-role-must-be-valid.ts +5 -5
  120. package/src/rules/html-attribute-double-quotes.ts +21 -6
  121. package/src/rules/html-attribute-equals-spacing.ts +41 -0
  122. package/src/rules/html-attribute-values-require-quotes.ts +29 -9
  123. package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
  124. package/src/rules/html-boolean-attributes-no-value.ts +17 -4
  125. package/src/rules/html-iframe-has-title.ts +62 -0
  126. package/src/rules/html-img-require-alt.ts +2 -7
  127. package/src/rules/html-navigation-has-label.ts +64 -0
  128. package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
  129. package/src/rules/html-no-duplicate-attributes.ts +28 -28
  130. package/src/rules/html-no-duplicate-ids.ts +4 -3
  131. package/src/rules/html-no-empty-headings.ts +2 -31
  132. package/src/rules/html-no-positive-tab-index.ts +33 -0
  133. package/src/rules/html-no-self-closing.ts +36 -0
  134. package/src/rules/html-no-title-attribute.ts +42 -0
  135. package/src/rules/html-tag-name-lowercase.ts +42 -29
  136. package/src/rules/index.ts +10 -0
  137. package/src/rules/rule-utils.ts +260 -39
  138. package/src/rules/svg-tag-name-capitalization.ts +2 -9
  139. package/src/types.ts +27 -0
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Visitor, Position, Location, isERBNode } from '@herb-tools/core';
1
+ import { Visitor, Position, Location, getStaticAttributeName, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, filterERBContentNodes, isERBNode, filterLiteralNodes, isERBOutputNode, getTagName as getTagName$1, isNode, HTMLOpenTagNode } from '@herb-tools/core';
2
2
 
3
3
  class ParserRule {
4
4
  static type = "parser";
@@ -49,12 +49,10 @@ class BaseRuleVisitor extends Visitor {
49
49
  }
50
50
  }
51
51
  /**
52
- * Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
52
+ * Gets attributes from an HTMLOpenTagNode
53
53
  */
54
54
  function getAttributes(node) {
55
- return node.type === "AST_HTML_SELF_CLOSE_TAG_NODE"
56
- ? node.attributes
57
- : node.children;
55
+ return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE");
58
56
  }
59
57
  /**
60
58
  * Gets the tag name from an HTML tag node (lowercased)
@@ -64,14 +62,66 @@ function getTagName(node) {
64
62
  }
65
63
  /**
66
64
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
65
+ * Returns null if the attribute name contains dynamic content (ERB)
67
66
  */
68
67
  function getAttributeName(attributeNode) {
69
68
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
70
69
  const nameNode = attributeNode.name;
71
- return nameNode.name?.value.toLowerCase() || null;
70
+ const staticName = getStaticAttributeName(nameNode);
71
+ return staticName ? staticName.toLowerCase() : null;
72
72
  }
73
73
  return null;
74
74
  }
75
+ /**
76
+ * Checks if an attribute has a dynamic (ERB-containing) name
77
+ */
78
+ function hasDynamicAttributeName(attributeNode) {
79
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
80
+ const nameNode = attributeNode.name;
81
+ return hasDynamicAttributeName$1(nameNode);
82
+ }
83
+ return false;
84
+ }
85
+ /**
86
+ * Gets the combined string representation of an attribute name (for debugging)
87
+ * This includes both static content and ERB syntax
88
+ */
89
+ function getCombinedAttributeNameString(attributeNode) {
90
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
91
+ const nameNode = attributeNode.name;
92
+ return getCombinedAttributeName(nameNode);
93
+ }
94
+ return "";
95
+ }
96
+ /**
97
+ * Checks if an attribute value contains only static content (no ERB)
98
+ */
99
+ function hasStaticAttributeValue(attributeNode) {
100
+ const valueNode = attributeNode.value;
101
+ if (!valueNode?.children)
102
+ return false;
103
+ return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
104
+ }
105
+ /**
106
+ * Gets the static string value of an attribute (returns null if it contains ERB)
107
+ */
108
+ function getStaticAttributeValue(attributeNode) {
109
+ if (!hasStaticAttributeValue(attributeNode))
110
+ return null;
111
+ const valueNode = attributeNode.value;
112
+ const result = valueNode.children
113
+ ?.filter(child => child.type === "AST_LITERAL_NODE")
114
+ .map(child => child.content)
115
+ .join("") || "";
116
+ return result;
117
+ }
118
+ /**
119
+ * Gets the value nodes array for dynamic inspection
120
+ */
121
+ function getAttributeValueNodes(attributeNode) {
122
+ const valueNode = attributeNode.value;
123
+ return valueNode?.children || [];
124
+ }
75
125
  /**
76
126
  * Gets the attribute value content from an HTMLAttributeValueNode
77
127
  */
@@ -109,9 +159,18 @@ function hasAttributeValue(attributeNode) {
109
159
  /**
110
160
  * Gets the quote type used for an attribute value
111
161
  */
112
- function getAttributeValueQuoteType(attributeNode) {
113
- if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
114
- const valueNode = attributeNode.value;
162
+ function getAttributeValueQuoteType(nodeOrAttribute) {
163
+ let valueNode;
164
+ if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
165
+ const attributeNode = nodeOrAttribute;
166
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
167
+ valueNode = attributeNode.value;
168
+ }
169
+ }
170
+ else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
171
+ valueNode = nodeOrAttribute;
172
+ }
173
+ if (valueNode) {
115
174
  if (valueNode.quoted && valueNode.open_quote) {
116
175
  return valueNode.open_quote.value === '"' ? "double" : "single";
117
176
  }
@@ -138,8 +197,14 @@ function findAttributeByName(attributes, attributeName) {
138
197
  * Checks if a tag has a specific attribute
139
198
  */
140
199
  function hasAttribute(node, attributeName) {
200
+ return getAttribute(node, attributeName) !== null;
201
+ }
202
+ /**
203
+ * Checks if a tag has a specific attribute
204
+ */
205
+ function getAttribute(node, attributeName) {
141
206
  const attributes = getAttributes(node);
142
- return findAttributeByName(attributes, attributeName) !== null;
207
+ return findAttributeByName(attributes, attributeName);
143
208
  }
144
209
  /**
145
210
  * Common HTML element categorization
@@ -156,6 +221,10 @@ const HTML_BLOCK_ELEMENTS = new Set([
156
221
  "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
157
222
  "ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
158
223
  ]);
224
+ const HTML_VOID_ELEMENTS = new Set([
225
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
226
+ "param", "source", "track", "wbr",
227
+ ]);
159
228
  const HTML_BOOLEAN_ATTRIBUTES = new Set([
160
229
  "autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
161
230
  "loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
@@ -294,6 +363,12 @@ function isInlineElement(tagName) {
294
363
  function isBlockElement(tagName) {
295
364
  return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase());
296
365
  }
366
+ /**
367
+ * Checks if an element is a void element
368
+ */
369
+ function isVoidElement(tagName) {
370
+ return HTML_VOID_ELEMENTS.has(tagName.toLowerCase());
371
+ }
297
372
  /**
298
373
  * Checks if an attribute is a boolean attribute
299
374
  */
@@ -301,9 +376,14 @@ function isBooleanAttribute(attributeName) {
301
376
  return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase());
302
377
  }
303
378
  /**
304
- * Abstract base class for rules that need to check individual attributes on HTML tags
305
- * Eliminates duplication of visitHTMLOpenTagNode/visitHTMLSelfCloseTagNode patterns
306
- * and attribute iteration logic. Provides simplified interface with extracted attribute info.
379
+ * Attribute visitor that provides granular processing based on both
380
+ * attribute name type (static/dynamic) and value type (static/dynamic)
381
+ *
382
+ * This gives you 4 distinct methods to override:
383
+ * - checkStaticAttributeStaticValue() - name="class" value="foo"
384
+ * - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
385
+ * - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
386
+ * - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
307
387
  */
308
388
  class AttributeVisitorMixin extends BaseRuleVisitor {
309
389
  constructor(ruleName, context) {
@@ -313,19 +393,69 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
313
393
  this.checkAttributesOnNode(node);
314
394
  super.visitHTMLOpenTagNode(node);
315
395
  }
316
- visitHTMLSelfCloseTagNode(node) {
317
- this.checkAttributesOnNode(node);
318
- super.visitHTMLSelfCloseTagNode(node);
319
- }
320
396
  checkAttributesOnNode(node) {
321
397
  forEachAttribute(node, (attributeNode) => {
322
- const attributeName = getAttributeName(attributeNode);
323
- const attributeValue = getAttributeValue(attributeNode);
324
- if (attributeName) {
325
- this.checkAttribute(attributeName, attributeValue, attributeNode, node);
398
+ const staticAttributeName = getAttributeName(attributeNode);
399
+ const isDynamicName = hasDynamicAttributeName(attributeNode);
400
+ const staticAttributeValue = getStaticAttributeValue(attributeNode);
401
+ const valueNodes = getAttributeValueNodes(attributeNode);
402
+ const hasOutputERB = hasERBOutput(valueNodes);
403
+ const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes);
404
+ if (staticAttributeName && staticAttributeValue !== null) {
405
+ this.checkStaticAttributeStaticValue({
406
+ attributeName: staticAttributeName,
407
+ attributeValue: staticAttributeValue,
408
+ attributeNode,
409
+ parentNode: node
410
+ });
411
+ }
412
+ else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
413
+ const validatableContent = getValidatableStaticContent(valueNodes) || "";
414
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
415
+ }
416
+ else if (staticAttributeName && hasOutputERB) {
417
+ const combinedValue = getAttributeValue(attributeNode);
418
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
419
+ }
420
+ else if (isDynamicName && staticAttributeValue !== null) {
421
+ const nameNode = attributeNode.name;
422
+ const nameNodes = nameNode.children || [];
423
+ const combinedName = getCombinedAttributeNameString(attributeNode);
424
+ this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName });
425
+ }
426
+ else if (isDynamicName) {
427
+ const nameNode = attributeNode.name;
428
+ const nameNodes = nameNode.children || [];
429
+ const combinedName = getCombinedAttributeNameString(attributeNode);
430
+ const combinedValue = getAttributeValue(attributeNode);
431
+ this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue });
326
432
  }
327
433
  });
328
434
  }
435
+ /**
436
+ * Static attribute name with static value: class="container"
437
+ */
438
+ checkStaticAttributeStaticValue(params) {
439
+ // Default implementation does nothing
440
+ }
441
+ /**
442
+ * Static attribute name with dynamic value: class="<%= css_class %>"
443
+ */
444
+ checkStaticAttributeDynamicValue(params) {
445
+ // Default implementation does nothing
446
+ }
447
+ /**
448
+ * Dynamic attribute name with static value: data-<%= key %>="foo"
449
+ */
450
+ checkDynamicAttributeStaticValue(params) {
451
+ // Default implementation does nothing
452
+ }
453
+ /**
454
+ * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
455
+ */
456
+ checkDynamicAttributeDynamicValue(params) {
457
+ // Default implementation does nothing
458
+ }
329
459
  }
330
460
  /**
331
461
  * Iterates over all attributes of a tag node, calling the callback for each attribute
@@ -354,7 +484,7 @@ class BaseSourceRuleVisitor {
354
484
  */
355
485
  createOffense(message, location, severity = "error") {
356
486
  return {
357
- rule: this.ruleName, // Type assertion for compatibility
487
+ rule: this.ruleName,
358
488
  code: this.ruleName,
359
489
  source: "Herb Linter",
360
490
  message,
@@ -457,15 +587,34 @@ class ERBNoOutputControlFlowRule extends ParserRule {
457
587
  }
458
588
  }
459
589
 
590
+ class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
591
+ visitHTMLAttributeNameNode(node) {
592
+ const erbNodes = filterERBContentNodes(node.children);
593
+ const silentNodes = erbNodes.filter(this.isSilentERBTag);
594
+ for (const node of silentNodes) {
595
+ this.addOffense(`Remove silent ERB tag from HTML attribute name. Silent ERB tags (\`${node.tag_opening?.value}\`) do not output content and should not be used in attribute names.`, node.location, "error");
596
+ }
597
+ }
598
+ // TODO: might be worth to extract
599
+ isSilentERBTag(node) {
600
+ const silentTags = ["<%", "<%-", "<%#"];
601
+ return silentTags.includes(node.tag_opening?.value || "");
602
+ }
603
+ }
604
+ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
605
+ name = "erb-no-silent-tag-in-attribute-name";
606
+ check(result, context) {
607
+ const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context);
608
+ visitor.visit(result.value);
609
+ return visitor.offenses;
610
+ }
611
+ }
612
+
460
613
  class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
461
614
  visitHTMLOpenTagNode(node) {
462
615
  this.checkImgTag(node);
463
616
  super.visitHTMLOpenTagNode(node);
464
617
  }
465
- visitHTMLSelfCloseTagNode(node) {
466
- this.checkImgTag(node);
467
- super.visitHTMLSelfCloseTagNode(node);
468
- }
469
618
  checkImgTag(node) {
470
619
  const tagName = getTagName(node);
471
620
  if (tagName !== "img") {
@@ -650,17 +799,18 @@ class HTMLAnchorRequireHrefRule extends ParserRule {
650
799
  }
651
800
 
652
801
  class AriaAttributeMustBeValid extends AttributeVisitorMixin {
653
- checkAttribute(attributeName, _attributeValue, attributeNode, _parentNode) {
802
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
803
+ this.check(attributeName, attributeNode);
804
+ }
805
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
806
+ this.check(attributeName, attributeNode);
807
+ }
808
+ check(attributeName, attributeNode) {
654
809
  if (!attributeName.startsWith("aria-"))
655
810
  return;
656
- if (!ARIA_ATTRIBUTES.has(attributeName)) {
657
- this.offenses.push({
658
- message: `The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`,
659
- severity: "error",
660
- location: attributeNode.location,
661
- rule: this.ruleName,
662
- });
663
- }
811
+ if (ARIA_ATTRIBUTES.has(attributeName))
812
+ return;
813
+ this.addOffense(`The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`, attributeNode.location, "error");
664
814
  }
665
815
  }
666
816
  class HTMLAriaAttributeMustBeValid extends ParserRule {
@@ -672,13 +822,65 @@ class HTMLAriaAttributeMustBeValid extends ParserRule {
672
822
  }
673
823
  }
674
824
 
825
+ class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
826
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
827
+ if (attributeName !== "aria-label")
828
+ return;
829
+ if (attributeValue.match(/[\r\n]+/) || attributeValue.match(/&#10;|&#13;|&#x0A;|&#x0D;/i)) {
830
+ this.addOffense("The `aria-label` attribute value text should not contain line breaks. Use concise, single-line descriptions.", attributeNode.location, "error");
831
+ return;
832
+ }
833
+ if (this.looksLikeId(attributeValue)) {
834
+ this.addOffense("The `aria-label` attribute value should not be formatted like an ID. Use natural, sentence-case text instead.", attributeNode.location, "error");
835
+ return;
836
+ }
837
+ if (attributeValue.match(/^[a-z]/)) {
838
+ this.addOffense("The `aria-label` attribute value text should be formatted like visual text. Use sentence case (capitalize the first letter).", attributeNode.location, "error");
839
+ }
840
+ }
841
+ looksLikeId(text) {
842
+ return (text.includes('_') ||
843
+ text.includes('-') ||
844
+ /^[a-z]+([A-Z][a-z]*)*$/.test(text)) && !text.includes(' ');
845
+ }
846
+ }
847
+ class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
848
+ name = "html-aria-label-is-well-formatted";
849
+ check(result, context) {
850
+ const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context);
851
+ visitor.visit(result.value);
852
+ return visitor.offenses;
853
+ }
854
+ }
855
+
675
856
  class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
676
- checkAttribute(attributeName, attributeValue, attributeNode, _parentNode) {
857
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
677
858
  if (attributeName !== "aria-level")
678
859
  return;
679
- if (attributeValue !== null && attributeValue.includes("<%"))
860
+ this.validateAriaLevel(attributeValue, attributeNode);
861
+ }
862
+ checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
863
+ if (attributeName !== "aria-level")
680
864
  return;
681
- if (attributeValue === null || attributeValue === "") {
865
+ const validatableContent = getValidatableStaticContent(valueNodes);
866
+ if (validatableContent !== null) {
867
+ this.validateAriaLevel(validatableContent, attributeNode);
868
+ return;
869
+ }
870
+ if (!hasERBOutput(valueNodes))
871
+ return;
872
+ const literalNodes = filterLiteralNodes(valueNodes);
873
+ const erbOutputNodes = filterERBContentNodes(valueNodes).filter(isERBOutputNode);
874
+ if (literalNodes.length > 0 && erbOutputNodes.length > 0) {
875
+ const staticPart = literalNodes.map(node => node.content).join("");
876
+ // TODO: this can be cleaned up using @herb-tools/printer
877
+ const erbPart = erbOutputNodes[0];
878
+ const erbText = `${erbPart.tag_opening?.value || ""}${erbPart.content?.value || ""}${erbPart.tag_closing?.value || ""}`;
879
+ this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${staticPart}\` and the ERB expression \`${erbText}\`.`, attributeNode.location);
880
+ }
881
+ }
882
+ validateAriaLevel(attributeValue, attributeNode) {
883
+ if (!attributeValue || attributeValue === "") {
682
884
  this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
683
885
  return;
684
886
  }
@@ -698,19 +900,13 @@ class HTMLAriaLevelMustBeValidRule extends ParserRule {
698
900
  }
699
901
 
700
902
  class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
701
- // We want to check 2 attributes here:
702
- // 1. role="heading"
703
- // 2. aria-level (which must be present if role="heading")
704
- checkAttribute(attributeName, attributeValue, attributeNode, parentNode) {
705
- if (!(attributeName === "role" && attributeValue === "heading")) {
903
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode, parentNode }) {
904
+ if (!(attributeName === "role" && attributeValue === "heading"))
706
905
  return;
707
- }
708
- const allAttributes = getAttributes(parentNode);
709
- // If we have a role="heading", we must check for aria-level
710
- const ariaLevelAttr = allAttributes.find(attr => getAttributeName(attr) === "aria-level");
711
- if (!ariaLevelAttr) {
712
- this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
713
- }
906
+ const ariaLevelAttributes = getAttributes(parentNode).find(attribute => getAttributeName(attribute) === "aria-level");
907
+ if (ariaLevelAttributes)
908
+ return;
909
+ this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
714
910
  }
715
911
  }
716
912
  class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
@@ -723,10 +919,10 @@ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
723
919
  }
724
920
 
725
921
  class AriaRoleMustBeValid extends AttributeVisitorMixin {
726
- checkAttribute(attributeName, attributeValue, attributeNode) {
922
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
727
923
  if (attributeName !== "role")
728
924
  return;
729
- if (attributeValue === null)
925
+ if (!attributeValue)
730
926
  return;
731
927
  if (VALID_ARIA_ROLES.has(attributeValue))
732
928
  return;
@@ -743,14 +939,23 @@ class HTMLAriaRoleMustBeValidRule extends ParserRule {
743
939
  }
744
940
 
745
941
  class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
746
- checkAttribute(attributeName, attributeValue, attributeNode) {
942
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
747
943
  if (!hasAttributeValue(attributeNode))
748
944
  return;
749
945
  if (getAttributeValueQuoteType(attributeNode) !== "single")
750
946
  return;
751
947
  if (attributeValue?.includes('"'))
752
- return; // Single quotes acceptable when value contains double quotes
753
- this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
948
+ return;
949
+ this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${attributeValue}"\`.`, attributeNode.value.location, "warning");
950
+ }
951
+ checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode, combinedValue }) {
952
+ if (!hasAttributeValue(attributeNode))
953
+ return;
954
+ if (getAttributeValueQuoteType(attributeNode) !== "single")
955
+ return;
956
+ if (filterLiteralNodes(valueNodes).some(node => node.content?.includes('"')))
957
+ return;
958
+ this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "warning");
754
959
  }
755
960
  }
756
961
  class HTMLAttributeDoubleQuotesRule extends ParserRule {
@@ -762,16 +967,49 @@ class HTMLAttributeDoubleQuotesRule extends ParserRule {
762
967
  }
763
968
  }
764
969
 
970
+ class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
971
+ visitHTMLAttributeNode(attribute) {
972
+ if (!attribute.equals || !attribute.name || !attribute.value) {
973
+ return;
974
+ }
975
+ if (attribute.equals.value.startsWith(" ")) {
976
+ this.addOffense("Remove whitespace before `=` in HTML attribute", attribute.equals.location, "error");
977
+ }
978
+ if (attribute.equals.value.endsWith(" ")) {
979
+ this.addOffense("Remove whitespace after `=` in HTML attribute", attribute.equals.location, "error");
980
+ }
981
+ }
982
+ }
983
+ class HTMLAttributeEqualsSpacingRule extends ParserRule {
984
+ name = "html-attribute-equals-spacing";
985
+ check(result, context) {
986
+ const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context);
987
+ visitor.visit(result.value);
988
+ return visitor.offenses;
989
+ }
990
+ }
991
+
765
992
  class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
766
- checkAttribute(attributeName, _attributeValue, attributeNode) {
767
- if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
993
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
994
+ if (this.hasAttributeValue(attributeNode))
768
995
  return;
769
- const valueNode = attributeNode.value;
770
- if (valueNode.quoted)
996
+ if (this.isQuoted(attributeNode))
997
+ return;
998
+ this.addOffense(`Attribute value should be quoted: \`${attributeName}="${attributeValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
999
+ }
1000
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1001
+ if (this.hasAttributeValue(attributeNode))
1002
+ return;
1003
+ if (this.isQuoted(attributeNode))
771
1004
  return;
772
- this.addOffense(
773
- // TODO: print actual attribute value in message
774
- `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
1005
+ this.addOffense(`Attribute value should be quoted: \`${attributeName}="${combinedValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
1006
+ }
1007
+ hasAttributeValue(attributeNode) {
1008
+ return attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE";
1009
+ }
1010
+ isQuoted(attributeNode) {
1011
+ const valueNode = attributeNode.value;
1012
+ return valueNode.quoted;
775
1013
  }
776
1014
  }
777
1015
  class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
@@ -783,14 +1021,66 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
783
1021
  }
784
1022
  }
785
1023
 
1024
+ const ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT = new Set([
1025
+ "button", "fieldset", "input", "optgroup", "option", "select", "textarea"
1026
+ ]);
1027
+ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
1028
+ visitHTMLOpenTagNode(node) {
1029
+ this.checkElement(node);
1030
+ super.visitHTMLOpenTagNode(node);
1031
+ }
1032
+ checkElement(node) {
1033
+ const tagName = getTagName(node);
1034
+ if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
1035
+ return;
1036
+ }
1037
+ const hasDisabled = hasAttribute(node, "disabled");
1038
+ const hasAriaDisabled = hasAttribute(node, "aria-disabled");
1039
+ if ((hasDisabled && this.hasERBContent(node, "disabled")) || (hasAriaDisabled && this.hasERBContent(node, "aria-disabled"))) {
1040
+ return;
1041
+ }
1042
+ if (hasDisabled && hasAriaDisabled) {
1043
+ this.addOffense("aria-disabled may be used in place of native HTML disabled to allow tab-focus on an otherwise ignored element. Setting both attributes is contradictory and confusing. Choose either disabled or aria-disabled, not both.", node.tag_name.location, "error");
1044
+ }
1045
+ }
1046
+ hasERBContent(node, attributeName) {
1047
+ const attributes = getAttributes(node);
1048
+ const attribute = findAttributeByName(attributes, attributeName);
1049
+ if (!attribute)
1050
+ return false;
1051
+ const valueNode = attribute.value;
1052
+ if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
1053
+ return false;
1054
+ const htmlValueNode = valueNode;
1055
+ if (!htmlValueNode.children)
1056
+ return false;
1057
+ return htmlValueNode.children.some((child) => child.type === "AST_ERB_CONTENT_NODE");
1058
+ }
1059
+ }
1060
+ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
1061
+ name = "html-avoid-both-disabled-and-aria-disabled";
1062
+ check(result, context) {
1063
+ const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context);
1064
+ visitor.visit(result.value);
1065
+ return visitor.offenses;
1066
+ }
1067
+ }
1068
+
786
1069
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
787
- checkAttribute(attributeName, _attributeValue, attributeNode) {
1070
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
788
1071
  if (!isBooleanAttribute(attributeName))
789
1072
  return;
790
1073
  if (!hasAttributeValue(attributeNode))
791
1074
  return;
792
1075
  this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
793
1076
  }
1077
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1078
+ if (!isBooleanAttribute(attributeName))
1079
+ return;
1080
+ if (!hasAttributeValue(attributeNode))
1081
+ return;
1082
+ this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "error");
1083
+ }
794
1084
  }
795
1085
  class HTMLBooleanAttributesNoValueRule extends ParserRule {
796
1086
  name = "html-boolean-attributes-no-value";
@@ -801,14 +1091,47 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
801
1091
  }
802
1092
  }
803
1093
 
804
- class ImgRequireAltVisitor extends BaseRuleVisitor {
1094
+ class IframeHasTitleVisitor extends BaseRuleVisitor {
805
1095
  visitHTMLOpenTagNode(node) {
806
- this.checkImgTag(node);
1096
+ this.checkIframeElement(node);
807
1097
  super.visitHTMLOpenTagNode(node);
808
1098
  }
809
- visitHTMLSelfCloseTagNode(node) {
1099
+ checkIframeElement(node) {
1100
+ const tagName = getTagName(node);
1101
+ if (tagName !== "iframe") {
1102
+ return;
1103
+ }
1104
+ const ariaHiddenAttribute = getAttribute(node, "aria-hidden");
1105
+ if (ariaHiddenAttribute) {
1106
+ const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
1107
+ if (ariaHiddenValue === "true") {
1108
+ return;
1109
+ }
1110
+ }
1111
+ const attribute = getAttribute(node, "title");
1112
+ if (!attribute) {
1113
+ this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
1114
+ return;
1115
+ }
1116
+ const value = getAttributeValue(attribute);
1117
+ if (!value || value.trim() === "") {
1118
+ this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
1119
+ }
1120
+ }
1121
+ }
1122
+ class HTMLIframeHasTitleRule extends ParserRule {
1123
+ name = "html-iframe-has-title";
1124
+ check(result, context) {
1125
+ const visitor = new IframeHasTitleVisitor(this.name, context);
1126
+ visitor.visit(result.value);
1127
+ return visitor.offenses;
1128
+ }
1129
+ }
1130
+
1131
+ class ImgRequireAltVisitor extends BaseRuleVisitor {
1132
+ visitHTMLOpenTagNode(node) {
810
1133
  this.checkImgTag(node);
811
- super.visitHTMLSelfCloseTagNode(node);
1134
+ super.visitHTMLOpenTagNode(node);
812
1135
  }
813
1136
  checkImgTag(node) {
814
1137
  const tagName = getTagName(node);
@@ -829,34 +1152,137 @@ class HTMLImgRequireAltRule extends ParserRule {
829
1152
  }
830
1153
  }
831
1154
 
832
- class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
1155
+ class NavigationHasLabelVisitor extends BaseRuleVisitor {
833
1156
  visitHTMLOpenTagNode(node) {
834
- this.checkDuplicateAttributes(node);
1157
+ this.checkNavigationElement(node);
835
1158
  super.visitHTMLOpenTagNode(node);
836
1159
  }
837
- visitHTMLSelfCloseTagNode(node) {
838
- this.checkDuplicateAttributes(node);
839
- super.visitHTMLSelfCloseTagNode(node);
1160
+ checkNavigationElement(node) {
1161
+ const tagName = getTagName(node);
1162
+ const isNavElement = tagName === "nav";
1163
+ const hasNavigationRole = this.hasRoleNavigation(node);
1164
+ if (!isNavElement && !hasNavigationRole) {
1165
+ return;
1166
+ }
1167
+ const hasAriaLabel = hasAttribute(node, "aria-label");
1168
+ const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
1169
+ if (!hasAriaLabel && !hasAriaLabelledby) {
1170
+ let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
1171
+ if (hasNavigationRole && !isNavElement) {
1172
+ message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
1173
+ }
1174
+ this.addOffense(message, node.tag_name.location, "error");
1175
+ }
840
1176
  }
841
- checkDuplicateAttributes(node) {
842
- const attributeNames = new Map();
843
- forEachAttribute(node, (attributeNode) => {
844
- if (attributeNode.name?.type !== "AST_HTML_ATTRIBUTE_NAME_NODE")
845
- return;
846
- const nameNode = attributeNode.name;
847
- if (!nameNode.name)
848
- return;
849
- const attributeName = nameNode.name.value.toLowerCase(); // HTML attributes are case-insensitive
850
- if (!attributeNames.has(attributeName)) {
851
- attributeNames.set(attributeName, []);
1177
+ hasRoleNavigation(node) {
1178
+ const attributes = getAttributes(node);
1179
+ const roleAttribute = findAttributeByName(attributes, "role");
1180
+ if (!roleAttribute) {
1181
+ return false;
1182
+ }
1183
+ const roleValue = getAttributeValue(roleAttribute);
1184
+ return roleValue === "navigation";
1185
+ }
1186
+ }
1187
+ class HTMLNavigationHasLabelRule extends ParserRule {
1188
+ name = "html-navigation-has-label";
1189
+ check(result, context) {
1190
+ const visitor = new NavigationHasLabelVisitor(this.name, context);
1191
+ visitor.visit(result.value);
1192
+ return visitor.offenses;
1193
+ }
1194
+ }
1195
+
1196
+ const INTERACTIVE_ELEMENTS = new Set([
1197
+ "button", "summary", "input", "select", "textarea", "a"
1198
+ ]);
1199
+ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
1200
+ visitHTMLOpenTagNode(node) {
1201
+ this.checkAriaHiddenOnFocusable(node);
1202
+ super.visitHTMLOpenTagNode(node);
1203
+ }
1204
+ checkAriaHiddenOnFocusable(node) {
1205
+ if (!this.hasAriaHiddenTrue(node))
1206
+ return;
1207
+ if (this.isFocusable(node)) {
1208
+ this.addOffense(`Elements that are focusable should not have \`aria-hidden="true"\` because it will cause confusion for assistive technology users.`, node.tag_name.location, "error");
1209
+ }
1210
+ }
1211
+ hasAriaHiddenTrue(node) {
1212
+ const attributes = getAttributes(node);
1213
+ const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden");
1214
+ if (!ariaHiddenAttr)
1215
+ return false;
1216
+ const value = getAttributeValue(ariaHiddenAttr);
1217
+ return value === "true";
1218
+ }
1219
+ isFocusable(node) {
1220
+ const tagName = getTagName(node);
1221
+ if (!tagName)
1222
+ return false;
1223
+ const tabIndexValue = this.getTabIndexValue(node);
1224
+ if (tagName === "a") {
1225
+ const hasHref = hasAttribute(node, "href");
1226
+ if (!hasHref) {
1227
+ return tabIndexValue !== null && tabIndexValue >= 0;
852
1228
  }
853
- attributeNames.get(attributeName).push(nameNode);
854
- });
855
- for (const [attributeName, nameNodes] of attributeNames) {
856
- if (nameNodes.length > 1) {
857
- for (let i = 1; i < nameNodes.length; i++) {
858
- const nameNode = nameNodes[i];
859
- this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, nameNode.location, "error");
1229
+ return tabIndexValue === null || tabIndexValue >= 0;
1230
+ }
1231
+ if (INTERACTIVE_ELEMENTS.has(tagName)) {
1232
+ // Interactive elements are focusable unless tabindex is negative
1233
+ return tabIndexValue === null || tabIndexValue >= 0;
1234
+ }
1235
+ else {
1236
+ // Non-interactive elements are focusable only if tabindex >= 0
1237
+ return tabIndexValue !== null && tabIndexValue >= 0;
1238
+ }
1239
+ }
1240
+ getTabIndexValue(node) {
1241
+ const attributes = getAttributes(node);
1242
+ const tabIndexAttribute = findAttributeByName(attributes, "tabindex");
1243
+ if (!tabIndexAttribute)
1244
+ return null;
1245
+ const value = getAttributeValue(tabIndexAttribute);
1246
+ if (!value)
1247
+ return null;
1248
+ const parsed = parseInt(value, 10);
1249
+ return isNaN(parsed) ? null : parsed;
1250
+ }
1251
+ }
1252
+ class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
1253
+ name = "html-no-aria-hidden-on-focusable";
1254
+ check(result, context) {
1255
+ const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context);
1256
+ visitor.visit(result.value);
1257
+ return visitor.offenses;
1258
+ }
1259
+ }
1260
+
1261
+ class NoDuplicateAttributesVisitor extends AttributeVisitorMixin {
1262
+ attributeNames = new Map();
1263
+ visitHTMLOpenTagNode(node) {
1264
+ this.attributeNames.clear();
1265
+ super.visitHTMLOpenTagNode(node);
1266
+ this.reportDuplicates();
1267
+ }
1268
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
1269
+ this.trackAttributeName(attributeName, attributeNode);
1270
+ }
1271
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
1272
+ this.trackAttributeName(attributeName, attributeNode);
1273
+ }
1274
+ trackAttributeName(attributeName, attributeNode) {
1275
+ if (!this.attributeNames.has(attributeName)) {
1276
+ this.attributeNames.set(attributeName, []);
1277
+ }
1278
+ this.attributeNames.get(attributeName).push(attributeNode);
1279
+ }
1280
+ reportDuplicates() {
1281
+ for (const [attributeName, attributeNodes] of this.attributeNames) {
1282
+ if (attributeNodes.length > 1) {
1283
+ for (let i = 1; i < attributeNodes.length; i++) {
1284
+ const attributeNode = attributeNodes[i];
1285
+ this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, attributeNode.name.location, "error");
860
1286
  }
861
1287
  }
862
1288
  }
@@ -873,7 +1299,7 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
873
1299
 
874
1300
  class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
875
1301
  documentIds = new Set();
876
- checkAttribute(attributeName, attributeValue, attributeNode) {
1302
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
877
1303
  if (attributeName.toLowerCase() !== "id")
878
1304
  return;
879
1305
  if (!attributeValue)
@@ -900,10 +1326,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
900
1326
  this.checkHeadingElement(node);
901
1327
  super.visitHTMLElementNode(node);
902
1328
  }
903
- visitHTMLSelfCloseTagNode(node) {
904
- this.checkSelfClosingHeading(node);
905
- super.visitHTMLSelfCloseTagNode(node);
906
- }
907
1329
  checkHeadingElement(node) {
908
1330
  if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
909
1331
  return;
@@ -925,23 +1347,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
925
1347
  this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.location, "error");
926
1348
  }
927
1349
  }
928
- checkSelfClosingHeading(node) {
929
- const tagName = getTagName(node);
930
- if (!tagName) {
931
- return;
932
- }
933
- // Check if it's a standard heading tag (h1-h6) or has role="heading"
934
- const isStandardHeading = HEADING_TAGS.has(tagName);
935
- const isAriaHeading = this.hasHeadingRole(node);
936
- if (!isStandardHeading && !isAriaHeading) {
937
- return;
938
- }
939
- // Self-closing headings are always empty
940
- const elementDescription = isStandardHeading
941
- ? `\`<${tagName}>\``
942
- : `\`<${tagName} role="heading">\``;
943
- this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.tag_name.location, "error");
944
- }
945
1350
  isEmptyHeading(node) {
946
1351
  if (!node.body || node.body.length === 0) {
947
1352
  return true;
@@ -1086,46 +1491,121 @@ class HTMLNoNestedLinksRule extends ParserRule {
1086
1491
  }
1087
1492
  }
1088
1493
 
1089
- class TagNameLowercaseVisitor extends BaseRuleVisitor {
1090
- visitHTMLElementNode(node) {
1091
- const tagName = node.tag_name?.value;
1092
- if (node.open_tag) {
1093
- this.checkTagName(node.open_tag);
1494
+ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
1495
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
1496
+ if (attributeName !== "tabindex")
1497
+ return;
1498
+ const tabIndexValue = parseInt(attributeValue, 10);
1499
+ if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
1500
+ this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex=\"-1\"\` to remove it from the tab sequence.`, attributeNode.location, "error");
1094
1501
  }
1095
- if (tagName && ["svg"].includes(tagName.toLowerCase())) {
1096
- if (node.close_tag) {
1097
- this.checkTagName(node.close_tag);
1098
- }
1502
+ }
1503
+ }
1504
+ class HTMLNoPositiveTabIndexRule extends ParserRule {
1505
+ name = "html-no-positive-tab-index";
1506
+ check(result, context) {
1507
+ const visitor = new NoPositiveTabIndexVisitor(this.name, context);
1508
+ visitor.visit(result.value);
1509
+ return visitor.offenses;
1510
+ }
1511
+ }
1512
+
1513
+ class NoSelfClosingVisitor extends BaseRuleVisitor {
1514
+ visitHTMLOpenTagNode(node) {
1515
+ if (node.tag_closing?.value === "/>") {
1516
+ const tagName = getTagName(node);
1517
+ const shouldBeVoid = tagName ? isVoidElement(tagName) : false;
1518
+ const instead = shouldBeVoid ? `Use \`<${tagName}>\` instead.` : `Use \`<${tagName}></${tagName}>\` instead.`;
1519
+ this.addOffense(`Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`, node.location, "error");
1520
+ }
1521
+ super.visitHTMLOpenTagNode(node);
1522
+ }
1523
+ }
1524
+ class HTMLNoSelfClosingRule extends ParserRule {
1525
+ name = "html-no-self-closing";
1526
+ check(result, context) {
1527
+ const visitor = new NoSelfClosingVisitor(this.name, context);
1528
+ visitor.visit(result.value);
1529
+ return visitor.offenses;
1530
+ }
1531
+ }
1532
+
1533
+ class NoTitleAttributeVisitor extends BaseRuleVisitor {
1534
+ ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
1535
+ visitHTMLOpenTagNode(node) {
1536
+ this.checkTitleAttribute(node);
1537
+ super.visitHTMLOpenTagNode(node);
1538
+ }
1539
+ checkTitleAttribute(node) {
1540
+ const tagName = getTagName(node);
1541
+ if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
1099
1542
  return;
1100
1543
  }
1101
- this.visitChildNodes(node);
1102
- if (node.close_tag) {
1544
+ if (hasAttribute(node, "title")) {
1545
+ this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
1546
+ }
1547
+ }
1548
+ }
1549
+ class HTMLNoTitleAttributeRule extends ParserRule {
1550
+ name = "html-no-title-attribute";
1551
+ check(result, context) {
1552
+ const visitor = new NoTitleAttributeVisitor(this.name, context);
1553
+ visitor.visit(result.value);
1554
+ return visitor.offenses;
1555
+ }
1556
+ }
1557
+
1558
+ class XMLDeclarationChecker extends BaseRuleVisitor {
1559
+ hasXMLDeclaration = false;
1560
+ visitXMLDeclarationNode(_node) {
1561
+ this.hasXMLDeclaration = true;
1562
+ }
1563
+ visitChildNodes(node) {
1564
+ if (this.hasXMLDeclaration)
1565
+ return;
1566
+ super.visitChildNodes(node);
1567
+ }
1568
+ }
1569
+ class TagNameLowercaseVisitor extends BaseRuleVisitor {
1570
+ visitHTMLElementNode(node) {
1571
+ if (getTagName$1(node).toLowerCase() === "svg") {
1572
+ this.checkTagName(node.open_tag);
1103
1573
  this.checkTagName(node.close_tag);
1104
1574
  }
1575
+ else {
1576
+ super.visitHTMLElementNode(node);
1577
+ }
1105
1578
  }
1106
- visitHTMLSelfCloseTagNode(node) {
1579
+ visitHTMLOpenTagNode(node) {
1580
+ this.checkTagName(node);
1581
+ }
1582
+ visitHTMLCloseTagNode(node) {
1107
1583
  this.checkTagName(node);
1108
- this.visitChildNodes(node);
1109
1584
  }
1110
1585
  checkTagName(node) {
1111
- const tagName = node.tag_name?.value;
1586
+ if (!node)
1587
+ return;
1588
+ const tagName = getTagName$1(node);
1112
1589
  if (!tagName)
1113
1590
  return;
1114
1591
  const lowercaseTagName = tagName.toLowerCase();
1592
+ const type = isNode(node, HTMLOpenTagNode) ? "Opening" : "Closing";
1593
+ const open = isNode(node, HTMLOpenTagNode) ? "<" : "</";
1115
1594
  if (tagName !== lowercaseTagName) {
1116
- let type = node.type;
1117
- if (node.type == "AST_HTML_OPEN_TAG_NODE")
1118
- type = "Opening";
1119
- if (node.type == "AST_HTML_CLOSE_TAG_NODE")
1120
- type = "Closing";
1121
- if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
1122
- type = "Self-closing";
1123
- this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`, node.tag_name.location, "error");
1595
+ this.addOffense(`${type} tag name \`${open}${tagName}>\` should be lowercase. Use \`${open}${lowercaseTagName}>\` instead.`, node.tag_name.location, "error");
1124
1596
  }
1125
1597
  }
1126
1598
  }
1127
1599
  class HTMLTagNameLowercaseRule extends ParserRule {
1128
1600
  name = "html-tag-name-lowercase";
1601
+ isEnabled(result, context) {
1602
+ if (context?.fileName?.endsWith(".xml") || context?.fileName?.endsWith(".xml.erb")) {
1603
+ return false;
1604
+ }
1605
+ const checker = new XMLDeclarationChecker(this.name);
1606
+ checker.visit(result.value);
1607
+ return !checker.hasXMLDeclaration;
1608
+ }
1129
1609
  check(result, context) {
1130
1610
  const visitor = new TagNameLowercaseVisitor(this.name, context);
1131
1611
  visitor.visit(result.value);
@@ -1171,12 +1651,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1171
1651
  }
1172
1652
  this.visitChildNodes(node);
1173
1653
  }
1174
- visitHTMLSelfCloseTagNode(node) {
1175
- if (this.insideSVG) {
1176
- this.checkTagName(node);
1177
- }
1178
- this.visitChildNodes(node);
1179
- }
1180
1654
  checkTagName(node) {
1181
1655
  const tagName = node.tag_name?.value;
1182
1656
  if (!tagName)
@@ -1191,8 +1665,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1191
1665
  type = "Opening";
1192
1666
  if (node.type == "AST_HTML_CLOSE_TAG_NODE")
1193
1667
  type = "Closing";
1194
- if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
1195
- type = "Self-closing";
1196
1668
  this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
1197
1669
  }
1198
1670
  }
@@ -1209,23 +1681,33 @@ class SVGTagNameCapitalizationRule extends ParserRule {
1209
1681
  const defaultRules = [
1210
1682
  ERBNoEmptyTagsRule,
1211
1683
  ERBNoOutputControlFlowRule,
1684
+ ERBNoSilentTagInAttributeNameRule,
1212
1685
  ERBPreferImageTagHelperRule,
1213
1686
  ERBRequiresTrailingNewlineRule,
1214
1687
  ERBRequireWhitespaceRule,
1215
1688
  HTMLAnchorRequireHrefRule,
1216
1689
  HTMLAriaAttributeMustBeValid,
1690
+ HTMLAriaLabelIsWellFormattedRule,
1217
1691
  HTMLAriaLevelMustBeValidRule,
1218
1692
  HTMLAriaRoleHeadingRequiresLevelRule,
1219
1693
  HTMLAriaRoleMustBeValidRule,
1220
1694
  HTMLAttributeDoubleQuotesRule,
1695
+ HTMLAttributeEqualsSpacingRule,
1221
1696
  HTMLAttributeValuesRequireQuotesRule,
1697
+ HTMLAvoidBothDisabledAndAriaDisabledRule,
1222
1698
  HTMLBooleanAttributesNoValueRule,
1699
+ HTMLIframeHasTitleRule,
1223
1700
  HTMLImgRequireAltRule,
1701
+ HTMLNavigationHasLabelRule,
1702
+ HTMLNoAriaHiddenOnFocusableRule,
1224
1703
  // HTMLNoBlockInsideInlineRule,
1225
1704
  HTMLNoDuplicateAttributesRule,
1226
1705
  HTMLNoDuplicateIdsRule,
1227
1706
  HTMLNoEmptyHeadingsRule,
1228
1707
  HTMLNoNestedLinksRule,
1708
+ HTMLNoPositiveTabIndexRule,
1709
+ HTMLNoSelfClosingRule,
1710
+ HTMLNoTitleAttributeRule,
1229
1711
  HTMLTagNameLowercaseRule,
1230
1712
  ParserNoErrorsRule,
1231
1713
  SVGTagNameCapitalizationRule,
@@ -1274,19 +1756,44 @@ class Linter {
1274
1756
  */
1275
1757
  lint(source, context) {
1276
1758
  this.offenses = [];
1277
- const parseResult = this.herb.parse(source);
1759
+ const parseResult = this.herb.parse(source, { track_whitespace: true });
1278
1760
  const lexResult = this.herb.lex(source);
1279
1761
  for (const RuleClass of this.rules) {
1280
1762
  const rule = new RuleClass();
1763
+ let isEnabled = true;
1281
1764
  let ruleOffenses;
1282
1765
  if (this.isLexerRule(rule)) {
1283
- ruleOffenses = rule.check(lexResult, context);
1766
+ if (rule.isEnabled) {
1767
+ isEnabled = rule.isEnabled(lexResult, context);
1768
+ }
1769
+ if (isEnabled) {
1770
+ ruleOffenses = rule.check(lexResult, context);
1771
+ }
1772
+ else {
1773
+ ruleOffenses = [];
1774
+ }
1284
1775
  }
1285
1776
  else if (this.isSourceRule(rule)) {
1286
- ruleOffenses = rule.check(source, context);
1777
+ if (rule.isEnabled) {
1778
+ isEnabled = rule.isEnabled(source, context);
1779
+ }
1780
+ if (isEnabled) {
1781
+ ruleOffenses = rule.check(source, context);
1782
+ }
1783
+ else {
1784
+ ruleOffenses = [];
1785
+ }
1287
1786
  }
1288
1787
  else {
1289
- ruleOffenses = rule.check(parseResult, context);
1788
+ if (rule.isEnabled) {
1789
+ isEnabled = rule.isEnabled(parseResult, context);
1790
+ }
1791
+ if (isEnabled) {
1792
+ ruleOffenses = rule.check(parseResult, context);
1793
+ }
1794
+ else {
1795
+ ruleOffenses = [];
1796
+ }
1290
1797
  }
1291
1798
  this.offenses.push(...ruleOffenses);
1292
1799
  }
@@ -1358,5 +1865,5 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
1358
1865
  }
1359
1866
  }
1360
1867
 
1361
- export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeValuesRequireQuotesRule, HTMLBooleanAttributesNoValueRule, HTMLImgRequireAltRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
1868
+ export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBooleanAttributesNoValueRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoTitleAttributeRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
1362
1869
  //# sourceMappingURL=index.js.map