@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.
Files changed (175) hide show
  1. package/README.md +34 -0
  2. package/bin/herb-lint +3 -0
  3. package/dist/herb-lint.js +16505 -0
  4. package/dist/herb-lint.js.map +1 -0
  5. package/dist/index.cjs +834 -0
  6. package/dist/index.cjs.map +1 -0
  7. package/dist/index.js +820 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/package.json +49 -0
  10. package/dist/src/cli/argument-parser.js +96 -0
  11. package/dist/src/cli/argument-parser.js.map +1 -0
  12. package/dist/src/cli/file-processor.js +58 -0
  13. package/dist/src/cli/file-processor.js.map +1 -0
  14. package/dist/src/cli/formatters/base-formatter.js +3 -0
  15. package/dist/src/cli/formatters/base-formatter.js.map +1 -0
  16. package/dist/src/cli/formatters/detailed-formatter.js +62 -0
  17. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -0
  18. package/dist/src/cli/formatters/index.js +4 -0
  19. package/dist/src/cli/formatters/index.js.map +1 -0
  20. package/dist/src/cli/formatters/simple-formatter.js +31 -0
  21. package/dist/src/cli/formatters/simple-formatter.js.map +1 -0
  22. package/dist/src/cli/index.js +5 -0
  23. package/dist/src/cli/index.js.map +1 -0
  24. package/dist/src/cli/summary-reporter.js +96 -0
  25. package/dist/src/cli/summary-reporter.js.map +1 -0
  26. package/dist/src/cli.js +50 -0
  27. package/dist/src/cli.js.map +1 -0
  28. package/dist/src/default-rules.js +31 -0
  29. package/dist/src/default-rules.js.map +1 -0
  30. package/dist/src/herb-lint.js +5 -0
  31. package/dist/src/herb-lint.js.map +1 -0
  32. package/dist/src/index.js +4 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/linter.js +39 -0
  35. package/dist/src/linter.js.map +1 -0
  36. package/dist/src/rules/erb-no-empty-tags.js +23 -0
  37. package/dist/src/rules/erb-no-empty-tags.js.map +1 -0
  38. package/dist/src/rules/erb-no-output-control-flow.js +47 -0
  39. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -0
  40. package/dist/src/rules/erb-require-whitespace-inside-tags.js +43 -0
  41. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -0
  42. package/dist/src/rules/html-anchor-require-href.js +25 -0
  43. package/dist/src/rules/html-anchor-require-href.js.map +1 -0
  44. package/dist/src/rules/html-aria-role-heading-requires-level.js +26 -0
  45. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -0
  46. package/dist/src/rules/html-attribute-double-quotes.js +21 -0
  47. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -0
  48. package/dist/src/rules/html-attribute-values-require-quotes.js +22 -0
  49. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -0
  50. package/dist/src/rules/html-boolean-attributes-no-value.js +19 -0
  51. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -0
  52. package/dist/src/rules/html-img-require-alt.js +29 -0
  53. package/dist/src/rules/html-img-require-alt.js.map +1 -0
  54. package/dist/src/rules/html-no-block-inside-inline.js +59 -0
  55. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -0
  56. package/dist/src/rules/html-no-duplicate-attributes.js +43 -0
  57. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -0
  58. package/dist/src/rules/html-no-empty-headings.js +148 -0
  59. package/dist/src/rules/html-no-empty-headings.js.map +1 -0
  60. package/dist/src/rules/html-no-nested-links.js +45 -0
  61. package/dist/src/rules/html-no-nested-links.js.map +1 -0
  62. package/dist/src/rules/html-tag-name-lowercase.js +39 -0
  63. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -0
  64. package/dist/src/rules/index.js +13 -0
  65. package/dist/src/rules/index.js.map +1 -0
  66. package/dist/src/rules/rule-utils.js +198 -0
  67. package/dist/src/rules/rule-utils.js.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/tsconfig.tsbuildinfo +1 -0
  71. package/dist/types/cli/argument-parser.d.ts +14 -0
  72. package/dist/types/cli/file-processor.d.ts +21 -0
  73. package/dist/types/cli/formatters/base-formatter.d.ts +6 -0
  74. package/dist/types/cli/formatters/detailed-formatter.d.ts +13 -0
  75. package/dist/types/cli/formatters/index.d.ts +3 -0
  76. package/dist/types/cli/formatters/simple-formatter.d.ts +7 -0
  77. package/dist/types/cli/summary-reporter.d.ts +22 -0
  78. package/dist/types/cli.d.ts +6 -0
  79. package/dist/types/default-rules.d.ts +2 -0
  80. package/dist/types/herb-lint.d.ts +2 -0
  81. package/dist/types/index.d.ts +3 -0
  82. package/dist/types/linter.d.ts +18 -0
  83. package/dist/types/rules/erb-no-empty-tags.d.ts +6 -0
  84. package/dist/types/rules/erb-no-output-control-flow.d.ts +6 -0
  85. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
  86. package/dist/types/rules/html-anchor-require-href.d.ts +6 -0
  87. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +6 -0
  88. package/dist/types/rules/html-attribute-double-quotes.d.ts +6 -0
  89. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +6 -0
  90. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +6 -0
  91. package/dist/types/rules/html-img-require-alt.d.ts +6 -0
  92. package/dist/types/rules/html-no-block-inside-inline.d.ts +6 -0
  93. package/dist/types/rules/html-no-duplicate-attributes.d.ts +6 -0
  94. package/dist/types/rules/html-no-empty-headings.d.ts +6 -0
  95. package/dist/types/rules/html-no-nested-links.d.ts +6 -0
  96. package/dist/types/rules/html-tag-name-lowercase.d.ts +6 -0
  97. package/dist/types/rules/index.d.ts +12 -0
  98. package/dist/types/rules/rule-utils.d.ts +89 -0
  99. package/dist/types/src/cli/argument-parser.d.ts +14 -0
  100. package/dist/types/src/cli/file-processor.d.ts +21 -0
  101. package/dist/types/src/cli/formatters/base-formatter.d.ts +6 -0
  102. package/dist/types/src/cli/formatters/detailed-formatter.d.ts +13 -0
  103. package/dist/types/src/cli/formatters/index.d.ts +3 -0
  104. package/dist/types/src/cli/formatters/simple-formatter.d.ts +7 -0
  105. package/dist/types/src/cli/index.d.ts +4 -0
  106. package/dist/types/src/cli/summary-reporter.d.ts +22 -0
  107. package/dist/types/src/cli.d.ts +6 -0
  108. package/dist/types/src/default-rules.d.ts +2 -0
  109. package/dist/types/src/herb-lint.d.ts +2 -0
  110. package/dist/types/src/index.d.ts +3 -0
  111. package/dist/types/src/linter.d.ts +18 -0
  112. package/dist/types/src/rules/erb-no-empty-tags.d.ts +6 -0
  113. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +6 -0
  114. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
  115. package/dist/types/src/rules/html-anchor-require-href.d.ts +6 -0
  116. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +6 -0
  117. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +6 -0
  118. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +6 -0
  119. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +6 -0
  120. package/dist/types/src/rules/html-img-require-alt.d.ts +6 -0
  121. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +6 -0
  122. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +6 -0
  123. package/dist/types/src/rules/html-no-empty-headings.d.ts +6 -0
  124. package/dist/types/src/rules/html-no-nested-links.d.ts +6 -0
  125. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +6 -0
  126. package/dist/types/src/rules/index.d.ts +12 -0
  127. package/dist/types/src/rules/rule-utils.d.ts +89 -0
  128. package/dist/types/src/types.d.ts +26 -0
  129. package/dist/types/types.d.ts +26 -0
  130. package/docs/rules/README.md +39 -0
  131. package/docs/rules/erb-no-empty-tags.md +38 -0
  132. package/docs/rules/erb-no-output-control-flow.md +45 -0
  133. package/docs/rules/erb-require-whitespace-inside-tags.md +43 -0
  134. package/docs/rules/html-anchor-require-href.md +32 -0
  135. package/docs/rules/html-aria-role-heading-requires-level.md +34 -0
  136. package/docs/rules/html-attribute-double-quotes.md +43 -0
  137. package/docs/rules/html-attribute-values-require-quotes.md +43 -0
  138. package/docs/rules/html-boolean-attributes-no-value.md +39 -0
  139. package/docs/rules/html-img-require-alt.md +44 -0
  140. package/docs/rules/html-no-block-inside-inline.md +66 -0
  141. package/docs/rules/html-no-duplicate-attributes.md +35 -0
  142. package/docs/rules/html-no-empty-headings.md +78 -0
  143. package/docs/rules/html-no-nested-links.md +44 -0
  144. package/docs/rules/html-tag-name-lowercase.md +44 -0
  145. package/package.json +49 -0
  146. package/src/cli/argument-parser.ts +125 -0
  147. package/src/cli/file-processor.ts +86 -0
  148. package/src/cli/formatters/base-formatter.ts +11 -0
  149. package/src/cli/formatters/detailed-formatter.ts +74 -0
  150. package/src/cli/formatters/index.ts +3 -0
  151. package/src/cli/formatters/simple-formatter.ts +40 -0
  152. package/src/cli/index.ts +4 -0
  153. package/src/cli/summary-reporter.ts +127 -0
  154. package/src/cli.ts +60 -0
  155. package/src/default-rules.ts +33 -0
  156. package/src/herb-lint.ts +6 -0
  157. package/src/index.ts +3 -0
  158. package/src/linter.ts +50 -0
  159. package/src/rules/erb-no-empty-tags.ts +34 -0
  160. package/src/rules/erb-no-output-control-flow.ts +61 -0
  161. package/src/rules/erb-require-whitespace-inside-tags.ts +61 -0
  162. package/src/rules/html-anchor-require-href.ts +39 -0
  163. package/src/rules/html-aria-role-heading-requires-level.ts +44 -0
  164. package/src/rules/html-attribute-double-quotes.ts +28 -0
  165. package/src/rules/html-attribute-values-require-quotes.ts +30 -0
  166. package/src/rules/html-boolean-attributes-no-value.ts +27 -0
  167. package/src/rules/html-img-require-alt.ts +42 -0
  168. package/src/rules/html-no-block-inside-inline.ts +84 -0
  169. package/src/rules/html-no-duplicate-attributes.ts +59 -0
  170. package/src/rules/html-no-empty-headings.ts +185 -0
  171. package/src/rules/html-no-nested-links.ts +65 -0
  172. package/src/rules/html-tag-name-lowercase.ts +50 -0
  173. package/src/rules/index.ts +12 -0
  174. package/src/rules/rule-utils.ts +257 -0
  175. 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
+ }