@herb-tools/linter 0.5.0 → 0.6.1

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 +6627 -1937
  2. package/dist/herb-lint.js.map +1 -1
  3. package/dist/index.cjs +1574 -210
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +1566 -212
  6. package/dist/index.js.map +1 -1
  7. package/dist/package.json +5 -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 +50 -64
  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 +134 -9
  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 +29 -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 +245 -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 +146 -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 +146 -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 +5 -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 +53 -76
  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 +189 -14
  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 +41 -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 +357 -39
  138. package/src/rules/svg-tag-name-capitalization.ts +2 -9
  139. package/src/types.ts +27 -0
@@ -114,11 +114,6 @@ export class ArgumentParser {
114
114
  const theme = values.theme || DEFAULT_THEME
115
115
  const pattern = this.getFilePattern(positionals)
116
116
 
117
- if (positionals.length === 0) {
118
- console.error("Please specify input file.")
119
- process.exit(1)
120
- }
121
-
122
117
  return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines }
123
118
  }
124
119
 
@@ -2,23 +2,33 @@ 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 { ERBNoSilentTagInAttributeNameRule } from "./rules/erb-no-silent-tag-in-attribute-name.js"
5
6
  import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
6
7
  import { ERBRequiresTrailingNewlineRule } from "./rules/erb-requires-trailing-newline.js"
7
8
  import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
8
9
  import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
9
10
  import { HTMLAriaAttributeMustBeValid } from "./rules/html-aria-attribute-must-be-valid.js"
11
+ import { HTMLAriaLabelIsWellFormattedRule } from "./rules/html-aria-label-is-well-formatted.js"
10
12
  import { HTMLAriaLevelMustBeValidRule } from "./rules/html-aria-level-must-be-valid.js"
11
13
  import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
12
14
  import { HTMLAriaRoleMustBeValidRule } from "./rules/html-aria-role-must-be-valid.js"
13
15
  import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
16
+ import { HTMLAttributeEqualsSpacingRule } from "./rules/html-attribute-equals-spacing.js"
14
17
  import { HTMLAttributeValuesRequireQuotesRule } from "./rules/html-attribute-values-require-quotes.js"
18
+ import { HTMLAvoidBothDisabledAndAriaDisabledRule } from "./rules/html-avoid-both-disabled-and-aria-disabled.js"
15
19
  import { HTMLBooleanAttributesNoValueRule } from "./rules/html-boolean-attributes-no-value.js"
20
+ import { HTMLIframeHasTitleRule } from "./rules/html-iframe-has-title.js"
16
21
  import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js"
22
+ import { HTMLNavigationHasLabelRule } from "./rules/html-navigation-has-label.js"
23
+ import { HTMLNoAriaHiddenOnFocusableRule } from "./rules/html-no-aria-hidden-on-focusable.js"
17
24
  // import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
18
25
  import { HTMLNoDuplicateAttributesRule } from "./rules/html-no-duplicate-attributes.js"
19
26
  import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
20
27
  import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
21
28
  import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
29
+ import { HTMLNoPositiveTabIndexRule } from "./rules/html-no-positive-tab-index.js"
30
+ import { HTMLNoSelfClosingRule } from "./rules/html-no-self-closing.js"
31
+ import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
22
32
  import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
23
33
  import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
24
34
  import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
@@ -26,23 +36,33 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio
26
36
  export const defaultRules: RuleClass[] = [
27
37
  ERBNoEmptyTagsRule,
28
38
  ERBNoOutputControlFlowRule,
39
+ ERBNoSilentTagInAttributeNameRule,
29
40
  ERBPreferImageTagHelperRule,
30
41
  ERBRequiresTrailingNewlineRule,
31
42
  ERBRequireWhitespaceRule,
32
43
  HTMLAnchorRequireHrefRule,
33
44
  HTMLAriaAttributeMustBeValid,
45
+ HTMLAriaLabelIsWellFormattedRule,
34
46
  HTMLAriaLevelMustBeValidRule,
35
47
  HTMLAriaRoleHeadingRequiresLevelRule,
36
48
  HTMLAriaRoleMustBeValidRule,
37
49
  HTMLAttributeDoubleQuotesRule,
50
+ HTMLAttributeEqualsSpacingRule,
38
51
  HTMLAttributeValuesRequireQuotesRule,
52
+ HTMLAvoidBothDisabledAndAriaDisabledRule,
39
53
  HTMLBooleanAttributesNoValueRule,
54
+ HTMLIframeHasTitleRule,
40
55
  HTMLImgRequireAltRule,
56
+ HTMLNavigationHasLabelRule,
57
+ HTMLNoAriaHiddenOnFocusableRule,
41
58
  // HTMLNoBlockInsideInlineRule,
42
59
  HTMLNoDuplicateAttributesRule,
43
60
  HTMLNoDuplicateIdsRule,
44
61
  HTMLNoEmptyHeadingsRule,
45
62
  HTMLNoNestedLinksRule,
63
+ HTMLNoPositiveTabIndexRule,
64
+ HTMLNoSelfClosingRule,
65
+ HTMLNoTitleAttributeRule,
46
66
  HTMLTagNameLowercaseRule,
47
67
  ParserNoErrorsRule,
48
68
  SVGTagNameCapitalizationRule,
package/src/linter.ts CHANGED
@@ -53,20 +53,46 @@ export class Linter {
53
53
  lint(source: string, context?: Partial<LintContext>): LintResult {
54
54
  this.offenses = []
55
55
 
56
- const parseResult = this.herb.parse(source)
56
+ const parseResult = this.herb.parse(source, { track_whitespace: true })
57
57
  const lexResult = this.herb.lex(source)
58
58
 
59
59
  for (const RuleClass of this.rules) {
60
60
  const rule = new RuleClass()
61
61
 
62
+ let isEnabled = true
62
63
  let ruleOffenses: LintOffense[]
63
64
 
64
65
  if (this.isLexerRule(rule)) {
65
- ruleOffenses = (rule as LexerRule).check(lexResult, context)
66
+ if (rule.isEnabled) {
67
+ isEnabled = rule.isEnabled(lexResult, context)
68
+ }
69
+
70
+ if (isEnabled) {
71
+ ruleOffenses = (rule as LexerRule).check(lexResult, context)
72
+ } else {
73
+ ruleOffenses = []
74
+ }
75
+
66
76
  } else if (this.isSourceRule(rule)) {
67
- ruleOffenses = (rule as SourceRule).check(source, context)
77
+ if (rule.isEnabled) {
78
+ isEnabled = rule.isEnabled(source, context)
79
+ }
80
+
81
+ if (isEnabled) {
82
+ ruleOffenses = (rule as SourceRule).check(source, context)
83
+ } else {
84
+ ruleOffenses = []
85
+ }
68
86
  } else {
69
- ruleOffenses = (rule as ParserRule).check(parseResult, context)
87
+ if (rule.isEnabled) {
88
+ isEnabled = rule.isEnabled(parseResult, context)
89
+ }
90
+
91
+ if (isEnabled) {
92
+ ruleOffenses = (rule as ParserRule).check(parseResult, context)
93
+ } else {
94
+ ruleOffenses = []
95
+ }
70
96
  }
71
97
 
72
98
  this.offenses.push(...ruleOffenses)
@@ -0,0 +1,40 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor } from "./rule-utils.js"
3
+ import { filterERBContentNodes } from "@herb-tools/core"
4
+
5
+ import type { LintOffense, LintContext } from "../types.js"
6
+ import type { ParseResult, HTMLAttributeNameNode, ERBContentNode } from "@herb-tools/core"
7
+
8
+ class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
9
+ visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void {
10
+ const erbNodes = filterERBContentNodes(node.children)
11
+ const silentNodes = erbNodes.filter(this.isSilentERBTag)
12
+
13
+ for (const node of silentNodes) {
14
+ this.addOffense(
15
+ `Remove silent ERB tag from HTML attribute name. Silent ERB tags (\`${node.tag_opening?.value}\`) do not output content and should not be used in attribute names.`,
16
+ node.location,
17
+ "error"
18
+ )
19
+ }
20
+ }
21
+
22
+ // TODO: might be worth to extract
23
+ private isSilentERBTag(node: ERBContentNode): boolean {
24
+ const silentTags = ["<%", "<%-", "<%#"]
25
+
26
+ return silentTags.includes(node.tag_opening?.value || "")
27
+ }
28
+ }
29
+
30
+ export class ERBNoSilentTagInAttributeNameRule extends ParserRule {
31
+ name = "erb-no-silent-tag-in-attribute-name"
32
+
33
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
34
+ const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context)
35
+
36
+ visitor.visit(result.value)
37
+
38
+ return visitor.offenses
39
+ }
40
+ }
@@ -1,8 +1,11 @@
1
+ import { ParserRule } from "../types.js"
1
2
  import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
2
3
 
3
- import { ParserRule } from "../types.js"
4
+ import { ERBToRubyStringPrinter } from "@herb-tools/printer"
5
+ import { filterNodes, ERBContentNode, LiteralNode, isNode } from "@herb-tools/core"
6
+
4
7
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, HTMLAttributeValueNode, ERBContentNode, LiteralNode, ParseResult } from "@herb-tools/core"
8
+ import type { HTMLOpenTagNode, HTMLAttributeValueNode, ParseResult } from "@herb-tools/core"
6
9
 
7
10
  class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
8
11
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
@@ -10,106 +13,80 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
10
13
  super.visitHTMLOpenTagNode(node)
11
14
  }
12
15
 
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)
16
+ private checkImgTag(openTag: HTMLOpenTagNode): void {
17
+ const tagName = getTagName(openTag)
20
18
 
21
- if (tagName !== "img") {
22
- return
23
- }
19
+ if (tagName !== "img") return
24
20
 
25
- const attributes = getAttributes(node)
21
+ const attributes = getAttributes(openTag)
26
22
  const srcAttribute = findAttributeByName(attributes, "src")
27
23
 
28
- if (!srcAttribute) {
29
- return
30
- }
31
-
32
- if (!srcAttribute.value) {
33
- return
34
- }
24
+ if (!srcAttribute) return
25
+ if (!srcAttribute.value) return
35
26
 
36
- const valueNode = srcAttribute.value as HTMLAttributeValueNode
37
- const hasERBContent = this.containsERBContent(valueNode)
27
+ const node = srcAttribute.value
28
+ const hasERBContent = this.containsERBContent(node)
38
29
 
39
30
  if (hasERBContent) {
40
- const suggestedExpression = this.buildSuggestedExpression(valueNode)
31
+ if (this.isDataUri(node)) return
41
32
 
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
- )
33
+ if (this.shouldFlagAsImageTagCandidate(node)) {
34
+ const suggestedExpression = this.buildSuggestedExpression(node)
35
+
36
+ this.addOffense(
37
+ `Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
38
+ srcAttribute.location,
39
+ "warning"
40
+ )
41
+ }
47
42
  }
48
43
  }
49
44
 
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")
45
+ private containsERBContent(node: HTMLAttributeValueNode): boolean {
46
+ return filterNodes(node.children, ERBContentNode).length > 0
54
47
  }
55
48
 
56
- private buildSuggestedExpression(valueNode: HTMLAttributeValueNode): string {
57
- if (!valueNode.children) return "expression"
49
+ private isOnlyERBContent(node: HTMLAttributeValueNode): boolean {
50
+ return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length
51
+ }
58
52
 
59
- let hasText = false
60
- let hasERB = false
53
+ private getContentofFirstChild(node: HTMLAttributeValueNode): string {
54
+ if (!node.children || node.children.length === 0) return ""
61
55
 
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
56
+ const firstChild = node.children[0]
67
57
 
68
- if (literalNode.content && literalNode.content.trim()) {
69
- hasText = true
70
- }
71
- }
58
+ if (isNode(firstChild, LiteralNode)) {
59
+ return (firstChild.content || "").trim()
72
60
  }
73
61
 
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
- }
62
+ return ""
63
+ }
88
64
 
89
- result += '"'
65
+ private isDataUri(node: HTMLAttributeValueNode): boolean {
66
+ return this.getContentofFirstChild(node).startsWith("data:")
67
+ }
90
68
 
91
- return result
92
- }
69
+ private isFullUrl(node: HTMLAttributeValueNode): boolean {
70
+ const content = this.getContentofFirstChild(node)
93
71
 
94
- if (hasERB && !hasText) {
95
- const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE") as ERBContentNode[]
72
+ return content.startsWith("http://") || content.startsWith("https://")
73
+ }
96
74
 
97
- if (erbNodes.length === 1) {
98
- return (erbNodes[0].content?.value || "").trim()
99
- } else if (erbNodes.length > 1) {
100
- let result = '"'
75
+ private shouldFlagAsImageTagCandidate(node: HTMLAttributeValueNode): boolean {
76
+ if (this.isOnlyERBContent(node)) return true
77
+ if (this.isFullUrl(node)) return false
101
78
 
102
- for (const erbNode of erbNodes) {
103
- result += `#{${(erbNode.content?.value || "").trim()}}`
104
- }
79
+ return true
80
+ }
105
81
 
106
- result += '"'
82
+ private buildSuggestedExpression(node: HTMLAttributeValueNode): string {
83
+ if (!node.children) return "expression"
107
84
 
108
- return result
109
- }
85
+ try {
86
+ return ERBToRubyStringPrinter.print(node, { ignoreErrors: false })
87
+ } catch (error) {
88
+ return "expression"
110
89
  }
111
-
112
- return "expression"
113
90
  }
114
91
  }
115
92
 
@@ -1,42 +1,38 @@
1
- import {
2
- ARIA_ATTRIBUTES,
3
- AttributeVisitorMixin,
4
- } from "./rule-utils.js";
5
- import { ParserRule } from "../types.js";
6
- import type { LintOffense, LintContext } from "../types.js";
7
- import type {
8
- HTMLAttributeNode,
9
- HTMLOpenTagNode,
10
- HTMLSelfCloseTagNode,
11
- ParseResult,
12
- } from "@herb-tools/core";
1
+ import { ParserRule } from "../types.js"
2
+ import { ARIA_ATTRIBUTES, AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
13
6
 
14
7
  class AriaAttributeMustBeValid extends AttributeVisitorMixin {
15
- checkAttribute(
16
- attributeName: string,
17
- _attributeValue: string | null,
18
- attributeNode: HTMLAttributeNode,
19
- _parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode,
20
- ): void {
21
- if (!attributeName.startsWith("aria-")) return;
22
-
23
- if (!ARIA_ATTRIBUTES.has(attributeName)){
24
- this.offenses.push({
25
- message: `The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`,
26
- severity: "error",
27
- location: attributeNode.location,
28
- rule: this.ruleName,
29
- });
30
- }
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams) {
9
+ this.check(attributeName, attributeNode)
10
+ }
11
+
12
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode }: StaticAttributeDynamicValueParams) {
13
+ this.check(attributeName, attributeNode)
14
+ }
15
+
16
+ private check(attributeName: string, attributeNode: HTMLAttributeNode) {
17
+ if (!attributeName.startsWith("aria-")) return
18
+ if (ARIA_ATTRIBUTES.has(attributeName)) return
19
+
20
+ this.addOffense(
21
+ `The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`,
22
+ attributeNode.location,
23
+ "error"
24
+ )
31
25
  }
32
26
  }
33
27
 
34
28
  export class HTMLAriaAttributeMustBeValid extends ParserRule {
35
- name = "html-aria-attribute-must-be-valid";
29
+ name = "html-aria-attribute-must-be-valid"
36
30
 
37
31
  check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
38
- const visitor = new AriaAttributeMustBeValid(this.name, context);
39
- visitor.visit(result.value);
40
- return visitor.offenses;
32
+ const visitor = new AriaAttributeMustBeValid(this.name, context)
33
+
34
+ visitor.visit(result.value)
35
+
36
+ return visitor.offenses
41
37
  }
42
38
  }
@@ -0,0 +1,59 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { ParseResult } from "@herb-tools/core"
6
+
7
+ class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
9
+ if (attributeName !== "aria-label") return
10
+
11
+ if (attributeValue.match(/[\r\n]+/) || attributeValue.match(/&#10;|&#13;|&#x0A;|&#x0D;/i)) {
12
+ this.addOffense(
13
+ "The `aria-label` attribute value text should not contain line breaks. Use concise, single-line descriptions.",
14
+ attributeNode.location,
15
+ "error"
16
+ )
17
+
18
+ return
19
+ }
20
+
21
+ if (this.looksLikeId(attributeValue)) {
22
+ this.addOffense(
23
+ "The `aria-label` attribute value should not be formatted like an ID. Use natural, sentence-case text instead.",
24
+ attributeNode.location,
25
+ "error"
26
+ )
27
+
28
+ return
29
+ }
30
+
31
+ if (attributeValue.match(/^[a-z]/)) {
32
+ this.addOffense(
33
+ "The `aria-label` attribute value text should be formatted like visual text. Use sentence case (capitalize the first letter).",
34
+ attributeNode.location,
35
+ "error"
36
+ )
37
+ }
38
+ }
39
+
40
+ private looksLikeId(text: string): boolean {
41
+ return (
42
+ text.includes('_') ||
43
+ text.includes('-') ||
44
+ /^[a-z]+([A-Z][a-z]*)*$/.test(text)
45
+ ) && !text.includes(' ')
46
+ }
47
+ }
48
+
49
+ export class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
50
+ name = "html-aria-label-is-well-formatted"
51
+
52
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
53
+ const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context)
54
+
55
+ visitor.visit(result.value)
56
+
57
+ return visitor.offenses
58
+ }
59
+ }
@@ -1,15 +1,48 @@
1
- import { AttributeVisitorMixin } from "./rule-utils.js"
2
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
3
+ import { getValidatableStaticContent, hasERBOutput, filterLiteralNodes, filterERBContentNodes, isERBOutputNode } from "@herb-tools/core"
3
4
 
4
5
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { ParseResult, HTMLAttributeNode, HTMLOpenTagNode, HTMLSelfCloseTagNode } from "@herb-tools/core"
6
+ import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
6
7
 
7
8
  class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
8
- protected checkAttribute(attributeName: string, attributeValue: string | null, attributeNode: HTMLAttributeNode, _parentNode: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
9
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams) {
9
10
  if (attributeName !== "aria-level") return
10
- if (attributeValue !== null && attributeValue.includes("<%")) return
11
11
 
12
- if (attributeValue === null || attributeValue === "") {
12
+ this.validateAriaLevel(attributeValue, attributeNode)
13
+ }
14
+
15
+ protected checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }: StaticAttributeDynamicValueParams) {
16
+ if (attributeName !== "aria-level") return
17
+
18
+ const validatableContent = getValidatableStaticContent(valueNodes)
19
+
20
+ if (validatableContent !== null) {
21
+ this.validateAriaLevel(validatableContent, attributeNode)
22
+ return
23
+ }
24
+
25
+ if (!hasERBOutput(valueNodes)) return
26
+
27
+ const literalNodes = filterLiteralNodes(valueNodes)
28
+ const erbOutputNodes = filterERBContentNodes(valueNodes).filter(isERBOutputNode)
29
+
30
+ if (literalNodes.length > 0 && erbOutputNodes.length > 0) {
31
+ const staticPart = literalNodes.map(node => node.content).join("")
32
+
33
+ // TODO: this can be cleaned up using @herb-tools/printer
34
+ const erbPart = erbOutputNodes[0]
35
+ const erbText = `${erbPart.tag_opening?.value || ""}${erbPart.content?.value || ""}${erbPart.tag_closing?.value || ""}`
36
+
37
+ this.addOffense(
38
+ `The \`aria-level\` attribute must be an integer between 1 and 6, got \`${staticPart}\` and the ERB expression \`${erbText}\`.`,
39
+ attributeNode.location,
40
+ )
41
+ }
42
+ }
43
+
44
+ private validateAriaLevel(attributeValue: string, attributeNode: HTMLAttributeNode): void {
45
+ if (!attributeValue || attributeValue === "") {
13
46
  this.addOffense(
14
47
  `The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`,
15
48
  attributeNode.location,
@@ -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
  }