@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,28 +1,203 @@
1
- import { AttributeVisitorMixin } from "./rule-utils"
2
1
  import { ParserRule } from "../types"
3
- import type { ParseResult, Node } from "@herb-tools/core"
2
+ import { ControlFlowTrackingVisitor, ControlFlowType } from "./rule-utils"
3
+ import { LiteralNode } from "@herb-tools/core"
4
+ import { Printer, IdentityPrinter } from "@herb-tools/printer"
5
+
6
+ import { hasERBOutput, getValidatableStaticContent, isEffectivelyStatic, isNode, getStaticAttributeName, isERBOutputNode } from "@herb-tools/core"
7
+
8
+ import type { ParseResult, HTMLAttributeNode, ERBContentNode } from "@herb-tools/core"
4
9
  import type { LintOffense, LintContext } from "../types"
5
10
 
6
- 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> {
7
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
+ }
46
+
47
+ this.currentBranchIds = new Set<string>()
48
+
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
+ }
60
+
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
+ }
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
8
115
 
9
- protected checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: Node): void {
10
- if (attributeName.toLowerCase() !== "id") return
11
- if (!attributeValue) return
116
+ if (!shouldTrackDuplicates) return
12
117
 
13
- const id = attributeValue.trim()
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
+ }
14
134
 
15
- if (this.documentIds.has(id)) {
16
- this.addOffense(
17
- `Duplicate ID \`${id}\` found. IDs must be unique within a document.`,
18
- attributeNode.location,
19
- "error"
20
- )
135
+ private handleLoopId(identifier: string, attributeNode: HTMLAttributeNode): void {
136
+ const isStaticId = this.isStaticId(attributeNode)
21
137
 
138
+ if (isStaticId) {
139
+ this.addDuplicateIdOffense(identifier, attributeNode.location)
22
140
  return
23
141
  }
24
142
 
25
- this.documentIds.add(id)
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)
165
+ return
166
+ }
167
+
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
+ )
26
201
  }
27
202
  }
28
203
 
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAtt
2
2
 
3
3
  import { ParserRule } from "../types.js"
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLElementNode, HTMLOpenTagNode, HTMLSelfCloseTagNode, ParseResult, LiteralNode, HTMLTextNode } from "@herb-tools/core"
5
+ import type { HTMLElementNode, HTMLOpenTagNode, ParseResult, LiteralNode, HTMLTextNode } from "@herb-tools/core"
6
6
 
7
7
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
8
8
  visitHTMLElementNode(node: HTMLElementNode): void {
@@ -10,10 +10,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
10
10
  super.visitHTMLElementNode(node)
11
11
  }
12
12
 
13
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
14
- this.checkSelfClosingHeading(node)
15
- super.visitHTMLSelfCloseTagNode(node)
16
- }
17
13
 
18
14
  private checkHeadingElement(node: HTMLElementNode): void {
19
15
  if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
@@ -47,31 +43,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
47
43
  }
48
44
  }
49
45
 
50
- private checkSelfClosingHeading(node: HTMLSelfCloseTagNode): void {
51
- const tagName = getTagName(node)
52
- if (!tagName) {
53
- return
54
- }
55
-
56
- // Check if it's a standard heading tag (h1-h6) or has role="heading"
57
- const isStandardHeading = HEADING_TAGS.has(tagName)
58
- const isAriaHeading = this.hasHeadingRole(node)
59
-
60
- if (!isStandardHeading && !isAriaHeading) {
61
- return
62
- }
63
-
64
- // Self-closing headings are always empty
65
- const elementDescription = isStandardHeading
66
- ? `\`<${tagName}>\``
67
- : `\`<${tagName} role="heading">\``
68
-
69
- this.addOffense(
70
- `Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`,
71
- node.tag_name!.location,
72
- "error"
73
- )
74
- }
75
46
 
76
47
  private isEmptyHeading(node: HTMLElementNode): boolean {
77
48
  if (!node.body || node.body.length === 0) {
@@ -114,7 +85,7 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
114
85
  return !hasAccessibleContent
115
86
  }
116
87
 
117
- private hasHeadingRole(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): boolean {
88
+ private hasHeadingRole(node: HTMLOpenTagNode): boolean {
118
89
  const attributes = getAttributes(node)
119
90
  const roleAttribute = findAttributeByName(attributes, "role")
120
91
 
@@ -0,0 +1,33 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { ParseResult } from "@herb-tools/core"
6
+
7
+ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
9
+ if (attributeName !== "tabindex") return
10
+
11
+ const tabIndexValue = parseInt(attributeValue, 10)
12
+
13
+ if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
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.`,
16
+ attributeNode.location,
17
+ "error"
18
+ )
19
+ }
20
+ }
21
+ }
22
+
23
+ export class HTMLNoPositiveTabIndexRule extends ParserRule {
24
+ name = "html-no-positive-tab-index"
25
+
26
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
27
+ const visitor = new NoPositiveTabIndexVisitor(this.name, context)
28
+
29
+ visitor.visit(result.value)
30
+
31
+ return visitor.offenses
32
+ }
33
+ }
@@ -0,0 +1,41 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, isVoidElement } from "./rule-utils.js"
3
+ import { getTagName } from "@herb-tools/core"
4
+
5
+ import type { LintContext, LintOffense } from "../types.js"
6
+ import type { HTMLOpenTagNode, HTMLElementNode, ParseResult } from "@herb-tools/core"
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
+
17
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
18
+ if (node.tag_closing?.value === "/>") {
19
+ const tagName = getTagName(node)
20
+ const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`
21
+
22
+ this.addOffense(
23
+ `Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`,
24
+ node.location,
25
+ "error"
26
+ )
27
+ }
28
+ }
29
+ }
30
+
31
+ export class HTMLNoSelfClosingRule extends ParserRule {
32
+ name = "html-no-self-closing"
33
+
34
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
35
+ const visitor = new NoSelfClosingVisitor(this.name, context)
36
+
37
+ visitor.visit(result.value)
38
+
39
+ return visitor.offenses
40
+ }
41
+ }
@@ -0,0 +1,42 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ class NoTitleAttributeVisitor extends BaseRuleVisitor {
8
+ ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"])
9
+
10
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
11
+ this.checkTitleAttribute(node)
12
+ super.visitHTMLOpenTagNode(node)
13
+ }
14
+
15
+ private checkTitleAttribute(node: HTMLOpenTagNode): void {
16
+ const tagName = getTagName(node)
17
+
18
+ if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
19
+ return
20
+ }
21
+
22
+ if (hasAttribute(node, "title")) {
23
+ this.addOffense(
24
+ "The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.",
25
+ node.tag_name!.location,
26
+ "error"
27
+ )
28
+ }
29
+ }
30
+ }
31
+
32
+ export class HTMLNoTitleAttributeRule extends ParserRule {
33
+ name = "html-no-title-attribute"
34
+
35
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
36
+ const visitor = new NoTitleAttributeVisitor(this.name, context)
37
+
38
+ visitor.visit(result.value)
39
+
40
+ return visitor.offenses
41
+ }
42
+ }
@@ -1,53 +1,56 @@
1
+ import { ParserRule } from "../types.js"
1
2
  import { BaseRuleVisitor } from "./rule-utils.js"
2
3
 
3
- import { ParserRule } from "../types.js"
4
+ import { isNode, getTagName, HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, ParseResult, XMLDeclarationNode, Node } from "@herb-tools/core"
5
+
4
6
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, ParseResult } from "@herb-tools/core"
6
7
 
7
- class TagNameLowercaseVisitor extends BaseRuleVisitor {
8
- visitHTMLElementNode(node: HTMLElementNode): void {
9
- const tagName = node.tag_name?.value
8
+ class XMLDeclarationChecker extends BaseRuleVisitor {
9
+ hasXMLDeclaration: boolean = false
10
10
 
11
- if (node.open_tag) {
12
- this.checkTagName(node.open_tag as HTMLOpenTagNode)
13
- }
11
+ visitXMLDeclarationNode(_node: XMLDeclarationNode): void {
12
+ this.hasXMLDeclaration = true
13
+ }
14
14
 
15
- if (tagName && ["svg"].includes(tagName.toLowerCase())) {
16
- if (node.close_tag) {
17
- this.checkTagName(node.close_tag as HTMLCloseTagNode)
18
- }
15
+ visitChildNodes(node: Node): void {
16
+ if (this.hasXMLDeclaration) return
17
+ super.visitChildNodes(node)
18
+ }
19
+ }
19
20
 
20
- return
21
+ class TagNameLowercaseVisitor extends BaseRuleVisitor {
22
+ visitHTMLElementNode(node: HTMLElementNode): void {
23
+ if (getTagName(node).toLowerCase() === "svg") {
24
+ this.checkTagName(node.open_tag)
25
+ this.checkTagName(node.close_tag)
26
+ } else {
27
+ super.visitHTMLElementNode(node)
21
28
  }
29
+ }
22
30
 
23
- this.visitChildNodes(node)
24
-
25
- if (node.close_tag) {
26
- this.checkTagName(node.close_tag as HTMLCloseTagNode)
27
- }
31
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode) {
32
+ this.checkTagName(node)
28
33
  }
29
34
 
30
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
35
+ visitHTMLCloseTagNode(node: HTMLCloseTagNode) {
31
36
  this.checkTagName(node)
32
- this.visitChildNodes(node)
33
37
  }
34
38
 
35
- private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | HTMLSelfCloseTagNode): void {
36
- const tagName = node.tag_name?.value
39
+ private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | null): void {
40
+ if (!node) return
41
+
42
+ const tagName = getTagName(node)
37
43
 
38
44
  if (!tagName) return
39
45
 
40
46
  const lowercaseTagName = tagName.toLowerCase()
41
47
 
42
- if (tagName !== lowercaseTagName) {
43
- let type: string = node.type
44
-
45
- if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
46
- if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
47
- if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"
48
+ const type = isNode(node, HTMLOpenTagNode) ? "Opening" : "Closing"
49
+ const open = isNode(node, HTMLOpenTagNode) ? "<" : "</"
48
50
 
51
+ if (tagName !== lowercaseTagName) {
49
52
  this.addOffense(
50
- `${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`,
53
+ `${type} tag name \`${open}${tagName}>\` should be lowercase. Use \`${open}${lowercaseTagName}>\` instead.`,
51
54
  node.tag_name!.location,
52
55
  "error"
53
56
  )
@@ -58,6 +61,16 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
58
61
  export class HTMLTagNameLowercaseRule extends ParserRule {
59
62
  name = "html-tag-name-lowercase"
60
63
 
64
+ isEnabled(result: ParseResult, context?: Partial<LintContext>): boolean {
65
+ if (context?.fileName?.endsWith(".xml") || context?.fileName?.endsWith(".xml.erb")) {
66
+ return false
67
+ }
68
+
69
+ const checker = new XMLDeclarationChecker(this.name)
70
+ checker.visit(result.value)
71
+ return !checker.hasXMLDeclaration
72
+ }
73
+
61
74
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
62
75
  const visitor = new TagNameLowercaseVisitor(this.name, context)
63
76
  visitor.visit(result.value)
@@ -1,19 +1,29 @@
1
1
  export * from "./erb-no-empty-tags.js"
2
2
  export * from "./erb-no-output-control-flow.js"
3
+ export * from "./erb-no-silent-tag-in-attribute-name.js"
3
4
  export * from "./erb-prefer-image-tag-helper.js"
4
5
  export * from "./erb-requires-trailing-newline.js"
5
6
  export * from "./html-anchor-require-href.js"
7
+ export * from "./html-aria-label-is-well-formatted.js"
6
8
  export * from "./html-aria-level-must-be-valid.js"
7
9
  export * from "./html-aria-role-heading-requires-level.js"
8
10
  export * from "./html-aria-role-must-be-valid.js"
9
11
  export * from "./html-attribute-double-quotes.js"
12
+ export * from "./html-attribute-equals-spacing.js"
10
13
  export * from "./html-attribute-values-require-quotes.js"
14
+ export * from "./html-avoid-both-disabled-and-aria-disabled.js"
11
15
  export * from "./html-boolean-attributes-no-value.js"
16
+ export * from "./html-iframe-has-title.js"
12
17
  export * from "./html-img-require-alt.js"
18
+ export * from "./html-navigation-has-label.js"
19
+ export * from "./html-no-aria-hidden-on-focusable.js"
13
20
  export * from "./html-no-block-inside-inline.js"
14
21
  export * from "./html-no-duplicate-attributes.js"
15
22
  export * from "./html-no-duplicate-ids.js"
16
23
  export * from "./html-no-empty-headings.js"
17
24
  export * from "./html-no-nested-links.js"
25
+ export * from "./html-no-positive-tab-index.js"
26
+ export * from "./html-no-self-closing.js"
27
+ export * from "./html-no-title-attribute.js"
18
28
  export * from "./html-tag-name-lowercase.js"
19
29
  export * from "./svg-tag-name-capitalization.js"