@herb-tools/linter 0.5.0 → 0.6.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.
Files changed (139) hide show
  1. package/dist/herb-lint.js +5131 -1647
  2. package/dist/herb-lint.js.map +1 -1
  3. package/dist/index.cjs +662 -145
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +654 -147
  6. package/dist/index.js.map +1 -1
  7. package/dist/package.json +4 -4
  8. package/dist/src/cli/argument-parser.js +0 -4
  9. package/dist/src/cli/argument-parser.js.map +1 -1
  10. package/dist/src/default-rules.js +20 -0
  11. package/dist/src/default-rules.js.map +1 -1
  12. package/dist/src/linter.js +29 -4
  13. package/dist/src/linter.js.map +1 -1
  14. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
  15. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
  16. package/dist/src/rules/erb-prefer-image-tag-helper.js +0 -4
  17. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  18. package/dist/src/rules/html-aria-attribute-must-be-valid.js +11 -10
  19. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  20. package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
  21. package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
  22. package/dist/src/rules/html-aria-level-must-be-valid.js +26 -4
  23. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
  24. package/dist/src/rules/html-aria-role-heading-requires-level.js +7 -13
  25. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  26. package/dist/src/rules/html-aria-role-must-be-valid.js +3 -3
  27. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  28. package/dist/src/rules/html-attribute-double-quotes.js +14 -4
  29. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  30. package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
  31. package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
  32. package/dist/src/rules/html-attribute-values-require-quotes.js +19 -8
  33. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  34. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
  35. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
  36. package/dist/src/rules/html-boolean-attributes-no-value.js +9 -2
  37. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  38. package/dist/src/rules/html-iframe-has-title.js +39 -0
  39. package/dist/src/rules/html-iframe-has-title.js.map +1 -0
  40. package/dist/src/rules/html-img-require-alt.js +0 -4
  41. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  42. package/dist/src/rules/html-navigation-has-label.js +43 -0
  43. package/dist/src/rules/html-navigation-has-label.js.map +1 -0
  44. package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
  45. package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
  46. package/dist/src/rules/html-no-duplicate-attributes.js +22 -25
  47. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  48. package/dist/src/rules/html-no-duplicate-ids.js +2 -2
  49. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  50. package/dist/src/rules/html-no-empty-headings.js +0 -21
  51. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  52. package/dist/src/rules/html-no-positive-tab-index.js +21 -0
  53. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
  54. package/dist/src/rules/html-no-self-closing.js +22 -0
  55. package/dist/src/rules/html-no-self-closing.js.map +1 -0
  56. package/dist/src/rules/html-no-title-attribute.js +27 -0
  57. package/dist/src/rules/html-no-title-attribute.js.map +1 -0
  58. package/dist/src/rules/html-tag-name-lowercase.js +35 -23
  59. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  60. package/dist/src/rules/index.js +10 -0
  61. package/dist/src/rules/index.js.map +1 -1
  62. package/dist/src/rules/rule-utils.js +176 -22
  63. package/dist/src/rules/rule-utils.js.map +1 -1
  64. package/dist/src/rules/svg-tag-name-capitalization.js +0 -8
  65. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/dist/types/cli/index.d.ts +4 -0
  69. package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  70. package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  71. package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
  72. package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  73. package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
  74. package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
  75. package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  76. package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
  77. package/dist/types/rules/html-no-self-closing.d.ts +7 -0
  78. package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
  79. package/dist/types/rules/html-tag-name-lowercase.d.ts +2 -1
  80. package/dist/types/rules/index.d.ts +10 -0
  81. package/dist/types/rules/rule-utils.d.ts +107 -13
  82. package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  83. package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  84. package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
  85. package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  86. package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
  87. package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
  88. package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  89. package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
  90. package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
  91. package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
  92. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +2 -1
  93. package/dist/types/src/rules/index.d.ts +10 -0
  94. package/dist/types/src/rules/rule-utils.d.ts +107 -13
  95. package/dist/types/src/types.d.ts +24 -0
  96. package/dist/types/types.d.ts +24 -0
  97. package/docs/rules/README.md +12 -2
  98. package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
  99. package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
  100. package/docs/rules/html-attribute-equals-spacing.md +35 -0
  101. package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
  102. package/docs/rules/html-iframe-has-title.md +43 -0
  103. package/docs/rules/html-navigation-has-label.md +61 -0
  104. package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
  105. package/docs/rules/html-no-positive-tab-index.md +55 -0
  106. package/docs/rules/html-no-self-closing.md +65 -0
  107. package/docs/rules/html-no-title-attribute.md +69 -0
  108. package/docs/rules/html-tag-name-lowercase.md +16 -3
  109. package/package.json +4 -4
  110. package/src/cli/argument-parser.ts +0 -5
  111. package/src/default-rules.ts +20 -0
  112. package/src/linter.ts +30 -4
  113. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
  114. package/src/rules/erb-prefer-image-tag-helper.ts +2 -7
  115. package/src/rules/html-aria-attribute-must-be-valid.ts +28 -32
  116. package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
  117. package/src/rules/html-aria-level-must-be-valid.ts +38 -5
  118. package/src/rules/html-aria-role-heading-requires-level.ts +16 -28
  119. package/src/rules/html-aria-role-must-be-valid.ts +5 -5
  120. package/src/rules/html-attribute-double-quotes.ts +21 -6
  121. package/src/rules/html-attribute-equals-spacing.ts +41 -0
  122. package/src/rules/html-attribute-values-require-quotes.ts +29 -9
  123. package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
  124. package/src/rules/html-boolean-attributes-no-value.ts +17 -4
  125. package/src/rules/html-iframe-has-title.ts +62 -0
  126. package/src/rules/html-img-require-alt.ts +2 -7
  127. package/src/rules/html-navigation-has-label.ts +64 -0
  128. package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
  129. package/src/rules/html-no-duplicate-attributes.ts +28 -28
  130. package/src/rules/html-no-duplicate-ids.ts +4 -3
  131. package/src/rules/html-no-empty-headings.ts +2 -31
  132. package/src/rules/html-no-positive-tab-index.ts +33 -0
  133. package/src/rules/html-no-self-closing.ts +36 -0
  134. package/src/rules/html-no-title-attribute.ts +42 -0
  135. package/src/rules/html-tag-name-lowercase.ts +42 -29
  136. package/src/rules/index.ts +10 -0
  137. package/src/rules/rule-utils.ts +260 -39
  138. package/src/rules/svg-tag-name-capitalization.ts +2 -9
  139. package/src/types.ts +27 -0
@@ -1,36 +1,22 @@
1
- import { AttributeVisitorMixin, getAttributeName, getAttributes } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, getAttributeName, getAttributes, StaticAttributeStaticValueParams } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { ParseResult, HTMLAttributeNode, HTMLOpenTagNode, HTMLSelfCloseTagNode } from "@herb-tools/core"
5
+ import type { ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode, parentNode }: StaticAttributeStaticValueParams): void {
9
+ if (!(attributeName === "role" && attributeValue === "heading")) return
10
+
11
+ const ariaLevelAttributes = getAttributes(parentNode).find(attribute => getAttributeName(attribute) === "aria-level")
8
12
 
9
- // We want to check 2 attributes here:
10
- // 1. role="heading"
11
- // 2. aria-level (which must be present if role="heading")
12
- checkAttribute(
13
- attributeName: string,
14
- attributeValue: string | null,
15
- attributeNode: HTMLAttributeNode,
16
- parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode
17
- ): void {
18
-
19
- if (!(attributeName === "role" && attributeValue === "heading")) {
20
- return
21
- }
22
-
23
- const allAttributes = getAttributes(parentNode)
24
-
25
- // If we have a role="heading", we must check for aria-level
26
- const ariaLevelAttr = allAttributes.find(attr => getAttributeName(attr) === "aria-level")
27
- if (!ariaLevelAttr) {
28
- this.addOffense(
29
- `Element with \`role="heading"\` must have an \`aria-level\` attribute.`,
30
- attributeNode.location,
31
- "error"
32
- )
33
- }
13
+ if (ariaLevelAttributes) return
14
+
15
+ this.addOffense(
16
+ `Element with \`role="heading"\` must have an \`aria-level\` attribute.`,
17
+ attributeNode.location,
18
+ "error"
19
+ )
34
20
  }
35
21
  }
36
22
 
@@ -39,7 +25,9 @@ export class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
39
25
 
40
26
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
41
27
  const visitor = new AriaRoleHeadingRequiresLevel(this.name, context)
28
+
42
29
  visitor.visit(result.value)
30
+
43
31
  return visitor.offenses
44
32
  }
45
33
  }
@@ -1,13 +1,13 @@
1
- import { AttributeVisitorMixin, VALID_ARIA_ROLES } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, VALID_ARIA_ROLES, StaticAttributeStaticValueParams } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
5
+ import type { ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class AriaRoleMustBeValid extends AttributeVisitorMixin {
8
- checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: HTMLAttributeNode,): void {
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
9
9
  if (attributeName !== "role") return
10
- if (attributeValue === null) return
10
+ if (!attributeValue) return
11
11
  if (VALID_ARIA_ROLES.has(attributeValue)) return
12
12
 
13
13
  this.addOffense(
@@ -1,17 +1,30 @@
1
- import { AttributeVisitorMixin, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
3
+ import { filterLiteralNodes } from "@herb-tools/core"
4
+
4
5
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
6
+ import type { ParseResult } from "@herb-tools/core"
6
7
 
7
8
  class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
8
- protected checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
9
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams) {
9
10
  if (!hasAttributeValue(attributeNode)) return
10
11
  if (getAttributeValueQuoteType(attributeNode) !== "single") return
11
- if (attributeValue?.includes('"')) return // Single quotes acceptable when value contains double quotes
12
+ if (attributeValue?.includes('"')) return
12
13
 
13
14
  this.addOffense(
14
- `Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`,
15
+ `Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${attributeValue}"\`.`,
16
+ attributeNode.value!.location,
17
+ "warning"
18
+ )
19
+ }
20
+
21
+ protected checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode, combinedValue }: StaticAttributeDynamicValueParams) {
22
+ if (!hasAttributeValue(attributeNode)) return
23
+ if (getAttributeValueQuoteType(attributeNode) !== "single") return
24
+ if (filterLiteralNodes(valueNodes).some(node => node.content?.includes('"'))) return
25
+
26
+ this.addOffense(
27
+ `Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${combinedValue}"\`.`,
15
28
  attributeNode.value!.location,
16
29
  "warning"
17
30
  )
@@ -23,7 +36,9 @@ export class HTMLAttributeDoubleQuotesRule extends ParserRule {
23
36
 
24
37
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
25
38
  const visitor = new AttributeDoubleQuotesVisitor(this.name, context)
39
+
26
40
  visitor.visit(result.value)
41
+
27
42
  return visitor.offenses
28
43
  }
29
44
  }
@@ -0,0 +1,41 @@
1
+ import { BaseRuleVisitor } from "./rule-utils.js"
2
+ import { ParserRule } from "../types.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
6
+
7
+ class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
8
+ visitHTMLAttributeNode(attribute: HTMLAttributeNode): void {
9
+ if (!attribute.equals || !attribute.name || !attribute.value) {
10
+ return
11
+ }
12
+
13
+ if (attribute.equals.value.startsWith(" ")) {
14
+ this.addOffense(
15
+ "Remove whitespace before `=` in HTML attribute",
16
+ attribute.equals.location,
17
+ "error"
18
+ )
19
+ }
20
+
21
+ if (attribute.equals.value.endsWith(" ")) {
22
+ this.addOffense(
23
+ "Remove whitespace after `=` in HTML attribute",
24
+ attribute.equals.location,
25
+ "error"
26
+ )
27
+ }
28
+ }
29
+ }
30
+
31
+ export class HTMLAttributeEqualsSpacingRule extends ParserRule {
32
+ name = "html-attribute-equals-spacing"
33
+
34
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
35
+ const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context)
36
+
37
+ visitor.visit(result.value)
38
+
39
+ return visitor.offenses
40
+ }
41
+ }
@@ -1,23 +1,41 @@
1
- import { AttributeVisitorMixin } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
5
  import type { HTMLAttributeNode, HTMLAttributeValueNode, ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
8
- protected checkAttribute(attributeName: string, _attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
9
- if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE") return
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
9
+ if (this.hasAttributeValue(attributeNode)) return
10
+ if (this.isQuoted(attributeNode)) return
10
11
 
11
- const valueNode = attributeNode.value as HTMLAttributeValueNode
12
- if (valueNode.quoted) return
12
+ this.addOffense(
13
+ `Attribute value should be quoted: \`${attributeName}="${attributeValue}"\`. Always wrap attribute values in quotes.`,
14
+ attributeNode.value!.location,
15
+ "error"
16
+ )
17
+ }
18
+
19
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }: StaticAttributeDynamicValueParams): void {
20
+ if (this.hasAttributeValue(attributeNode)) return
21
+ if (this.isQuoted(attributeNode)) return
13
22
 
14
23
  this.addOffense(
15
- // TODO: print actual attribute value in message
16
- `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`,
17
- valueNode.location,
24
+ `Attribute value should be quoted: \`${attributeName}="${combinedValue}"\`. Always wrap attribute values in quotes.`,
25
+ attributeNode.value!.location,
18
26
  "error"
19
27
  )
20
28
  }
29
+
30
+ private hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
31
+ return attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE"
32
+ }
33
+
34
+ private isQuoted(attributeNode: HTMLAttributeNode): boolean {
35
+ const valueNode = attributeNode.value as HTMLAttributeValueNode
36
+
37
+ return valueNode.quoted
38
+ }
21
39
  }
22
40
 
23
41
  export class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
@@ -25,7 +43,9 @@ export class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
25
43
 
26
44
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
27
45
  const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context)
46
+
28
47
  visitor.visit(result.value)
48
+
29
49
  return visitor.offenses
30
50
  }
31
51
  }
@@ -0,0 +1,66 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute, getAttributes, findAttributeByName } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, HTMLAttributeValueNode, ParseResult, Node } from "@herb-tools/core"
6
+
7
+ const ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT = new Set([
8
+ "button", "fieldset", "input", "optgroup", "option", "select", "textarea"
9
+ ])
10
+
11
+ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
12
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
13
+ this.checkElement(node)
14
+ super.visitHTMLOpenTagNode(node)
15
+ }
16
+
17
+ private checkElement(node: HTMLOpenTagNode): void {
18
+ const tagName = getTagName(node)
19
+
20
+ if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
21
+ return
22
+ }
23
+
24
+ const hasDisabled = hasAttribute(node, "disabled")
25
+ const hasAriaDisabled = hasAttribute(node, "aria-disabled")
26
+
27
+ if ((hasDisabled && this.hasERBContent(node, "disabled")) || (hasAriaDisabled && this.hasERBContent(node, "aria-disabled"))) {
28
+ return
29
+ }
30
+
31
+ if (hasDisabled && hasAriaDisabled) {
32
+ this.addOffense(
33
+ "aria-disabled may be used in place of native HTML disabled to allow tab-focus on an otherwise ignored element. Setting both attributes is contradictory and confusing. Choose either disabled or aria-disabled, not both.",
34
+ node.tag_name!.location,
35
+ "error"
36
+ )
37
+ }
38
+ }
39
+
40
+ private hasERBContent(node: HTMLOpenTagNode, attributeName: string): boolean {
41
+ const attributes = getAttributes(node)
42
+
43
+ const attribute = findAttributeByName(attributes, attributeName)
44
+ if (!attribute) return false
45
+
46
+ const valueNode = attribute.value
47
+ if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE") return false
48
+
49
+ const htmlValueNode = valueNode as HTMLAttributeValueNode
50
+ if (!htmlValueNode.children) return false
51
+
52
+ return htmlValueNode.children.some((child: Node) => child.type === "AST_ERB_CONTENT_NODE")
53
+ }
54
+ }
55
+
56
+ export class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
57
+ name = "html-avoid-both-disabled-and-aria-disabled"
58
+
59
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
60
+ const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context)
61
+
62
+ visitor.visit(result.value)
63
+
64
+ return visitor.offenses
65
+ }
66
+ }
@@ -1,11 +1,11 @@
1
- import { AttributeVisitorMixin, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLAttributeNode, ParseResult } from "@herb-tools/core"
5
+ import type { ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
8
- protected checkAttribute(attributeName: string, _attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams) {
9
9
  if (!isBooleanAttribute(attributeName)) return
10
10
  if (!hasAttributeValue(attributeNode)) return
11
11
 
@@ -15,6 +15,17 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
15
15
  "error"
16
16
  )
17
17
  }
18
+
19
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }: StaticAttributeDynamicValueParams) {
20
+ if (!isBooleanAttribute(attributeName)) return
21
+ if (!hasAttributeValue(attributeNode)) return
22
+
23
+ this.addOffense(
24
+ `Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`,
25
+ attributeNode.value!.location,
26
+ "error"
27
+ )
28
+ }
18
29
  }
19
30
 
20
31
  export class HTMLBooleanAttributesNoValueRule extends ParserRule {
@@ -22,7 +33,9 @@ export class HTMLBooleanAttributesNoValueRule extends ParserRule {
22
33
 
23
34
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
24
35
  const visitor = new BooleanAttributesNoValueVisitor(this.name, context)
36
+
25
37
  visitor.visit(result.value)
38
+
26
39
  return visitor.offenses
27
40
  }
28
41
  }
@@ -0,0 +1,62 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, getAttribute, getAttributeValue } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ class IframeHasTitleVisitor extends BaseRuleVisitor {
8
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
+ this.checkIframeElement(node)
10
+ super.visitHTMLOpenTagNode(node)
11
+ }
12
+
13
+ private checkIframeElement(node: HTMLOpenTagNode): void {
14
+ const tagName = getTagName(node)
15
+
16
+ if (tagName !== "iframe") {
17
+ return
18
+ }
19
+
20
+ const ariaHiddenAttribute = getAttribute(node, "aria-hidden")
21
+ if (ariaHiddenAttribute) {
22
+ const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute)
23
+ if (ariaHiddenValue === "true") {
24
+ return
25
+ }
26
+ }
27
+
28
+ const attribute = getAttribute(node, "title")
29
+
30
+ if (!attribute) {
31
+ this.addOffense(
32
+ "`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.",
33
+ node.location,
34
+ "error"
35
+ )
36
+
37
+ return
38
+ }
39
+
40
+ const value = getAttributeValue(attribute)
41
+
42
+ if (!value || value.trim() === "") {
43
+ this.addOffense(
44
+ "`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.",
45
+ node.location,
46
+ "error"
47
+ )
48
+ }
49
+ }
50
+ }
51
+
52
+ export class HTMLIframeHasTitleRule extends ParserRule {
53
+ name = "html-iframe-has-title"
54
+
55
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
56
+ const visitor = new IframeHasTitleVisitor(this.name, context)
57
+
58
+ visitor.visit(result.value)
59
+
60
+ return visitor.offenses
61
+ }
62
+ }
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
2
2
 
3
3
  import { ParserRule } from "../types.js"
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, ParseResult } from "@herb-tools/core"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class ImgRequireAltVisitor extends BaseRuleVisitor {
8
8
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
@@ -10,12 +10,7 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
10
10
  super.visitHTMLOpenTagNode(node)
11
11
  }
12
12
 
13
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
14
- this.checkImgTag(node)
15
- super.visitHTMLSelfCloseTagNode(node)
16
- }
17
-
18
- private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
13
+ private checkImgTag(node: HTMLOpenTagNode): void {
19
14
  const tagName = getTagName(node)
20
15
 
21
16
  if (tagName !== "img") {
@@ -0,0 +1,64 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute, getAttributeValue, findAttributeByName, getAttributes } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ class NavigationHasLabelVisitor extends BaseRuleVisitor {
8
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
+ this.checkNavigationElement(node)
10
+ super.visitHTMLOpenTagNode(node)
11
+ }
12
+
13
+ private checkNavigationElement(node: HTMLOpenTagNode): void {
14
+ const tagName = getTagName(node)
15
+ const isNavElement = tagName === "nav"
16
+ const hasNavigationRole = this.hasRoleNavigation(node)
17
+
18
+ if (!isNavElement && !hasNavigationRole) {
19
+ return
20
+ }
21
+
22
+ const hasAriaLabel = hasAttribute(node, "aria-label")
23
+ const hasAriaLabelledby = hasAttribute(node, "aria-labelledby")
24
+
25
+ if (!hasAriaLabel && !hasAriaLabelledby) {
26
+ let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`
27
+
28
+ if (hasNavigationRole && !isNavElement) {
29
+ message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`
30
+ }
31
+
32
+ this.addOffense(
33
+ message,
34
+ node.tag_name!.location,
35
+ "error"
36
+ )
37
+ }
38
+ }
39
+
40
+ private hasRoleNavigation(node: HTMLOpenTagNode): boolean {
41
+ const attributes = getAttributes(node)
42
+ const roleAttribute = findAttributeByName(attributes, "role")
43
+
44
+ if (!roleAttribute) {
45
+ return false
46
+ }
47
+
48
+ const roleValue = getAttributeValue(roleAttribute)
49
+
50
+ return roleValue === "navigation"
51
+ }
52
+ }
53
+
54
+ export class HTMLNavigationHasLabelRule extends ParserRule {
55
+ name = "html-navigation-has-label"
56
+
57
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
58
+ const visitor = new NavigationHasLabelVisitor(this.name, context)
59
+
60
+ visitor.visit(result.value)
61
+
62
+ return visitor.offenses
63
+ }
64
+ }
@@ -0,0 +1,90 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute, getAttributeValue, findAttributeByName, getAttributes } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ const INTERACTIVE_ELEMENTS = new Set([
8
+ "button", "summary", "input", "select", "textarea", "a"
9
+ ])
10
+
11
+ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
12
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
13
+ this.checkAriaHiddenOnFocusable(node)
14
+ super.visitHTMLOpenTagNode(node)
15
+ }
16
+
17
+ private checkAriaHiddenOnFocusable(node: HTMLOpenTagNode): void {
18
+ if (!this.hasAriaHiddenTrue(node)) return
19
+
20
+ if (this.isFocusable(node)) {
21
+ this.addOffense(
22
+ `Elements that are focusable should not have \`aria-hidden="true"\` because it will cause confusion for assistive technology users.`,
23
+ node.tag_name!.location,
24
+ "error"
25
+ )
26
+ }
27
+ }
28
+
29
+ private hasAriaHiddenTrue(node: HTMLOpenTagNode): boolean {
30
+ const attributes = getAttributes(node)
31
+ const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden")
32
+
33
+ if (!ariaHiddenAttr) return false
34
+
35
+ const value = getAttributeValue(ariaHiddenAttr)
36
+
37
+ return value === "true"
38
+ }
39
+
40
+ private isFocusable(node: HTMLOpenTagNode): boolean {
41
+ const tagName = getTagName(node)
42
+ if (!tagName) return false
43
+
44
+ const tabIndexValue = this.getTabIndexValue(node)
45
+
46
+ if (tagName === "a") {
47
+ const hasHref = hasAttribute(node, "href")
48
+
49
+ if (!hasHref) {
50
+ return tabIndexValue !== null && tabIndexValue >= 0
51
+ }
52
+
53
+ return tabIndexValue === null || tabIndexValue >= 0
54
+ }
55
+
56
+ if (INTERACTIVE_ELEMENTS.has(tagName)) {
57
+ // Interactive elements are focusable unless tabindex is negative
58
+ return tabIndexValue === null || tabIndexValue >= 0
59
+ } else {
60
+ // Non-interactive elements are focusable only if tabindex >= 0
61
+ return tabIndexValue !== null && tabIndexValue >= 0
62
+ }
63
+ }
64
+
65
+ private getTabIndexValue(node: HTMLOpenTagNode): number | null {
66
+ const attributes = getAttributes(node)
67
+ const tabIndexAttribute = findAttributeByName(attributes, "tabindex")
68
+
69
+ if (!tabIndexAttribute) return null
70
+
71
+ const value = getAttributeValue(tabIndexAttribute)
72
+ if (!value) return null
73
+
74
+ const parsed = parseInt(value, 10)
75
+
76
+ return isNaN(parsed) ? null : parsed
77
+ }
78
+ }
79
+
80
+ export class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
81
+ name = "html-no-aria-hidden-on-focusable"
82
+
83
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
84
+ const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context)
85
+
86
+ visitor.visit(result.value)
87
+
88
+ return visitor.offenses
89
+ }
90
+ }
@@ -1,46 +1,44 @@
1
- import { BaseRuleVisitor, forEachAttribute } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, HTMLAttributeNameNode, ParseResult } from "@herb-tools/core"
5
+ import type { HTMLOpenTagNode, HTMLAttributeNode, ParseResult } from "@herb-tools/core"
6
+
7
+ class NoDuplicateAttributesVisitor extends AttributeVisitorMixin {
8
+ private attributeNames = new Map<string, HTMLAttributeNode[]>()
6
9
 
7
- class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
8
10
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
- this.checkDuplicateAttributes(node)
11
+ this.attributeNames.clear()
10
12
  super.visitHTMLOpenTagNode(node)
13
+ this.reportDuplicates()
11
14
  }
12
15
 
13
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
14
- this.checkDuplicateAttributes(node)
15
- super.visitHTMLSelfCloseTagNode(node)
16
- }
17
16
 
18
- private checkDuplicateAttributes(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
19
- const attributeNames = new Map<string, HTMLAttributeNameNode[]>()
20
-
21
- forEachAttribute(node, (attributeNode) => {
22
- if (attributeNode.name?.type !== "AST_HTML_ATTRIBUTE_NAME_NODE") return
23
-
24
- const nameNode = attributeNode.name as HTMLAttributeNameNode
25
- if (!nameNode.name) return
17
+ protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams): void {
18
+ this.trackAttributeName(attributeName, attributeNode)
19
+ }
26
20
 
27
- const attributeName = nameNode.name.value.toLowerCase() // HTML attributes are case-insensitive
21
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode }: StaticAttributeDynamicValueParams): void {
22
+ this.trackAttributeName(attributeName, attributeNode)
23
+ }
28
24
 
29
- if (!attributeNames.has(attributeName)) {
30
- attributeNames.set(attributeName, [])
31
- }
25
+ private trackAttributeName(attributeName: string, attributeNode: HTMLAttributeNode): void {
26
+ if (!this.attributeNames.has(attributeName)) {
27
+ this.attributeNames.set(attributeName, [])
28
+ }
32
29
 
33
- attributeNames.get(attributeName)!.push(nameNode)
34
- })
30
+ this.attributeNames.get(attributeName)!.push(attributeNode)
31
+ }
35
32
 
36
- for (const [attributeName, nameNodes] of attributeNames) {
37
- if (nameNodes.length > 1) {
38
- for (let i = 1; i < nameNodes.length; i++) {
39
- const nameNode = nameNodes[i]
33
+ private reportDuplicates(): void {
34
+ for (const [attributeName, attributeNodes] of this.attributeNames) {
35
+ if (attributeNodes.length > 1) {
36
+ for (let i = 1; i < attributeNodes.length; i++) {
37
+ const attributeNode = attributeNodes[i]
40
38
 
41
39
  this.addOffense(
42
40
  `Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`,
43
- nameNode.location,
41
+ attributeNode.name!.location,
44
42
  "error"
45
43
  )
46
44
  }
@@ -54,7 +52,9 @@ export class HTMLNoDuplicateAttributesRule extends ParserRule {
54
52
 
55
53
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
56
54
  const visitor = new NoDuplicateAttributesVisitor(this.name, context)
55
+
57
56
  visitor.visit(result.value)
57
+
58
58
  return visitor.offenses
59
59
  }
60
60
  }