@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,61 @@
|
|
|
1
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Node, ERBIfNode, ERBUnlessNode, ERBElseNode, ERBEndNode } from "@herb-tools/core"
|
|
4
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
5
|
+
|
|
6
|
+
class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitERBIfNode(node: ERBIfNode): void {
|
|
8
|
+
this.checkOutputControlFlow(node)
|
|
9
|
+
this.visitChildNodes(node)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
visitERBUnlessNode(node: ERBUnlessNode): void {
|
|
13
|
+
this.checkOutputControlFlow(node)
|
|
14
|
+
this.visitChildNodes(node)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
visitERBElseNode(node: ERBElseNode): void {
|
|
18
|
+
this.checkOutputControlFlow(node)
|
|
19
|
+
this.visitChildNodes(node)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
visitERBEndNode(node: ERBEndNode): void {
|
|
23
|
+
this.checkOutputControlFlow(node)
|
|
24
|
+
this.visitChildNodes(node)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private checkOutputControlFlow(controlBlock: ERBIfNode | ERBUnlessNode | ERBElseNode | ERBEndNode): void {
|
|
28
|
+
const openTag = controlBlock.tag_opening;
|
|
29
|
+
if (!openTag) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (openTag.value === "<%="){
|
|
34
|
+
let controlBlockType: string = controlBlock.type
|
|
35
|
+
|
|
36
|
+
if (controlBlock.type === "AST_ERB_IF_NODE") controlBlockType = "if"
|
|
37
|
+
if (controlBlock.type === "AST_ERB_ELSE_NODE") controlBlockType = "else"
|
|
38
|
+
if (controlBlock.type === "AST_ERB_END_NODE") controlBlockType = "end"
|
|
39
|
+
if (controlBlock.type === "AST_ERB_UNLESS_NODE") controlBlockType = "unless"
|
|
40
|
+
|
|
41
|
+
this.addOffense(
|
|
42
|
+
`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`,
|
|
43
|
+
openTag.location,
|
|
44
|
+
"error"
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ERBNoOutputControlFlowRule implements Rule {
|
|
53
|
+
name = "erb-no-output-control-flow"
|
|
54
|
+
check(node: Node): LintOffense[] {
|
|
55
|
+
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name)
|
|
56
|
+
|
|
57
|
+
visitor.visit(node)
|
|
58
|
+
|
|
59
|
+
return visitor.offenses
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Node, Token } from "@herb-tools/core"
|
|
2
|
+
import { isERBNode } from "@herb-tools/core";
|
|
3
|
+
import type { LintOffense, Rule } from "../types.js"
|
|
4
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
5
|
+
|
|
6
|
+
class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
7
|
+
|
|
8
|
+
visitChildNodes(node: Node): void {
|
|
9
|
+
this.checkWhitespace(node)
|
|
10
|
+
super.visitChildNodes(node)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private checkWhitespace(node: Node): void {
|
|
14
|
+
if (!isERBNode(node)) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const openTag = node.tag_opening
|
|
18
|
+
const closeTag = node.tag_closing
|
|
19
|
+
const content = node.content
|
|
20
|
+
|
|
21
|
+
if (!openTag || !closeTag || !content) {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const value = content.value
|
|
26
|
+
|
|
27
|
+
this.checkOpenTagWhitespace(openTag, value)
|
|
28
|
+
this.checkCloseTagWhitespace(closeTag, value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private checkOpenTagWhitespace(openTag: Token, content:string):void {
|
|
32
|
+
if (content.startsWith(" ") || content.startsWith("\n")) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
this.addOffense(
|
|
36
|
+
`Add whitespace after \`${openTag.value}\`.`,
|
|
37
|
+
openTag.location,
|
|
38
|
+
"error"
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private checkCloseTagWhitespace(closeTag: Token, content:string):void {
|
|
43
|
+
if (content.endsWith(" ") || content.endsWith("\n")) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
this.addOffense(
|
|
47
|
+
`Add whitespace before \`${closeTag.value}\`.`,
|
|
48
|
+
closeTag.location,
|
|
49
|
+
"error"
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ERBRequireWhitespaceRule implements Rule {
|
|
55
|
+
name = "erb-require-whitespace-inside-tags"
|
|
56
|
+
check(node: Node): LintOffense[] {
|
|
57
|
+
const visitor = new RequireWhitespaceInsideTags(this.name)
|
|
58
|
+
visitor.visit(node)
|
|
59
|
+
return visitor.offenses
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLOpenTagNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class AnchorRechireHrefVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
8
|
+
this.checkATag(node)
|
|
9
|
+
super.visitHTMLOpenTagNode(node)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private checkATag(node: HTMLOpenTagNode): void {
|
|
13
|
+
const tagName = getTagName(node)
|
|
14
|
+
|
|
15
|
+
if (tagName !== "a") {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!hasAttribute(node, "href")) {
|
|
20
|
+
this.addOffense(
|
|
21
|
+
"Add an `href` attribute to `<a>` to ensure it is focusable and accessible.",
|
|
22
|
+
node.tag_name!.location,
|
|
23
|
+
"error",
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class HTMLAnchorRequireHrefRule implements Rule {
|
|
30
|
+
name = "html-anchor-require-href"
|
|
31
|
+
|
|
32
|
+
check(node: Node): LintOffense[] {
|
|
33
|
+
const visitor = new AnchorRechireHrefVisitor(this.name)
|
|
34
|
+
|
|
35
|
+
visitor.visit(node)
|
|
36
|
+
|
|
37
|
+
return visitor.offenses
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AttributeVisitorMixin, getAttributeName, getAttributes } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { Node, HTMLAttributeNode, HTMLOpenTagNode, HTMLSelfCloseTagNode } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
|
|
7
|
+
|
|
8
|
+
// We want to check 2 attributes here:
|
|
9
|
+
// 1. role="heading"
|
|
10
|
+
// 2. aria-level (which must be present if role="heading")
|
|
11
|
+
checkAttribute(
|
|
12
|
+
attributeName: string,
|
|
13
|
+
attributeValue: string | null,
|
|
14
|
+
attributeNode: HTMLAttributeNode,
|
|
15
|
+
parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
|
|
16
|
+
): void {
|
|
17
|
+
|
|
18
|
+
if (!(attributeName === "role" && attributeValue === "heading")) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const allAttributes = getAttributes(parentNode)
|
|
23
|
+
|
|
24
|
+
// If we have a role="heading", we must check for aria-level
|
|
25
|
+
const ariaLevelAttr = allAttributes.find(attr => getAttributeName(attr) === "aria-level")
|
|
26
|
+
if (!ariaLevelAttr) {
|
|
27
|
+
this.addOffense(
|
|
28
|
+
`Element with \`role="heading"\` must have an \`aria-level\` attribute.`,
|
|
29
|
+
attributeNode.location,
|
|
30
|
+
"error"
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class HTMLAriaRoleHeadingRequiresLevelRule implements Rule {
|
|
37
|
+
name = "html-aria-role-heading-requires-level"
|
|
38
|
+
|
|
39
|
+
check(node: Node): LintOffense[] {
|
|
40
|
+
const visitor = new AriaRoleHeadingRequiresLevel(this.name)
|
|
41
|
+
visitor.visit(node)
|
|
42
|
+
return visitor.offenses
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AttributeVisitorMixin, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { Node, HTMLAttributeNode } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
7
|
+
protected checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
|
|
8
|
+
if (!hasAttributeValue(attributeNode)) return
|
|
9
|
+
if (getAttributeValueQuoteType(attributeNode) !== "single") return
|
|
10
|
+
if (attributeValue?.includes('"')) return // Single quotes acceptable when value contains double quotes
|
|
11
|
+
|
|
12
|
+
this.addOffense(
|
|
13
|
+
`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`,
|
|
14
|
+
attributeNode.value!.location,
|
|
15
|
+
"warning"
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class HTMLAttributeDoubleQuotesRule implements Rule {
|
|
21
|
+
name = "html-attribute-double-quotes"
|
|
22
|
+
|
|
23
|
+
check(node: Node): LintOffense[] {
|
|
24
|
+
const visitor = new AttributeDoubleQuotesVisitor(this.name)
|
|
25
|
+
visitor.visit(node)
|
|
26
|
+
return visitor.offenses
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AttributeVisitorMixin } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLAttributeNode, HTMLAttributeValueNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
|
|
7
|
+
protected checkAttribute(attributeName: string, _attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
|
|
8
|
+
if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE") return
|
|
9
|
+
|
|
10
|
+
const valueNode = attributeNode.value as HTMLAttributeValueNode
|
|
11
|
+
if (valueNode.quoted) return
|
|
12
|
+
|
|
13
|
+
this.addOffense(
|
|
14
|
+
// TODO: print actual attribute value in message
|
|
15
|
+
`Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`,
|
|
16
|
+
valueNode.location,
|
|
17
|
+
"error"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class HTMLAttributeValuesRequireQuotesRule implements Rule {
|
|
23
|
+
name = "html-attribute-values-require-quotes"
|
|
24
|
+
|
|
25
|
+
check(node: Node): LintOffense[] {
|
|
26
|
+
const visitor = new AttributeValuesRequireQuotesVisitor(this.name)
|
|
27
|
+
visitor.visit(node)
|
|
28
|
+
return visitor.offenses
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AttributeVisitorMixin, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLAttributeNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
7
|
+
protected checkAttribute(attributeName: string, _attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
|
|
8
|
+
if (!isBooleanAttribute(attributeName)) return
|
|
9
|
+
if (!hasAttributeValue(attributeNode)) return
|
|
10
|
+
|
|
11
|
+
this.addOffense(
|
|
12
|
+
`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`,
|
|
13
|
+
attributeNode.value!.location,
|
|
14
|
+
"error"
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class HTMLBooleanAttributesNoValueRule implements Rule {
|
|
20
|
+
name = "html-boolean-attributes-no-value"
|
|
21
|
+
|
|
22
|
+
check(node: Node): LintOffense[] {
|
|
23
|
+
const visitor = new BooleanAttributesNoValueVisitor(this.name)
|
|
24
|
+
visitor.visit(node)
|
|
25
|
+
return visitor.offenses
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
8
|
+
this.checkImgTag(node)
|
|
9
|
+
super.visitHTMLOpenTagNode(node)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
13
|
+
this.checkImgTag(node)
|
|
14
|
+
super.visitHTMLSelfCloseTagNode(node)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
18
|
+
const tagName = getTagName(node)
|
|
19
|
+
|
|
20
|
+
if (tagName !== "img") {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!hasAttribute(node, "alt")) {
|
|
25
|
+
this.addOffense(
|
|
26
|
+
'Missing required `alt` attribute on `<img>` tag. Add `alt=""` for decorative images or `alt="description"` for informative images.',
|
|
27
|
+
node.tag_name!.location,
|
|
28
|
+
"error"
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class HTMLImgRequireAltRule implements Rule {
|
|
35
|
+
name = "html-img-require-alt"
|
|
36
|
+
|
|
37
|
+
check(node: Node): LintOffense[] {
|
|
38
|
+
const visitor = new ImgRequireAltVisitor(this.name)
|
|
39
|
+
visitor.visit(node)
|
|
40
|
+
return visitor.offenses
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BaseRuleVisitor, isInlineElement, isBlockElement } 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 BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
7
|
+
private inlineStack: string[] = []
|
|
8
|
+
|
|
9
|
+
private isValidHTMLOpenTag(node: HTMLElementNode): boolean {
|
|
10
|
+
return !!(node.open_tag && node.open_tag.type === "AST_HTML_OPEN_TAG_NODE")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private getElementType(tagName: string): { isInline: boolean; isBlock: boolean; isUnknown: boolean } {
|
|
14
|
+
const isInline = isInlineElement(tagName)
|
|
15
|
+
const isBlock = isBlockElement(tagName)
|
|
16
|
+
const isUnknown = !isInline && !isBlock
|
|
17
|
+
|
|
18
|
+
return { isInline, isBlock, isUnknown }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private addViolationMessage(tagName: string, isBlock: boolean, openTag: HTMLOpenTagNode): void {
|
|
22
|
+
const parentInline = this.inlineStack[this.inlineStack.length - 1]
|
|
23
|
+
const elementType = isBlock ? "Block-level" : "Unknown"
|
|
24
|
+
|
|
25
|
+
this.addOffense(
|
|
26
|
+
`${elementType} element \`<${tagName}>\` cannot be placed inside inline element \`<${parentInline}>\`.`,
|
|
27
|
+
openTag.tag_name!.location,
|
|
28
|
+
"error"
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private visitInlineElement(node: HTMLElementNode, tagName: string): void {
|
|
33
|
+
this.inlineStack.push(tagName)
|
|
34
|
+
super.visitHTMLElementNode(node)
|
|
35
|
+
this.inlineStack.pop()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private visitBlockElement(node: HTMLElementNode): void {
|
|
39
|
+
const savedStack = [...this.inlineStack]
|
|
40
|
+
this.inlineStack = []
|
|
41
|
+
super.visitHTMLElementNode(node)
|
|
42
|
+
this.inlineStack = savedStack
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
46
|
+
if (!this.isValidHTMLOpenTag(node)) {
|
|
47
|
+
super.visitHTMLElementNode(node)
|
|
48
|
+
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const openTag = node.open_tag as HTMLOpenTagNode
|
|
53
|
+
const tagName = openTag.tag_name?.value.toLowerCase()
|
|
54
|
+
|
|
55
|
+
if (!tagName) {
|
|
56
|
+
super.visitHTMLElementNode(node)
|
|
57
|
+
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { isInline, isBlock, isUnknown } = this.getElementType(tagName)
|
|
62
|
+
|
|
63
|
+
if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
|
|
64
|
+
this.addViolationMessage(tagName, isBlock, openTag)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isInline) {
|
|
68
|
+
this.visitInlineElement(node, tagName)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.visitBlockElement(node)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class HTMLNoBlockInsideInlineRule implements Rule {
|
|
77
|
+
name = "html-no-block-inside-inline"
|
|
78
|
+
|
|
79
|
+
check(node: Node): LintOffense[] {
|
|
80
|
+
const visitor = new BlockInsideInlineVisitor(this.name)
|
|
81
|
+
visitor.visit(node)
|
|
82
|
+
return visitor.offenses
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BaseRuleVisitor, forEachAttribute } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, HTMLAttributeNameNode, Node } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
8
|
+
this.checkDuplicateAttributes(node)
|
|
9
|
+
super.visitHTMLOpenTagNode(node)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
13
|
+
this.checkDuplicateAttributes(node)
|
|
14
|
+
super.visitHTMLSelfCloseTagNode(node)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private checkDuplicateAttributes(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
|
|
18
|
+
const attributeNames = new Map<string, HTMLAttributeNameNode[]>()
|
|
19
|
+
|
|
20
|
+
forEachAttribute(node, (attributeNode) => {
|
|
21
|
+
if (attributeNode.name?.type !== "AST_HTML_ATTRIBUTE_NAME_NODE") return
|
|
22
|
+
|
|
23
|
+
const nameNode = attributeNode.name as HTMLAttributeNameNode
|
|
24
|
+
if (!nameNode.name) return
|
|
25
|
+
|
|
26
|
+
const attributeName = nameNode.name.value.toLowerCase() // HTML attributes are case-insensitive
|
|
27
|
+
|
|
28
|
+
if (!attributeNames.has(attributeName)) {
|
|
29
|
+
attributeNames.set(attributeName, [])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
attributeNames.get(attributeName)!.push(nameNode)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
for (const [attributeName, nameNodes] of attributeNames) {
|
|
36
|
+
if (nameNodes.length > 1) {
|
|
37
|
+
for (let i = 1; i < nameNodes.length; i++) {
|
|
38
|
+
const nameNode = nameNodes[i]
|
|
39
|
+
|
|
40
|
+
this.addOffense(
|
|
41
|
+
`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`,
|
|
42
|
+
nameNode.location,
|
|
43
|
+
"error"
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class HTMLNoDuplicateAttributesRule implements Rule {
|
|
52
|
+
name = "html-no-duplicate-attributes"
|
|
53
|
+
|
|
54
|
+
check(node: Node): LintOffense[] {
|
|
55
|
+
const visitor = new NoDuplicateAttributesVisitor(this.name)
|
|
56
|
+
visitor.visit(node)
|
|
57
|
+
return visitor.offenses
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAttributeValue, HEADING_TAGS } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { HTMLElementNode, HTMLOpenTagNode, HTMLSelfCloseTagNode, Node, LiteralNode, HTMLTextNode } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
8
|
+
this.checkHeadingElement(node)
|
|
9
|
+
super.visitHTMLElementNode(node)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
13
|
+
this.checkSelfClosingHeading(node)
|
|
14
|
+
super.visitHTMLSelfCloseTagNode(node)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private checkHeadingElement(node: HTMLElementNode): void {
|
|
18
|
+
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const openTag = node.open_tag as HTMLOpenTagNode
|
|
23
|
+
const tagName = getTagName(openTag)
|
|
24
|
+
|
|
25
|
+
if (!tagName) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isStandardHeading = HEADING_TAGS.has(tagName)
|
|
30
|
+
const isAriaHeading = this.hasHeadingRole(openTag)
|
|
31
|
+
|
|
32
|
+
if (!isStandardHeading && !isAriaHeading) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (this.isEmptyHeading(node)) {
|
|
37
|
+
const elementDescription = isStandardHeading
|
|
38
|
+
? `\`<${tagName}>\``
|
|
39
|
+
: `\`<${tagName} role="heading">\``
|
|
40
|
+
|
|
41
|
+
this.addOffense(
|
|
42
|
+
`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`,
|
|
43
|
+
node.location,
|
|
44
|
+
"error"
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private checkSelfClosingHeading(node: HTMLSelfCloseTagNode): void {
|
|
50
|
+
const tagName = getTagName(node)
|
|
51
|
+
if (!tagName) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check if it's a standard heading tag (h1-h6) or has role="heading"
|
|
56
|
+
const isStandardHeading = HEADING_TAGS.has(tagName)
|
|
57
|
+
const isAriaHeading = this.hasHeadingRole(node)
|
|
58
|
+
|
|
59
|
+
if (!isStandardHeading && !isAriaHeading) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Self-closing headings are always empty
|
|
64
|
+
const elementDescription = isStandardHeading
|
|
65
|
+
? `\`<${tagName}>\``
|
|
66
|
+
: `\`<${tagName} role="heading">\``
|
|
67
|
+
|
|
68
|
+
this.addOffense(
|
|
69
|
+
`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`,
|
|
70
|
+
node.tag_name!.location,
|
|
71
|
+
"error"
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private isEmptyHeading(node: HTMLElementNode): boolean {
|
|
76
|
+
if (!node.body || node.body.length === 0) {
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check if all content is just whitespace or inaccessible
|
|
81
|
+
let hasAccessibleContent = false
|
|
82
|
+
|
|
83
|
+
for (const child of node.body) {
|
|
84
|
+
if (child.type === "AST_LITERAL_NODE") {
|
|
85
|
+
const literalNode = child as LiteralNode
|
|
86
|
+
|
|
87
|
+
if (literalNode.content.trim().length > 0) {
|
|
88
|
+
hasAccessibleContent = true
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
} else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
92
|
+
const textNode = child as HTMLTextNode
|
|
93
|
+
|
|
94
|
+
if (textNode.content.trim().length > 0) {
|
|
95
|
+
hasAccessibleContent = true
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
} else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
99
|
+
const elementNode = child as HTMLElementNode
|
|
100
|
+
|
|
101
|
+
// Check if this element is accessible (not aria-hidden="true")
|
|
102
|
+
if (this.isElementAccessible(elementNode)) {
|
|
103
|
+
hasAccessibleContent = true
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
// If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
|
|
108
|
+
hasAccessibleContent = true
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return !hasAccessibleContent
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private hasHeadingRole(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): boolean {
|
|
117
|
+
const attributes = getAttributes(node)
|
|
118
|
+
const roleAttribute = findAttributeByName(attributes, "role")
|
|
119
|
+
|
|
120
|
+
if (!roleAttribute) {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const roleValue = getAttributeValue(roleAttribute)
|
|
125
|
+
return roleValue === "heading"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private isElementAccessible(node: HTMLElementNode): boolean {
|
|
129
|
+
// Check if the element has aria-hidden="true"
|
|
130
|
+
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const openTag = node.open_tag as HTMLOpenTagNode
|
|
135
|
+
const attributes = getAttributes(openTag)
|
|
136
|
+
const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden")
|
|
137
|
+
|
|
138
|
+
if (ariaHiddenAttribute) {
|
|
139
|
+
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute)
|
|
140
|
+
|
|
141
|
+
if (ariaHiddenValue === "true") {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Recursively check if the element has any accessible content
|
|
147
|
+
if (!node.body || node.body.length === 0) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const child of node.body) {
|
|
152
|
+
if (child.type === "AST_LITERAL_NODE") {
|
|
153
|
+
const literalNode = child as LiteralNode
|
|
154
|
+
if (literalNode.content.trim().length > 0) {
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
} else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
158
|
+
const textNode = child as HTMLTextNode
|
|
159
|
+
if (textNode.content.trim().length > 0) {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
} else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
163
|
+
const elementNode = child as HTMLElementNode
|
|
164
|
+
if (this.isElementAccessible(elementNode)) {
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
// If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class HTMLNoEmptyHeadingsRule implements Rule {
|
|
178
|
+
name = "html-no-empty-headings"
|
|
179
|
+
|
|
180
|
+
check(node: Node): LintOffense[] {
|
|
181
|
+
const visitor = new NoEmptyHeadingsVisitor(this.name)
|
|
182
|
+
visitor.visit(node)
|
|
183
|
+
return visitor.offenses
|
|
184
|
+
}
|
|
185
|
+
}
|