@herb-tools/linter 0.4.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 +34 -0
- package/bin/herb-lint +3 -0
- package/dist/herb-lint.js +16505 -0
- package/dist/herb-lint.js.map +1 -0
- package/dist/index.cjs +834 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +820 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +49 -0
- package/dist/src/cli/argument-parser.js +96 -0
- package/dist/src/cli/argument-parser.js.map +1 -0
- package/dist/src/cli/file-processor.js +58 -0
- package/dist/src/cli/file-processor.js.map +1 -0
- package/dist/src/cli/formatters/base-formatter.js +3 -0
- package/dist/src/cli/formatters/base-formatter.js.map +1 -0
- package/dist/src/cli/formatters/detailed-formatter.js +62 -0
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -0
- package/dist/src/cli/formatters/index.js +4 -0
- package/dist/src/cli/formatters/index.js.map +1 -0
- package/dist/src/cli/formatters/simple-formatter.js +31 -0
- package/dist/src/cli/formatters/simple-formatter.js.map +1 -0
- package/dist/src/cli/index.js +5 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/cli/summary-reporter.js +96 -0
- package/dist/src/cli/summary-reporter.js.map +1 -0
- package/dist/src/cli.js +50 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/default-rules.js +31 -0
- package/dist/src/default-rules.js.map +1 -0
- package/dist/src/herb-lint.js +5 -0
- package/dist/src/herb-lint.js.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/linter.js +39 -0
- package/dist/src/linter.js.map +1 -0
- package/dist/src/rules/erb-no-empty-tags.js +23 -0
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -0
- package/dist/src/rules/erb-no-output-control-flow.js +47 -0
- package/dist/src/rules/erb-no-output-control-flow.js.map +1 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +43 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -0
- package/dist/src/rules/html-anchor-require-href.js +25 -0
- package/dist/src/rules/html-anchor-require-href.js.map +1 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js +26 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -0
- package/dist/src/rules/html-attribute-double-quotes.js +21 -0
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js +22 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js +19 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -0
- package/dist/src/rules/html-img-require-alt.js +29 -0
- package/dist/src/rules/html-img-require-alt.js.map +1 -0
- package/dist/src/rules/html-no-block-inside-inline.js +59 -0
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -0
- package/dist/src/rules/html-no-duplicate-attributes.js +43 -0
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -0
- package/dist/src/rules/html-no-empty-headings.js +148 -0
- package/dist/src/rules/html-no-empty-headings.js.map +1 -0
- package/dist/src/rules/html-no-nested-links.js +45 -0
- package/dist/src/rules/html-no-nested-links.js.map +1 -0
- package/dist/src/rules/html-tag-name-lowercase.js +39 -0
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -0
- package/dist/src/rules/index.js +13 -0
- package/dist/src/rules/index.js.map +1 -0
- package/dist/src/rules/rule-utils.js +198 -0
- package/dist/src/rules/rule-utils.js.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/cli/argument-parser.d.ts +14 -0
- package/dist/types/cli/file-processor.d.ts +21 -0
- package/dist/types/cli/formatters/base-formatter.d.ts +6 -0
- package/dist/types/cli/formatters/detailed-formatter.d.ts +13 -0
- package/dist/types/cli/formatters/index.d.ts +3 -0
- package/dist/types/cli/formatters/simple-formatter.d.ts +7 -0
- package/dist/types/cli/summary-reporter.d.ts +22 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/default-rules.d.ts +2 -0
- package/dist/types/herb-lint.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/linter.d.ts +18 -0
- package/dist/types/rules/erb-no-empty-tags.d.ts +6 -0
- package/dist/types/rules/erb-no-output-control-flow.d.ts +6 -0
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
- package/dist/types/rules/html-anchor-require-href.d.ts +6 -0
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +6 -0
- package/dist/types/rules/html-attribute-double-quotes.d.ts +6 -0
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +6 -0
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +6 -0
- package/dist/types/rules/html-img-require-alt.d.ts +6 -0
- package/dist/types/rules/html-no-block-inside-inline.d.ts +6 -0
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +6 -0
- package/dist/types/rules/html-no-empty-headings.d.ts +6 -0
- package/dist/types/rules/html-no-nested-links.d.ts +6 -0
- package/dist/types/rules/html-tag-name-lowercase.d.ts +6 -0
- package/dist/types/rules/index.d.ts +12 -0
- package/dist/types/rules/rule-utils.d.ts +89 -0
- package/dist/types/src/cli/argument-parser.d.ts +14 -0
- package/dist/types/src/cli/file-processor.d.ts +21 -0
- package/dist/types/src/cli/formatters/base-formatter.d.ts +6 -0
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +13 -0
- package/dist/types/src/cli/formatters/index.d.ts +3 -0
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +7 -0
- package/dist/types/src/cli/index.d.ts +4 -0
- package/dist/types/src/cli/summary-reporter.d.ts +22 -0
- package/dist/types/src/cli.d.ts +6 -0
- package/dist/types/src/default-rules.d.ts +2 -0
- package/dist/types/src/herb-lint.d.ts +2 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/linter.d.ts +18 -0
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +6 -0
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +6 -0
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
- package/dist/types/src/rules/html-anchor-require-href.d.ts +6 -0
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +6 -0
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +6 -0
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +6 -0
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +6 -0
- package/dist/types/src/rules/html-img-require-alt.d.ts +6 -0
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +6 -0
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +6 -0
- package/dist/types/src/rules/html-no-empty-headings.d.ts +6 -0
- package/dist/types/src/rules/html-no-nested-links.d.ts +6 -0
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +6 -0
- package/dist/types/src/rules/index.d.ts +12 -0
- package/dist/types/src/rules/rule-utils.d.ts +89 -0
- package/dist/types/src/types.d.ts +26 -0
- package/dist/types/types.d.ts +26 -0
- package/docs/rules/README.md +39 -0
- package/docs/rules/erb-no-empty-tags.md +38 -0
- package/docs/rules/erb-no-output-control-flow.md +45 -0
- package/docs/rules/erb-require-whitespace-inside-tags.md +43 -0
- package/docs/rules/html-anchor-require-href.md +32 -0
- package/docs/rules/html-aria-role-heading-requires-level.md +34 -0
- package/docs/rules/html-attribute-double-quotes.md +43 -0
- package/docs/rules/html-attribute-values-require-quotes.md +43 -0
- package/docs/rules/html-boolean-attributes-no-value.md +39 -0
- package/docs/rules/html-img-require-alt.md +44 -0
- package/docs/rules/html-no-block-inside-inline.md +66 -0
- package/docs/rules/html-no-duplicate-attributes.md +35 -0
- package/docs/rules/html-no-empty-headings.md +78 -0
- package/docs/rules/html-no-nested-links.md +44 -0
- package/docs/rules/html-tag-name-lowercase.md +44 -0
- package/package.json +49 -0
- package/src/cli/argument-parser.ts +125 -0
- package/src/cli/file-processor.ts +86 -0
- package/src/cli/formatters/base-formatter.ts +11 -0
- package/src/cli/formatters/detailed-formatter.ts +74 -0
- package/src/cli/formatters/index.ts +3 -0
- package/src/cli/formatters/simple-formatter.ts +40 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/summary-reporter.ts +127 -0
- package/src/cli.ts +60 -0
- package/src/default-rules.ts +33 -0
- package/src/herb-lint.ts +6 -0
- package/src/index.ts +3 -0
- package/src/linter.ts +50 -0
- package/src/rules/erb-no-empty-tags.ts +34 -0
- package/src/rules/erb-no-output-control-flow.ts +61 -0
- package/src/rules/erb-require-whitespace-inside-tags.ts +61 -0
- package/src/rules/html-anchor-require-href.ts +39 -0
- package/src/rules/html-aria-role-heading-requires-level.ts +44 -0
- package/src/rules/html-attribute-double-quotes.ts +28 -0
- package/src/rules/html-attribute-values-require-quotes.ts +30 -0
- package/src/rules/html-boolean-attributes-no-value.ts +27 -0
- package/src/rules/html-img-require-alt.ts +42 -0
- package/src/rules/html-no-block-inside-inline.ts +84 -0
- package/src/rules/html-no-duplicate-attributes.ts +59 -0
- package/src/rules/html-no-empty-headings.ts +185 -0
- package/src/rules/html-no-nested-links.ts +65 -0
- package/src/rules/html-tag-name-lowercase.ts +50 -0
- package/src/rules/index.ts +12 -0
- package/src/rules/rule-utils.ts +257 -0
- package/src/types.ts +32 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BaseRuleVisitor, getTagName } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLOpenTagNode, HTMLElementNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class NestedLinkVisitor extends BaseRuleVisitor {
|
|
7
|
+
private linkStack: HTMLOpenTagNode[] = []
|
|
8
|
+
|
|
9
|
+
private checkNestedLink(openTag: HTMLOpenTagNode): boolean {
|
|
10
|
+
if (this.linkStack.length > 0) {
|
|
11
|
+
this.addOffense(
|
|
12
|
+
"Nested `<a>` elements are not allowed. Links cannot contain other links.",
|
|
13
|
+
openTag.tag_name!.location,
|
|
14
|
+
"error"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
24
|
+
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
25
|
+
super.visitHTMLElementNode(node)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const openTag = node.open_tag as HTMLOpenTagNode
|
|
30
|
+
const tagName = getTagName(openTag)
|
|
31
|
+
|
|
32
|
+
if (tagName !== "a") {
|
|
33
|
+
super.visitHTMLElementNode(node)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// If we're already inside a link, this is a nested link
|
|
38
|
+
this.checkNestedLink(openTag)
|
|
39
|
+
|
|
40
|
+
this.linkStack.push(openTag)
|
|
41
|
+
super.visitHTMLElementNode(node)
|
|
42
|
+
this.linkStack.pop()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Handle self-closing <a> tags (though they're not valid HTML, they might exist)
|
|
46
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
47
|
+
const tagName = getTagName(node)
|
|
48
|
+
|
|
49
|
+
if (tagName === "a" && node.is_void) {
|
|
50
|
+
this.checkNestedLink(node)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
super.visitHTMLOpenTagNode(node)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class HTMLNoNestedLinksRule implements Rule {
|
|
58
|
+
name = "html-no-nested-links"
|
|
59
|
+
|
|
60
|
+
check(node: Node): LintOffense[] {
|
|
61
|
+
const visitor = new NestedLinkVisitor(this.name)
|
|
62
|
+
visitor.visit(node)
|
|
63
|
+
return visitor.offenses
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
8
|
+
this.checkTagName(node)
|
|
9
|
+
this.visitChildNodes(node)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
|
|
13
|
+
this.checkTagName(node)
|
|
14
|
+
this.visitChildNodes(node)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
18
|
+
this.checkTagName(node)
|
|
19
|
+
this.visitChildNodes(node)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | HTMLSelfCloseTagNode): void {
|
|
23
|
+
const tagName = node.tag_name?.value
|
|
24
|
+
if (!tagName) return
|
|
25
|
+
|
|
26
|
+
if (tagName !== tagName.toLowerCase()) {
|
|
27
|
+
let type: string = node.type
|
|
28
|
+
|
|
29
|
+
if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
|
|
30
|
+
if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
|
|
31
|
+
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"
|
|
32
|
+
|
|
33
|
+
this.addOffense(
|
|
34
|
+
`${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`,
|
|
35
|
+
node.tag_name!.location,
|
|
36
|
+
"error"
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class HTMLTagNameLowercaseRule implements Rule {
|
|
43
|
+
name = "html-tag-name-lowercase"
|
|
44
|
+
|
|
45
|
+
check(node: Node): LintOffense[] {
|
|
46
|
+
const visitor = new TagNameLowercaseVisitor(this.name)
|
|
47
|
+
visitor.visit(node)
|
|
48
|
+
return visitor.offenses
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./erb-no-empty-tags.js"
|
|
2
|
+
export * from "./erb-no-output-control-flow.js"
|
|
3
|
+
export * from "./html-anchor-require-href.js"
|
|
4
|
+
export * from "./html-attribute-double-quotes.js"
|
|
5
|
+
export * from "./html-attribute-values-require-quotes.js"
|
|
6
|
+
export * from "./html-boolean-attributes-no-value.js"
|
|
7
|
+
export * from "./html-img-require-alt.js"
|
|
8
|
+
export * from "./html-no-block-inside-inline.js"
|
|
9
|
+
export * from "./html-no-duplicate-attributes.js"
|
|
10
|
+
export * from "./html-no-empty-headings.js"
|
|
11
|
+
export * from "./html-no-nested-links.js"
|
|
12
|
+
export * from "./html-tag-name-lowercase.js"
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Visitor
|
|
3
|
+
} from "@herb-tools/core"
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ERBNode,
|
|
7
|
+
HTMLAttributeNameNode,
|
|
8
|
+
HTMLAttributeNode,
|
|
9
|
+
HTMLAttributeValueNode,
|
|
10
|
+
HTMLOpenTagNode,
|
|
11
|
+
HTMLSelfCloseTagNode,
|
|
12
|
+
LiteralNode,
|
|
13
|
+
Location,
|
|
14
|
+
Node
|
|
15
|
+
} from "@herb-tools/core"
|
|
16
|
+
import type { LintOffense, LintSeverity, } from "../types.js"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Base visitor class that provides common functionality for rule visitors
|
|
20
|
+
*/
|
|
21
|
+
export abstract class BaseRuleVisitor extends Visitor {
|
|
22
|
+
public readonly offenses: LintOffense[] = []
|
|
23
|
+
protected ruleName: string
|
|
24
|
+
|
|
25
|
+
constructor(ruleName: string) {
|
|
26
|
+
super()
|
|
27
|
+
|
|
28
|
+
this.ruleName = ruleName
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Helper method to create a lint offense
|
|
33
|
+
*/
|
|
34
|
+
protected createOffense(message: string, location: Location, severity: LintSeverity = "error"): LintOffense {
|
|
35
|
+
return {
|
|
36
|
+
rule: this.ruleName,
|
|
37
|
+
code: this.ruleName,
|
|
38
|
+
source: "Herb Linter",
|
|
39
|
+
message,
|
|
40
|
+
location,
|
|
41
|
+
severity,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Helper method to add an offense to the offenses array
|
|
47
|
+
*/
|
|
48
|
+
protected addOffense(message: string, location: Location, severity: LintSeverity = "error"): void {
|
|
49
|
+
this.offenses.push(this.createOffense(message, location, severity))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
|
|
55
|
+
*/
|
|
56
|
+
export function getAttributes(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): any[] {
|
|
57
|
+
return node.type === "AST_HTML_SELF_CLOSE_TAG_NODE"
|
|
58
|
+
? (node as HTMLSelfCloseTagNode).attributes
|
|
59
|
+
: (node as HTMLOpenTagNode).children
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the tag name from an HTML tag node (lowercased)
|
|
64
|
+
*/
|
|
65
|
+
export function getTagName(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): string | null {
|
|
66
|
+
return node.tag_name?.value.toLowerCase() || null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
71
|
+
*/
|
|
72
|
+
export function getAttributeName(attributeNode: HTMLAttributeNode): string | null {
|
|
73
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
74
|
+
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
75
|
+
|
|
76
|
+
return nameNode.name?.value.toLowerCase() || null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
84
|
+
*/
|
|
85
|
+
export function getAttributeValue(attributeNode: HTMLAttributeNode): string | null {
|
|
86
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
87
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode
|
|
88
|
+
|
|
89
|
+
if (valueNode.children && valueNode.children.length > 0) {
|
|
90
|
+
return valueNode.children
|
|
91
|
+
.filter(child => child.type === "AST_LITERAL_NODE")
|
|
92
|
+
.map(child => (child as LiteralNode).content)
|
|
93
|
+
.join("")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Checks if an attribute has a value
|
|
102
|
+
*/
|
|
103
|
+
export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
|
|
104
|
+
return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gets the quote type used for an attribute value
|
|
109
|
+
*/
|
|
110
|
+
export function getAttributeValueQuoteType(attributeNode: HTMLAttributeNode): "single" | "double" | "none" | null {
|
|
111
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
112
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode
|
|
113
|
+
if (valueNode.quoted && valueNode.open_quote) {
|
|
114
|
+
return valueNode.open_quote.value === '"' ? "double" : "single"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return "none"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Finds an attribute by name in a list of attributes
|
|
125
|
+
*/
|
|
126
|
+
export function findAttributeByName(attributes: any[], attributeName: string): HTMLAttributeNode | null {
|
|
127
|
+
for (const child of attributes) {
|
|
128
|
+
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
129
|
+
const attributeNode = child as HTMLAttributeNode
|
|
130
|
+
const name = getAttributeName(attributeNode)
|
|
131
|
+
if (name === attributeName.toLowerCase()) {
|
|
132
|
+
return attributeNode
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Checks if a tag has a specific attribute
|
|
141
|
+
*/
|
|
142
|
+
export function hasAttribute(node: HTMLOpenTagNode | HTMLSelfCloseTagNode, attributeName: string): boolean {
|
|
143
|
+
const attributes = getAttributes(node)
|
|
144
|
+
return findAttributeByName(attributes, attributeName) !== null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Common HTML element categorization
|
|
149
|
+
*/
|
|
150
|
+
export const HTML_INLINE_ELEMENTS = new Set([
|
|
151
|
+
"a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code",
|
|
152
|
+
"dfn", "em", "i", "img", "input", "kbd", "label", "map", "object", "output",
|
|
153
|
+
"q", "samp", "script", "select", "small", "span", "strong", "sub", "sup",
|
|
154
|
+
"textarea", "time", "tt", "var"
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
export const HTML_BLOCK_ELEMENTS = new Set([
|
|
158
|
+
"address", "article", "aside", "blockquote", "canvas", "dd", "div", "dl",
|
|
159
|
+
"dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2",
|
|
160
|
+
"h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
|
|
161
|
+
"ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
|
|
162
|
+
])
|
|
163
|
+
|
|
164
|
+
export const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
165
|
+
"autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
|
|
166
|
+
"loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
|
|
167
|
+
"open", "default", "formnovalidate", "novalidate", "itemscope", "scoped",
|
|
168
|
+
"seamless", "allowfullscreen", "async", "compact", "declare", "nohref",
|
|
169
|
+
"noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
export const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Checks if an element is inline
|
|
176
|
+
*/
|
|
177
|
+
export function isInlineElement(tagName: string): boolean {
|
|
178
|
+
return HTML_INLINE_ELEMENTS.has(tagName.toLowerCase())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Checks if an element is block-level
|
|
183
|
+
*/
|
|
184
|
+
export function isBlockElement(tagName: string): boolean {
|
|
185
|
+
return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Checks if an attribute is a boolean attribute
|
|
190
|
+
*/
|
|
191
|
+
export function isBooleanAttribute(attributeName: string): boolean {
|
|
192
|
+
return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Abstract base class for rules that need to check individual attributes on HTML tags
|
|
197
|
+
* Eliminates duplication of visitHTMLOpenTagNode/visitHTMLSelfCloseTagNode patterns
|
|
198
|
+
* and attribute iteration logic. Provides simplified interface with extracted attribute info.
|
|
199
|
+
*/
|
|
200
|
+
export abstract class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
201
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
202
|
+
this.checkAttributesOnNode(node)
|
|
203
|
+
super.visitHTMLOpenTagNode(node)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
207
|
+
this.checkAttributesOnNode(node)
|
|
208
|
+
super.visitHTMLSelfCloseTagNode(node)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private checkAttributesOnNode(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
212
|
+
forEachAttribute(node, (attributeNode) => {
|
|
213
|
+
const attributeName = getAttributeName(attributeNode)
|
|
214
|
+
const attributeValue = getAttributeValue(attributeNode)
|
|
215
|
+
|
|
216
|
+
if (attributeName) {
|
|
217
|
+
this.checkAttribute(attributeName, attributeValue, attributeNode, node)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
protected abstract checkAttribute(
|
|
223
|
+
attributeName: string,
|
|
224
|
+
attributeValue: string | null,
|
|
225
|
+
attributeNode: HTMLAttributeNode,
|
|
226
|
+
parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
|
|
227
|
+
): void
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Checks if an attribute value is quoted
|
|
232
|
+
*/
|
|
233
|
+
export function isAttributeValueQuoted(attributeNode: HTMLAttributeNode): boolean {
|
|
234
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
235
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode
|
|
236
|
+
|
|
237
|
+
return !!valueNode.quoted
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
245
|
+
*/
|
|
246
|
+
export function forEachAttribute(
|
|
247
|
+
node: HTMLOpenTagNode | HTMLSelfCloseTagNode,
|
|
248
|
+
callback: (attributeNode: HTMLAttributeNode) => void
|
|
249
|
+
): void {
|
|
250
|
+
const attributes = getAttributes(node)
|
|
251
|
+
|
|
252
|
+
for (const child of attributes) {
|
|
253
|
+
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
254
|
+
callback(child as HTMLAttributeNode)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Node, Diagnostic } from "@herb-tools/core"
|
|
2
|
+
import type { defaultRules } from "./default-rules.js"
|
|
3
|
+
|
|
4
|
+
export type LintSeverity = "error" | "warning"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Automatically inferred union type of all available linter rule names.
|
|
8
|
+
* This type extracts the 'name' property from each rule class instance.
|
|
9
|
+
*/
|
|
10
|
+
export type LinterRule = InstanceType<typeof defaultRules[number]>['name']
|
|
11
|
+
|
|
12
|
+
export interface LintOffense extends Diagnostic {
|
|
13
|
+
rule: LinterRule
|
|
14
|
+
severity: LintSeverity
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LintResult {
|
|
18
|
+
offenses: LintOffense[]
|
|
19
|
+
errors: number
|
|
20
|
+
warnings: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Rule {
|
|
24
|
+
name: string
|
|
25
|
+
check(node: Node): LintOffense[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Type representing a rule class constructor.
|
|
30
|
+
* The Linter accepts rule classes rather than instances for better performance and memory usage.
|
|
31
|
+
*/
|
|
32
|
+
export type RuleClass = new () => Rule
|