@herb-tools/linter 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/herb-lint.js +6627 -1937
  2. package/dist/herb-lint.js.map +1 -1
  3. package/dist/index.cjs +1574 -210
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +1566 -212
  6. package/dist/index.js.map +1 -1
  7. package/dist/package.json +5 -4
  8. package/dist/src/cli/argument-parser.js +0 -4
  9. package/dist/src/cli/argument-parser.js.map +1 -1
  10. package/dist/src/default-rules.js +20 -0
  11. package/dist/src/default-rules.js.map +1 -1
  12. package/dist/src/linter.js +29 -4
  13. package/dist/src/linter.js.map +1 -1
  14. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
  15. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
  16. package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -64
  17. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  18. package/dist/src/rules/html-aria-attribute-must-be-valid.js +11 -10
  19. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  20. package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
  21. package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
  22. package/dist/src/rules/html-aria-level-must-be-valid.js +26 -4
  23. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
  24. package/dist/src/rules/html-aria-role-heading-requires-level.js +7 -13
  25. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  26. package/dist/src/rules/html-aria-role-must-be-valid.js +3 -3
  27. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  28. package/dist/src/rules/html-attribute-double-quotes.js +14 -4
  29. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  30. package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
  31. package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
  32. package/dist/src/rules/html-attribute-values-require-quotes.js +19 -8
  33. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  34. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
  35. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
  36. package/dist/src/rules/html-boolean-attributes-no-value.js +9 -2
  37. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  38. package/dist/src/rules/html-iframe-has-title.js +39 -0
  39. package/dist/src/rules/html-iframe-has-title.js.map +1 -0
  40. package/dist/src/rules/html-img-require-alt.js +0 -4
  41. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  42. package/dist/src/rules/html-navigation-has-label.js +43 -0
  43. package/dist/src/rules/html-navigation-has-label.js.map +1 -0
  44. package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
  45. package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
  46. package/dist/src/rules/html-no-duplicate-attributes.js +22 -25
  47. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  48. package/dist/src/rules/html-no-duplicate-ids.js +134 -9
  49. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  50. package/dist/src/rules/html-no-empty-headings.js +0 -21
  51. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  52. package/dist/src/rules/html-no-positive-tab-index.js +21 -0
  53. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
  54. package/dist/src/rules/html-no-self-closing.js +29 -0
  55. package/dist/src/rules/html-no-self-closing.js.map +1 -0
  56. package/dist/src/rules/html-no-title-attribute.js +27 -0
  57. package/dist/src/rules/html-no-title-attribute.js.map +1 -0
  58. package/dist/src/rules/html-tag-name-lowercase.js +35 -23
  59. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  60. package/dist/src/rules/index.js +10 -0
  61. package/dist/src/rules/index.js.map +1 -1
  62. package/dist/src/rules/rule-utils.js +245 -22
  63. package/dist/src/rules/rule-utils.js.map +1 -1
  64. package/dist/src/rules/svg-tag-name-capitalization.js +0 -8
  65. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/dist/types/cli/index.d.ts +4 -0
  69. package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  70. package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  71. package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
  72. package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  73. package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
  74. package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
  75. package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  76. package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
  77. package/dist/types/rules/html-no-self-closing.d.ts +7 -0
  78. package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
  79. package/dist/types/rules/html-tag-name-lowercase.d.ts +2 -1
  80. package/dist/types/rules/index.d.ts +10 -0
  81. package/dist/types/rules/rule-utils.d.ts +146 -13
  82. package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  83. package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  84. package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
  85. package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  86. package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
  87. package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
  88. package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  89. package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
  90. package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
  91. package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
  92. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +2 -1
  93. package/dist/types/src/rules/index.d.ts +10 -0
  94. package/dist/types/src/rules/rule-utils.d.ts +146 -13
  95. package/dist/types/src/types.d.ts +24 -0
  96. package/dist/types/types.d.ts +24 -0
  97. package/docs/rules/README.md +12 -2
  98. package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
  99. package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
  100. package/docs/rules/html-attribute-equals-spacing.md +35 -0
  101. package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
  102. package/docs/rules/html-iframe-has-title.md +43 -0
  103. package/docs/rules/html-navigation-has-label.md +61 -0
  104. package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
  105. package/docs/rules/html-no-positive-tab-index.md +55 -0
  106. package/docs/rules/html-no-self-closing.md +65 -0
  107. package/docs/rules/html-no-title-attribute.md +69 -0
  108. package/docs/rules/html-tag-name-lowercase.md +16 -3
  109. package/package.json +5 -4
  110. package/src/cli/argument-parser.ts +0 -5
  111. package/src/default-rules.ts +20 -0
  112. package/src/linter.ts +30 -4
  113. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
  114. package/src/rules/erb-prefer-image-tag-helper.ts +53 -76
  115. package/src/rules/html-aria-attribute-must-be-valid.ts +28 -32
  116. package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
  117. package/src/rules/html-aria-level-must-be-valid.ts +38 -5
  118. package/src/rules/html-aria-role-heading-requires-level.ts +16 -28
  119. package/src/rules/html-aria-role-must-be-valid.ts +5 -5
  120. package/src/rules/html-attribute-double-quotes.ts +21 -6
  121. package/src/rules/html-attribute-equals-spacing.ts +41 -0
  122. package/src/rules/html-attribute-values-require-quotes.ts +29 -9
  123. package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
  124. package/src/rules/html-boolean-attributes-no-value.ts +17 -4
  125. package/src/rules/html-iframe-has-title.ts +62 -0
  126. package/src/rules/html-img-require-alt.ts +2 -7
  127. package/src/rules/html-navigation-has-label.ts +64 -0
  128. package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
  129. package/src/rules/html-no-duplicate-attributes.ts +28 -28
  130. package/src/rules/html-no-duplicate-ids.ts +189 -14
  131. package/src/rules/html-no-empty-headings.ts +2 -31
  132. package/src/rules/html-no-positive-tab-index.ts +33 -0
  133. package/src/rules/html-no-self-closing.ts +41 -0
  134. package/src/rules/html-no-title-attribute.ts +42 -0
  135. package/src/rules/html-tag-name-lowercase.ts +42 -29
  136. package/src/rules/index.ts +10 -0
  137. package/src/rules/rule-utils.ts +357 -39
  138. package/src/rules/svg-tag-name-capitalization.ts +2 -9
  139. package/src/types.ts +27 -0
@@ -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,24 @@ 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
+
27
+ import { IdentityPrinter } from "@herb-tools/printer"
28
+
19
29
  import { DEFAULT_LINT_CONTEXT } from "../types.js"
20
30
 
31
+ import type * as Nodes from "@herb-tools/core"
32
+ import type { LintOffense, LintSeverity, LintContext } from "../types.js"
33
+
34
+ export enum ControlFlowType {
35
+ CONDITIONAL,
36
+ LOOP
37
+ }
38
+
21
39
  /**
22
40
  * Base visitor class that provides common functionality for rule visitors
23
41
  */
@@ -56,34 +74,215 @@ export abstract class BaseRuleVisitor extends Visitor {
56
74
  }
57
75
 
58
76
  /**
59
- * Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
77
+ * Mixin that adds control flow tracking capabilities to rule visitors
78
+ * This allows rules to track state across different control flow structures
79
+ * like if/else branches, loops, etc.
80
+ *
81
+ * @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
82
+ * @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
83
+ */
84
+ export abstract class ControlFlowTrackingVisitor<TControlFlowState = any, TBranchState = any> extends BaseRuleVisitor {
85
+ protected isInControlFlow: boolean = false
86
+ protected currentControlFlowType: ControlFlowType | null = null
87
+
88
+ /**
89
+ * Handle visiting a control flow node with proper scope management
90
+ */
91
+ protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void {
92
+ const wasInControlFlow = this.isInControlFlow
93
+ const previousControlFlowType = this.currentControlFlowType
94
+
95
+ this.isInControlFlow = true
96
+ this.currentControlFlowType = controlFlowType
97
+
98
+ const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow)
99
+
100
+ visitChildren()
101
+
102
+ this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore)
103
+
104
+ this.isInControlFlow = wasInControlFlow
105
+ this.currentControlFlowType = previousControlFlowType
106
+ }
107
+
108
+ /**
109
+ * Handle visiting a branch node (like else, when) with proper scope management
110
+ */
111
+ protected startNewBranch(visitChildren: () => void): void {
112
+ const stateToRestore = this.onEnterBranch()
113
+
114
+ visitChildren()
115
+
116
+ this.onExitBranch(stateToRestore)
117
+ }
118
+
119
+ visitERBIfNode(node: Nodes.ERBIfNode): void {
120
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node))
121
+ }
122
+
123
+ visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
124
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node))
125
+ }
126
+
127
+ visitERBCaseNode(node: Nodes.ERBCaseNode): void {
128
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node))
129
+ }
130
+
131
+ visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void {
132
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node))
133
+ }
134
+
135
+ visitERBWhileNode(node: Nodes.ERBWhileNode): void {
136
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node))
137
+ }
138
+
139
+ visitERBForNode(node: Nodes.ERBForNode): void {
140
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node))
141
+ }
142
+
143
+ visitERBUntilNode(node: Nodes.ERBUntilNode): void {
144
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node))
145
+ }
146
+
147
+ visitERBBlockNode(node: Nodes.ERBBlockNode): void {
148
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node))
149
+ }
150
+
151
+ visitERBElseNode(node: Nodes.ERBElseNode): void {
152
+ this.startNewBranch(() => super.visitERBElseNode(node))
153
+ }
154
+
155
+ visitERBWhenNode(node: Nodes.ERBWhenNode): void {
156
+ this.startNewBranch(() => super.visitERBWhenNode(node))
157
+ }
158
+
159
+ protected abstract onEnterControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): TControlFlowState
160
+ protected abstract onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: TControlFlowState): void
161
+ protected abstract onEnterBranch(): TBranchState
162
+ protected abstract onExitBranch(stateToRestore: TBranchState): void
163
+ }
164
+
165
+ /**
166
+ * Gets attributes from an HTMLOpenTagNode
60
167
  */
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
168
+ export function getAttributes(node: HTMLOpenTagNode): HTMLAttributeNode[] {
169
+ return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE") as HTMLAttributeNode[]
65
170
  }
66
171
 
67
172
  /**
68
173
  * Gets the tag name from an HTML tag node (lowercased)
69
174
  */
70
- export function getTagName(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): string | null {
175
+ export function getTagName(node: HTMLOpenTagNode): string | null {
71
176
  return node.tag_name?.value.toLowerCase() || null
72
177
  }
73
178
 
74
179
  /**
75
180
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
181
+ * Returns null if the attribute name contains dynamic content (ERB)
76
182
  */
77
183
  export function getAttributeName(attributeNode: HTMLAttributeNode): string | null {
78
184
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
79
185
  const nameNode = attributeNode.name as HTMLAttributeNameNode
186
+ const staticName = getStaticAttributeName(nameNode)
80
187
 
81
- return nameNode.name?.value.toLowerCase() || null
188
+ return staticName ? staticName.toLowerCase() : null
82
189
  }
83
190
 
84
191
  return null
85
192
  }
86
193
 
194
+ /**
195
+ * Checks if an attribute has a dynamic (ERB-containing) name
196
+ */
197
+ export function hasDynamicAttributeName(attributeNode: HTMLAttributeNode): boolean {
198
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
199
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
200
+ return hasNodeDynamicAttributeName(nameNode)
201
+ }
202
+
203
+ return false
204
+ }
205
+
206
+ /**
207
+ * Gets the combined string representation of an attribute name (for debugging)
208
+ * This includes both static content and ERB syntax
209
+ */
210
+ export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string {
211
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
212
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
213
+
214
+ return getCombinedAttributeName(nameNode)
215
+ }
216
+
217
+ return ""
218
+ }
219
+
220
+ /**
221
+ * Checks if an attribute value contains only static content (no ERB)
222
+ */
223
+ export function hasStaticAttributeValue(attributeNode: HTMLAttributeNode): boolean {
224
+ const valueNode = attributeNode.value as HTMLAttributeValueNode | null
225
+
226
+ if (!valueNode?.children) return false
227
+
228
+ return valueNode.children.every(child => child.type === "AST_LITERAL_NODE")
229
+ }
230
+
231
+ /**
232
+ * Checks if an attribute value contains dynamic content (ERB)
233
+ */
234
+ export function hasDynamicAttributeValue(attributeNode: HTMLAttributeNode): boolean {
235
+ const valueNode = attributeNode.value as HTMLAttributeValueNode | null
236
+
237
+ if (!valueNode?.children) return false
238
+
239
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
240
+ }
241
+
242
+ /**
243
+ * Gets the static string value of an attribute (returns null if it contains ERB)
244
+ */
245
+ export function getStaticAttributeValue(attributeNode: HTMLAttributeNode): string | null {
246
+ if (!hasStaticAttributeValue(attributeNode)) return null
247
+
248
+ const valueNode = attributeNode.value as HTMLAttributeValueNode
249
+
250
+ const result = valueNode.children
251
+ ?.filter(child => child.type === "AST_LITERAL_NODE")
252
+ .map(child => (child as LiteralNode).content)
253
+ .join("") || ""
254
+
255
+ return result
256
+ }
257
+
258
+ /**
259
+ * Gets the value nodes array for dynamic inspection
260
+ */
261
+ export function getAttributeValueNodes(attributeNode: HTMLAttributeNode): Node[] {
262
+ const valueNode = attributeNode.value as HTMLAttributeValueNode | null
263
+
264
+ return valueNode?.children || []
265
+ }
266
+
267
+ /**
268
+ * Checks if an attribute value contains any static content (for validation purposes)
269
+ */
270
+ export function hasStaticAttributeValueContent(attributeNode: HTMLAttributeNode): boolean {
271
+ const valueNodes = getAttributeValueNodes(attributeNode)
272
+
273
+ return hasStaticContent(valueNodes)
274
+ }
275
+
276
+ /**
277
+ * Gets the static content of an attribute value (all literal parts combined)
278
+ * Returns the concatenated literal content, or null if no literal nodes exist
279
+ */
280
+ export function getStaticAttributeValueContent(attributeNode: HTMLAttributeNode): string | null {
281
+ const valueNodes = getAttributeValueNodes(attributeNode)
282
+
283
+ return getStaticContentFromNodes(valueNodes)
284
+ }
285
+
87
286
  /**
88
287
  * Gets the attribute value content from an HTMLAttributeValueNode
89
288
  */
@@ -130,9 +329,20 @@ export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
130
329
  /**
131
330
  * Gets the quote type used for an attribute value
132
331
  */
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
332
+ export function getAttributeValueQuoteType(nodeOrAttribute: HTMLAttributeNode | HTMLAttributeValueNode): "single" | "double" | "none" | null {
333
+ let valueNode: HTMLAttributeValueNode | undefined
334
+
335
+ if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
336
+ const attributeNode = nodeOrAttribute as HTMLAttributeNode
337
+
338
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
339
+ valueNode = attributeNode.value as HTMLAttributeValueNode
340
+ }
341
+ } else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
342
+ valueNode = nodeOrAttribute as HTMLAttributeValueNode
343
+ }
344
+
345
+ if (valueNode) {
136
346
  if (valueNode.quoted && valueNode.open_quote) {
137
347
  return valueNode.open_quote.value === '"' ? "double" : "single"
138
348
  }
@@ -146,25 +356,35 @@ export function getAttributeValueQuoteType(attributeNode: HTMLAttributeNode): "s
146
356
  /**
147
357
  * Finds an attribute by name in a list of attributes
148
358
  */
149
- export function findAttributeByName(attributes: any[], attributeName: string): HTMLAttributeNode | null {
359
+ export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null {
150
360
  for (const child of attributes) {
151
361
  if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
152
362
  const attributeNode = child as HTMLAttributeNode
153
363
  const name = getAttributeName(attributeNode)
364
+
154
365
  if (name === attributeName.toLowerCase()) {
155
366
  return attributeNode
156
367
  }
157
368
  }
158
369
  }
370
+
159
371
  return null
160
372
  }
161
373
 
162
374
  /**
163
375
  * Checks if a tag has a specific attribute
164
376
  */
165
- export function hasAttribute(node: HTMLOpenTagNode | HTMLSelfCloseTagNode, attributeName: string): boolean {
377
+ export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): boolean {
378
+ return getAttribute(node, attributeName) !== null
379
+ }
380
+
381
+ /**
382
+ * Checks if a tag has a specific attribute
383
+ */
384
+ export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null {
166
385
  const attributes = getAttributes(node)
167
- return findAttributeByName(attributes, attributeName) !== null
386
+
387
+ return findAttributeByName(attributes, attributeName)
168
388
  }
169
389
 
170
390
  /**
@@ -184,6 +404,11 @@ export const HTML_BLOCK_ELEMENTS = new Set([
184
404
  "ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
185
405
  ])
186
406
 
407
+ export const HTML_VOID_ELEMENTS = new Set([
408
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
409
+ "param", "source", "track", "wbr",
410
+ ])
411
+
187
412
  export const HTML_BOOLEAN_ATTRIBUTES = new Set([
188
413
  "autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
189
414
  "loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
@@ -254,6 +479,41 @@ export const VALID_ARIA_ROLES = new Set([
254
479
  "log", "marquee"
255
480
  ]);
256
481
 
482
+ /**
483
+ * Parameter types for AttributeVisitorMixin methods
484
+ */
485
+ export interface StaticAttributeStaticValueParams {
486
+ attributeName: string
487
+ attributeValue: string
488
+ attributeNode: HTMLAttributeNode
489
+ parentNode: HTMLOpenTagNode
490
+ }
491
+
492
+ export interface StaticAttributeDynamicValueParams {
493
+ attributeName: string
494
+ valueNodes: Node[]
495
+ attributeNode: HTMLAttributeNode
496
+ parentNode: HTMLOpenTagNode
497
+ combinedValue?: string | null
498
+ }
499
+
500
+ export interface DynamicAttributeStaticValueParams {
501
+ nameNodes: Node[]
502
+ attributeValue: string
503
+ attributeNode: HTMLAttributeNode
504
+ parentNode: HTMLOpenTagNode
505
+ combinedName?: string
506
+ }
507
+
508
+ export interface DynamicAttributeDynamicValueParams {
509
+ nameNodes: Node[]
510
+ valueNodes: Node[]
511
+ attributeNode: HTMLAttributeNode
512
+ parentNode: HTMLOpenTagNode
513
+ combinedName?: string
514
+ combinedValue?: string | null
515
+ }
516
+
257
517
  export const ARIA_ATTRIBUTES = new Set([
258
518
  'aria-activedescendant',
259
519
  'aria-atomic',
@@ -335,6 +595,13 @@ export function isBlockElement(tagName: string): boolean {
335
595
  return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase())
336
596
  }
337
597
 
598
+ /**
599
+ * Checks if an element is a void element
600
+ */
601
+ export function isVoidElement(tagName: string): boolean {
602
+ return HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
603
+ }
604
+
338
605
  /**
339
606
  * Checks if an attribute is a boolean attribute
340
607
  */
@@ -343,9 +610,14 @@ export function isBooleanAttribute(attributeName: string): boolean {
343
610
  }
344
611
 
345
612
  /**
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.
613
+ * Attribute visitor that provides granular processing based on both
614
+ * attribute name type (static/dynamic) and value type (static/dynamic)
615
+ *
616
+ * This gives you 4 distinct methods to override:
617
+ * - checkStaticAttributeStaticValue() - name="class" value="foo"
618
+ * - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
619
+ * - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
620
+ * - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
349
621
  */
350
622
  export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
351
623
  constructor(ruleName: string, context?: Partial<LintContext>) {
@@ -357,28 +629,74 @@ export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
357
629
  super.visitHTMLOpenTagNode(node)
358
630
  }
359
631
 
360
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
361
- this.checkAttributesOnNode(node)
362
- super.visitHTMLSelfCloseTagNode(node)
363
- }
364
-
365
- private checkAttributesOnNode(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
632
+ private checkAttributesOnNode(node: HTMLOpenTagNode): void {
366
633
  forEachAttribute(node, (attributeNode) => {
367
- const attributeName = getAttributeName(attributeNode)
368
- const attributeValue = getAttributeValue(attributeNode)
369
-
370
- if (attributeName) {
371
- this.checkAttribute(attributeName, attributeValue, attributeNode, node)
634
+ const staticAttributeName = getAttributeName(attributeNode)
635
+ const isDynamicName = hasDynamicAttributeName(attributeNode)
636
+ const staticAttributeValue = getStaticAttributeValue(attributeNode)
637
+ const valueNodes = getAttributeValueNodes(attributeNode)
638
+ const hasOutputERB = hasERBOutput(valueNodes)
639
+ const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes)
640
+
641
+ if (staticAttributeName && staticAttributeValue !== null) {
642
+ this.checkStaticAttributeStaticValue({
643
+ attributeName: staticAttributeName,
644
+ attributeValue: staticAttributeValue,
645
+ attributeNode,
646
+ parentNode: node
647
+ })
648
+ } else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
649
+ const validatableContent = getValidatableStaticContent(valueNodes) || ""
650
+
651
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node })
652
+ } else if (staticAttributeName && hasOutputERB) {
653
+ const combinedValue = getAttributeValue(attributeNode)
654
+
655
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue })
656
+ } else if (isDynamicName && staticAttributeValue !== null) {
657
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
658
+ const nameNodes = nameNode.children || []
659
+ const combinedName = getCombinedAttributeNameString(attributeNode)
660
+
661
+ this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName })
662
+ } else if (isDynamicName) {
663
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
664
+ const nameNodes = nameNode.children || []
665
+ const combinedName = getCombinedAttributeNameString(attributeNode)
666
+ const combinedValue = getAttributeValue(attributeNode)
667
+
668
+ this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue })
372
669
  }
373
670
  })
374
671
  }
375
672
 
376
- protected abstract checkAttribute(
377
- attributeName: string,
378
- attributeValue: string | null,
379
- attributeNode: HTMLAttributeNode,
380
- parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
381
- ): void
673
+ /**
674
+ * Static attribute name with static value: class="container"
675
+ */
676
+ protected checkStaticAttributeStaticValue(params: StaticAttributeStaticValueParams): void {
677
+ // Default implementation does nothing
678
+ }
679
+
680
+ /**
681
+ * Static attribute name with dynamic value: class="<%= css_class %>"
682
+ */
683
+ protected checkStaticAttributeDynamicValue(params: StaticAttributeDynamicValueParams): void {
684
+ // Default implementation does nothing
685
+ }
686
+
687
+ /**
688
+ * Dynamic attribute name with static value: data-<%= key %>="foo"
689
+ */
690
+ protected checkDynamicAttributeStaticValue(params: DynamicAttributeStaticValueParams): void {
691
+ // Default implementation does nothing
692
+ }
693
+
694
+ /**
695
+ * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
696
+ */
697
+ protected checkDynamicAttributeDynamicValue(params: DynamicAttributeDynamicValueParams): void {
698
+ // Default implementation does nothing
699
+ }
382
700
  }
383
701
 
384
702
  /**
@@ -398,7 +716,7 @@ export function isAttributeValueQuoted(attributeNode: HTMLAttributeNode): boolea
398
716
  * Iterates over all attributes of a tag node, calling the callback for each attribute
399
717
  */
400
718
  export function forEachAttribute(
401
- node: HTMLOpenTagNode | HTMLSelfCloseTagNode,
719
+ node: HTMLOpenTagNode,
402
720
  callback: (attributeNode: HTMLAttributeNode) => void
403
721
  ): void {
404
722
  const attributes = getAttributes(node)
@@ -490,7 +808,7 @@ export abstract class BaseSourceRuleVisitor {
490
808
  */
491
809
  protected createOffense(message: string, location: Location, severity: LintSeverity = "error"): LintOffense {
492
810
  return {
493
- rule: this.ruleName as any, // Type assertion for compatibility
811
+ rule: this.ruleName,
494
812
  code: this.ruleName,
495
813
  source: "Herb Linter",
496
814
  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, ParseResult } 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.`,
package/src/types.ts CHANGED
@@ -24,12 +24,30 @@ export abstract class ParserRule {
24
24
  static type = "parser" as const
25
25
  abstract name: string
26
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 {