@herb-tools/linter 0.6.0 → 0.7.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 (99) hide show
  1. package/README.md +60 -16
  2. package/dist/herb-lint.js +1684 -295
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +1226 -158
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +1188 -160
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +11 -4
  9. package/dist/src/cli/argument-parser.js +11 -6
  10. package/dist/src/cli/argument-parser.js.map +1 -1
  11. package/dist/src/cli/file-processor.js +5 -6
  12. package/dist/src/cli/file-processor.js.map +1 -1
  13. package/dist/src/cli/formatters/detailed-formatter.js +3 -5
  14. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
  15. package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
  16. package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
  17. package/dist/src/cli/index.js +1 -0
  18. package/dist/src/cli/index.js.map +1 -1
  19. package/dist/src/cli/output-manager.js +23 -5
  20. package/dist/src/cli/output-manager.js.map +1 -1
  21. package/dist/src/cli/summary-reporter.js +2 -11
  22. package/dist/src/cli/summary-reporter.js.map +1 -1
  23. package/dist/src/cli.js +88 -4
  24. package/dist/src/cli.js.map +1 -1
  25. package/dist/src/default-rules.js +8 -4
  26. package/dist/src/default-rules.js.map +1 -1
  27. package/dist/src/linter.js.map +1 -1
  28. package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
  29. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  30. package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
  31. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  32. package/dist/src/rules/html-no-duplicate-ids.js +134 -9
  33. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  34. package/dist/src/rules/html-no-empty-attributes.js +56 -0
  35. package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
  36. package/dist/src/rules/html-no-positive-tab-index.js +1 -1
  37. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
  38. package/dist/src/rules/html-no-self-closing.js +12 -5
  39. package/dist/src/rules/html-no-self-closing.js.map +1 -1
  40. package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
  41. package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
  42. package/dist/src/rules/index.js +3 -0
  43. package/dist/src/rules/index.js.map +1 -1
  44. package/dist/src/rules/rule-utils.js +80 -7
  45. package/dist/src/rules/rule-utils.js.map +1 -1
  46. package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
  47. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/dist/types/cli/argument-parser.d.ts +2 -1
  50. package/dist/types/cli/file-processor.d.ts +6 -1
  51. package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
  52. package/dist/types/cli/index.d.ts +1 -0
  53. package/dist/types/cli/output-manager.d.ts +1 -0
  54. package/dist/types/cli.d.ts +20 -5
  55. package/dist/types/linter.d.ts +7 -7
  56. package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
  57. package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  58. package/dist/types/rules/index.d.ts +3 -0
  59. package/dist/types/rules/rule-utils.d.ts +46 -5
  60. package/dist/types/src/cli/argument-parser.d.ts +2 -1
  61. package/dist/types/src/cli/file-processor.d.ts +6 -1
  62. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
  63. package/dist/types/src/cli/index.d.ts +1 -0
  64. package/dist/types/src/cli/output-manager.d.ts +1 -0
  65. package/dist/types/src/cli.d.ts +20 -5
  66. package/dist/types/src/linter.d.ts +7 -7
  67. package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
  68. package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  69. package/dist/types/src/rules/index.d.ts +3 -0
  70. package/dist/types/src/rules/rule-utils.d.ts +46 -5
  71. package/docs/rules/README.md +2 -0
  72. package/docs/rules/html-img-require-alt.md +0 -2
  73. package/docs/rules/html-no-empty-attributes.md +77 -0
  74. package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
  75. package/package.json +11 -4
  76. package/src/cli/argument-parser.ts +15 -7
  77. package/src/cli/file-processor.ts +11 -7
  78. package/src/cli/formatters/detailed-formatter.ts +5 -7
  79. package/src/cli/formatters/github-actions-formatter.ts +64 -11
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/output-manager.ts +27 -5
  82. package/src/cli/summary-reporter.ts +3 -11
  83. package/src/cli.ts +125 -20
  84. package/src/default-rules.ts +8 -4
  85. package/src/linter.ts +6 -6
  86. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
  87. package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
  88. package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
  89. package/src/rules/html-attribute-double-quotes.ts +1 -1
  90. package/src/rules/html-boolean-attributes-no-value.ts +9 -11
  91. package/src/rules/html-no-duplicate-ids.ts +188 -14
  92. package/src/rules/html-no-empty-attributes.ts +75 -0
  93. package/src/rules/html-no-positive-tab-index.ts +1 -1
  94. package/src/rules/html-no-self-closing.ts +13 -8
  95. package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
  96. package/src/rules/html-tag-name-lowercase.ts +1 -1
  97. package/src/rules/index.ts +3 -0
  98. package/src/rules/rule-utils.ts +110 -9
  99. package/src/rules/svg-tag-name-capitalization.ts +2 -2
@@ -26,8 +26,14 @@ import type {
26
26
 
27
27
  import { DEFAULT_LINT_CONTEXT } from "../types.js"
28
28
 
29
+ import type * as Nodes from "@herb-tools/core"
29
30
  import type { LintOffense, LintSeverity, LintContext } from "../types.js"
30
31
 
32
+ export enum ControlFlowType {
33
+ CONDITIONAL,
34
+ LOOP
35
+ }
36
+
31
37
  /**
32
38
  * Base visitor class that provides common functionality for rule visitors
33
39
  */
@@ -65,6 +71,95 @@ export abstract class BaseRuleVisitor extends Visitor {
65
71
  }
66
72
  }
67
73
 
74
+ /**
75
+ * Mixin that adds control flow tracking capabilities to rule visitors
76
+ * This allows rules to track state across different control flow structures
77
+ * like if/else branches, loops, etc.
78
+ *
79
+ * @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
80
+ * @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
81
+ */
82
+ export abstract class ControlFlowTrackingVisitor<TControlFlowState = any, TBranchState = any> extends BaseRuleVisitor {
83
+ protected isInControlFlow: boolean = false
84
+ protected currentControlFlowType: ControlFlowType | null = null
85
+
86
+ /**
87
+ * Handle visiting a control flow node with proper scope management
88
+ */
89
+ protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void {
90
+ const wasInControlFlow = this.isInControlFlow
91
+ const previousControlFlowType = this.currentControlFlowType
92
+
93
+ this.isInControlFlow = true
94
+ this.currentControlFlowType = controlFlowType
95
+
96
+ const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow)
97
+
98
+ visitChildren()
99
+
100
+ this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore)
101
+
102
+ this.isInControlFlow = wasInControlFlow
103
+ this.currentControlFlowType = previousControlFlowType
104
+ }
105
+
106
+ /**
107
+ * Handle visiting a branch node (like else, when) with proper scope management
108
+ */
109
+ protected startNewBranch(visitChildren: () => void): void {
110
+ const stateToRestore = this.onEnterBranch()
111
+
112
+ visitChildren()
113
+
114
+ this.onExitBranch(stateToRestore)
115
+ }
116
+
117
+ visitERBIfNode(node: Nodes.ERBIfNode): void {
118
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node))
119
+ }
120
+
121
+ visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
122
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node))
123
+ }
124
+
125
+ visitERBCaseNode(node: Nodes.ERBCaseNode): void {
126
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node))
127
+ }
128
+
129
+ visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void {
130
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node))
131
+ }
132
+
133
+ visitERBWhileNode(node: Nodes.ERBWhileNode): void {
134
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node))
135
+ }
136
+
137
+ visitERBForNode(node: Nodes.ERBForNode): void {
138
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node))
139
+ }
140
+
141
+ visitERBUntilNode(node: Nodes.ERBUntilNode): void {
142
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node))
143
+ }
144
+
145
+ visitERBBlockNode(node: Nodes.ERBBlockNode): void {
146
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node))
147
+ }
148
+
149
+ visitERBElseNode(node: Nodes.ERBElseNode): void {
150
+ this.startNewBranch(() => super.visitERBElseNode(node))
151
+ }
152
+
153
+ visitERBWhenNode(node: Nodes.ERBWhenNode): void {
154
+ this.startNewBranch(() => super.visitERBWhenNode(node))
155
+ }
156
+
157
+ protected abstract onEnterControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): TControlFlowState
158
+ protected abstract onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: TControlFlowState): void
159
+ protected abstract onEnterBranch(): TBranchState
160
+ protected abstract onExitBranch(stateToRestore: TBranchState): void
161
+ }
162
+
68
163
  /**
69
164
  * Gets attributes from an HTMLOpenTagNode
70
165
  */
@@ -83,11 +178,13 @@ export function getTagName(node: HTMLOpenTagNode): string | null {
83
178
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
84
179
  * Returns null if the attribute name contains dynamic content (ERB)
85
180
  */
86
- export function getAttributeName(attributeNode: HTMLAttributeNode): string | null {
181
+ export function getAttributeName(attributeNode: HTMLAttributeNode, lowercase = true): string | null {
87
182
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
88
183
  const nameNode = attributeNode.name as HTMLAttributeNameNode
89
184
  const staticName = getStaticAttributeName(nameNode)
90
185
 
186
+ if (!lowercase) return staticName
187
+
91
188
  return staticName ? staticName.toLowerCase() : null
92
189
  }
93
190
 
@@ -190,7 +287,7 @@ export function getStaticAttributeValueContent(attributeNode: HTMLAttributeNode)
190
287
  * Gets the attribute value content from an HTMLAttributeValueNode
191
288
  */
192
289
  export function getAttributeValue(attributeNode: HTMLAttributeNode): string | null {
193
- const valueNode: HTMLAttributeValueNode | null = attributeNode.value as HTMLAttributeValueNode
290
+ const valueNode: HTMLAttributeValueNode | null = attributeNode.value as HTMLAttributeValueNode
194
291
 
195
292
  if (valueNode === null) return null
196
293
 
@@ -284,7 +381,7 @@ export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): bool
284
381
  /**
285
382
  * Checks if a tag has a specific attribute
286
383
  */
287
- export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null {
384
+ export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null {
288
385
  const attributes = getAttributes(node)
289
386
 
290
387
  return findAttributeByName(attributes, attributeName)
@@ -389,6 +486,7 @@ export interface StaticAttributeStaticValueParams {
389
486
  attributeName: string
390
487
  attributeValue: string
391
488
  attributeNode: HTMLAttributeNode
489
+ originalAttributeName: string
392
490
  parentNode: HTMLOpenTagNode
393
491
  }
394
492
 
@@ -396,6 +494,7 @@ export interface StaticAttributeDynamicValueParams {
396
494
  attributeName: string
397
495
  valueNodes: Node[]
398
496
  attributeNode: HTMLAttributeNode
497
+ originalAttributeName: string
399
498
  parentNode: HTMLOpenTagNode
400
499
  combinedValue?: string | null
401
500
  }
@@ -535,6 +634,7 @@ export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
535
634
  private checkAttributesOnNode(node: HTMLOpenTagNode): void {
536
635
  forEachAttribute(node, (attributeNode) => {
537
636
  const staticAttributeName = getAttributeName(attributeNode)
637
+ const originalAttributeName = getAttributeName(attributeNode, false) || ""
538
638
  const isDynamicName = hasDynamicAttributeName(attributeNode)
539
639
  const staticAttributeValue = getStaticAttributeValue(attributeNode)
540
640
  const valueNodes = getAttributeValueNodes(attributeNode)
@@ -546,16 +646,17 @@ export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
546
646
  attributeName: staticAttributeName,
547
647
  attributeValue: staticAttributeValue,
548
648
  attributeNode,
649
+ originalAttributeName,
549
650
  parentNode: node
550
651
  })
551
652
  } else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
552
653
  const validatableContent = getValidatableStaticContent(valueNodes) || ""
553
654
 
554
- this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node })
655
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, originalAttributeName, parentNode: node })
555
656
  } else if (staticAttributeName && hasOutputERB) {
556
657
  const combinedValue = getAttributeValue(attributeNode)
557
658
 
558
- this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue })
659
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, originalAttributeName, combinedValue })
559
660
  } else if (isDynamicName && staticAttributeValue !== null) {
560
661
  const nameNode = attributeNode.name as HTMLAttributeNameNode
561
662
  const nameNodes = nameNode.children || []
@@ -576,28 +677,28 @@ export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
576
677
  /**
577
678
  * Static attribute name with static value: class="container"
578
679
  */
579
- protected checkStaticAttributeStaticValue(params: StaticAttributeStaticValueParams): void {
680
+ protected checkStaticAttributeStaticValue(_params: StaticAttributeStaticValueParams): void {
580
681
  // Default implementation does nothing
581
682
  }
582
683
 
583
684
  /**
584
685
  * Static attribute name with dynamic value: class="<%= css_class %>"
585
686
  */
586
- protected checkStaticAttributeDynamicValue(params: StaticAttributeDynamicValueParams): void {
687
+ protected checkStaticAttributeDynamicValue(_params: StaticAttributeDynamicValueParams): void {
587
688
  // Default implementation does nothing
588
689
  }
589
690
 
590
691
  /**
591
692
  * Dynamic attribute name with static value: data-<%= key %>="foo"
592
693
  */
593
- protected checkDynamicAttributeStaticValue(params: DynamicAttributeStaticValueParams): void {
694
+ protected checkDynamicAttributeStaticValue(_params: DynamicAttributeStaticValueParams): void {
594
695
  // Default implementation does nothing
595
696
  }
596
697
 
597
698
  /**
598
699
  * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
599
700
  */
600
- protected checkDynamicAttributeDynamicValue(params: DynamicAttributeDynamicValueParams): void {
701
+ protected checkDynamicAttributeDynamicValue(_params: DynamicAttributeDynamicValueParams): void {
601
702
  // Default implementation does nothing
602
703
  }
603
704
  }
@@ -44,8 +44,8 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
44
44
  if (correctCamelCase && tagName !== correctCamelCase) {
45
45
  let type: string = node.type
46
46
 
47
- if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
48
- if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
47
+ if (node.type === "AST_HTML_OPEN_TAG_NODE") type = "Opening"
48
+ if (node.type === "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
49
49
 
50
50
  this.addOffense(
51
51
  `${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`,