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