@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.
- package/README.md +60 -16
- package/dist/herb-lint.js +1684 -295
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +1226 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1188 -160
- package/dist/index.js.map +1 -1
- package/dist/package.json +11 -4
- package/dist/src/cli/argument-parser.js +11 -6
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +5 -6
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +3 -5
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
- package/dist/src/cli/index.js +1 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/output-manager.js +23 -5
- package/dist/src/cli/output-manager.js.map +1 -1
- package/dist/src/cli/summary-reporter.js +2 -11
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +88 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +8 -4
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
- package/dist/src/rules/html-boolean-attributes-no-value.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-attributes.js +56 -0
- package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
- package/dist/src/rules/html-no-positive-tab-index.js +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
- package/dist/src/rules/html-no-self-closing.js +12 -5
- package/dist/src/rules/html-no-self-closing.js.map +1 -1
- package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
- package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
- package/dist/src/rules/index.js +3 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +80 -7
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +2 -1
- package/dist/types/cli/file-processor.d.ts +6 -1
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/output-manager.d.ts +1 -0
- package/dist/types/cli.d.ts +20 -5
- package/dist/types/linter.d.ts +7 -7
- package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/rules/index.d.ts +3 -0
- package/dist/types/rules/rule-utils.d.ts +46 -5
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -1
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/src/cli/index.d.ts +1 -0
- package/dist/types/src/cli/output-manager.d.ts +1 -0
- package/dist/types/src/cli.d.ts +20 -5
- package/dist/types/src/linter.d.ts +7 -7
- package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/src/rules/index.d.ts +3 -0
- package/dist/types/src/rules/rule-utils.d.ts +46 -5
- package/docs/rules/README.md +2 -0
- package/docs/rules/html-img-require-alt.md +0 -2
- package/docs/rules/html-no-empty-attributes.md +77 -0
- package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
- package/package.json +11 -4
- package/src/cli/argument-parser.ts +15 -7
- package/src/cli/file-processor.ts +11 -7
- package/src/cli/formatters/detailed-formatter.ts +5 -7
- package/src/cli/formatters/github-actions-formatter.ts +64 -11
- package/src/cli/index.ts +2 -0
- package/src/cli/output-manager.ts +27 -5
- package/src/cli/summary-reporter.ts +3 -11
- package/src/cli.ts +125 -20
- package/src/default-rules.ts +8 -4
- package/src/linter.ts +6 -6
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
- package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
- package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
- package/src/rules/html-attribute-double-quotes.ts +1 -1
- package/src/rules/html-boolean-attributes-no-value.ts +9 -11
- package/src/rules/html-no-duplicate-ids.ts +188 -14
- package/src/rules/html-no-empty-attributes.ts +75 -0
- package/src/rules/html-no-positive-tab-index.ts +1 -1
- package/src/rules/html-no-self-closing.ts +13 -8
- package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
- package/src/rules/html-tag-name-lowercase.ts +1 -1
- package/src/rules/index.ts +3 -0
- package/src/rules/rule-utils.ts +110 -9
- 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 {
|
|
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,
|
|
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(
|
|
14
|
-
const tagName = getTagName(
|
|
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(
|
|
21
|
+
const attributes = getAttributes(openTag)
|
|
21
22
|
const srcAttribute = findAttributeByName(attributes, "src")
|
|
22
23
|
|
|
23
|
-
if (!srcAttribute)
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (!srcAttribute.value) {
|
|
28
|
-
return
|
|
29
|
-
}
|
|
24
|
+
if (!srcAttribute) return
|
|
25
|
+
if (!srcAttribute.value) return
|
|
30
26
|
|
|
31
|
-
const
|
|
32
|
-
const hasERBContent = this.containsERBContent(
|
|
27
|
+
const node = srcAttribute.value
|
|
28
|
+
const hasERBContent = this.containsERBContent(node)
|
|
33
29
|
|
|
34
30
|
if (hasERBContent) {
|
|
35
|
-
|
|
31
|
+
if (this.isDataUri(node)) return
|
|
32
|
+
|
|
33
|
+
if (this.shouldFlagAsImageTagCandidate(node)) {
|
|
34
|
+
const suggestedExpression = this.buildSuggestedExpression(node)
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
46
|
-
|
|
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
|
|
52
|
-
|
|
49
|
+
private isOnlyERBContent(node: HTMLAttributeValueNode): boolean {
|
|
50
|
+
return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length
|
|
51
|
+
}
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
private getContentofFirstChild(node: HTMLAttributeValueNode): string {
|
|
54
|
+
if (!node.children || node.children.length === 0) return ""
|
|
56
55
|
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
}
|
|
58
|
+
if (isNode(firstChild, LiteralNode)) {
|
|
59
|
+
return (firstChild.content || "").trim()
|
|
67
60
|
}
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
private isDataUri(node: HTMLAttributeValueNode): boolean {
|
|
66
|
+
return this.getContentofFirstChild(node).startsWith("data:")
|
|
67
|
+
}
|
|
83
68
|
|
|
84
|
-
|
|
69
|
+
private isFullUrl(node: HTMLAttributeValueNode): boolean {
|
|
70
|
+
const content = this.getContentofFirstChild(node)
|
|
85
71
|
|
|
86
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
100
81
|
|
|
101
|
-
|
|
82
|
+
private buildSuggestedExpression(node: HTMLAttributeValueNode): string {
|
|
83
|
+
if (!node.children) return "expression"
|
|
102
84
|
|
|
103
|
-
|
|
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(" ") ||
|
|
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(" ") ||
|
|
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 {
|
|
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({
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
protected checkStaticAttributeStaticValue({ originalAttributeName, attributeNode }: StaticAttributeStaticValueParams) {
|
|
10
|
+
this.checkAttribute(originalAttributeName, attributeNode)
|
|
11
|
+
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
attributeNode.value!.location,
|
|
15
|
-
"error"
|
|
16
|
-
)
|
|
13
|
+
protected checkStaticAttributeDynamicValue({ originalAttributeName, attributeNode }: StaticAttributeDynamicValueParams) {
|
|
14
|
+
this.checkAttribute(originalAttributeName, attributeNode)
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
|
|
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 \`${
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
if (attributeName.toLowerCase() !== "id") return
|
|
12
|
-
if (!attributeValue) return
|
|
47
|
+
this.currentBranchIds = new Set<string>()
|
|
13
48
|
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
`
|
|
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 |
|
|
39
|
+
private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | null): void {
|
|
40
40
|
if (!node) return
|
|
41
41
|
|
|
42
42
|
const tagName = getTagName(node)
|
package/src/rules/index.ts
CHANGED
|
@@ -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"
|