@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.
- package/dist/herb-lint.js +6627 -1937
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +1574 -210
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1566 -212
- package/dist/index.js.map +1 -1
- package/dist/package.json +5 -4
- package/dist/src/cli/argument-parser.js +0 -4
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/default-rules.js +20 -0
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js +29 -4
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
- package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -64
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +11 -10
- package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
- package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
- package/dist/src/rules/html-aria-level-must-be-valid.js +26 -4
- package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-role-heading-requires-level.js +7 -13
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
- package/dist/src/rules/html-aria-role-must-be-valid.js +3 -3
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +14 -4
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
- package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
- package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js +19 -8
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js +9 -2
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-iframe-has-title.js +39 -0
- package/dist/src/rules/html-iframe-has-title.js.map +1 -0
- package/dist/src/rules/html-img-require-alt.js +0 -4
- package/dist/src/rules/html-img-require-alt.js.map +1 -1
- package/dist/src/rules/html-navigation-has-label.js +43 -0
- package/dist/src/rules/html-navigation-has-label.js.map +1 -0
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
- package/dist/src/rules/html-no-duplicate-attributes.js +22 -25
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +134 -9
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +0 -21
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js +21 -0
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
- package/dist/src/rules/html-no-self-closing.js +29 -0
- package/dist/src/rules/html-no-self-closing.js.map +1 -0
- package/dist/src/rules/html-no-title-attribute.js +27 -0
- package/dist/src/rules/html-no-title-attribute.js.map +1 -0
- package/dist/src/rules/html-tag-name-lowercase.js +35 -23
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
- package/dist/src/rules/index.js +10 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +245 -22
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +0 -8
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/index.d.ts +4 -0
- package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
- package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
- package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
- package/dist/types/rules/html-no-self-closing.d.ts +7 -0
- package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
- package/dist/types/rules/html-tag-name-lowercase.d.ts +2 -1
- package/dist/types/rules/index.d.ts +10 -0
- package/dist/types/rules/rule-utils.d.ts +146 -13
- package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
- package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
- package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
- package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
- package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +2 -1
- package/dist/types/src/rules/index.d.ts +10 -0
- package/dist/types/src/rules/rule-utils.d.ts +146 -13
- package/dist/types/src/types.d.ts +24 -0
- package/dist/types/types.d.ts +24 -0
- package/docs/rules/README.md +12 -2
- package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
- package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
- package/docs/rules/html-attribute-equals-spacing.md +35 -0
- package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
- package/docs/rules/html-iframe-has-title.md +43 -0
- package/docs/rules/html-navigation-has-label.md +61 -0
- package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
- package/docs/rules/html-no-positive-tab-index.md +55 -0
- package/docs/rules/html-no-self-closing.md +65 -0
- package/docs/rules/html-no-title-attribute.md +69 -0
- package/docs/rules/html-tag-name-lowercase.md +16 -3
- package/package.json +5 -4
- package/src/cli/argument-parser.ts +0 -5
- package/src/default-rules.ts +20 -0
- package/src/linter.ts +30 -4
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
- package/src/rules/erb-prefer-image-tag-helper.ts +53 -76
- package/src/rules/html-aria-attribute-must-be-valid.ts +28 -32
- package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
- package/src/rules/html-aria-level-must-be-valid.ts +38 -5
- package/src/rules/html-aria-role-heading-requires-level.ts +16 -28
- package/src/rules/html-aria-role-must-be-valid.ts +5 -5
- package/src/rules/html-attribute-double-quotes.ts +21 -6
- package/src/rules/html-attribute-equals-spacing.ts +41 -0
- package/src/rules/html-attribute-values-require-quotes.ts +29 -9
- package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
- package/src/rules/html-boolean-attributes-no-value.ts +17 -4
- package/src/rules/html-iframe-has-title.ts +62 -0
- package/src/rules/html-img-require-alt.ts +2 -7
- package/src/rules/html-navigation-has-label.ts +64 -0
- package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
- package/src/rules/html-no-duplicate-attributes.ts +28 -28
- package/src/rules/html-no-duplicate-ids.ts +189 -14
- package/src/rules/html-no-empty-headings.ts +2 -31
- package/src/rules/html-no-positive-tab-index.ts +33 -0
- package/src/rules/html-no-self-closing.ts +41 -0
- package/src/rules/html-no-title-attribute.ts +42 -0
- package/src/rules/html-tag-name-lowercase.ts +42 -29
- package/src/rules/index.ts +10 -0
- package/src/rules/rule-utils.ts +357 -39
- package/src/rules/svg-tag-name-capitalization.ts +2 -9
- package/src/types.ts +27 -0
package/src/rules/rule-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
62
|
-
return node.type === "
|
|
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
|
|
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
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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
|
-
|
|
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
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 {
|