@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
@@ -1,8 +1,11 @@
1
+ import { ParserRule } from "../types.js"
1
2
  import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
2
3
 
3
- import { ParserRule } from "../types.js"
4
+ import { ERBToRubyStringPrinter } from "@herb-tools/printer"
5
+ import { filterNodes, ERBContentNode, LiteralNode, isNode } from "@herb-tools/core"
6
+
4
7
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLOpenTagNode, HTMLAttributeValueNode, ERBContentNode, LiteralNode, ParseResult } from "@herb-tools/core"
8
+ import type { HTMLOpenTagNode, HTMLAttributeValueNode, ParseResult } from "@herb-tools/core"
6
9
 
7
10
  class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
8
11
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
@@ -10,101 +13,80 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
10
13
  super.visitHTMLOpenTagNode(node)
11
14
  }
12
15
 
13
- private checkImgTag(node: HTMLOpenTagNode): void {
14
- const tagName = getTagName(node)
16
+ private checkImgTag(openTag: HTMLOpenTagNode): void {
17
+ const tagName = getTagName(openTag)
15
18
 
16
- if (tagName !== "img") {
17
- return
18
- }
19
+ if (tagName !== "img") return
19
20
 
20
- const attributes = getAttributes(node)
21
+ const attributes = getAttributes(openTag)
21
22
  const srcAttribute = findAttributeByName(attributes, "src")
22
23
 
23
- if (!srcAttribute) {
24
- return
25
- }
26
-
27
- if (!srcAttribute.value) {
28
- return
29
- }
24
+ if (!srcAttribute) return
25
+ if (!srcAttribute.value) return
30
26
 
31
- const valueNode = srcAttribute.value as HTMLAttributeValueNode
32
- const hasERBContent = this.containsERBContent(valueNode)
27
+ const node = srcAttribute.value
28
+ const hasERBContent = this.containsERBContent(node)
33
29
 
34
30
  if (hasERBContent) {
35
- const suggestedExpression = this.buildSuggestedExpression(valueNode)
31
+ if (this.isDataUri(node)) return
32
+
33
+ if (this.shouldFlagAsImageTagCandidate(node)) {
34
+ const suggestedExpression = this.buildSuggestedExpression(node)
36
35
 
37
- this.addOffense(
38
- `Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
39
- srcAttribute.location,
40
- "warning"
41
- )
36
+ this.addOffense(
37
+ `Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
38
+ srcAttribute.location,
39
+ "warning"
40
+ )
41
+ }
42
42
  }
43
43
  }
44
44
 
45
- private containsERBContent(valueNode: HTMLAttributeValueNode): boolean {
46
- if (!valueNode.children) return false
47
-
48
- return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
45
+ private containsERBContent(node: HTMLAttributeValueNode): boolean {
46
+ return filterNodes(node.children, ERBContentNode).length > 0
49
47
  }
50
48
 
51
- private buildSuggestedExpression(valueNode: HTMLAttributeValueNode): string {
52
- if (!valueNode.children) return "expression"
49
+ private isOnlyERBContent(node: HTMLAttributeValueNode): boolean {
50
+ return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length
51
+ }
53
52
 
54
- let hasText = false
55
- let hasERB = false
53
+ private getContentofFirstChild(node: HTMLAttributeValueNode): string {
54
+ if (!node.children || node.children.length === 0) return ""
56
55
 
57
- for (const child of valueNode.children) {
58
- if (child.type === "AST_ERB_CONTENT_NODE") {
59
- hasERB = true
60
- } else if (child.type === "AST_LITERAL_NODE") {
61
- const literalNode = child as LiteralNode
56
+ const firstChild = node.children[0]
62
57
 
63
- if (literalNode.content && literalNode.content.trim()) {
64
- hasText = true
65
- }
66
- }
58
+ if (isNode(firstChild, LiteralNode)) {
59
+ return (firstChild.content || "").trim()
67
60
  }
68
61
 
69
- if (hasText && hasERB) {
70
- let result = '"'
71
-
72
- for (const child of valueNode.children) {
73
- if (child.type === "AST_ERB_CONTENT_NODE") {
74
- const erbNode = child as ERBContentNode
75
-
76
- result += `#{${(erbNode.content?.value || "").trim()}}`
77
- } else if (child.type === "AST_LITERAL_NODE") {
78
- const literalNode = child as LiteralNode
62
+ return ""
63
+ }
79
64
 
80
- result += literalNode.content || ""
81
- }
82
- }
65
+ private isDataUri(node: HTMLAttributeValueNode): boolean {
66
+ return this.getContentofFirstChild(node).startsWith("data:")
67
+ }
83
68
 
84
- result += '"'
69
+ private isFullUrl(node: HTMLAttributeValueNode): boolean {
70
+ const content = this.getContentofFirstChild(node)
85
71
 
86
- return result
87
- }
88
-
89
- if (hasERB && !hasText) {
90
- const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE") as ERBContentNode[]
72
+ return content.startsWith("http://") || content.startsWith("https://")
73
+ }
91
74
 
92
- if (erbNodes.length === 1) {
93
- return (erbNodes[0].content?.value || "").trim()
94
- } else if (erbNodes.length > 1) {
95
- let result = '"'
75
+ private shouldFlagAsImageTagCandidate(node: HTMLAttributeValueNode): boolean {
76
+ if (this.isOnlyERBContent(node)) return true
77
+ if (this.isFullUrl(node)) return false
96
78
 
97
- for (const erbNode of erbNodes) {
98
- result += `#{${(erbNode.content?.value || "").trim()}}`
99
- }
79
+ return true
80
+ }
100
81
 
101
- result += '"'
82
+ private buildSuggestedExpression(node: HTMLAttributeValueNode): string {
83
+ if (!node.children) return "expression"
102
84
 
103
- return result
104
- }
85
+ try {
86
+ return ERBToRubyStringPrinter.print(node, { ignoreErrors: false })
87
+ } catch {
88
+ return "expression"
105
89
  }
106
-
107
- return "expression"
108
90
  }
109
91
  }
110
92
 
@@ -58,7 +58,7 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
58
58
  }
59
59
 
60
60
  private checkOpenTagWhitespace(openTag: Token, content:string):void {
61
- if (content.startsWith(" ") || content.startsWith("\n")) {
61
+ if (content.startsWith(" ") || content.startsWith("\n")) {
62
62
  return
63
63
  }
64
64
 
@@ -70,7 +70,7 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
70
70
  }
71
71
 
72
72
  private checkCloseTagWhitespace(closeTag: Token, content:string):void {
73
- if (content.endsWith(" ") || content.endsWith("\n")) {
73
+ if (content.endsWith(" ") || content.endsWith("\n")) {
74
74
  return
75
75
  }
76
76
 
@@ -1,6 +1,6 @@
1
1
  import { ParserRule } from "../types.js"
2
2
  import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
3
- import { filterLiteralNodes } from "@herb-tools/core"
3
+ import { filterLiteralNodes } from "@herb-tools/core"
4
4
 
5
5
  import type { LintOffense, LintContext } from "../types.js"
6
6
  import type { ParseResult } from "@herb-tools/core"
@@ -1,27 +1,25 @@
1
1
  import { ParserRule } from "../types.js"
2
2
  import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
3
+ import { IdentityPrinter } from "@herb-tools/printer"
3
4
 
4
5
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { ParseResult } from "@herb-tools/core"
6
+ import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
6
7
 
7
8
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
8
- protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams) {
9
- if (!isBooleanAttribute(attributeName)) return
10
- if (!hasAttributeValue(attributeNode)) return
9
+ protected checkStaticAttributeStaticValue({ originalAttributeName, attributeNode }: StaticAttributeStaticValueParams) {
10
+ this.checkAttribute(originalAttributeName, attributeNode)
11
+ }
11
12
 
12
- this.addOffense(
13
- `Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`,
14
- attributeNode.value!.location,
15
- "error"
16
- )
13
+ protected checkStaticAttributeDynamicValue({ originalAttributeName, attributeNode }: StaticAttributeDynamicValueParams) {
14
+ this.checkAttribute(originalAttributeName, attributeNode)
17
15
  }
18
16
 
19
- protected checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }: StaticAttributeDynamicValueParams) {
17
+ private checkAttribute(attributeName: string, attributeNode: HTMLAttributeNode) {
20
18
  if (!isBooleanAttribute(attributeName)) return
21
19
  if (!hasAttributeValue(attributeNode)) return
22
20
 
23
21
  this.addOffense(
24
- `Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`,
22
+ `Boolean attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not have a value. Use \`${attributeName.toLowerCase()}\` instead of \`${IdentityPrinter.print(attributeNode)}\`.`,
25
23
  attributeNode.value!.location,
26
24
  "error"
27
25
  )
@@ -1,29 +1,203 @@
1
1
  import { ParserRule } from "../types"
2
- import { AttributeVisitorMixin, StaticAttributeStaticValueParams } from "./rule-utils"
2
+ import { ControlFlowTrackingVisitor, ControlFlowType } from "./rule-utils"
3
+ import { LiteralNode } from "@herb-tools/core"
4
+ import { Printer, IdentityPrinter } from "@herb-tools/printer"
3
5
 
4
- import type { ParseResult } from "@herb-tools/core"
6
+ import { hasERBOutput, getValidatableStaticContent, isEffectivelyStatic, isNode, getStaticAttributeName, isERBOutputNode } from "@herb-tools/core"
7
+
8
+ import type { ParseResult, HTMLAttributeNode, ERBContentNode } from "@herb-tools/core"
5
9
  import type { LintOffense, LintContext } from "../types"
6
10
 
7
- class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
11
+ interface ControlFlowState {
12
+ previousBranchIds: Set<string>
13
+ previousControlFlowIds: Set<string>
14
+ }
15
+
16
+ interface BranchState {
17
+ previousBranchIds: Set<string>
18
+ }
19
+
20
+ class OutputPrinter extends Printer {
21
+ visitLiteralNode(node: LiteralNode) {
22
+ this.write(IdentityPrinter.print(node))
23
+ }
24
+
25
+ visitERBContentNode(node: ERBContentNode) {
26
+ if (isERBOutputNode(node)) {
27
+ this.write(IdentityPrinter.print(node))
28
+ }
29
+ }
30
+ }
31
+
32
+ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor<ControlFlowState, BranchState> {
8
33
  private documentIds: Set<string> = new Set<string>()
34
+ private currentBranchIds: Set<string> = new Set<string>()
35
+ private controlFlowIds: Set<string> = new Set<string>()
36
+
37
+ visitHTMLAttributeNode(node: HTMLAttributeNode): void {
38
+ this.checkAttribute(node)
39
+ }
40
+
41
+ protected onEnterControlFlow(_controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): ControlFlowState {
42
+ const stateToRestore: ControlFlowState = {
43
+ previousBranchIds: this.currentBranchIds,
44
+ previousControlFlowIds: this.controlFlowIds
45
+ }
9
46
 
10
- protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
11
- if (attributeName.toLowerCase() !== "id") return
12
- if (!attributeValue) return
47
+ this.currentBranchIds = new Set<string>()
13
48
 
14
- const id = attributeValue.trim()
49
+ if (!wasAlreadyInControlFlow) {
50
+ this.controlFlowIds = new Set<string>()
51
+ }
52
+
53
+ return stateToRestore
54
+ }
55
+
56
+ protected onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: ControlFlowState): void {
57
+ if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
58
+ this.controlFlowIds.forEach(id => this.documentIds.add(id))
59
+ }
15
60
 
16
- if (this.documentIds.has(id)) {
17
- this.addOffense(
18
- `Duplicate ID \`${id}\` found. IDs must be unique within a document.`,
19
- attributeNode.location,
20
- "error"
21
- )
61
+ this.currentBranchIds = stateToRestore.previousBranchIds
62
+ this.controlFlowIds = stateToRestore.previousControlFlowIds
63
+ }
64
+
65
+ protected onEnterBranch(): BranchState {
66
+ const stateToRestore: BranchState = {
67
+ previousBranchIds: this.currentBranchIds
68
+ }
69
+
70
+ if (this.isInControlFlow) {
71
+ this.currentBranchIds = new Set<string>()
72
+ }
73
+
74
+ return stateToRestore
75
+ }
76
+
77
+ protected onExitBranch(_stateToRestore: BranchState): void {}
78
+
79
+ private checkAttribute(attributeNode: HTMLAttributeNode): void {
80
+ if (!this.isIdAttribute(attributeNode)) return
81
+
82
+ const idValue = this.extractIdValue(attributeNode)
83
+
84
+ if (!idValue) return
85
+ if (this.isWhitespaceOnlyId(idValue.identifier)) return
86
+
87
+ this.processIdDuplicate(idValue, attributeNode)
88
+ }
89
+
90
+ private isIdAttribute(attributeNode: HTMLAttributeNode): boolean {
91
+ if (!attributeNode.name?.children || !attributeNode.value) return false
92
+
93
+ return getStaticAttributeName(attributeNode.name) === "id"
94
+ }
95
+
96
+ private extractIdValue(attributeNode: HTMLAttributeNode): { identifier: string; shouldTrackDuplicates: boolean } | null {
97
+ const valueNodes = attributeNode.value?.children || []
98
+
99
+ if (hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
100
+ return null
101
+ }
22
102
 
103
+ const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes)
104
+ if (!identifier) return null
105
+
106
+ return { identifier, shouldTrackDuplicates: true }
107
+ }
108
+
109
+ private isWhitespaceOnlyId(identifier: string): boolean {
110
+ return identifier !== '' && identifier.trim() === ''
111
+ }
112
+
113
+ private processIdDuplicate(idValue: { identifier: string; shouldTrackDuplicates: boolean }, attributeNode: HTMLAttributeNode): void {
114
+ const { identifier, shouldTrackDuplicates } = idValue
115
+
116
+ if (!shouldTrackDuplicates) return
117
+
118
+ if (this.isInControlFlow) {
119
+ this.handleControlFlowId(identifier, attributeNode)
120
+ } else {
121
+ this.handleGlobalId(identifier, attributeNode)
122
+ }
123
+ }
124
+
125
+ private handleControlFlowId(identifier: string, attributeNode: HTMLAttributeNode): void {
126
+ if (this.currentControlFlowType === ControlFlowType.LOOP) {
127
+ this.handleLoopId(identifier, attributeNode)
128
+ } else {
129
+ this.handleConditionalId(identifier, attributeNode)
130
+ }
131
+
132
+ this.currentBranchIds.add(identifier)
133
+ }
134
+
135
+ private handleLoopId(identifier: string, attributeNode: HTMLAttributeNode): void {
136
+ const isStaticId = this.isStaticId(attributeNode)
137
+
138
+ if (isStaticId) {
139
+ this.addDuplicateIdOffense(identifier, attributeNode.location)
140
+ return
141
+ }
142
+
143
+ if (this.currentBranchIds.has(identifier)) {
144
+ this.addSameLoopIterationOffense(identifier, attributeNode.location)
145
+ }
146
+ }
147
+
148
+ private handleConditionalId(identifier: string, attributeNode: HTMLAttributeNode): void {
149
+ if (this.currentBranchIds.has(identifier)) {
150
+ this.addSameBranchOffense(identifier, attributeNode.location)
151
+ return
152
+ }
153
+
154
+ if (this.documentIds.has(identifier)) {
155
+ this.addDuplicateIdOffense(identifier, attributeNode.location)
156
+ return
157
+ }
158
+
159
+ this.controlFlowIds.add(identifier)
160
+ }
161
+
162
+ private handleGlobalId(identifier: string, attributeNode: HTMLAttributeNode): void {
163
+ if (this.documentIds.has(identifier)) {
164
+ this.addDuplicateIdOffense(identifier, attributeNode.location)
23
165
  return
24
166
  }
25
167
 
26
- this.documentIds.add(id)
168
+ this.documentIds.add(identifier)
169
+ }
170
+
171
+ private isStaticId(attributeNode: HTMLAttributeNode): boolean {
172
+ const valueNodes = attributeNode.value!.children
173
+ const isCompletelyStatic = valueNodes.every(child => isNode(child, LiteralNode))
174
+ const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes)
175
+
176
+ return isCompletelyStatic || isEffectivelyStaticValue
177
+ }
178
+
179
+ private addDuplicateIdOffense(identifier: string, location: any): void {
180
+ this.addOffense(
181
+ `Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`,
182
+ location,
183
+ "error"
184
+ )
185
+ }
186
+
187
+ private addSameLoopIterationOffense(identifier: string, location: any): void {
188
+ this.addOffense(
189
+ `Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`,
190
+ location,
191
+ "error"
192
+ )
193
+ }
194
+
195
+ private addSameBranchOffense(identifier: string, location: any): void {
196
+ this.addOffense(
197
+ `Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`,
198
+ location,
199
+ "error"
200
+ )
27
201
  }
28
202
  }
29
203
 
@@ -0,0 +1,75 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, DynamicAttributeStaticValueParams } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { ParseResult } from "@herb-tools/core"
6
+
7
+ // Attributes that must not have empty values
8
+ const RESTRICTED_ATTRIBUTES = new Set([
9
+ 'id',
10
+ 'class',
11
+ 'name',
12
+ 'for',
13
+ 'src',
14
+ 'href',
15
+ 'title',
16
+ 'data',
17
+ 'role'
18
+ ])
19
+
20
+ // Check if attribute name matches any restricted patterns
21
+ function isRestrictedAttribute(attributeName: string): boolean {
22
+ // Check direct matches
23
+ if (RESTRICTED_ATTRIBUTES.has(attributeName)) {
24
+ return true
25
+ }
26
+
27
+ // Check for data-* attributes
28
+ if (attributeName.startsWith('data-')) {
29
+ return true
30
+ }
31
+
32
+ // Check for aria-* attributes
33
+ if (attributeName.startsWith('aria-')) {
34
+ return true
35
+ }
36
+
37
+ return false
38
+ }
39
+
40
+ class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
41
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
42
+ if (!isRestrictedAttribute(attributeName)) return
43
+ if (attributeValue.trim() !== "") return
44
+
45
+ this.addOffense(
46
+ `Attribute \`${attributeName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`,
47
+ attributeNode.name!.location,
48
+ "warning"
49
+ )
50
+ }
51
+
52
+ protected checkDynamicAttributeStaticValue({ combinedName, attributeValue, attributeNode }: DynamicAttributeStaticValueParams): void {
53
+ const name = (combinedName || "").toLowerCase()
54
+ if (!isRestrictedAttribute(name)) return
55
+ if (attributeValue.trim() !== "") return
56
+
57
+ this.addOffense(
58
+ `Attribute \`${combinedName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`,
59
+ attributeNode.name!.location,
60
+ "warning"
61
+ )
62
+ }
63
+ }
64
+
65
+ export class HTMLNoEmptyAttributesRule extends ParserRule {
66
+ name = "html-no-empty-attributes"
67
+
68
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
69
+ const visitor = new NoEmptyAttributesVisitor(this.name, context)
70
+
71
+ visitor.visit(result.value)
72
+
73
+ return visitor.offenses
74
+ }
75
+ }
@@ -12,7 +12,7 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
12
12
 
13
13
  if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
14
14
  this.addOffense(
15
- `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.`,
15
+ `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.`,
16
16
  attributeNode.location,
17
17
  "error"
18
18
  )
@@ -1,25 +1,30 @@
1
1
  import { ParserRule } from "../types.js"
2
- import { BaseRuleVisitor, getTagName, isVoidElement } from "./rule-utils.js"
2
+ import { BaseRuleVisitor, isVoidElement } from "./rule-utils.js"
3
+ import { getTagName } from "@herb-tools/core"
3
4
 
4
5
  import type { LintContext, LintOffense } from "../types.js"
5
- import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+ import type { HTMLOpenTagNode, HTMLElementNode, ParseResult } from "@herb-tools/core"
6
7
 
7
8
  class NoSelfClosingVisitor extends BaseRuleVisitor {
9
+ visitHTMLElementNode(node: HTMLElementNode): void {
10
+ if (getTagName(node) === "svg") {
11
+ this.visit(node.open_tag)
12
+ } else {
13
+ this.visitChildNodes(node)
14
+ }
15
+ }
16
+
8
17
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
18
  if (node.tag_closing?.value === "/>") {
10
19
  const tagName = getTagName(node)
11
-
12
- const shouldBeVoid = tagName ? isVoidElement(tagName) : false
13
- const instead = shouldBeVoid ? `Use \`<${tagName}>\` instead.` : `Use \`<${tagName}></${tagName}>\` instead.`
20
+ const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`
14
21
 
15
22
  this.addOffense(
16
- `Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`,
23
+ `Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`,
17
24
  node.location,
18
25
  "error"
19
26
  )
20
27
  }
21
-
22
- super.visitHTMLOpenTagNode(node)
23
28
  }
24
29
  }
25
30
 
@@ -0,0 +1,58 @@
1
+ import { ParserRule } from "../types.js"
2
+ import {
3
+ AttributeVisitorMixin,
4
+ StaticAttributeStaticValueParams,
5
+ StaticAttributeDynamicValueParams,
6
+ DynamicAttributeStaticValueParams,
7
+ DynamicAttributeDynamicValueParams
8
+ } from "./rule-utils.js"
9
+ import { getStaticContentFromNodes } from "@herb-tools/core"
10
+ import { IdentityPrinter } from "@herb-tools/printer"
11
+ import type { LintContext, LintOffense } from "../types.js"
12
+ import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
13
+
14
+ class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
15
+ protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams): void {
16
+ this.check(attributeName, attributeNode)
17
+ }
18
+
19
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode }: StaticAttributeDynamicValueParams): void {
20
+ this.check(attributeName, attributeNode)
21
+ }
22
+
23
+ protected checkDynamicAttributeStaticValue({ nameNodes, attributeNode }: DynamicAttributeStaticValueParams) {
24
+ const attributeName = getStaticContentFromNodes(nameNodes)
25
+
26
+ this.check(attributeName, attributeNode)
27
+ }
28
+
29
+ protected checkDynamicAttributeDynamicValue({ nameNodes, attributeNode }: DynamicAttributeDynamicValueParams) {
30
+ const attributeName = getStaticContentFromNodes(nameNodes)
31
+
32
+ this.check(attributeName, attributeNode)
33
+ }
34
+
35
+ private check(attributeName: string | null, attributeNode: HTMLAttributeNode): void {
36
+ if (!attributeName) return
37
+
38
+ if (attributeName.includes("_")) {
39
+ this.addOffense(
40
+ `Attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not contain underscores. Use hyphens (-) instead.`,
41
+ attributeNode.value!.location,
42
+ "warning"
43
+ )
44
+ }
45
+ }
46
+ }
47
+
48
+ export class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
49
+ name = "html-no-underscores-in-attribute-names"
50
+
51
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
52
+ const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.name, context)
53
+
54
+ visitor.visit(result.value)
55
+
56
+ return visitor.offenses
57
+ }
58
+ }
@@ -36,7 +36,7 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
36
36
  this.checkTagName(node)
37
37
  }
38
38
 
39
- private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | null): void {
39
+ private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | null): void {
40
40
  if (!node) return
41
41
 
42
42
  const tagName = getTagName(node)
@@ -1,3 +1,4 @@
1
+ export * from "./rule-utils.js"
1
2
  export * from "./erb-no-empty-tags.js"
2
3
  export * from "./erb-no-output-control-flow.js"
3
4
  export * from "./erb-no-silent-tag-in-attribute-name.js"
@@ -20,6 +21,7 @@ export * from "./html-no-aria-hidden-on-focusable.js"
20
21
  export * from "./html-no-block-inside-inline.js"
21
22
  export * from "./html-no-duplicate-attributes.js"
22
23
  export * from "./html-no-duplicate-ids.js"
24
+ export * from "./html-no-empty-attributes.js"
23
25
  export * from "./html-no-empty-headings.js"
24
26
  export * from "./html-no-nested-links.js"
25
27
  export * from "./html-no-positive-tab-index.js"
@@ -27,3 +29,4 @@ export * from "./html-no-self-closing.js"
27
29
  export * from "./html-no-title-attribute.js"
28
30
  export * from "./html-tag-name-lowercase.js"
29
31
  export * from "./svg-tag-name-capitalization.js"
32
+ export * from "./html-no-underscores-in-attribute-names.js"