@herb-tools/linter 0.4.3 → 0.6.0

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