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