@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
@@ -1,7 +1,15 @@
1
1
  import {
2
2
  Visitor,
3
3
  Position,
4
- Location
4
+ Location,
5
+ getStaticAttributeName,
6
+ hasDynamicAttributeName as hasNodeDynamicAttributeName,
7
+ getCombinedAttributeName,
8
+ hasERBOutput,
9
+ getStaticContentFromNodes,
10
+ hasStaticContent,
11
+ isEffectivelyStatic,
12
+ getValidatableStaticContent
5
13
  } from "@herb-tools/core"
6
14
 
7
15
  import type {
@@ -10,14 +18,16 @@ import type {
10
18
  HTMLAttributeNode,
11
19
  HTMLAttributeValueNode,
12
20
  HTMLOpenTagNode,
13
- HTMLSelfCloseTagNode,
14
21
  LiteralNode,
15
22
  LexResult,
16
- Token
23
+ Token,
24
+ Node
17
25
  } from "@herb-tools/core"
18
- import type { LintOffense, LintSeverity, LintContext } from "../types.js"
26
+
19
27
  import { DEFAULT_LINT_CONTEXT } from "../types.js"
20
28
 
29
+ import type { LintOffense, LintSeverity, LintContext } from "../types.js"
30
+
21
31
  /**
22
32
  * Base visitor class that provides common functionality for rule visitors
23
33
  */
@@ -56,34 +66,126 @@ export abstract class BaseRuleVisitor extends Visitor {
56
66
  }
57
67
 
58
68
  /**
59
- * Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
69
+ * Gets attributes from an HTMLOpenTagNode
60
70
  */
61
- export function getAttributes(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): any[] {
62
- return node.type === "AST_HTML_SELF_CLOSE_TAG_NODE"
63
- ? (node as HTMLSelfCloseTagNode).attributes
64
- : (node as HTMLOpenTagNode).children
71
+ export function getAttributes(node: HTMLOpenTagNode): HTMLAttributeNode[] {
72
+ return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE") as HTMLAttributeNode[]
65
73
  }
66
74
 
67
75
  /**
68
76
  * Gets the tag name from an HTML tag node (lowercased)
69
77
  */
70
- export function getTagName(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): string | null {
78
+ export function getTagName(node: HTMLOpenTagNode): string | null {
71
79
  return node.tag_name?.value.toLowerCase() || null
72
80
  }
73
81
 
74
82
  /**
75
83
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
84
+ * Returns null if the attribute name contains dynamic content (ERB)
76
85
  */
77
86
  export function getAttributeName(attributeNode: HTMLAttributeNode): string | null {
78
87
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
79
88
  const nameNode = attributeNode.name as HTMLAttributeNameNode
89
+ const staticName = getStaticAttributeName(nameNode)
80
90
 
81
- return nameNode.name?.value.toLowerCase() || null
91
+ return staticName ? staticName.toLowerCase() : null
82
92
  }
83
93
 
84
94
  return null
85
95
  }
86
96
 
97
+ /**
98
+ * Checks if an attribute has a dynamic (ERB-containing) name
99
+ */
100
+ export function hasDynamicAttributeName(attributeNode: HTMLAttributeNode): boolean {
101
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
102
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
103
+ return hasNodeDynamicAttributeName(nameNode)
104
+ }
105
+
106
+ return false
107
+ }
108
+
109
+ /**
110
+ * Gets the combined string representation of an attribute name (for debugging)
111
+ * This includes both static content and ERB syntax
112
+ */
113
+ export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string {
114
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
115
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
116
+
117
+ return getCombinedAttributeName(nameNode)
118
+ }
119
+
120
+ return ""
121
+ }
122
+
123
+ /**
124
+ * Checks if an attribute value contains only static content (no ERB)
125
+ */
126
+ export function hasStaticAttributeValue(attributeNode: HTMLAttributeNode): boolean {
127
+ const valueNode = attributeNode.value as HTMLAttributeValueNode | null
128
+
129
+ if (!valueNode?.children) return false
130
+
131
+ return valueNode.children.every(child => child.type === "AST_LITERAL_NODE")
132
+ }
133
+
134
+ /**
135
+ * Checks if an attribute value contains dynamic content (ERB)
136
+ */
137
+ export function hasDynamicAttributeValue(attributeNode: HTMLAttributeNode): boolean {
138
+ const valueNode = attributeNode.value as HTMLAttributeValueNode | null
139
+
140
+ if (!valueNode?.children) return false
141
+
142
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
143
+ }
144
+
145
+ /**
146
+ * Gets the static string value of an attribute (returns null if it contains ERB)
147
+ */
148
+ export function getStaticAttributeValue(attributeNode: HTMLAttributeNode): string | null {
149
+ if (!hasStaticAttributeValue(attributeNode)) return null
150
+
151
+ const valueNode = attributeNode.value as HTMLAttributeValueNode
152
+
153
+ const result = valueNode.children
154
+ ?.filter(child => child.type === "AST_LITERAL_NODE")
155
+ .map(child => (child as LiteralNode).content)
156
+ .join("") || ""
157
+
158
+ return result
159
+ }
160
+
161
+ /**
162
+ * Gets the value nodes array for dynamic inspection
163
+ */
164
+ export function getAttributeValueNodes(attributeNode: HTMLAttributeNode): Node[] {
165
+ const valueNode = attributeNode.value as HTMLAttributeValueNode | null
166
+
167
+ return valueNode?.children || []
168
+ }
169
+
170
+ /**
171
+ * Checks if an attribute value contains any static content (for validation purposes)
172
+ */
173
+ export function hasStaticAttributeValueContent(attributeNode: HTMLAttributeNode): boolean {
174
+ const valueNodes = getAttributeValueNodes(attributeNode)
175
+
176
+ return hasStaticContent(valueNodes)
177
+ }
178
+
179
+ /**
180
+ * Gets the static content of an attribute value (all literal parts combined)
181
+ * Returns the concatenated literal content, or null if no literal nodes exist
182
+ */
183
+ export function getStaticAttributeValueContent(attributeNode: HTMLAttributeNode): string | null {
184
+ const valueNodes = getAttributeValueNodes(attributeNode)
185
+
186
+ return getStaticContentFromNodes(valueNodes)
187
+ }
188
+
87
189
  /**
88
190
  * Gets the attribute value content from an HTMLAttributeValueNode
89
191
  */
@@ -130,9 +232,20 @@ export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
130
232
  /**
131
233
  * Gets the quote type used for an attribute value
132
234
  */
133
- export function getAttributeValueQuoteType(attributeNode: HTMLAttributeNode): "single" | "double" | "none" | null {
134
- if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
135
- const valueNode = attributeNode.value as HTMLAttributeValueNode
235
+ export function getAttributeValueQuoteType(nodeOrAttribute: HTMLAttributeNode | HTMLAttributeValueNode): "single" | "double" | "none" | null {
236
+ let valueNode: HTMLAttributeValueNode | undefined
237
+
238
+ if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
239
+ const attributeNode = nodeOrAttribute as HTMLAttributeNode
240
+
241
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
242
+ valueNode = attributeNode.value as HTMLAttributeValueNode
243
+ }
244
+ } else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
245
+ valueNode = nodeOrAttribute as HTMLAttributeValueNode
246
+ }
247
+
248
+ if (valueNode) {
136
249
  if (valueNode.quoted && valueNode.open_quote) {
137
250
  return valueNode.open_quote.value === '"' ? "double" : "single"
138
251
  }
@@ -146,25 +259,35 @@ export function getAttributeValueQuoteType(attributeNode: HTMLAttributeNode): "s
146
259
  /**
147
260
  * Finds an attribute by name in a list of attributes
148
261
  */
149
- export function findAttributeByName(attributes: any[], attributeName: string): HTMLAttributeNode | null {
262
+ export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null {
150
263
  for (const child of attributes) {
151
264
  if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
152
265
  const attributeNode = child as HTMLAttributeNode
153
266
  const name = getAttributeName(attributeNode)
267
+
154
268
  if (name === attributeName.toLowerCase()) {
155
269
  return attributeNode
156
270
  }
157
271
  }
158
272
  }
273
+
159
274
  return null
160
275
  }
161
276
 
162
277
  /**
163
278
  * Checks if a tag has a specific attribute
164
279
  */
165
- export function hasAttribute(node: HTMLOpenTagNode | HTMLSelfCloseTagNode, attributeName: string): boolean {
280
+ export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): boolean {
281
+ return getAttribute(node, attributeName) !== null
282
+ }
283
+
284
+ /**
285
+ * Checks if a tag has a specific attribute
286
+ */
287
+ export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null {
166
288
  const attributes = getAttributes(node)
167
- return findAttributeByName(attributes, attributeName) !== null
289
+
290
+ return findAttributeByName(attributes, attributeName)
168
291
  }
169
292
 
170
293
  /**
@@ -184,6 +307,11 @@ export const HTML_BLOCK_ELEMENTS = new Set([
184
307
  "ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
185
308
  ])
186
309
 
310
+ export const HTML_VOID_ELEMENTS = new Set([
311
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
312
+ "param", "source", "track", "wbr",
313
+ ])
314
+
187
315
  export const HTML_BOOLEAN_ATTRIBUTES = new Set([
188
316
  "autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
189
317
  "loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
@@ -254,6 +382,41 @@ export const VALID_ARIA_ROLES = new Set([
254
382
  "log", "marquee"
255
383
  ]);
256
384
 
385
+ /**
386
+ * Parameter types for AttributeVisitorMixin methods
387
+ */
388
+ export interface StaticAttributeStaticValueParams {
389
+ attributeName: string
390
+ attributeValue: string
391
+ attributeNode: HTMLAttributeNode
392
+ parentNode: HTMLOpenTagNode
393
+ }
394
+
395
+ export interface StaticAttributeDynamicValueParams {
396
+ attributeName: string
397
+ valueNodes: Node[]
398
+ attributeNode: HTMLAttributeNode
399
+ parentNode: HTMLOpenTagNode
400
+ combinedValue?: string | null
401
+ }
402
+
403
+ export interface DynamicAttributeStaticValueParams {
404
+ nameNodes: Node[]
405
+ attributeValue: string
406
+ attributeNode: HTMLAttributeNode
407
+ parentNode: HTMLOpenTagNode
408
+ combinedName?: string
409
+ }
410
+
411
+ export interface DynamicAttributeDynamicValueParams {
412
+ nameNodes: Node[]
413
+ valueNodes: Node[]
414
+ attributeNode: HTMLAttributeNode
415
+ parentNode: HTMLOpenTagNode
416
+ combinedName?: string
417
+ combinedValue?: string | null
418
+ }
419
+
257
420
  export const ARIA_ATTRIBUTES = new Set([
258
421
  'aria-activedescendant',
259
422
  'aria-atomic',
@@ -335,6 +498,13 @@ export function isBlockElement(tagName: string): boolean {
335
498
  return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase())
336
499
  }
337
500
 
501
+ /**
502
+ * Checks if an element is a void element
503
+ */
504
+ export function isVoidElement(tagName: string): boolean {
505
+ return HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
506
+ }
507
+
338
508
  /**
339
509
  * Checks if an attribute is a boolean attribute
340
510
  */
@@ -343,9 +513,14 @@ export function isBooleanAttribute(attributeName: string): boolean {
343
513
  }
344
514
 
345
515
  /**
346
- * Abstract base class for rules that need to check individual attributes on HTML tags
347
- * Eliminates duplication of visitHTMLOpenTagNode/visitHTMLSelfCloseTagNode patterns
348
- * and attribute iteration logic. Provides simplified interface with extracted attribute info.
516
+ * Attribute visitor that provides granular processing based on both
517
+ * attribute name type (static/dynamic) and value type (static/dynamic)
518
+ *
519
+ * This gives you 4 distinct methods to override:
520
+ * - checkStaticAttributeStaticValue() - name="class" value="foo"
521
+ * - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
522
+ * - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
523
+ * - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
349
524
  */
350
525
  export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
351
526
  constructor(ruleName: string, context?: Partial<LintContext>) {
@@ -357,28 +532,74 @@ export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
357
532
  super.visitHTMLOpenTagNode(node)
358
533
  }
359
534
 
360
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
361
- this.checkAttributesOnNode(node)
362
- super.visitHTMLSelfCloseTagNode(node)
363
- }
364
-
365
- private checkAttributesOnNode(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
535
+ private checkAttributesOnNode(node: HTMLOpenTagNode): void {
366
536
  forEachAttribute(node, (attributeNode) => {
367
- const attributeName = getAttributeName(attributeNode)
368
- const attributeValue = getAttributeValue(attributeNode)
369
-
370
- if (attributeName) {
371
- this.checkAttribute(attributeName, attributeValue, attributeNode, node)
537
+ const staticAttributeName = getAttributeName(attributeNode)
538
+ const isDynamicName = hasDynamicAttributeName(attributeNode)
539
+ const staticAttributeValue = getStaticAttributeValue(attributeNode)
540
+ const valueNodes = getAttributeValueNodes(attributeNode)
541
+ const hasOutputERB = hasERBOutput(valueNodes)
542
+ const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes)
543
+
544
+ if (staticAttributeName && staticAttributeValue !== null) {
545
+ this.checkStaticAttributeStaticValue({
546
+ attributeName: staticAttributeName,
547
+ attributeValue: staticAttributeValue,
548
+ attributeNode,
549
+ parentNode: node
550
+ })
551
+ } else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
552
+ const validatableContent = getValidatableStaticContent(valueNodes) || ""
553
+
554
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node })
555
+ } else if (staticAttributeName && hasOutputERB) {
556
+ const combinedValue = getAttributeValue(attributeNode)
557
+
558
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue })
559
+ } else if (isDynamicName && staticAttributeValue !== null) {
560
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
561
+ const nameNodes = nameNode.children || []
562
+ const combinedName = getCombinedAttributeNameString(attributeNode)
563
+
564
+ this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName })
565
+ } else if (isDynamicName) {
566
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
567
+ const nameNodes = nameNode.children || []
568
+ const combinedName = getCombinedAttributeNameString(attributeNode)
569
+ const combinedValue = getAttributeValue(attributeNode)
570
+
571
+ this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue })
372
572
  }
373
573
  })
374
574
  }
375
575
 
376
- protected abstract checkAttribute(
377
- attributeName: string,
378
- attributeValue: string | null,
379
- attributeNode: HTMLAttributeNode,
380
- parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
381
- ): void
576
+ /**
577
+ * Static attribute name with static value: class="container"
578
+ */
579
+ protected checkStaticAttributeStaticValue(params: StaticAttributeStaticValueParams): void {
580
+ // Default implementation does nothing
581
+ }
582
+
583
+ /**
584
+ * Static attribute name with dynamic value: class="<%= css_class %>"
585
+ */
586
+ protected checkStaticAttributeDynamicValue(params: StaticAttributeDynamicValueParams): void {
587
+ // Default implementation does nothing
588
+ }
589
+
590
+ /**
591
+ * Dynamic attribute name with static value: data-<%= key %>="foo"
592
+ */
593
+ protected checkDynamicAttributeStaticValue(params: DynamicAttributeStaticValueParams): void {
594
+ // Default implementation does nothing
595
+ }
596
+
597
+ /**
598
+ * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
599
+ */
600
+ protected checkDynamicAttributeDynamicValue(params: DynamicAttributeDynamicValueParams): void {
601
+ // Default implementation does nothing
602
+ }
382
603
  }
383
604
 
384
605
  /**
@@ -398,7 +619,7 @@ export function isAttributeValueQuoted(attributeNode: HTMLAttributeNode): boolea
398
619
  * Iterates over all attributes of a tag node, calling the callback for each attribute
399
620
  */
400
621
  export function forEachAttribute(
401
- node: HTMLOpenTagNode | HTMLSelfCloseTagNode,
622
+ node: HTMLOpenTagNode,
402
623
  callback: (attributeNode: HTMLAttributeNode) => void
403
624
  ): void {
404
625
  const attributes = getAttributes(node)
@@ -490,7 +711,7 @@ export abstract class BaseSourceRuleVisitor {
490
711
  */
491
712
  protected createOffense(message: string, location: Location, severity: LintSeverity = "error"): LintOffense {
492
713
  return {
493
- rule: this.ruleName as any, // Type assertion for compatibility
714
+ rule: this.ruleName,
494
715
  code: this.ruleName,
495
716
  source: "Herb Linter",
496
717
  message,
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE }
2
2
 
3
3
  import { ParserRule } from "../types.js"
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
5
+ import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
8
8
  private insideSVG = false
@@ -30,14 +30,8 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
30
30
  this.visitChildNodes(node)
31
31
  }
32
32
 
33
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
34
- if (this.insideSVG) {
35
- this.checkTagName(node)
36
- }
37
- this.visitChildNodes(node)
38
- }
39
33
 
40
- private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | HTMLSelfCloseTagNode): void {
34
+ private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode): void {
41
35
  const tagName = node.tag_name?.value
42
36
 
43
37
  if (!tagName) return
@@ -52,7 +46,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
52
46
 
53
47
  if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
54
48
  if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
55
- if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"
56
49
 
57
50
  this.addOffense(
58
51
  `${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`,
@@ -66,9 +59,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
66
59
  export class SVGTagNameCapitalizationRule extends ParserRule {
67
60
  name = "svg-tag-name-capitalization"
68
61
 
69
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
62
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
70
63
  const visitor = new SVGTagNameCapitalizationVisitor(this.name, context)
71
- visitor.visit(node)
64
+ visitor.visit(result.value)
72
65
  return visitor.offenses
73
66
  }
74
67
  }
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Node, Diagnostic, LexResult } from "@herb-tools/core"
1
+ import { Diagnostic, LexResult, ParseResult } from "@herb-tools/core"
2
2
  import type { defaultRules } from "./default-rules.js"
3
3
 
4
- export type LintSeverity = "error" | "warning"
4
+ export type LintSeverity = "error" | "warning" | "info" | "hint"
5
5
 
6
6
  /**
7
7
  * Automatically inferred union type of all available linter rule names.
@@ -23,13 +23,31 @@ export interface LintResult {
23
23
  export abstract class ParserRule {
24
24
  static type = "parser" as const
25
25
  abstract name: string
26
- abstract check(node: Node, context?: Partial<LintContext>): LintOffense[]
26
+ abstract check(result: ParseResult, context?: Partial<LintContext>): LintOffense[]
27
+
28
+ /**
29
+ * Optional method to determine if this rule should run.
30
+ * If not implemented, rule is always enabled.
31
+ * @param result - The parse result to analyze
32
+ * @param context - Optional context for linting
33
+ * @returns true if rule should run, false to skip
34
+ */
35
+ isEnabled?(result: ParseResult, context?: Partial<LintContext>): boolean
27
36
  }
28
37
 
29
38
  export abstract class LexerRule {
30
39
  static type = "lexer" as const
31
40
  abstract name: string
32
41
  abstract check(lexResult: LexResult, context?: Partial<LintContext>): LintOffense[]
42
+
43
+ /**
44
+ * Optional method to determine if this rule should run.
45
+ * If not implemented, rule is always enabled.
46
+ * @param lexResult - The lex result to analyze
47
+ * @param context - Optional context for linting
48
+ * @returns true if rule should run, false to skip
49
+ */
50
+ isEnabled?(lexResult: LexResult, context?: Partial<LintContext>): boolean
33
51
  }
34
52
 
35
53
  export interface LexerRuleConstructor {
@@ -56,6 +74,15 @@ export abstract class SourceRule {
56
74
  static type = "source" as const
57
75
  abstract name: string
58
76
  abstract check(source: string, context?: Partial<LintContext>): LintOffense[]
77
+
78
+ /**
79
+ * Optional method to determine if this rule should run.
80
+ * If not implemented, rule is always enabled.
81
+ * @param source - The source code to analyze
82
+ * @param context - Optional context for linting
83
+ * @returns true if rule should run, false to skip
84
+ */
85
+ isEnabled?(source: string, context?: Partial<LintContext>): boolean
59
86
  }
60
87
 
61
88
  export interface SourceRuleConstructor {