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