@herb-tools/linter 0.5.0 → 0.6.1

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 +6627 -1937
  2. package/dist/herb-lint.js.map +1 -1
  3. package/dist/index.cjs +1574 -210
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +1566 -212
  6. package/dist/index.js.map +1 -1
  7. package/dist/package.json +5 -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 +50 -64
  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 +134 -9
  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 +29 -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 +245 -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 +146 -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 +146 -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 +5 -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 +53 -76
  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 +189 -14
  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 +41 -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 +357 -39
  138. package/src/rules/svg-tag-name-capitalization.ts +2 -9
  139. package/src/types.ts +27 -0
package/dist/index.cjs CHANGED
@@ -18,6 +18,11 @@ class SourceRule {
18
18
  static type = "source";
19
19
  }
20
20
 
21
+ var ControlFlowType;
22
+ (function (ControlFlowType) {
23
+ ControlFlowType[ControlFlowType["CONDITIONAL"] = 0] = "CONDITIONAL";
24
+ ControlFlowType[ControlFlowType["LOOP"] = 1] = "LOOP";
25
+ })(ControlFlowType || (ControlFlowType = {}));
21
26
  /**
22
27
  * Base visitor class that provides common functionality for rule visitors
23
28
  */
@@ -51,12 +56,74 @@ class BaseRuleVisitor extends core.Visitor {
51
56
  }
52
57
  }
53
58
  /**
54
- * Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
59
+ * Mixin that adds control flow tracking capabilities to rule visitors
60
+ * This allows rules to track state across different control flow structures
61
+ * like if/else branches, loops, etc.
62
+ *
63
+ * @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
64
+ * @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
65
+ */
66
+ class ControlFlowTrackingVisitor extends BaseRuleVisitor {
67
+ isInControlFlow = false;
68
+ currentControlFlowType = null;
69
+ /**
70
+ * Handle visiting a control flow node with proper scope management
71
+ */
72
+ handleControlFlowNode(node, controlFlowType, visitChildren) {
73
+ const wasInControlFlow = this.isInControlFlow;
74
+ const previousControlFlowType = this.currentControlFlowType;
75
+ this.isInControlFlow = true;
76
+ this.currentControlFlowType = controlFlowType;
77
+ const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow);
78
+ visitChildren();
79
+ this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore);
80
+ this.isInControlFlow = wasInControlFlow;
81
+ this.currentControlFlowType = previousControlFlowType;
82
+ }
83
+ /**
84
+ * Handle visiting a branch node (like else, when) with proper scope management
85
+ */
86
+ startNewBranch(visitChildren) {
87
+ const stateToRestore = this.onEnterBranch();
88
+ visitChildren();
89
+ this.onExitBranch(stateToRestore);
90
+ }
91
+ visitERBIfNode(node) {
92
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
93
+ }
94
+ visitERBUnlessNode(node) {
95
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
96
+ }
97
+ visitERBCaseNode(node) {
98
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
99
+ }
100
+ visitERBCaseMatchNode(node) {
101
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
102
+ }
103
+ visitERBWhileNode(node) {
104
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
105
+ }
106
+ visitERBForNode(node) {
107
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node));
108
+ }
109
+ visitERBUntilNode(node) {
110
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
111
+ }
112
+ visitERBBlockNode(node) {
113
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
114
+ }
115
+ visitERBElseNode(node) {
116
+ this.startNewBranch(() => super.visitERBElseNode(node));
117
+ }
118
+ visitERBWhenNode(node) {
119
+ this.startNewBranch(() => super.visitERBWhenNode(node));
120
+ }
121
+ }
122
+ /**
123
+ * Gets attributes from an HTMLOpenTagNode
55
124
  */
56
125
  function getAttributes(node) {
57
- return node.type === "AST_HTML_SELF_CLOSE_TAG_NODE"
58
- ? node.attributes
59
- : node.children;
126
+ return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE");
60
127
  }
61
128
  /**
62
129
  * Gets the tag name from an HTML tag node (lowercased)
@@ -66,14 +133,66 @@ function getTagName(node) {
66
133
  }
67
134
  /**
68
135
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
136
+ * Returns null if the attribute name contains dynamic content (ERB)
69
137
  */
70
138
  function getAttributeName(attributeNode) {
71
139
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
72
140
  const nameNode = attributeNode.name;
73
- return nameNode.name?.value.toLowerCase() || null;
141
+ const staticName = core.getStaticAttributeName(nameNode);
142
+ return staticName ? staticName.toLowerCase() : null;
74
143
  }
75
144
  return null;
76
145
  }
146
+ /**
147
+ * Checks if an attribute has a dynamic (ERB-containing) name
148
+ */
149
+ function hasDynamicAttributeName(attributeNode) {
150
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
151
+ const nameNode = attributeNode.name;
152
+ return core.hasDynamicAttributeName(nameNode);
153
+ }
154
+ return false;
155
+ }
156
+ /**
157
+ * Gets the combined string representation of an attribute name (for debugging)
158
+ * This includes both static content and ERB syntax
159
+ */
160
+ function getCombinedAttributeNameString(attributeNode) {
161
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
162
+ const nameNode = attributeNode.name;
163
+ return core.getCombinedAttributeName(nameNode);
164
+ }
165
+ return "";
166
+ }
167
+ /**
168
+ * Checks if an attribute value contains only static content (no ERB)
169
+ */
170
+ function hasStaticAttributeValue(attributeNode) {
171
+ const valueNode = attributeNode.value;
172
+ if (!valueNode?.children)
173
+ return false;
174
+ return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
175
+ }
176
+ /**
177
+ * Gets the static string value of an attribute (returns null if it contains ERB)
178
+ */
179
+ function getStaticAttributeValue(attributeNode) {
180
+ if (!hasStaticAttributeValue(attributeNode))
181
+ return null;
182
+ const valueNode = attributeNode.value;
183
+ const result = valueNode.children
184
+ ?.filter(child => child.type === "AST_LITERAL_NODE")
185
+ .map(child => child.content)
186
+ .join("") || "";
187
+ return result;
188
+ }
189
+ /**
190
+ * Gets the value nodes array for dynamic inspection
191
+ */
192
+ function getAttributeValueNodes(attributeNode) {
193
+ const valueNode = attributeNode.value;
194
+ return valueNode?.children || [];
195
+ }
77
196
  /**
78
197
  * Gets the attribute value content from an HTMLAttributeValueNode
79
198
  */
@@ -111,9 +230,18 @@ function hasAttributeValue(attributeNode) {
111
230
  /**
112
231
  * Gets the quote type used for an attribute value
113
232
  */
114
- function getAttributeValueQuoteType(attributeNode) {
115
- if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
116
- const valueNode = attributeNode.value;
233
+ function getAttributeValueQuoteType(nodeOrAttribute) {
234
+ let valueNode;
235
+ if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
236
+ const attributeNode = nodeOrAttribute;
237
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
238
+ valueNode = attributeNode.value;
239
+ }
240
+ }
241
+ else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
242
+ valueNode = nodeOrAttribute;
243
+ }
244
+ if (valueNode) {
117
245
  if (valueNode.quoted && valueNode.open_quote) {
118
246
  return valueNode.open_quote.value === '"' ? "double" : "single";
119
247
  }
@@ -140,8 +268,14 @@ function findAttributeByName(attributes, attributeName) {
140
268
  * Checks if a tag has a specific attribute
141
269
  */
142
270
  function hasAttribute(node, attributeName) {
271
+ return getAttribute(node, attributeName) !== null;
272
+ }
273
+ /**
274
+ * Checks if a tag has a specific attribute
275
+ */
276
+ function getAttribute(node, attributeName) {
143
277
  const attributes = getAttributes(node);
144
- return findAttributeByName(attributes, attributeName) !== null;
278
+ return findAttributeByName(attributes, attributeName);
145
279
  }
146
280
  /**
147
281
  * Common HTML element categorization
@@ -158,6 +292,10 @@ const HTML_BLOCK_ELEMENTS = new Set([
158
292
  "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
159
293
  "ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
160
294
  ]);
295
+ const HTML_VOID_ELEMENTS = new Set([
296
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
297
+ "param", "source", "track", "wbr",
298
+ ]);
161
299
  const HTML_BOOLEAN_ATTRIBUTES = new Set([
162
300
  "autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
163
301
  "loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
@@ -296,6 +434,12 @@ function isInlineElement(tagName) {
296
434
  function isBlockElement(tagName) {
297
435
  return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase());
298
436
  }
437
+ /**
438
+ * Checks if an element is a void element
439
+ */
440
+ function isVoidElement(tagName) {
441
+ return HTML_VOID_ELEMENTS.has(tagName.toLowerCase());
442
+ }
299
443
  /**
300
444
  * Checks if an attribute is a boolean attribute
301
445
  */
@@ -303,9 +447,14 @@ function isBooleanAttribute(attributeName) {
303
447
  return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase());
304
448
  }
305
449
  /**
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.
450
+ * Attribute visitor that provides granular processing based on both
451
+ * attribute name type (static/dynamic) and value type (static/dynamic)
452
+ *
453
+ * This gives you 4 distinct methods to override:
454
+ * - checkStaticAttributeStaticValue() - name="class" value="foo"
455
+ * - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
456
+ * - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
457
+ * - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
309
458
  */
310
459
  class AttributeVisitorMixin extends BaseRuleVisitor {
311
460
  constructor(ruleName, context) {
@@ -315,19 +464,69 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
315
464
  this.checkAttributesOnNode(node);
316
465
  super.visitHTMLOpenTagNode(node);
317
466
  }
318
- visitHTMLSelfCloseTagNode(node) {
319
- this.checkAttributesOnNode(node);
320
- super.visitHTMLSelfCloseTagNode(node);
321
- }
322
467
  checkAttributesOnNode(node) {
323
468
  forEachAttribute(node, (attributeNode) => {
324
- const attributeName = getAttributeName(attributeNode);
325
- const attributeValue = getAttributeValue(attributeNode);
326
- if (attributeName) {
327
- this.checkAttribute(attributeName, attributeValue, attributeNode, node);
469
+ const staticAttributeName = getAttributeName(attributeNode);
470
+ const isDynamicName = hasDynamicAttributeName(attributeNode);
471
+ const staticAttributeValue = getStaticAttributeValue(attributeNode);
472
+ const valueNodes = getAttributeValueNodes(attributeNode);
473
+ const hasOutputERB = core.hasERBOutput(valueNodes);
474
+ const isEffectivelyStaticValue = core.isEffectivelyStatic(valueNodes);
475
+ if (staticAttributeName && staticAttributeValue !== null) {
476
+ this.checkStaticAttributeStaticValue({
477
+ attributeName: staticAttributeName,
478
+ attributeValue: staticAttributeValue,
479
+ attributeNode,
480
+ parentNode: node
481
+ });
482
+ }
483
+ else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
484
+ const validatableContent = core.getValidatableStaticContent(valueNodes) || "";
485
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
486
+ }
487
+ else if (staticAttributeName && hasOutputERB) {
488
+ const combinedValue = getAttributeValue(attributeNode);
489
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
490
+ }
491
+ else if (isDynamicName && staticAttributeValue !== null) {
492
+ const nameNode = attributeNode.name;
493
+ const nameNodes = nameNode.children || [];
494
+ const combinedName = getCombinedAttributeNameString(attributeNode);
495
+ this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName });
496
+ }
497
+ else if (isDynamicName) {
498
+ const nameNode = attributeNode.name;
499
+ const nameNodes = nameNode.children || [];
500
+ const combinedName = getCombinedAttributeNameString(attributeNode);
501
+ const combinedValue = getAttributeValue(attributeNode);
502
+ this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue });
328
503
  }
329
504
  });
330
505
  }
506
+ /**
507
+ * Static attribute name with static value: class="container"
508
+ */
509
+ checkStaticAttributeStaticValue(params) {
510
+ // Default implementation does nothing
511
+ }
512
+ /**
513
+ * Static attribute name with dynamic value: class="<%= css_class %>"
514
+ */
515
+ checkStaticAttributeDynamicValue(params) {
516
+ // Default implementation does nothing
517
+ }
518
+ /**
519
+ * Dynamic attribute name with static value: data-<%= key %>="foo"
520
+ */
521
+ checkDynamicAttributeStaticValue(params) {
522
+ // Default implementation does nothing
523
+ }
524
+ /**
525
+ * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
526
+ */
527
+ checkDynamicAttributeDynamicValue(params) {
528
+ // Default implementation does nothing
529
+ }
331
530
  }
332
531
  /**
333
532
  * Iterates over all attributes of a tag node, calling the callback for each attribute
@@ -356,7 +555,7 @@ class BaseSourceRuleVisitor {
356
555
  */
357
556
  createOffense(message, location, severity = "error") {
358
557
  return {
359
- rule: this.ruleName, // Type assertion for compatibility
558
+ rule: this.ruleName,
360
559
  code: this.ruleName,
361
560
  source: "Herb Linter",
362
561
  message,
@@ -459,86 +658,755 @@ class ERBNoOutputControlFlowRule extends ParserRule {
459
658
  }
460
659
  }
461
660
 
462
- class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
661
+ class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
662
+ visitHTMLAttributeNameNode(node) {
663
+ const erbNodes = core.filterERBContentNodes(node.children);
664
+ const silentNodes = erbNodes.filter(this.isSilentERBTag);
665
+ for (const node of silentNodes) {
666
+ 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");
667
+ }
668
+ }
669
+ // TODO: might be worth to extract
670
+ isSilentERBTag(node) {
671
+ const silentTags = ["<%", "<%-", "<%#"];
672
+ return silentTags.includes(node.tag_opening?.value || "");
673
+ }
674
+ }
675
+ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
676
+ name = "erb-no-silent-tag-in-attribute-name";
677
+ check(result, context) {
678
+ const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context);
679
+ visitor.visit(result.value);
680
+ return visitor.offenses;
681
+ }
682
+ }
683
+
684
+ class PrintContext {
685
+ output = "";
686
+ indentLevel = 0;
687
+ currentColumn = 0;
688
+ preserveStack = [];
689
+ /**
690
+ * Write text to the output
691
+ */
692
+ write(text) {
693
+ this.output += text;
694
+ this.currentColumn += text.length;
695
+ }
696
+ /**
697
+ * Write text and update column tracking for newlines
698
+ */
699
+ writeWithColumnTracking(text) {
700
+ this.output += text;
701
+ const lines = text.split('\n');
702
+ if (lines.length > 1) {
703
+ this.currentColumn = lines[lines.length - 1].length;
704
+ }
705
+ else {
706
+ this.currentColumn += text.length;
707
+ }
708
+ }
709
+ /**
710
+ * Increase indentation level
711
+ */
712
+ indent() {
713
+ this.indentLevel++;
714
+ }
715
+ /**
716
+ * Decrease indentation level
717
+ */
718
+ dedent() {
719
+ if (this.indentLevel > 0) {
720
+ this.indentLevel--;
721
+ }
722
+ }
723
+ /**
724
+ * Enter a tag that may preserve whitespace
725
+ */
726
+ enterTag(tagName) {
727
+ this.preserveStack.push(tagName.toLowerCase());
728
+ }
729
+ /**
730
+ * Exit the current tag
731
+ */
732
+ exitTag() {
733
+ this.preserveStack.pop();
734
+ }
735
+ /**
736
+ * Check if we're at the start of a line
737
+ */
738
+ isAtStartOfLine() {
739
+ return this.currentColumn === 0;
740
+ }
741
+ /**
742
+ * Get current indentation level
743
+ */
744
+ getCurrentIndentLevel() {
745
+ return this.indentLevel;
746
+ }
747
+ /**
748
+ * Get current column position
749
+ */
750
+ getCurrentColumn() {
751
+ return this.currentColumn;
752
+ }
753
+ /**
754
+ * Get the current tag stack (for debugging)
755
+ */
756
+ getTagStack() {
757
+ return [...this.preserveStack];
758
+ }
759
+ /**
760
+ * Get the complete output string
761
+ */
762
+ getOutput() {
763
+ return this.output;
764
+ }
765
+ /**
766
+ * Reset the context for reuse
767
+ */
768
+ reset() {
769
+ this.output = "";
770
+ this.indentLevel = 0;
771
+ this.currentColumn = 0;
772
+ this.preserveStack = [];
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Default print options used when none are provided
778
+ */
779
+ const DEFAULT_PRINT_OPTIONS = {
780
+ ignoreErrors: false
781
+ };
782
+ class Printer extends core.Visitor {
783
+ context = new PrintContext();
784
+ /**
785
+ * Static method to print a node without creating an instance
786
+ *
787
+ * @param input - The AST Node, Token, or ParseResult to print
788
+ * @param options - Print options to control behavior
789
+ * @returns The printed string representation of the input
790
+ * @throws {Error} When node has parse errors and ignoreErrors is false
791
+ */
792
+ static print(input, options = DEFAULT_PRINT_OPTIONS) {
793
+ const printer = new this();
794
+ return printer.print(input, options);
795
+ }
796
+ /**
797
+ * Print a node, token, or parse result to a string
798
+ *
799
+ * @param input - The AST Node, Token, or ParseResult to print
800
+ * @param options - Print options to control behavior
801
+ * @returns The printed string representation of the input
802
+ * @throws {Error} When node has parse errors and ignoreErrors is false
803
+ */
804
+ print(input, options = DEFAULT_PRINT_OPTIONS) {
805
+ if (core.isToken(input)) {
806
+ return input.value;
807
+ }
808
+ if (Array.isArray(input)) {
809
+ this.context.reset();
810
+ input.forEach(node => this.visit(node));
811
+ return this.context.getOutput();
812
+ }
813
+ const node = core.isParseResult(input) ? input.value : input;
814
+ if (options.ignoreErrors === false && node.recursiveErrors().length > 0) {
815
+ throw new Error(`Cannot print the node (${node.type}) since it or any of its children has parse errors. Either pass in a valid Node or call \`print()\` using \`print(node, { ignoreErrors: true })\``);
816
+ }
817
+ this.context.reset();
818
+ this.visit(node);
819
+ return this.context.getOutput();
820
+ }
821
+ write(content) {
822
+ this.context.write(content);
823
+ }
824
+ }
825
+
826
+ /**
827
+ * IdentityPrinter - Provides lossless reconstruction of the original source
828
+ *
829
+ * This printer aims to reconstruct the original input as faithfully as possible,
830
+ * preserving all whitespace, formatting, and structure. It's useful for:
831
+ * - Testing parser accuracy (input should equal output)
832
+ * - Baseline printing before applying transformations
833
+ * - Verifying AST round-trip fidelity
834
+ */
835
+ class IdentityPrinter extends Printer {
836
+ visitLiteralNode(node) {
837
+ this.write(node.content);
838
+ }
839
+ visitHTMLTextNode(node) {
840
+ this.write(node.content);
841
+ }
842
+ visitWhitespaceNode(node) {
843
+ if (node.value) {
844
+ this.write(node.value.value);
845
+ }
846
+ }
463
847
  visitHTMLOpenTagNode(node) {
464
- this.checkImgTag(node);
465
- super.visitHTMLOpenTagNode(node);
848
+ if (node.tag_opening) {
849
+ this.write(node.tag_opening.value);
850
+ }
851
+ if (node.tag_name) {
852
+ this.write(node.tag_name.value);
853
+ }
854
+ this.visitChildNodes(node);
855
+ if (node.tag_closing) {
856
+ this.write(node.tag_closing.value);
857
+ }
466
858
  }
467
- visitHTMLSelfCloseTagNode(node) {
468
- this.checkImgTag(node);
469
- super.visitHTMLSelfCloseTagNode(node);
859
+ visitHTMLCloseTagNode(node) {
860
+ if (node.tag_opening) {
861
+ this.write(node.tag_opening.value);
862
+ }
863
+ if (node.tag_name) {
864
+ const before = core.getNodesBeforePosition(node.children, node.tag_name.location.start, true);
865
+ const after = core.getNodesAfterPosition(node.children, node.tag_name.location.end);
866
+ this.visitAll(before);
867
+ this.write(node.tag_name.value);
868
+ this.visitAll(after);
869
+ }
870
+ else {
871
+ this.visitAll(node.children);
872
+ }
873
+ if (node.tag_closing) {
874
+ this.write(node.tag_closing.value);
875
+ }
470
876
  }
471
- checkImgTag(node) {
472
- const tagName = getTagName(node);
473
- if (tagName !== "img") {
474
- return;
877
+ visitHTMLElementNode(node) {
878
+ const tagName = node.tag_name?.value;
879
+ if (tagName) {
880
+ this.context.enterTag(tagName);
475
881
  }
476
- const attributes = getAttributes(node);
477
- const srcAttribute = findAttributeByName(attributes, "src");
478
- if (!srcAttribute) {
479
- return;
882
+ if (node.open_tag) {
883
+ this.visit(node.open_tag);
480
884
  }
481
- if (!srcAttribute.value) {
482
- return;
885
+ if (node.body) {
886
+ node.body.forEach(child => this.visit(child));
483
887
  }
484
- const valueNode = srcAttribute.value;
485
- const hasERBContent = this.containsERBContent(valueNode);
486
- if (hasERBContent) {
487
- const suggestedExpression = this.buildSuggestedExpression(valueNode);
488
- this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
888
+ if (node.close_tag) {
889
+ this.visit(node.close_tag);
890
+ }
891
+ if (tagName) {
892
+ this.context.exitTag();
489
893
  }
490
894
  }
491
- containsERBContent(valueNode) {
492
- if (!valueNode.children)
493
- return false;
494
- return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
895
+ visitHTMLAttributeNode(node) {
896
+ if (node.name) {
897
+ this.visit(node.name);
898
+ }
899
+ if (node.equals) {
900
+ this.write(node.equals.value);
901
+ }
902
+ if (node.equals && node.value) {
903
+ this.visit(node.value);
904
+ }
495
905
  }
496
- buildSuggestedExpression(valueNode) {
497
- if (!valueNode.children)
498
- return "expression";
499
- let hasText = false;
500
- let hasERB = false;
501
- for (const child of valueNode.children) {
502
- if (child.type === "AST_ERB_CONTENT_NODE") {
503
- hasERB = true;
906
+ visitHTMLAttributeNameNode(node) {
907
+ this.visitChildNodes(node);
908
+ }
909
+ visitHTMLAttributeValueNode(node) {
910
+ if (node.quoted && node.open_quote) {
911
+ this.write(node.open_quote.value);
912
+ }
913
+ this.visitChildNodes(node);
914
+ if (node.quoted && node.close_quote) {
915
+ this.write(node.close_quote.value);
916
+ }
917
+ }
918
+ visitHTMLCommentNode(node) {
919
+ if (node.comment_start) {
920
+ this.write(node.comment_start.value);
921
+ }
922
+ this.visitChildNodes(node);
923
+ if (node.comment_end) {
924
+ this.write(node.comment_end.value);
925
+ }
926
+ }
927
+ visitHTMLDoctypeNode(node) {
928
+ if (node.tag_opening) {
929
+ this.write(node.tag_opening.value);
930
+ }
931
+ this.visitChildNodes(node);
932
+ if (node.tag_closing) {
933
+ this.write(node.tag_closing.value);
934
+ }
935
+ }
936
+ visitXMLDeclarationNode(node) {
937
+ if (node.tag_opening) {
938
+ this.write(node.tag_opening.value);
939
+ }
940
+ this.visitChildNodes(node);
941
+ if (node.tag_closing) {
942
+ this.write(node.tag_closing.value);
943
+ }
944
+ }
945
+ visitCDATANode(node) {
946
+ if (node.tag_opening) {
947
+ this.write(node.tag_opening.value);
948
+ }
949
+ this.visitChildNodes(node);
950
+ if (node.tag_closing) {
951
+ this.write(node.tag_closing.value);
952
+ }
953
+ }
954
+ visitERBContentNode(node) {
955
+ this.printERBNode(node);
956
+ }
957
+ visitERBIfNode(node) {
958
+ this.printERBNode(node);
959
+ if (node.statements) {
960
+ node.statements.forEach(statement => this.visit(statement));
961
+ }
962
+ if (node.subsequent) {
963
+ this.visit(node.subsequent);
964
+ }
965
+ if (node.end_node) {
966
+ this.visit(node.end_node);
967
+ }
968
+ }
969
+ visitERBElseNode(node) {
970
+ this.printERBNode(node);
971
+ if (node.statements) {
972
+ node.statements.forEach(statement => this.visit(statement));
973
+ }
974
+ }
975
+ visitERBEndNode(node) {
976
+ this.printERBNode(node);
977
+ }
978
+ visitERBBlockNode(node) {
979
+ this.printERBNode(node);
980
+ if (node.body) {
981
+ node.body.forEach(child => this.visit(child));
982
+ }
983
+ if (node.end_node) {
984
+ this.visit(node.end_node);
985
+ }
986
+ }
987
+ visitERBCaseNode(node) {
988
+ this.printERBNode(node);
989
+ if (node.children) {
990
+ node.children.forEach(child => this.visit(child));
991
+ }
992
+ if (node.conditions) {
993
+ node.conditions.forEach(condition => this.visit(condition));
994
+ }
995
+ if (node.else_clause) {
996
+ this.visit(node.else_clause);
997
+ }
998
+ if (node.end_node) {
999
+ this.visit(node.end_node);
1000
+ }
1001
+ }
1002
+ visitERBWhenNode(node) {
1003
+ this.printERBNode(node);
1004
+ if (node.statements) {
1005
+ node.statements.forEach(statement => this.visit(statement));
1006
+ }
1007
+ }
1008
+ visitERBWhileNode(node) {
1009
+ this.printERBNode(node);
1010
+ if (node.statements) {
1011
+ node.statements.forEach(statement => this.visit(statement));
1012
+ }
1013
+ if (node.end_node) {
1014
+ this.visit(node.end_node);
1015
+ }
1016
+ }
1017
+ visitERBUntilNode(node) {
1018
+ this.printERBNode(node);
1019
+ if (node.statements) {
1020
+ node.statements.forEach(statement => this.visit(statement));
1021
+ }
1022
+ if (node.end_node) {
1023
+ this.visit(node.end_node);
1024
+ }
1025
+ }
1026
+ visitERBForNode(node) {
1027
+ this.printERBNode(node);
1028
+ if (node.statements) {
1029
+ node.statements.forEach(statement => this.visit(statement));
1030
+ }
1031
+ if (node.end_node) {
1032
+ this.visit(node.end_node);
1033
+ }
1034
+ }
1035
+ visitERBBeginNode(node) {
1036
+ this.printERBNode(node);
1037
+ if (node.statements) {
1038
+ node.statements.forEach(statement => this.visit(statement));
1039
+ }
1040
+ if (node.rescue_clause) {
1041
+ this.visit(node.rescue_clause);
1042
+ }
1043
+ if (node.else_clause) {
1044
+ this.visit(node.else_clause);
1045
+ }
1046
+ if (node.ensure_clause) {
1047
+ this.visit(node.ensure_clause);
1048
+ }
1049
+ if (node.end_node) {
1050
+ this.visit(node.end_node);
1051
+ }
1052
+ }
1053
+ visitERBRescueNode(node) {
1054
+ this.printERBNode(node);
1055
+ if (node.statements) {
1056
+ node.statements.forEach(statement => this.visit(statement));
1057
+ }
1058
+ if (node.subsequent) {
1059
+ this.visit(node.subsequent);
1060
+ }
1061
+ }
1062
+ visitERBEnsureNode(node) {
1063
+ this.printERBNode(node);
1064
+ if (node.statements) {
1065
+ node.statements.forEach(statement => this.visit(statement));
1066
+ }
1067
+ }
1068
+ visitERBUnlessNode(node) {
1069
+ this.printERBNode(node);
1070
+ if (node.statements) {
1071
+ node.statements.forEach(statement => this.visit(statement));
1072
+ }
1073
+ if (node.else_clause) {
1074
+ this.visit(node.else_clause);
1075
+ }
1076
+ if (node.end_node) {
1077
+ this.visit(node.end_node);
1078
+ }
1079
+ }
1080
+ visitERBYieldNode(node) {
1081
+ this.printERBNode(node);
1082
+ }
1083
+ visitERBInNode(node) {
1084
+ this.printERBNode(node);
1085
+ if (node.statements) {
1086
+ node.statements.forEach(statement => this.visit(statement));
1087
+ }
1088
+ }
1089
+ visitERBCaseMatchNode(node) {
1090
+ this.printERBNode(node);
1091
+ if (node.children) {
1092
+ node.children.forEach(child => this.visit(child));
1093
+ }
1094
+ if (node.conditions) {
1095
+ node.conditions.forEach(condition => this.visit(condition));
1096
+ }
1097
+ if (node.else_clause) {
1098
+ this.visit(node.else_clause);
1099
+ }
1100
+ if (node.end_node) {
1101
+ this.visit(node.end_node);
1102
+ }
1103
+ }
1104
+ /**
1105
+ * Print ERB node tags and content
1106
+ */
1107
+ printERBNode(node) {
1108
+ if (node.tag_opening) {
1109
+ this.write(node.tag_opening.value);
1110
+ }
1111
+ if (node.content) {
1112
+ this.write(node.content.value);
1113
+ }
1114
+ if (node.tag_closing) {
1115
+ this.write(node.tag_closing.value);
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
1121
+ ...DEFAULT_PRINT_OPTIONS,
1122
+ forceQuotes: false
1123
+ };
1124
+ /**
1125
+ * ERBToRubyStringPrinter - Converts ERB snippets to Ruby strings with interpolation
1126
+ *
1127
+ * This printer transforms ERB templates into Ruby strings by:
1128
+ * - Converting literal text to string content
1129
+ * - Converting <%= %> tags to #{} interpolation
1130
+ * - Converting simple if/else blocks to ternary operators
1131
+ * - Ignoring <% %> tags (they don't produce output)
1132
+ *
1133
+ * Examples:
1134
+ * - `hello world <%= hello %>` => `"hello world #{hello}"`
1135
+ * - `hello world <% hello %>` => `"hello world "`
1136
+ * - `Welcome <%= user.name %>!` => `"Welcome #{user.name}!"`
1137
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>` => `"logged_in? ? "Welcome" : "Login"`
1138
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
1139
+ */
1140
+ class ERBToRubyStringPrinter extends IdentityPrinter {
1141
+ // TODO: cleanup `.type === "AST_*" checks`
1142
+ static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
1143
+ const erbNodes = core.filterNodes([node], core.ERBContentNode);
1144
+ if (erbNodes.length === 1 && core.isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
1145
+ return (erbNodes[0].content?.value || "").trim();
1146
+ }
1147
+ if ('children' in node && Array.isArray(node.children)) {
1148
+ const childErbNodes = core.filterNodes(node.children, core.ERBContentNode);
1149
+ const hasOnlyERBContent = node.children.length > 0 && node.children.length === childErbNodes.length;
1150
+ if (hasOnlyERBContent && childErbNodes.length === 1 && core.isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
1151
+ return (childErbNodes[0].content?.value || "").trim();
504
1152
  }
505
- else if (child.type === "AST_LITERAL_NODE") {
506
- const literalNode = child;
507
- if (literalNode.content && literalNode.content.trim()) {
508
- hasText = true;
1153
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
1154
+ const ifNode = node.children[0];
1155
+ const printer = new ERBToRubyStringPrinter();
1156
+ if (printer.canConvertToTernary(ifNode)) {
1157
+ printer.convertToTernaryWithoutWrapper(ifNode);
1158
+ return printer.context.getOutput();
509
1159
  }
510
1160
  }
511
- }
512
- if (hasText && hasERB) {
513
- let result = '"';
514
- for (const child of valueNode.children) {
515
- if (child.type === "AST_ERB_CONTENT_NODE") {
516
- const erbNode = child;
517
- result += `#{${(erbNode.content?.value || "").trim()}}`;
518
- }
519
- else if (child.type === "AST_LITERAL_NODE") {
520
- const literalNode = child;
521
- result += literalNode.content || "";
1161
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
1162
+ const unlessNode = node.children[0];
1163
+ const printer = new ERBToRubyStringPrinter();
1164
+ if (printer.canConvertUnlessToTernary(unlessNode)) {
1165
+ printer.convertUnlessToTernaryWithoutWrapper(unlessNode);
1166
+ return printer.context.getOutput();
522
1167
  }
523
1168
  }
524
- result += '"';
525
- return result;
526
1169
  }
527
- if (hasERB && !hasText) {
528
- const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE");
529
- if (erbNodes.length === 1) {
530
- return (erbNodes[0].content?.value || "").trim();
1170
+ const printer = new ERBToRubyStringPrinter();
1171
+ printer.context.write('"');
1172
+ printer.visit(node);
1173
+ printer.context.write('"');
1174
+ return printer.context.getOutput();
1175
+ }
1176
+ visitHTMLTextNode(node) {
1177
+ if (node.content) {
1178
+ const escapedContent = node.content.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1179
+ this.context.write(escapedContent);
1180
+ }
1181
+ }
1182
+ visitERBContentNode(node) {
1183
+ if (core.isERBOutputNode(node)) {
1184
+ this.context.write("#{");
1185
+ if (node.content?.value) {
1186
+ this.context.write(node.content.value.trim());
531
1187
  }
532
- else if (erbNodes.length > 1) {
533
- let result = '"';
534
- for (const erbNode of erbNodes) {
535
- result += `#{${(erbNode.content?.value || "").trim()}}`;
536
- }
537
- result += '"';
538
- return result;
1188
+ this.context.write("}");
1189
+ }
1190
+ }
1191
+ visitERBIfNode(node) {
1192
+ if (this.canConvertToTernary(node)) {
1193
+ this.convertToTernary(node);
1194
+ }
1195
+ }
1196
+ visitERBUnlessNode(node) {
1197
+ if (this.canConvertUnlessToTernary(node)) {
1198
+ this.convertUnlessToTernary(node);
1199
+ }
1200
+ }
1201
+ visitHTMLAttributeValueNode(node) {
1202
+ this.visitChildNodes(node);
1203
+ }
1204
+ canConvertToTernary(node) {
1205
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
1206
+ return false;
1207
+ }
1208
+ const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
1209
+ if (!ifOnlyText)
1210
+ return false;
1211
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
1212
+ return node.subsequent.statements
1213
+ ? node.subsequent.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
1214
+ : true;
1215
+ }
1216
+ return true;
1217
+ }
1218
+ convertToTernary(node) {
1219
+ this.context.write("#{");
1220
+ if (node.content?.value) {
1221
+ const condition = node.content.value.trim();
1222
+ const cleanCondition = condition.replace(/^if\s+/, '');
1223
+ const needsParentheses = cleanCondition.includes(' ');
1224
+ if (needsParentheses) {
1225
+ this.context.write("(");
1226
+ }
1227
+ this.context.write(cleanCondition);
1228
+ if (needsParentheses) {
1229
+ this.context.write(")");
1230
+ }
1231
+ }
1232
+ this.context.write(" ? ");
1233
+ this.context.write('"');
1234
+ if (node.statements) {
1235
+ node.statements.forEach(statement => this.visit(statement));
1236
+ }
1237
+ this.context.write('"');
1238
+ this.context.write(" : ");
1239
+ this.context.write('"');
1240
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
1241
+ node.subsequent.statements.forEach(statement => this.visit(statement));
1242
+ }
1243
+ this.context.write('"');
1244
+ this.context.write("}");
1245
+ }
1246
+ convertToTernaryWithoutWrapper(node) {
1247
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
1248
+ return false;
1249
+ }
1250
+ if (node.content?.value) {
1251
+ const condition = node.content.value.trim();
1252
+ const cleanCondition = condition.replace(/^if\s+/, '');
1253
+ const needsParentheses = cleanCondition.includes(' ');
1254
+ if (needsParentheses) {
1255
+ this.context.write("(");
1256
+ }
1257
+ this.context.write(cleanCondition);
1258
+ if (needsParentheses) {
1259
+ this.context.write(")");
1260
+ }
1261
+ }
1262
+ this.context.write(" ? ");
1263
+ this.context.write('"');
1264
+ if (node.statements) {
1265
+ node.statements.forEach(statement => this.visit(statement));
1266
+ }
1267
+ this.context.write('"');
1268
+ this.context.write(" : ");
1269
+ this.context.write('"');
1270
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
1271
+ node.subsequent.statements.forEach(statement => this.visit(statement));
1272
+ }
1273
+ this.context.write('"');
1274
+ }
1275
+ canConvertUnlessToTernary(node) {
1276
+ const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
1277
+ if (!unlessOnlyText)
1278
+ return false;
1279
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1280
+ return node.else_clause.statements
1281
+ ? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
1282
+ : true;
1283
+ }
1284
+ return true;
1285
+ }
1286
+ convertUnlessToTernary(node) {
1287
+ this.context.write("#{");
1288
+ if (node.content?.value) {
1289
+ const condition = node.content.value.trim();
1290
+ const cleanCondition = condition.replace(/^unless\s+/, '');
1291
+ const needsParentheses = cleanCondition.includes(' ');
1292
+ this.context.write("!(");
1293
+ if (needsParentheses) {
1294
+ this.context.write("(");
1295
+ }
1296
+ this.context.write(cleanCondition);
1297
+ if (needsParentheses) {
1298
+ this.context.write(")");
1299
+ }
1300
+ this.context.write(")");
1301
+ }
1302
+ this.context.write(" ? ");
1303
+ this.context.write('"');
1304
+ if (node.statements) {
1305
+ node.statements.forEach(statement => this.visit(statement));
1306
+ }
1307
+ this.context.write('"');
1308
+ this.context.write(" : ");
1309
+ this.context.write('"');
1310
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1311
+ node.else_clause.statements.forEach(statement => this.visit(statement));
1312
+ }
1313
+ this.context.write('"');
1314
+ this.context.write("}");
1315
+ }
1316
+ convertUnlessToTernaryWithoutWrapper(node) {
1317
+ if (node.content?.value) {
1318
+ const condition = node.content.value.trim();
1319
+ const cleanCondition = condition.replace(/^unless\s+/, '');
1320
+ const needsParentheses = cleanCondition.includes(' ');
1321
+ this.context.write("!(");
1322
+ if (needsParentheses) {
1323
+ this.context.write("(");
1324
+ }
1325
+ this.context.write(cleanCondition);
1326
+ if (needsParentheses) {
1327
+ this.context.write(")");
1328
+ }
1329
+ this.context.write(")");
1330
+ }
1331
+ this.context.write(" ? ");
1332
+ this.context.write('"');
1333
+ if (node.statements) {
1334
+ node.statements.forEach(statement => this.visit(statement));
1335
+ }
1336
+ this.context.write('"');
1337
+ this.context.write(" : ");
1338
+ this.context.write('"');
1339
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1340
+ node.else_clause.statements.forEach(statement => this.visit(statement));
1341
+ }
1342
+ this.context.write('"');
1343
+ }
1344
+ }
1345
+
1346
+ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
1347
+ visitHTMLOpenTagNode(node) {
1348
+ this.checkImgTag(node);
1349
+ super.visitHTMLOpenTagNode(node);
1350
+ }
1351
+ checkImgTag(openTag) {
1352
+ const tagName = getTagName(openTag);
1353
+ if (tagName !== "img")
1354
+ return;
1355
+ const attributes = getAttributes(openTag);
1356
+ const srcAttribute = findAttributeByName(attributes, "src");
1357
+ if (!srcAttribute)
1358
+ return;
1359
+ if (!srcAttribute.value)
1360
+ return;
1361
+ const node = srcAttribute.value;
1362
+ const hasERBContent = this.containsERBContent(node);
1363
+ if (hasERBContent) {
1364
+ if (this.isDataUri(node))
1365
+ return;
1366
+ if (this.shouldFlagAsImageTagCandidate(node)) {
1367
+ const suggestedExpression = this.buildSuggestedExpression(node);
1368
+ this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
539
1369
  }
540
1370
  }
541
- return "expression";
1371
+ }
1372
+ containsERBContent(node) {
1373
+ return core.filterNodes(node.children, core.ERBContentNode).length > 0;
1374
+ }
1375
+ isOnlyERBContent(node) {
1376
+ return node.children.length > 0 && node.children.length === core.filterNodes(node.children, core.ERBContentNode).length;
1377
+ }
1378
+ getContentofFirstChild(node) {
1379
+ if (!node.children || node.children.length === 0)
1380
+ return "";
1381
+ const firstChild = node.children[0];
1382
+ if (core.isNode(firstChild, core.LiteralNode)) {
1383
+ return (firstChild.content || "").trim();
1384
+ }
1385
+ return "";
1386
+ }
1387
+ isDataUri(node) {
1388
+ return this.getContentofFirstChild(node).startsWith("data:");
1389
+ }
1390
+ isFullUrl(node) {
1391
+ const content = this.getContentofFirstChild(node);
1392
+ return content.startsWith("http://") || content.startsWith("https://");
1393
+ }
1394
+ shouldFlagAsImageTagCandidate(node) {
1395
+ if (this.isOnlyERBContent(node))
1396
+ return true;
1397
+ if (this.isFullUrl(node))
1398
+ return false;
1399
+ return true;
1400
+ }
1401
+ buildSuggestedExpression(node) {
1402
+ if (!node.children)
1403
+ return "expression";
1404
+ try {
1405
+ return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
1406
+ }
1407
+ catch (error) {
1408
+ return "expression";
1409
+ }
542
1410
  }
543
1411
  }
544
1412
  class ERBPreferImageTagHelperRule extends ParserRule {
@@ -652,17 +1520,18 @@ class HTMLAnchorRequireHrefRule extends ParserRule {
652
1520
  }
653
1521
 
654
1522
  class AriaAttributeMustBeValid extends AttributeVisitorMixin {
655
- checkAttribute(attributeName, _attributeValue, attributeNode, _parentNode) {
1523
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
1524
+ this.check(attributeName, attributeNode);
1525
+ }
1526
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
1527
+ this.check(attributeName, attributeNode);
1528
+ }
1529
+ check(attributeName, attributeNode) {
656
1530
  if (!attributeName.startsWith("aria-"))
657
1531
  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
- }
1532
+ if (ARIA_ATTRIBUTES.has(attributeName))
1533
+ return;
1534
+ this.addOffense(`The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`, attributeNode.location, "error");
666
1535
  }
667
1536
  }
668
1537
  class HTMLAriaAttributeMustBeValid extends ParserRule {
@@ -674,13 +1543,65 @@ class HTMLAriaAttributeMustBeValid extends ParserRule {
674
1543
  }
675
1544
  }
676
1545
 
1546
+ class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
1547
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
1548
+ if (attributeName !== "aria-label")
1549
+ return;
1550
+ if (attributeValue.match(/[\r\n]+/) || attributeValue.match(/&#10;|&#13;|&#x0A;|&#x0D;/i)) {
1551
+ this.addOffense("The `aria-label` attribute value text should not contain line breaks. Use concise, single-line descriptions.", attributeNode.location, "error");
1552
+ return;
1553
+ }
1554
+ if (this.looksLikeId(attributeValue)) {
1555
+ this.addOffense("The `aria-label` attribute value should not be formatted like an ID. Use natural, sentence-case text instead.", attributeNode.location, "error");
1556
+ return;
1557
+ }
1558
+ if (attributeValue.match(/^[a-z]/)) {
1559
+ this.addOffense("The `aria-label` attribute value text should be formatted like visual text. Use sentence case (capitalize the first letter).", attributeNode.location, "error");
1560
+ }
1561
+ }
1562
+ looksLikeId(text) {
1563
+ return (text.includes('_') ||
1564
+ text.includes('-') ||
1565
+ /^[a-z]+([A-Z][a-z]*)*$/.test(text)) && !text.includes(' ');
1566
+ }
1567
+ }
1568
+ class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
1569
+ name = "html-aria-label-is-well-formatted";
1570
+ check(result, context) {
1571
+ const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context);
1572
+ visitor.visit(result.value);
1573
+ return visitor.offenses;
1574
+ }
1575
+ }
1576
+
677
1577
  class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
678
- checkAttribute(attributeName, attributeValue, attributeNode, _parentNode) {
1578
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
679
1579
  if (attributeName !== "aria-level")
680
1580
  return;
681
- if (attributeValue !== null && attributeValue.includes("<%"))
1581
+ this.validateAriaLevel(attributeValue, attributeNode);
1582
+ }
1583
+ checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
1584
+ if (attributeName !== "aria-level")
1585
+ return;
1586
+ const validatableContent = core.getValidatableStaticContent(valueNodes);
1587
+ if (validatableContent !== null) {
1588
+ this.validateAriaLevel(validatableContent, attributeNode);
682
1589
  return;
683
- if (attributeValue === null || attributeValue === "") {
1590
+ }
1591
+ if (!core.hasERBOutput(valueNodes))
1592
+ return;
1593
+ const literalNodes = core.filterLiteralNodes(valueNodes);
1594
+ const erbOutputNodes = core.filterERBContentNodes(valueNodes).filter(core.isERBOutputNode);
1595
+ if (literalNodes.length > 0 && erbOutputNodes.length > 0) {
1596
+ const staticPart = literalNodes.map(node => node.content).join("");
1597
+ // TODO: this can be cleaned up using @herb-tools/printer
1598
+ const erbPart = erbOutputNodes[0];
1599
+ const erbText = `${erbPart.tag_opening?.value || ""}${erbPart.content?.value || ""}${erbPart.tag_closing?.value || ""}`;
1600
+ this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${staticPart}\` and the ERB expression \`${erbText}\`.`, attributeNode.location);
1601
+ }
1602
+ }
1603
+ validateAriaLevel(attributeValue, attributeNode) {
1604
+ if (!attributeValue || attributeValue === "") {
684
1605
  this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
685
1606
  return;
686
1607
  }
@@ -700,19 +1621,13 @@ class HTMLAriaLevelMustBeValidRule extends ParserRule {
700
1621
  }
701
1622
 
702
1623
  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")) {
1624
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode, parentNode }) {
1625
+ if (!(attributeName === "role" && attributeValue === "heading"))
708
1626
  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
- }
1627
+ const ariaLevelAttributes = getAttributes(parentNode).find(attribute => getAttributeName(attribute) === "aria-level");
1628
+ if (ariaLevelAttributes)
1629
+ return;
1630
+ this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
716
1631
  }
717
1632
  }
718
1633
  class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
@@ -725,10 +1640,10 @@ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
725
1640
  }
726
1641
 
727
1642
  class AriaRoleMustBeValid extends AttributeVisitorMixin {
728
- checkAttribute(attributeName, attributeValue, attributeNode) {
1643
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
729
1644
  if (attributeName !== "role")
730
1645
  return;
731
- if (attributeValue === null)
1646
+ if (!attributeValue)
732
1647
  return;
733
1648
  if (VALID_ARIA_ROLES.has(attributeValue))
734
1649
  return;
@@ -745,35 +1660,77 @@ class HTMLAriaRoleMustBeValidRule extends ParserRule {
745
1660
  }
746
1661
 
747
1662
  class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
748
- checkAttribute(attributeName, attributeValue, attributeNode) {
1663
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
749
1664
  if (!hasAttributeValue(attributeNode))
750
1665
  return;
751
1666
  if (getAttributeValueQuoteType(attributeNode) !== "single")
752
1667
  return;
753
1668
  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");
1669
+ return;
1670
+ this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${attributeValue}"\`.`, attributeNode.value.location, "warning");
1671
+ }
1672
+ checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode, combinedValue }) {
1673
+ if (!hasAttributeValue(attributeNode))
1674
+ return;
1675
+ if (getAttributeValueQuoteType(attributeNode) !== "single")
1676
+ return;
1677
+ if (core.filterLiteralNodes(valueNodes).some(node => node.content?.includes('"')))
1678
+ return;
1679
+ this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "warning");
756
1680
  }
757
1681
  }
758
1682
  class HTMLAttributeDoubleQuotesRule extends ParserRule {
759
1683
  name = "html-attribute-double-quotes";
760
1684
  check(result, context) {
761
- const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
1685
+ const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
1686
+ visitor.visit(result.value);
1687
+ return visitor.offenses;
1688
+ }
1689
+ }
1690
+
1691
+ class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
1692
+ visitHTMLAttributeNode(attribute) {
1693
+ if (!attribute.equals || !attribute.name || !attribute.value) {
1694
+ return;
1695
+ }
1696
+ if (attribute.equals.value.startsWith(" ")) {
1697
+ this.addOffense("Remove whitespace before `=` in HTML attribute", attribute.equals.location, "error");
1698
+ }
1699
+ if (attribute.equals.value.endsWith(" ")) {
1700
+ this.addOffense("Remove whitespace after `=` in HTML attribute", attribute.equals.location, "error");
1701
+ }
1702
+ }
1703
+ }
1704
+ class HTMLAttributeEqualsSpacingRule extends ParserRule {
1705
+ name = "html-attribute-equals-spacing";
1706
+ check(result, context) {
1707
+ const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context);
762
1708
  visitor.visit(result.value);
763
1709
  return visitor.offenses;
764
1710
  }
765
1711
  }
766
1712
 
767
1713
  class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
768
- checkAttribute(attributeName, _attributeValue, attributeNode) {
769
- if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
1714
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
1715
+ if (this.hasAttributeValue(attributeNode))
770
1716
  return;
771
- const valueNode = attributeNode.value;
772
- if (valueNode.quoted)
1717
+ if (this.isQuoted(attributeNode))
1718
+ return;
1719
+ this.addOffense(`Attribute value should be quoted: \`${attributeName}="${attributeValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
1720
+ }
1721
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1722
+ if (this.hasAttributeValue(attributeNode))
773
1723
  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");
1724
+ if (this.isQuoted(attributeNode))
1725
+ return;
1726
+ this.addOffense(`Attribute value should be quoted: \`${attributeName}="${combinedValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
1727
+ }
1728
+ hasAttributeValue(attributeNode) {
1729
+ return attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE";
1730
+ }
1731
+ isQuoted(attributeNode) {
1732
+ const valueNode = attributeNode.value;
1733
+ return valueNode.quoted;
777
1734
  }
778
1735
  }
779
1736
  class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
@@ -785,14 +1742,66 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
785
1742
  }
786
1743
  }
787
1744
 
1745
+ const ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT = new Set([
1746
+ "button", "fieldset", "input", "optgroup", "option", "select", "textarea"
1747
+ ]);
1748
+ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
1749
+ visitHTMLOpenTagNode(node) {
1750
+ this.checkElement(node);
1751
+ super.visitHTMLOpenTagNode(node);
1752
+ }
1753
+ checkElement(node) {
1754
+ const tagName = getTagName(node);
1755
+ if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
1756
+ return;
1757
+ }
1758
+ const hasDisabled = hasAttribute(node, "disabled");
1759
+ const hasAriaDisabled = hasAttribute(node, "aria-disabled");
1760
+ if ((hasDisabled && this.hasERBContent(node, "disabled")) || (hasAriaDisabled && this.hasERBContent(node, "aria-disabled"))) {
1761
+ return;
1762
+ }
1763
+ if (hasDisabled && hasAriaDisabled) {
1764
+ 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");
1765
+ }
1766
+ }
1767
+ hasERBContent(node, attributeName) {
1768
+ const attributes = getAttributes(node);
1769
+ const attribute = findAttributeByName(attributes, attributeName);
1770
+ if (!attribute)
1771
+ return false;
1772
+ const valueNode = attribute.value;
1773
+ if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
1774
+ return false;
1775
+ const htmlValueNode = valueNode;
1776
+ if (!htmlValueNode.children)
1777
+ return false;
1778
+ return htmlValueNode.children.some((child) => child.type === "AST_ERB_CONTENT_NODE");
1779
+ }
1780
+ }
1781
+ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
1782
+ name = "html-avoid-both-disabled-and-aria-disabled";
1783
+ check(result, context) {
1784
+ const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context);
1785
+ visitor.visit(result.value);
1786
+ return visitor.offenses;
1787
+ }
1788
+ }
1789
+
788
1790
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
789
- checkAttribute(attributeName, _attributeValue, attributeNode) {
1791
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
790
1792
  if (!isBooleanAttribute(attributeName))
791
1793
  return;
792
1794
  if (!hasAttributeValue(attributeNode))
793
1795
  return;
794
1796
  this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
795
1797
  }
1798
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1799
+ if (!isBooleanAttribute(attributeName))
1800
+ return;
1801
+ if (!hasAttributeValue(attributeNode))
1802
+ return;
1803
+ this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "error");
1804
+ }
796
1805
  }
797
1806
  class HTMLBooleanAttributesNoValueRule extends ParserRule {
798
1807
  name = "html-boolean-attributes-no-value";
@@ -803,14 +1812,47 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
803
1812
  }
804
1813
  }
805
1814
 
806
- class ImgRequireAltVisitor extends BaseRuleVisitor {
1815
+ class IframeHasTitleVisitor extends BaseRuleVisitor {
807
1816
  visitHTMLOpenTagNode(node) {
808
- this.checkImgTag(node);
1817
+ this.checkIframeElement(node);
809
1818
  super.visitHTMLOpenTagNode(node);
810
1819
  }
811
- visitHTMLSelfCloseTagNode(node) {
1820
+ checkIframeElement(node) {
1821
+ const tagName = getTagName(node);
1822
+ if (tagName !== "iframe") {
1823
+ return;
1824
+ }
1825
+ const ariaHiddenAttribute = getAttribute(node, "aria-hidden");
1826
+ if (ariaHiddenAttribute) {
1827
+ const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
1828
+ if (ariaHiddenValue === "true") {
1829
+ return;
1830
+ }
1831
+ }
1832
+ const attribute = getAttribute(node, "title");
1833
+ if (!attribute) {
1834
+ this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
1835
+ return;
1836
+ }
1837
+ const value = getAttributeValue(attribute);
1838
+ if (!value || value.trim() === "") {
1839
+ this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
1840
+ }
1841
+ }
1842
+ }
1843
+ class HTMLIframeHasTitleRule extends ParserRule {
1844
+ name = "html-iframe-has-title";
1845
+ check(result, context) {
1846
+ const visitor = new IframeHasTitleVisitor(this.name, context);
1847
+ visitor.visit(result.value);
1848
+ return visitor.offenses;
1849
+ }
1850
+ }
1851
+
1852
+ class ImgRequireAltVisitor extends BaseRuleVisitor {
1853
+ visitHTMLOpenTagNode(node) {
812
1854
  this.checkImgTag(node);
813
- super.visitHTMLSelfCloseTagNode(node);
1855
+ super.visitHTMLOpenTagNode(node);
814
1856
  }
815
1857
  checkImgTag(node) {
816
1858
  const tagName = getTagName(node);
@@ -831,34 +1873,137 @@ class HTMLImgRequireAltRule extends ParserRule {
831
1873
  }
832
1874
  }
833
1875
 
834
- class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
1876
+ class NavigationHasLabelVisitor extends BaseRuleVisitor {
1877
+ visitHTMLOpenTagNode(node) {
1878
+ this.checkNavigationElement(node);
1879
+ super.visitHTMLOpenTagNode(node);
1880
+ }
1881
+ checkNavigationElement(node) {
1882
+ const tagName = getTagName(node);
1883
+ const isNavElement = tagName === "nav";
1884
+ const hasNavigationRole = this.hasRoleNavigation(node);
1885
+ if (!isNavElement && !hasNavigationRole) {
1886
+ return;
1887
+ }
1888
+ const hasAriaLabel = hasAttribute(node, "aria-label");
1889
+ const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
1890
+ if (!hasAriaLabel && !hasAriaLabelledby) {
1891
+ 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.`;
1892
+ if (hasNavigationRole && !isNavElement) {
1893
+ message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
1894
+ }
1895
+ this.addOffense(message, node.tag_name.location, "error");
1896
+ }
1897
+ }
1898
+ hasRoleNavigation(node) {
1899
+ const attributes = getAttributes(node);
1900
+ const roleAttribute = findAttributeByName(attributes, "role");
1901
+ if (!roleAttribute) {
1902
+ return false;
1903
+ }
1904
+ const roleValue = getAttributeValue(roleAttribute);
1905
+ return roleValue === "navigation";
1906
+ }
1907
+ }
1908
+ class HTMLNavigationHasLabelRule extends ParserRule {
1909
+ name = "html-navigation-has-label";
1910
+ check(result, context) {
1911
+ const visitor = new NavigationHasLabelVisitor(this.name, context);
1912
+ visitor.visit(result.value);
1913
+ return visitor.offenses;
1914
+ }
1915
+ }
1916
+
1917
+ const INTERACTIVE_ELEMENTS = new Set([
1918
+ "button", "summary", "input", "select", "textarea", "a"
1919
+ ]);
1920
+ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
835
1921
  visitHTMLOpenTagNode(node) {
836
- this.checkDuplicateAttributes(node);
1922
+ this.checkAriaHiddenOnFocusable(node);
837
1923
  super.visitHTMLOpenTagNode(node);
838
1924
  }
839
- visitHTMLSelfCloseTagNode(node) {
840
- this.checkDuplicateAttributes(node);
841
- super.visitHTMLSelfCloseTagNode(node);
1925
+ checkAriaHiddenOnFocusable(node) {
1926
+ if (!this.hasAriaHiddenTrue(node))
1927
+ return;
1928
+ if (this.isFocusable(node)) {
1929
+ 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");
1930
+ }
842
1931
  }
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, []);
1932
+ hasAriaHiddenTrue(node) {
1933
+ const attributes = getAttributes(node);
1934
+ const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden");
1935
+ if (!ariaHiddenAttr)
1936
+ return false;
1937
+ const value = getAttributeValue(ariaHiddenAttr);
1938
+ return value === "true";
1939
+ }
1940
+ isFocusable(node) {
1941
+ const tagName = getTagName(node);
1942
+ if (!tagName)
1943
+ return false;
1944
+ const tabIndexValue = this.getTabIndexValue(node);
1945
+ if (tagName === "a") {
1946
+ const hasHref = hasAttribute(node, "href");
1947
+ if (!hasHref) {
1948
+ return tabIndexValue !== null && tabIndexValue >= 0;
854
1949
  }
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");
1950
+ return tabIndexValue === null || tabIndexValue >= 0;
1951
+ }
1952
+ if (INTERACTIVE_ELEMENTS.has(tagName)) {
1953
+ // Interactive elements are focusable unless tabindex is negative
1954
+ return tabIndexValue === null || tabIndexValue >= 0;
1955
+ }
1956
+ else {
1957
+ // Non-interactive elements are focusable only if tabindex >= 0
1958
+ return tabIndexValue !== null && tabIndexValue >= 0;
1959
+ }
1960
+ }
1961
+ getTabIndexValue(node) {
1962
+ const attributes = getAttributes(node);
1963
+ const tabIndexAttribute = findAttributeByName(attributes, "tabindex");
1964
+ if (!tabIndexAttribute)
1965
+ return null;
1966
+ const value = getAttributeValue(tabIndexAttribute);
1967
+ if (!value)
1968
+ return null;
1969
+ const parsed = parseInt(value, 10);
1970
+ return isNaN(parsed) ? null : parsed;
1971
+ }
1972
+ }
1973
+ class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
1974
+ name = "html-no-aria-hidden-on-focusable";
1975
+ check(result, context) {
1976
+ const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context);
1977
+ visitor.visit(result.value);
1978
+ return visitor.offenses;
1979
+ }
1980
+ }
1981
+
1982
+ class NoDuplicateAttributesVisitor extends AttributeVisitorMixin {
1983
+ attributeNames = new Map();
1984
+ visitHTMLOpenTagNode(node) {
1985
+ this.attributeNames.clear();
1986
+ super.visitHTMLOpenTagNode(node);
1987
+ this.reportDuplicates();
1988
+ }
1989
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
1990
+ this.trackAttributeName(attributeName, attributeNode);
1991
+ }
1992
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
1993
+ this.trackAttributeName(attributeName, attributeNode);
1994
+ }
1995
+ trackAttributeName(attributeName, attributeNode) {
1996
+ if (!this.attributeNames.has(attributeName)) {
1997
+ this.attributeNames.set(attributeName, []);
1998
+ }
1999
+ this.attributeNames.get(attributeName).push(attributeNode);
2000
+ }
2001
+ reportDuplicates() {
2002
+ for (const [attributeName, attributeNodes] of this.attributeNames) {
2003
+ if (attributeNodes.length > 1) {
2004
+ for (let i = 1; i < attributeNodes.length; i++) {
2005
+ const attributeNode = attributeNodes[i];
2006
+ this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, attributeNode.name.location, "error");
862
2007
  }
863
2008
  }
864
2009
  }
@@ -873,19 +2018,141 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
873
2018
  }
874
2019
  }
875
2020
 
876
- class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
2021
+ class OutputPrinter extends Printer {
2022
+ visitLiteralNode(node) {
2023
+ this.write(IdentityPrinter.print(node));
2024
+ }
2025
+ visitERBContentNode(node) {
2026
+ if (core.isERBOutputNode(node)) {
2027
+ this.write(IdentityPrinter.print(node));
2028
+ }
2029
+ }
2030
+ }
2031
+ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
877
2032
  documentIds = new Set();
878
- checkAttribute(attributeName, attributeValue, attributeNode) {
879
- if (attributeName.toLowerCase() !== "id")
2033
+ currentBranchIds = new Set();
2034
+ controlFlowIds = new Set();
2035
+ visitHTMLAttributeNode(node) {
2036
+ this.checkAttribute(node);
2037
+ }
2038
+ onEnterControlFlow(_controlFlowType, wasAlreadyInControlFlow) {
2039
+ const stateToRestore = {
2040
+ previousBranchIds: this.currentBranchIds,
2041
+ previousControlFlowIds: this.controlFlowIds
2042
+ };
2043
+ this.currentBranchIds = new Set();
2044
+ if (!wasAlreadyInControlFlow) {
2045
+ this.controlFlowIds = new Set();
2046
+ }
2047
+ return stateToRestore;
2048
+ }
2049
+ onExitControlFlow(controlFlowType, wasAlreadyInControlFlow, stateToRestore) {
2050
+ if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
2051
+ this.controlFlowIds.forEach(id => this.documentIds.add(id));
2052
+ }
2053
+ this.currentBranchIds = stateToRestore.previousBranchIds;
2054
+ this.controlFlowIds = stateToRestore.previousControlFlowIds;
2055
+ }
2056
+ onEnterBranch() {
2057
+ const stateToRestore = {
2058
+ previousBranchIds: this.currentBranchIds
2059
+ };
2060
+ if (this.isInControlFlow) {
2061
+ this.currentBranchIds = new Set();
2062
+ }
2063
+ return stateToRestore;
2064
+ }
2065
+ onExitBranch(_stateToRestore) { }
2066
+ checkAttribute(attributeNode) {
2067
+ if (!this.isIdAttribute(attributeNode))
880
2068
  return;
881
- if (!attributeValue)
2069
+ const idValue = this.extractIdValue(attributeNode);
2070
+ if (!idValue)
2071
+ return;
2072
+ if (this.isWhitespaceOnlyId(idValue.identifier))
2073
+ return;
2074
+ this.processIdDuplicate(idValue, attributeNode);
2075
+ }
2076
+ isIdAttribute(attributeNode) {
2077
+ if (!attributeNode.name?.children || !attributeNode.value)
2078
+ return false;
2079
+ return core.getStaticAttributeName(attributeNode.name) === "id";
2080
+ }
2081
+ extractIdValue(attributeNode) {
2082
+ const valueNodes = attributeNode.value?.children || [];
2083
+ if (core.hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
2084
+ return null;
2085
+ }
2086
+ const identifier = core.isEffectivelyStatic(valueNodes) ? core.getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
2087
+ if (!identifier)
2088
+ return null;
2089
+ return { identifier, shouldTrackDuplicates: true };
2090
+ }
2091
+ isWhitespaceOnlyId(identifier) {
2092
+ return identifier !== '' && identifier.trim() === '';
2093
+ }
2094
+ processIdDuplicate(idValue, attributeNode) {
2095
+ const { identifier, shouldTrackDuplicates } = idValue;
2096
+ if (!shouldTrackDuplicates)
2097
+ return;
2098
+ if (this.isInControlFlow) {
2099
+ this.handleControlFlowId(identifier, attributeNode);
2100
+ }
2101
+ else {
2102
+ this.handleGlobalId(identifier, attributeNode);
2103
+ }
2104
+ }
2105
+ handleControlFlowId(identifier, attributeNode) {
2106
+ if (this.currentControlFlowType === ControlFlowType.LOOP) {
2107
+ this.handleLoopId(identifier, attributeNode);
2108
+ }
2109
+ else {
2110
+ this.handleConditionalId(identifier, attributeNode);
2111
+ }
2112
+ this.currentBranchIds.add(identifier);
2113
+ }
2114
+ handleLoopId(identifier, attributeNode) {
2115
+ const isStaticId = this.isStaticId(attributeNode);
2116
+ if (isStaticId) {
2117
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2118
+ return;
2119
+ }
2120
+ if (this.currentBranchIds.has(identifier)) {
2121
+ this.addSameLoopIterationOffense(identifier, attributeNode.location);
2122
+ }
2123
+ }
2124
+ handleConditionalId(identifier, attributeNode) {
2125
+ if (this.currentBranchIds.has(identifier)) {
2126
+ this.addSameBranchOffense(identifier, attributeNode.location);
882
2127
  return;
883
- const id = attributeValue.trim();
884
- if (this.documentIds.has(id)) {
885
- this.addOffense(`Duplicate ID \`${id}\` found. IDs must be unique within a document.`, attributeNode.location, "error");
2128
+ }
2129
+ if (this.documentIds.has(identifier)) {
2130
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2131
+ return;
2132
+ }
2133
+ this.controlFlowIds.add(identifier);
2134
+ }
2135
+ handleGlobalId(identifier, attributeNode) {
2136
+ if (this.documentIds.has(identifier)) {
2137
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
886
2138
  return;
887
2139
  }
888
- this.documentIds.add(id);
2140
+ this.documentIds.add(identifier);
2141
+ }
2142
+ isStaticId(attributeNode) {
2143
+ const valueNodes = attributeNode.value.children;
2144
+ const isCompletelyStatic = valueNodes.every(child => core.isNode(child, core.LiteralNode));
2145
+ const isEffectivelyStaticValue = core.isEffectivelyStatic(valueNodes);
2146
+ return isCompletelyStatic || isEffectivelyStaticValue;
2147
+ }
2148
+ addDuplicateIdOffense(identifier, location) {
2149
+ this.addOffense(`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`, location, "error");
2150
+ }
2151
+ addSameLoopIterationOffense(identifier, location) {
2152
+ this.addOffense(`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`, location, "error");
2153
+ }
2154
+ addSameBranchOffense(identifier, location) {
2155
+ this.addOffense(`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`, location, "error");
889
2156
  }
890
2157
  }
891
2158
  class HTMLNoDuplicateIdsRule extends ParserRule {
@@ -902,10 +2169,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
902
2169
  this.checkHeadingElement(node);
903
2170
  super.visitHTMLElementNode(node);
904
2171
  }
905
- visitHTMLSelfCloseTagNode(node) {
906
- this.checkSelfClosingHeading(node);
907
- super.visitHTMLSelfCloseTagNode(node);
908
- }
909
2172
  checkHeadingElement(node) {
910
2173
  if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
911
2174
  return;
@@ -927,23 +2190,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
927
2190
  this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.location, "error");
928
2191
  }
929
2192
  }
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
2193
  isEmptyHeading(node) {
948
2194
  if (!node.body || node.body.length === 0) {
949
2195
  return true;
@@ -1088,46 +2334,127 @@ class HTMLNoNestedLinksRule extends ParserRule {
1088
2334
  }
1089
2335
  }
1090
2336
 
1091
- class TagNameLowercaseVisitor extends BaseRuleVisitor {
2337
+ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
2338
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
2339
+ if (attributeName !== "tabindex")
2340
+ return;
2341
+ const tabIndexValue = parseInt(attributeValue, 10);
2342
+ if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
2343
+ 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");
2344
+ }
2345
+ }
2346
+ }
2347
+ class HTMLNoPositiveTabIndexRule extends ParserRule {
2348
+ name = "html-no-positive-tab-index";
2349
+ check(result, context) {
2350
+ const visitor = new NoPositiveTabIndexVisitor(this.name, context);
2351
+ visitor.visit(result.value);
2352
+ return visitor.offenses;
2353
+ }
2354
+ }
2355
+
2356
+ class NoSelfClosingVisitor extends BaseRuleVisitor {
1092
2357
  visitHTMLElementNode(node) {
1093
- const tagName = node.tag_name?.value;
1094
- if (node.open_tag) {
1095
- this.checkTagName(node.open_tag);
2358
+ if (core.getTagName(node) === "svg") {
2359
+ this.visit(node.open_tag);
1096
2360
  }
1097
- if (tagName && ["svg"].includes(tagName.toLowerCase())) {
1098
- if (node.close_tag) {
1099
- this.checkTagName(node.close_tag);
1100
- }
2361
+ else {
2362
+ this.visitChildNodes(node);
2363
+ }
2364
+ }
2365
+ visitHTMLOpenTagNode(node) {
2366
+ if (node.tag_closing?.value === "/>") {
2367
+ const tagName = core.getTagName(node);
2368
+ const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
2369
+ this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, "error");
2370
+ }
2371
+ }
2372
+ }
2373
+ class HTMLNoSelfClosingRule extends ParserRule {
2374
+ name = "html-no-self-closing";
2375
+ check(result, context) {
2376
+ const visitor = new NoSelfClosingVisitor(this.name, context);
2377
+ visitor.visit(result.value);
2378
+ return visitor.offenses;
2379
+ }
2380
+ }
2381
+
2382
+ class NoTitleAttributeVisitor extends BaseRuleVisitor {
2383
+ ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
2384
+ visitHTMLOpenTagNode(node) {
2385
+ this.checkTitleAttribute(node);
2386
+ super.visitHTMLOpenTagNode(node);
2387
+ }
2388
+ checkTitleAttribute(node) {
2389
+ const tagName = getTagName(node);
2390
+ if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
1101
2391
  return;
1102
2392
  }
1103
- this.visitChildNodes(node);
1104
- if (node.close_tag) {
2393
+ if (hasAttribute(node, "title")) {
2394
+ 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");
2395
+ }
2396
+ }
2397
+ }
2398
+ class HTMLNoTitleAttributeRule extends ParserRule {
2399
+ name = "html-no-title-attribute";
2400
+ check(result, context) {
2401
+ const visitor = new NoTitleAttributeVisitor(this.name, context);
2402
+ visitor.visit(result.value);
2403
+ return visitor.offenses;
2404
+ }
2405
+ }
2406
+
2407
+ class XMLDeclarationChecker extends BaseRuleVisitor {
2408
+ hasXMLDeclaration = false;
2409
+ visitXMLDeclarationNode(_node) {
2410
+ this.hasXMLDeclaration = true;
2411
+ }
2412
+ visitChildNodes(node) {
2413
+ if (this.hasXMLDeclaration)
2414
+ return;
2415
+ super.visitChildNodes(node);
2416
+ }
2417
+ }
2418
+ class TagNameLowercaseVisitor extends BaseRuleVisitor {
2419
+ visitHTMLElementNode(node) {
2420
+ if (core.getTagName(node).toLowerCase() === "svg") {
2421
+ this.checkTagName(node.open_tag);
1105
2422
  this.checkTagName(node.close_tag);
1106
2423
  }
2424
+ else {
2425
+ super.visitHTMLElementNode(node);
2426
+ }
1107
2427
  }
1108
- visitHTMLSelfCloseTagNode(node) {
2428
+ visitHTMLOpenTagNode(node) {
2429
+ this.checkTagName(node);
2430
+ }
2431
+ visitHTMLCloseTagNode(node) {
1109
2432
  this.checkTagName(node);
1110
- this.visitChildNodes(node);
1111
2433
  }
1112
2434
  checkTagName(node) {
1113
- const tagName = node.tag_name?.value;
2435
+ if (!node)
2436
+ return;
2437
+ const tagName = core.getTagName(node);
1114
2438
  if (!tagName)
1115
2439
  return;
1116
2440
  const lowercaseTagName = tagName.toLowerCase();
2441
+ const type = core.isNode(node, core.HTMLOpenTagNode) ? "Opening" : "Closing";
2442
+ const open = core.isNode(node, core.HTMLOpenTagNode) ? "<" : "</";
1117
2443
  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");
2444
+ this.addOffense(`${type} tag name \`${open}${tagName}>\` should be lowercase. Use \`${open}${lowercaseTagName}>\` instead.`, node.tag_name.location, "error");
1126
2445
  }
1127
2446
  }
1128
2447
  }
1129
2448
  class HTMLTagNameLowercaseRule extends ParserRule {
1130
2449
  name = "html-tag-name-lowercase";
2450
+ isEnabled(result, context) {
2451
+ if (context?.fileName?.endsWith(".xml") || context?.fileName?.endsWith(".xml.erb")) {
2452
+ return false;
2453
+ }
2454
+ const checker = new XMLDeclarationChecker(this.name);
2455
+ checker.visit(result.value);
2456
+ return !checker.hasXMLDeclaration;
2457
+ }
1131
2458
  check(result, context) {
1132
2459
  const visitor = new TagNameLowercaseVisitor(this.name, context);
1133
2460
  visitor.visit(result.value);
@@ -1173,12 +2500,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1173
2500
  }
1174
2501
  this.visitChildNodes(node);
1175
2502
  }
1176
- visitHTMLSelfCloseTagNode(node) {
1177
- if (this.insideSVG) {
1178
- this.checkTagName(node);
1179
- }
1180
- this.visitChildNodes(node);
1181
- }
1182
2503
  checkTagName(node) {
1183
2504
  const tagName = node.tag_name?.value;
1184
2505
  if (!tagName)
@@ -1193,8 +2514,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1193
2514
  type = "Opening";
1194
2515
  if (node.type == "AST_HTML_CLOSE_TAG_NODE")
1195
2516
  type = "Closing";
1196
- if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
1197
- type = "Self-closing";
1198
2517
  this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
1199
2518
  }
1200
2519
  }
@@ -1211,23 +2530,33 @@ class SVGTagNameCapitalizationRule extends ParserRule {
1211
2530
  const defaultRules = [
1212
2531
  ERBNoEmptyTagsRule,
1213
2532
  ERBNoOutputControlFlowRule,
2533
+ ERBNoSilentTagInAttributeNameRule,
1214
2534
  ERBPreferImageTagHelperRule,
1215
2535
  ERBRequiresTrailingNewlineRule,
1216
2536
  ERBRequireWhitespaceRule,
1217
2537
  HTMLAnchorRequireHrefRule,
1218
2538
  HTMLAriaAttributeMustBeValid,
2539
+ HTMLAriaLabelIsWellFormattedRule,
1219
2540
  HTMLAriaLevelMustBeValidRule,
1220
2541
  HTMLAriaRoleHeadingRequiresLevelRule,
1221
2542
  HTMLAriaRoleMustBeValidRule,
1222
2543
  HTMLAttributeDoubleQuotesRule,
2544
+ HTMLAttributeEqualsSpacingRule,
1223
2545
  HTMLAttributeValuesRequireQuotesRule,
2546
+ HTMLAvoidBothDisabledAndAriaDisabledRule,
1224
2547
  HTMLBooleanAttributesNoValueRule,
2548
+ HTMLIframeHasTitleRule,
1225
2549
  HTMLImgRequireAltRule,
2550
+ HTMLNavigationHasLabelRule,
2551
+ HTMLNoAriaHiddenOnFocusableRule,
1226
2552
  // HTMLNoBlockInsideInlineRule,
1227
2553
  HTMLNoDuplicateAttributesRule,
1228
2554
  HTMLNoDuplicateIdsRule,
1229
2555
  HTMLNoEmptyHeadingsRule,
1230
2556
  HTMLNoNestedLinksRule,
2557
+ HTMLNoPositiveTabIndexRule,
2558
+ HTMLNoSelfClosingRule,
2559
+ HTMLNoTitleAttributeRule,
1231
2560
  HTMLTagNameLowercaseRule,
1232
2561
  ParserNoErrorsRule,
1233
2562
  SVGTagNameCapitalizationRule,
@@ -1276,19 +2605,44 @@ class Linter {
1276
2605
  */
1277
2606
  lint(source, context) {
1278
2607
  this.offenses = [];
1279
- const parseResult = this.herb.parse(source);
2608
+ const parseResult = this.herb.parse(source, { track_whitespace: true });
1280
2609
  const lexResult = this.herb.lex(source);
1281
2610
  for (const RuleClass of this.rules) {
1282
2611
  const rule = new RuleClass();
2612
+ let isEnabled = true;
1283
2613
  let ruleOffenses;
1284
2614
  if (this.isLexerRule(rule)) {
1285
- ruleOffenses = rule.check(lexResult, context);
2615
+ if (rule.isEnabled) {
2616
+ isEnabled = rule.isEnabled(lexResult, context);
2617
+ }
2618
+ if (isEnabled) {
2619
+ ruleOffenses = rule.check(lexResult, context);
2620
+ }
2621
+ else {
2622
+ ruleOffenses = [];
2623
+ }
1286
2624
  }
1287
2625
  else if (this.isSourceRule(rule)) {
1288
- ruleOffenses = rule.check(source, context);
2626
+ if (rule.isEnabled) {
2627
+ isEnabled = rule.isEnabled(source, context);
2628
+ }
2629
+ if (isEnabled) {
2630
+ ruleOffenses = rule.check(source, context);
2631
+ }
2632
+ else {
2633
+ ruleOffenses = [];
2634
+ }
1289
2635
  }
1290
2636
  else {
1291
- ruleOffenses = rule.check(parseResult, context);
2637
+ if (rule.isEnabled) {
2638
+ isEnabled = rule.isEnabled(parseResult, context);
2639
+ }
2640
+ if (isEnabled) {
2641
+ ruleOffenses = rule.check(parseResult, context);
2642
+ }
2643
+ else {
2644
+ ruleOffenses = [];
2645
+ }
1292
2646
  }
1293
2647
  this.offenses.push(...ruleOffenses);
1294
2648
  }
@@ -1363,21 +2717,31 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
1363
2717
  exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
1364
2718
  exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
1365
2719
  exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
2720
+ exports.ERBNoSilentTagInAttributeNameRule = ERBNoSilentTagInAttributeNameRule;
1366
2721
  exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
1367
2722
  exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
1368
2723
  exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
2724
+ exports.HTMLAriaLabelIsWellFormattedRule = HTMLAriaLabelIsWellFormattedRule;
1369
2725
  exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
1370
2726
  exports.HTMLAriaRoleHeadingRequiresLevelRule = HTMLAriaRoleHeadingRequiresLevelRule;
1371
2727
  exports.HTMLAriaRoleMustBeValidRule = HTMLAriaRoleMustBeValidRule;
1372
2728
  exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
2729
+ exports.HTMLAttributeEqualsSpacingRule = HTMLAttributeEqualsSpacingRule;
1373
2730
  exports.HTMLAttributeValuesRequireQuotesRule = HTMLAttributeValuesRequireQuotesRule;
2731
+ exports.HTMLAvoidBothDisabledAndAriaDisabledRule = HTMLAvoidBothDisabledAndAriaDisabledRule;
1374
2732
  exports.HTMLBooleanAttributesNoValueRule = HTMLBooleanAttributesNoValueRule;
2733
+ exports.HTMLIframeHasTitleRule = HTMLIframeHasTitleRule;
1375
2734
  exports.HTMLImgRequireAltRule = HTMLImgRequireAltRule;
2735
+ exports.HTMLNavigationHasLabelRule = HTMLNavigationHasLabelRule;
2736
+ exports.HTMLNoAriaHiddenOnFocusableRule = HTMLNoAriaHiddenOnFocusableRule;
1376
2737
  exports.HTMLNoBlockInsideInlineRule = HTMLNoBlockInsideInlineRule;
1377
2738
  exports.HTMLNoDuplicateAttributesRule = HTMLNoDuplicateAttributesRule;
1378
2739
  exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
1379
2740
  exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
1380
2741
  exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
2742
+ exports.HTMLNoPositiveTabIndexRule = HTMLNoPositiveTabIndexRule;
2743
+ exports.HTMLNoSelfClosingRule = HTMLNoSelfClosingRule;
2744
+ exports.HTMLNoTitleAttributeRule = HTMLNoTitleAttributeRule;
1381
2745
  exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
1382
2746
  exports.LexerRule = LexerRule;
1383
2747
  exports.Linter = Linter;