@herb-tools/linter 0.4.2 → 0.4.3

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 (146) hide show
  1. package/README.md +16 -2
  2. package/dist/herb-lint.js +426 -116
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +322 -61
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +317 -63
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +4 -4
  9. package/dist/src/cli/file-processor.js +2 -4
  10. package/dist/src/cli/file-processor.js.map +1 -1
  11. package/dist/src/default-rules.js +6 -0
  12. package/dist/src/default-rules.js.map +1 -1
  13. package/dist/src/linter.js +37 -6
  14. package/dist/src/linter.js.map +1 -1
  15. package/dist/src/rules/erb-no-empty-tags.js +4 -3
  16. package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
  17. package/dist/src/rules/erb-no-output-control-flow.js +4 -3
  18. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
  19. package/dist/src/rules/erb-prefer-image-tag-helper.js +93 -0
  20. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -0
  21. package/dist/src/rules/erb-require-whitespace-inside-tags.js +4 -3
  22. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
  23. package/dist/src/rules/erb-requires-trailing-newline.js +22 -0
  24. package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -0
  25. package/dist/src/rules/html-anchor-require-href.js +4 -3
  26. package/dist/src/rules/html-anchor-require-href.js.map +1 -1
  27. package/dist/src/rules/html-aria-attribute-must-be-valid.js +4 -3
  28. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  29. package/dist/src/rules/html-aria-level-must-be-valid.js +27 -0
  30. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -0
  31. package/dist/src/rules/html-aria-role-heading-requires-level.js +4 -3
  32. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  33. package/dist/src/rules/html-aria-role-must-be-valid.js +4 -3
  34. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  35. package/dist/src/rules/html-attribute-double-quotes.js +4 -3
  36. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  37. package/dist/src/rules/html-attribute-values-require-quotes.js +4 -3
  38. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  39. package/dist/src/rules/html-boolean-attributes-no-value.js +4 -3
  40. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  41. package/dist/src/rules/html-img-require-alt.js +4 -3
  42. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  43. package/dist/src/rules/html-no-block-inside-inline.js +4 -3
  44. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
  45. package/dist/src/rules/html-no-duplicate-attributes.js +4 -3
  46. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  47. package/dist/src/rules/html-no-duplicate-ids.js +4 -3
  48. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  49. package/dist/src/rules/html-no-empty-headings.js +4 -3
  50. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  51. package/dist/src/rules/html-no-nested-links.js +4 -3
  52. package/dist/src/rules/html-no-nested-links.js.map +1 -1
  53. package/dist/src/rules/html-tag-name-lowercase.js +4 -3
  54. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  55. package/dist/src/rules/index.js +3 -0
  56. package/dist/src/rules/index.js.map +1 -1
  57. package/dist/src/rules/rule-utils.js +125 -2
  58. package/dist/src/rules/rule-utils.js.map +1 -1
  59. package/dist/src/rules/svg-tag-name-capitalization.js +4 -3
  60. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  61. package/dist/src/types.js +15 -1
  62. package/dist/src/types.js.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/dist/types/linter.d.ts +20 -5
  65. package/dist/types/rules/erb-no-empty-tags.d.ts +4 -3
  66. package/dist/types/rules/erb-no-output-control-flow.d.ts +4 -3
  67. package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +7 -0
  68. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +4 -3
  69. package/dist/types/rules/erb-requires-trailing-newline.d.ts +6 -0
  70. package/dist/types/rules/html-anchor-require-href.d.ts +4 -3
  71. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +4 -3
  72. package/dist/types/rules/html-aria-level-must-be-valid.d.ts +7 -0
  73. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +4 -3
  74. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +4 -3
  75. package/dist/types/rules/html-attribute-double-quotes.d.ts +4 -3
  76. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +4 -3
  77. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +4 -3
  78. package/dist/types/rules/html-img-require-alt.d.ts +4 -3
  79. package/dist/types/rules/html-no-block-inside-inline.d.ts +4 -3
  80. package/dist/types/rules/html-no-duplicate-attributes.d.ts +4 -3
  81. package/dist/types/rules/html-no-duplicate-ids.d.ts +4 -3
  82. package/dist/types/rules/html-no-empty-headings.d.ts +4 -3
  83. package/dist/types/rules/html-no-nested-links.d.ts +4 -3
  84. package/dist/types/rules/html-tag-name-lowercase.d.ts +4 -3
  85. package/dist/types/rules/index.d.ts +3 -0
  86. package/dist/types/rules/rule-utils.d.ts +73 -4
  87. package/dist/types/rules/svg-tag-name-capitalization.d.ts +4 -3
  88. package/dist/types/src/linter.d.ts +20 -5
  89. package/dist/types/src/rules/erb-no-empty-tags.d.ts +4 -3
  90. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +4 -3
  91. package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +7 -0
  92. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +4 -3
  93. package/dist/types/src/rules/erb-requires-trailing-newline.d.ts +6 -0
  94. package/dist/types/src/rules/html-anchor-require-href.d.ts +4 -3
  95. package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +4 -3
  96. package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +7 -0
  97. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +4 -3
  98. package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +4 -3
  99. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +4 -3
  100. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +4 -3
  101. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +4 -3
  102. package/dist/types/src/rules/html-img-require-alt.d.ts +4 -3
  103. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +4 -3
  104. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +4 -3
  105. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +4 -3
  106. package/dist/types/src/rules/html-no-empty-headings.d.ts +4 -3
  107. package/dist/types/src/rules/html-no-nested-links.d.ts +4 -3
  108. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +4 -3
  109. package/dist/types/src/rules/index.d.ts +3 -0
  110. package/dist/types/src/rules/rule-utils.d.ts +73 -4
  111. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +4 -3
  112. package/dist/types/src/types.d.ts +49 -6
  113. package/dist/types/types.d.ts +49 -6
  114. package/docs/rules/README.md +4 -1
  115. package/docs/rules/erb-prefer-image-tag-helper.md +65 -0
  116. package/docs/rules/erb-requires-trailing-newline.md +37 -0
  117. package/docs/rules/html-anchor-require-href.md +1 -1
  118. package/docs/rules/html-aria-level-must-be-valid.md +37 -0
  119. package/package.json +4 -4
  120. package/src/cli/file-processor.ts +2 -4
  121. package/src/default-rules.ts +6 -0
  122. package/src/linter.ts +42 -8
  123. package/src/rules/erb-no-empty-tags.ts +5 -4
  124. package/src/rules/erb-no-output-control-flow.ts +6 -4
  125. package/src/rules/erb-prefer-image-tag-helper.ts +124 -0
  126. package/src/rules/erb-require-whitespace-inside-tags.ts +5 -4
  127. package/src/rules/erb-requires-trailing-newline.ts +27 -0
  128. package/src/rules/html-anchor-require-href.ts +5 -4
  129. package/src/rules/html-aria-attribute-must-be-valid.ts +5 -5
  130. package/src/rules/html-aria-level-must-be-valid.ts +42 -0
  131. package/src/rules/html-aria-role-heading-requires-level.ts +5 -4
  132. package/src/rules/html-aria-role-must-be-valid.ts +5 -4
  133. package/src/rules/html-attribute-double-quotes.ts +5 -4
  134. package/src/rules/html-attribute-values-require-quotes.ts +5 -4
  135. package/src/rules/html-boolean-attributes-no-value.ts +5 -4
  136. package/src/rules/html-img-require-alt.ts +5 -4
  137. package/src/rules/html-no-block-inside-inline.ts +5 -4
  138. package/src/rules/html-no-duplicate-attributes.ts +5 -4
  139. package/src/rules/html-no-duplicate-ids.ts +5 -5
  140. package/src/rules/html-no-empty-headings.ts +5 -4
  141. package/src/rules/html-no-nested-links.ts +5 -4
  142. package/src/rules/html-tag-name-lowercase.ts +5 -4
  143. package/src/rules/index.ts +3 -0
  144. package/src/rules/rule-utils.ts +156 -4
  145. package/src/rules/svg-tag-name-capitalization.ts +5 -4
  146. package/src/types.ts +60 -6
@@ -2,9 +2,12 @@ import type { RuleClass } from "./types.js"
2
2
 
3
3
  import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
4
4
  import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
5
+ import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
6
+ import { ERBRequiresTrailingNewlineRule } from "./rules/erb-requires-trailing-newline.js"
5
7
  import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
6
8
  import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
7
9
  import { HTMLAriaAttributeMustBeValid } from "./rules/html-aria-attribute-must-be-valid.js"
10
+ import { HTMLAriaLevelMustBeValidRule } from "./rules/html-aria-level-must-be-valid.js"
8
11
  import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
9
12
  import { HTMLAriaRoleMustBeValidRule } from "./rules/html-aria-role-must-be-valid.js"
10
13
  import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
@@ -22,9 +25,12 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio
22
25
  export const defaultRules: RuleClass[] = [
23
26
  ERBNoEmptyTagsRule,
24
27
  ERBNoOutputControlFlowRule,
28
+ ERBPreferImageTagHelperRule,
29
+ ERBRequiresTrailingNewlineRule,
25
30
  ERBRequireWhitespaceRule,
26
31
  HTMLAnchorRequireHrefRule,
27
32
  HTMLAriaAttributeMustBeValid,
33
+ HTMLAriaLevelMustBeValidRule,
28
34
  HTMLAriaRoleHeadingRequiresLevelRule,
29
35
  HTMLAriaRoleMustBeValidRule,
30
36
  HTMLAttributeDoubleQuotesRule,
package/src/linter.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  import { defaultRules } from "./default-rules.js"
2
2
 
3
- import type { RuleClass, LintResult, LintOffense } from "./types.js"
4
- import type { DocumentNode } from "@herb-tools/core"
3
+ import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, LintContext } from "./types.js"
4
+ import type { HerbBackend } from "@herb-tools/core"
5
5
 
6
6
  export class Linter {
7
7
  private rules: RuleClass[]
8
+ private herb: HerbBackend
8
9
  private offenses: LintOffense[]
9
10
 
10
11
  /**
11
12
  * Creates a new Linter instance.
12
- * @param rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
13
+ * @param herb - The Herb backend instance for parsing and lexing
14
+ * @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
13
15
  */
14
- constructor(rules?: RuleClass[]) {
16
+ constructor(herb: HerbBackend, rules?: RuleClass[]) {
17
+ this.herb = herb
15
18
  this.rules = rules !== undefined ? rules : this.getDefaultRules()
16
19
  this.offenses = []
17
20
  }
@@ -28,12 +31,43 @@ export class Linter {
28
31
  return this.rules.length
29
32
  }
30
33
 
31
- lint(document: DocumentNode): LintResult {
34
+ /**
35
+ * Type guard to check if a rule is a LexerRule
36
+ */
37
+ private isLexerRule(rule: Rule): rule is LexerRule {
38
+ return (rule.constructor as any).type === "lexer"
39
+ }
40
+
41
+ /**
42
+ * Type guard to check if a rule is a SourceRule
43
+ */
44
+ private isSourceRule(rule: Rule): rule is SourceRule {
45
+ return (rule.constructor as any).type === "source"
46
+ }
47
+
48
+ /**
49
+ * Lint source code using Parser/AST, Lexer, and Source rules.
50
+ * @param source - The source code to lint
51
+ * @param context - Optional context for linting (e.g., fileName for distinguishing files vs snippets)
52
+ */
53
+ lint(source: string, context?: Partial<LintContext>): LintResult {
32
54
  this.offenses = []
33
55
 
34
- for (const Rule of this.rules) {
35
- const rule = new Rule()
36
- const ruleOffenses = rule.check(document)
56
+ const parseResult = this.herb.parse(source)
57
+ const lexResult = this.herb.lex(source)
58
+
59
+ for (const RuleClass of this.rules) {
60
+ const rule = new RuleClass()
61
+
62
+ let ruleOffenses: LintOffense[]
63
+
64
+ if (this.isLexerRule(rule)) {
65
+ ruleOffenses = (rule as LexerRule).check(lexResult, context)
66
+ } else if (this.isSourceRule(rule)) {
67
+ ruleOffenses = (rule as SourceRule).check(source, context)
68
+ } else {
69
+ ruleOffenses = (rule as ParserRule).check(parseResult.value, context)
70
+ }
37
71
 
38
72
  this.offenses.push(...ruleOffenses)
39
73
  }
@@ -1,6 +1,7 @@
1
1
  import { BaseRuleVisitor } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { Node, ERBContentNode } from "@herb-tools/core"
5
6
 
6
7
  class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
@@ -21,11 +22,11 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
21
22
  }
22
23
  }
23
24
 
24
- export class ERBNoEmptyTagsRule implements Rule {
25
+ export class ERBNoEmptyTagsRule extends ParserRule {
25
26
  name = "erb-no-empty-tags"
26
27
 
27
- check(node: Node): LintOffense[] {
28
- const visitor = new ERBNoEmptyTagsVisitor(this.name)
28
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
29
+ const visitor = new ERBNoEmptyTagsVisitor(this.name, context)
29
30
 
30
31
  visitor.visit(node)
31
32
 
@@ -1,7 +1,8 @@
1
1
  import { BaseRuleVisitor } from "./rule-utils.js"
2
2
 
3
3
  import type { Node, ERBIfNode, ERBUnlessNode, ERBElseNode, ERBEndNode } from "@herb-tools/core"
4
- import type { Rule, LintOffense } from "../types.js"
4
+ import { ParserRule } from "../types.js"
5
+ import type { LintOffense, LintContext } from "../types.js"
5
6
 
6
7
  class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
7
8
  visitERBIfNode(node: ERBIfNode): void {
@@ -49,10 +50,11 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
49
50
  }
50
51
  }
51
52
 
52
- export class ERBNoOutputControlFlowRule implements Rule {
53
+ export class ERBNoOutputControlFlowRule extends ParserRule {
53
54
  name = "erb-no-output-control-flow"
54
- check(node: Node): LintOffense[] {
55
- const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name)
55
+
56
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
57
+ const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context)
56
58
 
57
59
  visitor.visit(node)
58
60
 
@@ -0,0 +1,124 @@
1
+ import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
2
+
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, HTMLAttributeValueNode, ERBContentNode, LiteralNode, Node } from "@herb-tools/core"
6
+
7
+ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
8
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
+ this.checkImgTag(node)
10
+ super.visitHTMLOpenTagNode(node)
11
+ }
12
+
13
+ visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
14
+ this.checkImgTag(node)
15
+ super.visitHTMLSelfCloseTagNode(node)
16
+ }
17
+
18
+ private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
19
+ const tagName = getTagName(node)
20
+
21
+ if (tagName !== "img") {
22
+ return
23
+ }
24
+
25
+ const attributes = getAttributes(node)
26
+ const srcAttribute = findAttributeByName(attributes, "src")
27
+
28
+ if (!srcAttribute) {
29
+ return
30
+ }
31
+
32
+ if (!srcAttribute.value) {
33
+ return
34
+ }
35
+
36
+ const valueNode = srcAttribute.value as HTMLAttributeValueNode
37
+ const hasERBContent = this.containsERBContent(valueNode)
38
+
39
+ if (hasERBContent) {
40
+ const suggestedExpression = this.buildSuggestedExpression(valueNode)
41
+
42
+ this.addOffense(
43
+ `Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
44
+ srcAttribute.location,
45
+ "warning"
46
+ )
47
+ }
48
+ }
49
+
50
+ private containsERBContent(valueNode: HTMLAttributeValueNode): boolean {
51
+ if (!valueNode.children) return false
52
+
53
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
54
+ }
55
+
56
+ private buildSuggestedExpression(valueNode: HTMLAttributeValueNode): string {
57
+ if (!valueNode.children) return "expression"
58
+
59
+ let hasText = false
60
+ let hasERB = false
61
+
62
+ for (const child of valueNode.children) {
63
+ if (child.type === "AST_ERB_CONTENT_NODE") {
64
+ hasERB = true
65
+ } else if (child.type === "AST_LITERAL_NODE") {
66
+ const literalNode = child as LiteralNode
67
+
68
+ if (literalNode.content && literalNode.content.trim()) {
69
+ hasText = true
70
+ }
71
+ }
72
+ }
73
+
74
+ if (hasText && hasERB) {
75
+ let result = '"'
76
+
77
+ for (const child of valueNode.children) {
78
+ if (child.type === "AST_ERB_CONTENT_NODE") {
79
+ const erbNode = child as ERBContentNode
80
+
81
+ result += `#{${(erbNode.content?.value || "").trim()}}`
82
+ } else if (child.type === "AST_LITERAL_NODE") {
83
+ const literalNode = child as LiteralNode
84
+
85
+ result += literalNode.content || ""
86
+ }
87
+ }
88
+
89
+ result += '"'
90
+
91
+ return result
92
+ }
93
+
94
+ if (hasERB && !hasText) {
95
+ const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE") as ERBContentNode[]
96
+
97
+ if (erbNodes.length === 1) {
98
+ return (erbNodes[0].content?.value || "").trim()
99
+ } else if (erbNodes.length > 1) {
100
+ let result = '"'
101
+
102
+ for (const erbNode of erbNodes) {
103
+ result += `#{${(erbNode.content?.value || "").trim()}}`
104
+ }
105
+
106
+ result += '"'
107
+
108
+ return result
109
+ }
110
+ }
111
+
112
+ return "expression"
113
+ }
114
+ }
115
+
116
+ export class ERBPreferImageTagHelperRule extends ParserRule {
117
+ name = "erb-prefer-image-tag-helper"
118
+
119
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
120
+ const visitor = new ERBPreferImageTagHelperVisitor(this.name, context)
121
+ visitor.visit(node)
122
+ return visitor.offenses
123
+ }
124
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Node, Token } from "@herb-tools/core"
2
2
  import { isERBNode } from "@herb-tools/core";
3
- import type { LintOffense, Rule } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import { BaseRuleVisitor } from "./rule-utils.js"
5
6
 
6
7
  class RequireWhitespaceInsideTags extends BaseRuleVisitor {
@@ -81,11 +82,11 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
81
82
  }
82
83
  }
83
84
 
84
- export class ERBRequireWhitespaceRule implements Rule {
85
+ export class ERBRequireWhitespaceRule extends ParserRule {
85
86
  name = "erb-require-whitespace-inside-tags"
86
87
 
87
- check(node: Node): LintOffense[] {
88
- const visitor = new RequireWhitespaceInsideTags(this.name)
88
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
89
+ const visitor = new RequireWhitespaceInsideTags(this.name, context)
89
90
  visitor.visit(node)
90
91
  return visitor.offenses
91
92
  }
@@ -0,0 +1,27 @@
1
+ import { BaseSourceRuleVisitor, createEndOfFileLocation } from "./rule-utils.js"
2
+ import { SourceRule } from "../types.js"
3
+ import type { LintOffense, LintContext } from "../types.js"
4
+
5
+ class ERBRequiresTrailingNewlineVisitor extends BaseSourceRuleVisitor {
6
+ protected visitSource(source: string): void {
7
+ if (source.length === 0) return
8
+ if (source.endsWith('\n')) return
9
+ if (!this.context.fileName) return
10
+
11
+ this.addOffense(
12
+ "File must end with trailing newline",
13
+ createEndOfFileLocation(source),
14
+ "error"
15
+ )
16
+ }
17
+ }
18
+
19
+ export class ERBRequiresTrailingNewlineRule extends SourceRule {
20
+ name = "erb-requires-trailing-newline"
21
+
22
+ check(source: string, context?: Partial<LintContext>): LintOffense[] {
23
+ const visitor = new ERBRequiresTrailingNewlineVisitor(this.name, context)
24
+ visitor.visit(source)
25
+ return visitor.offenses
26
+ }
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
2
2
 
3
- import { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLOpenTagNode, Node } from "@herb-tools/core"
5
6
 
6
7
  class AnchorRechireHrefVisitor extends BaseRuleVisitor {
@@ -26,11 +27,11 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
26
27
  }
27
28
  }
28
29
 
29
- export class HTMLAnchorRequireHrefRule implements Rule {
30
+ export class HTMLAnchorRequireHrefRule extends ParserRule {
30
31
  name = "html-anchor-require-href"
31
32
 
32
- check(node: Node): LintOffense[] {
33
- const visitor = new AnchorRechireHrefVisitor(this.name)
33
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
34
+ const visitor = new AnchorRechireHrefVisitor(this.name, context)
34
35
 
35
36
  visitor.visit(node)
36
37
 
@@ -2,8 +2,8 @@ import {
2
2
  ARIA_ATTRIBUTES,
3
3
  AttributeVisitorMixin,
4
4
  } from "./rule-utils.js";
5
-
6
- import type { LintOffense, Rule } from "../types.js";
5
+ import { ParserRule } from "../types.js";
6
+ import type { LintOffense, LintContext } from "../types.js";
7
7
  import type {
8
8
  HTMLAttributeNode,
9
9
  HTMLOpenTagNode,
@@ -31,11 +31,11 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
31
31
  }
32
32
  }
33
33
 
34
- export class HTMLAriaAttributeMustBeValid implements Rule {
34
+ export class HTMLAriaAttributeMustBeValid extends ParserRule {
35
35
  name = "html-aria-attribute-must-be-valid";
36
36
 
37
- check(node: Node): LintOffense[] {
38
- const visitor = new AriaAttributeMustBeValid(this.name);
37
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
38
+ const visitor = new AriaAttributeMustBeValid(this.name, context);
39
39
  visitor.visit(node);
40
40
  return visitor.offenses;
41
41
  }
@@ -0,0 +1,42 @@
1
+ import { AttributeVisitorMixin } from "./rule-utils.js"
2
+ import { ParserRule } from "../types.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { Node, HTMLAttributeNode, HTMLOpenTagNode, HTMLSelfCloseTagNode } from "@herb-tools/core"
6
+
7
+ class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
8
+ protected checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: HTMLAttributeNode, _parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
9
+ if (attributeName !== "aria-level") return
10
+ if (attributeValue !== null && attributeValue.includes("<%")) return
11
+
12
+ if (attributeValue === null || attributeValue === "") {
13
+ this.addOffense(
14
+ `The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`,
15
+ attributeNode.location,
16
+ )
17
+
18
+ return
19
+ }
20
+
21
+ const number = parseInt(attributeValue)
22
+
23
+ if (isNaN(number) || number < 1 || number > 6 || attributeValue !== number.toString()) {
24
+ this.addOffense(
25
+ `The \`aria-level\` attribute must be an integer between 1 and 6, got \`${attributeValue}\`.`,
26
+ attributeNode.location,
27
+ )
28
+ }
29
+ }
30
+ }
31
+
32
+ export class HTMLAriaLevelMustBeValidRule extends ParserRule {
33
+ name = "html-aria-level-must-be-valid"
34
+
35
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
36
+ const visitor = new HTMLAriaLevelMustBeValidVisitor(this.name, context)
37
+
38
+ visitor.visit(node)
39
+
40
+ return visitor.offenses
41
+ }
42
+ }
@@ -1,6 +1,7 @@
1
1
  import { AttributeVisitorMixin, getAttributeName, getAttributes } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { Node, HTMLAttributeNode, HTMLOpenTagNode, HTMLSelfCloseTagNode } from "@herb-tools/core"
5
6
 
6
7
  class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
@@ -33,11 +34,11 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
33
34
  }
34
35
  }
35
36
 
36
- export class HTMLAriaRoleHeadingRequiresLevelRule implements Rule {
37
+ export class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
37
38
  name = "html-aria-role-heading-requires-level"
38
39
 
39
- check(node: Node): LintOffense[] {
40
- const visitor = new AriaRoleHeadingRequiresLevel(this.name)
40
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
41
+ const visitor = new AriaRoleHeadingRequiresLevel(this.name, context)
41
42
  visitor.visit(node)
42
43
  return visitor.offenses
43
44
  }
@@ -1,6 +1,7 @@
1
1
  import { AttributeVisitorMixin, VALID_ARIA_ROLES } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { Node, HTMLAttributeNode } from "@herb-tools/core"
5
6
 
6
7
  class AriaRoleMustBeValid extends AttributeVisitorMixin {
@@ -17,11 +18,11 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
17
18
  }
18
19
  }
19
20
 
20
- export class HTMLAriaRoleMustBeValidRule implements Rule {
21
+ export class HTMLAriaRoleMustBeValidRule extends ParserRule {
21
22
  name = "html-aria-role-must-be-valid"
22
23
 
23
- check(node: Node): LintOffense[] {
24
- const visitor = new AriaRoleMustBeValid(this.name)
24
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
25
+ const visitor = new AriaRoleMustBeValid(this.name, context)
25
26
 
26
27
  visitor.visit(node)
27
28
 
@@ -1,6 +1,7 @@
1
1
  import { AttributeVisitorMixin, getAttributeValueQuoteType, hasAttributeValue } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { Node, HTMLAttributeNode } from "@herb-tools/core"
5
6
 
6
7
  class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
@@ -17,11 +18,11 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
17
18
  }
18
19
  }
19
20
 
20
- export class HTMLAttributeDoubleQuotesRule implements Rule {
21
+ export class HTMLAttributeDoubleQuotesRule extends ParserRule {
21
22
  name = "html-attribute-double-quotes"
22
23
 
23
- check(node: Node): LintOffense[] {
24
- const visitor = new AttributeDoubleQuotesVisitor(this.name)
24
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
25
+ const visitor = new AttributeDoubleQuotesVisitor(this.name, context)
25
26
  visitor.visit(node)
26
27
  return visitor.offenses
27
28
  }
@@ -1,6 +1,7 @@
1
1
  import { AttributeVisitorMixin } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLAttributeNode, HTMLAttributeValueNode, Node } from "@herb-tools/core"
5
6
 
6
7
  class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
@@ -19,11 +20,11 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
19
20
  }
20
21
  }
21
22
 
22
- export class HTMLAttributeValuesRequireQuotesRule implements Rule {
23
+ export class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
23
24
  name = "html-attribute-values-require-quotes"
24
25
 
25
- check(node: Node): LintOffense[] {
26
- const visitor = new AttributeValuesRequireQuotesVisitor(this.name)
26
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
27
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context)
27
28
  visitor.visit(node)
28
29
  return visitor.offenses
29
30
  }
@@ -1,6 +1,7 @@
1
1
  import { AttributeVisitorMixin, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLAttributeNode, Node } from "@herb-tools/core"
5
6
 
6
7
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
@@ -16,11 +17,11 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
16
17
  }
17
18
  }
18
19
 
19
- export class HTMLBooleanAttributesNoValueRule implements Rule {
20
+ export class HTMLBooleanAttributesNoValueRule extends ParserRule {
20
21
  name = "html-boolean-attributes-no-value"
21
22
 
22
- check(node: Node): LintOffense[] {
23
- const visitor = new BooleanAttributesNoValueVisitor(this.name)
23
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
24
+ const visitor = new BooleanAttributesNoValueVisitor(this.name, context)
24
25
  visitor.visit(node)
25
26
  return visitor.offenses
26
27
  }
@@ -1,6 +1,7 @@
1
1
  import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
5
6
 
6
7
  class ImgRequireAltVisitor extends BaseRuleVisitor {
@@ -31,11 +32,11 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
31
32
  }
32
33
  }
33
34
 
34
- export class HTMLImgRequireAltRule implements Rule {
35
+ export class HTMLImgRequireAltRule extends ParserRule {
35
36
  name = "html-img-require-alt"
36
37
 
37
- check(node: Node): LintOffense[] {
38
- const visitor = new ImgRequireAltVisitor(this.name)
38
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
39
+ const visitor = new ImgRequireAltVisitor(this.name, context)
39
40
  visitor.visit(node)
40
41
  return visitor.offenses
41
42
  }
@@ -1,6 +1,7 @@
1
1
  import { BaseRuleVisitor, isInlineElement, isBlockElement } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLOpenTagNode, HTMLElementNode, Node } from "@herb-tools/core"
5
6
 
6
7
  class BlockInsideInlineVisitor extends BaseRuleVisitor {
@@ -73,11 +74,11 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
73
74
  }
74
75
  }
75
76
 
76
- export class HTMLNoBlockInsideInlineRule implements Rule {
77
+ export class HTMLNoBlockInsideInlineRule extends ParserRule {
77
78
  name = "html-no-block-inside-inline"
78
79
 
79
- check(node: Node): LintOffense[] {
80
- const visitor = new BlockInsideInlineVisitor(this.name)
80
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
81
+ const visitor = new BlockInsideInlineVisitor(this.name, context)
81
82
  visitor.visit(node)
82
83
  return visitor.offenses
83
84
  }
@@ -1,6 +1,7 @@
1
1
  import { BaseRuleVisitor, forEachAttribute } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, HTMLAttributeNameNode, Node } from "@herb-tools/core"
5
6
 
6
7
  class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
@@ -48,11 +49,11 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
48
49
  }
49
50
  }
50
51
 
51
- export class HTMLNoDuplicateAttributesRule implements Rule {
52
+ export class HTMLNoDuplicateAttributesRule extends ParserRule {
52
53
  name = "html-no-duplicate-attributes"
53
54
 
54
- check(node: Node): LintOffense[] {
55
- const visitor = new NoDuplicateAttributesVisitor(this.name)
55
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
56
+ const visitor = new NoDuplicateAttributesVisitor(this.name, context)
56
57
  visitor.visit(node)
57
58
  return visitor.offenses
58
59
  }
@@ -1,7 +1,7 @@
1
1
  import { AttributeVisitorMixin } from "./rule-utils"
2
-
2
+ import { ParserRule } from "../types"
3
3
  import type { Node } from "@herb-tools/core"
4
- import type { LintOffense, Rule } from "../types"
4
+ import type { LintOffense, LintContext } from "../types"
5
5
 
6
6
  class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
7
7
  private documentIds: Set<string> = new Set<string>()
@@ -26,11 +26,11 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
26
26
  }
27
27
  }
28
28
 
29
- export class HTMLNoDuplicateIdsRule implements Rule {
29
+ export class HTMLNoDuplicateIdsRule extends ParserRule {
30
30
  name = "html-no-duplicate-ids"
31
31
 
32
- check(node: Node): LintOffense[] {
33
- const visitor = new NoDuplicateIdsVisitor(this.name)
32
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
33
+ const visitor = new NoDuplicateIdsVisitor(this.name, context)
34
34
 
35
35
  visitor.visit(node)
36
36
 
@@ -1,6 +1,7 @@
1
1
  import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAttributeValue, HEADING_TAGS } from "./rule-utils.js"
2
2
 
3
- import type { Rule, LintOffense } from "../types.js"
3
+ import { ParserRule } from "../types.js"
4
+ import type { LintOffense, LintContext } from "../types.js"
4
5
  import type { HTMLElementNode, HTMLOpenTagNode, HTMLSelfCloseTagNode, Node, LiteralNode, HTMLTextNode } from "@herb-tools/core"
5
6
 
6
7
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
@@ -174,11 +175,11 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
174
175
  }
175
176
  }
176
177
 
177
- export class HTMLNoEmptyHeadingsRule implements Rule {
178
+ export class HTMLNoEmptyHeadingsRule extends ParserRule {
178
179
  name = "html-no-empty-headings"
179
180
 
180
- check(node: Node): LintOffense[] {
181
- const visitor = new NoEmptyHeadingsVisitor(this.name)
181
+ check(node: Node, context?: Partial<LintContext>): LintOffense[] {
182
+ const visitor = new NoEmptyHeadingsVisitor(this.name, context)
182
183
  visitor.visit(node)
183
184
  return visitor.offenses
184
185
  }