@herb-tools/linter 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/README.md +216 -19
  2. package/dist/herb-lint.js +5559 -1860
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +722 -187
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +714 -189
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +4 -4
  9. package/dist/src/cli/argument-parser.js +28 -22
  10. package/dist/src/cli/argument-parser.js.map +1 -1
  11. package/dist/src/cli/file-processor.js +19 -13
  12. package/dist/src/cli/file-processor.js.map +1 -1
  13. package/dist/src/cli/formatters/detailed-formatter.js +9 -9
  14. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
  15. package/dist/src/cli/formatters/github-actions-formatter.js +50 -0
  16. package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -0
  17. package/dist/src/cli/formatters/index.js +2 -0
  18. package/dist/src/cli/formatters/index.js.map +1 -1
  19. package/dist/src/cli/formatters/json-formatter.js +58 -0
  20. package/dist/src/cli/formatters/json-formatter.js.map +1 -0
  21. package/dist/src/cli/formatters/simple-formatter.js +15 -15
  22. package/dist/src/cli/formatters/simple-formatter.js.map +1 -1
  23. package/dist/src/cli/output-manager.js +120 -0
  24. package/dist/src/cli/output-manager.js.map +1 -0
  25. package/dist/src/cli/summary-reporter.js +22 -22
  26. package/dist/src/cli/summary-reporter.js.map +1 -1
  27. package/dist/src/cli.js +41 -26
  28. package/dist/src/cli.js.map +1 -1
  29. package/dist/src/default-rules.js +22 -0
  30. package/dist/src/default-rules.js.map +1 -1
  31. package/dist/src/linter.js +29 -4
  32. package/dist/src/linter.js.map +1 -1
  33. package/dist/src/rules/erb-no-empty-tags.js +2 -2
  34. package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
  35. package/dist/src/rules/erb-no-output-control-flow.js +2 -2
  36. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
  37. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
  38. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
  39. package/dist/src/rules/erb-prefer-image-tag-helper.js +2 -6
  40. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  41. package/dist/src/rules/erb-require-whitespace-inside-tags.js +2 -2
  42. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
  43. package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -1
  44. package/dist/src/rules/html-anchor-require-href.js +2 -2
  45. package/dist/src/rules/html-anchor-require-href.js.map +1 -1
  46. package/dist/src/rules/html-aria-attribute-must-be-valid.js +13 -12
  47. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  48. package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
  49. package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
  50. package/dist/src/rules/html-aria-level-must-be-valid.js +28 -6
  51. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
  52. package/dist/src/rules/html-aria-role-heading-requires-level.js +9 -15
  53. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  54. package/dist/src/rules/html-aria-role-must-be-valid.js +5 -5
  55. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  56. package/dist/src/rules/html-attribute-double-quotes.js +16 -6
  57. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  58. package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
  59. package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
  60. package/dist/src/rules/html-attribute-values-require-quotes.js +21 -10
  61. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  62. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
  63. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
  64. package/dist/src/rules/html-boolean-attributes-no-value.js +11 -4
  65. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  66. package/dist/src/rules/html-iframe-has-title.js +39 -0
  67. package/dist/src/rules/html-iframe-has-title.js.map +1 -0
  68. package/dist/src/rules/html-img-require-alt.js +2 -6
  69. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  70. package/dist/src/rules/html-navigation-has-label.js +43 -0
  71. package/dist/src/rules/html-navigation-has-label.js.map +1 -0
  72. package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
  73. package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
  74. package/dist/src/rules/html-no-block-inside-inline.js +4 -4
  75. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
  76. package/dist/src/rules/html-no-duplicate-attributes.js +24 -27
  77. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  78. package/dist/src/rules/html-no-duplicate-ids.js +4 -4
  79. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  80. package/dist/src/rules/html-no-empty-headings.js +2 -23
  81. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  82. package/dist/src/rules/html-no-nested-links.js +2 -2
  83. package/dist/src/rules/html-no-nested-links.js.map +1 -1
  84. package/dist/src/rules/html-no-positive-tab-index.js +21 -0
  85. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
  86. package/dist/src/rules/html-no-self-closing.js +22 -0
  87. package/dist/src/rules/html-no-self-closing.js.map +1 -0
  88. package/dist/src/rules/html-no-title-attribute.js +27 -0
  89. package/dist/src/rules/html-no-title-attribute.js.map +1 -0
  90. package/dist/src/rules/html-tag-name-lowercase.js +37 -25
  91. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  92. package/dist/src/rules/index.js +10 -0
  93. package/dist/src/rules/index.js.map +1 -1
  94. package/dist/src/rules/parser-no-errors.js +18 -0
  95. package/dist/src/rules/parser-no-errors.js.map +1 -0
  96. package/dist/src/rules/rule-utils.js +176 -22
  97. package/dist/src/rules/rule-utils.js.map +1 -1
  98. package/dist/src/rules/svg-tag-name-capitalization.js +2 -10
  99. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  100. package/dist/src/types.js.map +1 -1
  101. package/dist/tsconfig.tsbuildinfo +1 -1
  102. package/dist/types/cli/argument-parser.d.ts +2 -1
  103. package/dist/types/cli/file-processor.d.ts +6 -5
  104. package/dist/types/cli/formatters/base-formatter.d.ts +2 -2
  105. package/dist/types/cli/formatters/detailed-formatter.d.ts +2 -2
  106. package/dist/types/cli/formatters/github-actions-formatter.d.ts +12 -0
  107. package/dist/types/cli/formatters/index.d.ts +2 -0
  108. package/dist/types/cli/formatters/json-formatter.d.ts +42 -0
  109. package/dist/types/cli/formatters/simple-formatter.d.ts +2 -2
  110. package/dist/types/cli/index.d.ts +4 -0
  111. package/dist/types/cli/output-manager.d.ts +31 -0
  112. package/dist/types/cli/summary-reporter.d.ts +3 -3
  113. package/dist/types/cli.d.ts +3 -1
  114. package/dist/types/rules/erb-no-empty-tags.d.ts +2 -2
  115. package/dist/types/rules/erb-no-output-control-flow.d.ts +2 -2
  116. package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  117. package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +2 -2
  118. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +2 -2
  119. package/dist/types/rules/html-anchor-require-href.d.ts +2 -2
  120. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +2 -2
  121. package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  122. package/dist/types/rules/html-aria-level-must-be-valid.d.ts +2 -2
  123. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +2 -2
  124. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +2 -2
  125. package/dist/types/rules/html-attribute-double-quotes.d.ts +2 -2
  126. package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
  127. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +2 -2
  128. package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  129. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +2 -2
  130. package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
  131. package/dist/types/rules/html-img-require-alt.d.ts +2 -2
  132. package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
  133. package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  134. package/dist/types/rules/html-no-block-inside-inline.d.ts +2 -2
  135. package/dist/types/rules/html-no-duplicate-attributes.d.ts +2 -2
  136. package/dist/types/rules/html-no-duplicate-ids.d.ts +2 -2
  137. package/dist/types/rules/html-no-empty-headings.d.ts +2 -2
  138. package/dist/types/rules/html-no-nested-links.d.ts +2 -2
  139. package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
  140. package/dist/types/rules/html-no-self-closing.d.ts +7 -0
  141. package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
  142. package/dist/types/rules/html-tag-name-lowercase.d.ts +3 -2
  143. package/dist/types/rules/index.d.ts +10 -0
  144. package/dist/types/rules/parser-no-errors.d.ts +8 -0
  145. package/dist/types/rules/rule-utils.d.ts +107 -13
  146. package/dist/types/rules/svg-tag-name-capitalization.d.ts +2 -2
  147. package/dist/types/src/cli/argument-parser.d.ts +2 -1
  148. package/dist/types/src/cli/file-processor.d.ts +6 -5
  149. package/dist/types/src/cli/formatters/base-formatter.d.ts +2 -2
  150. package/dist/types/src/cli/formatters/detailed-formatter.d.ts +2 -2
  151. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +12 -0
  152. package/dist/types/src/cli/formatters/index.d.ts +2 -0
  153. package/dist/types/src/cli/formatters/json-formatter.d.ts +42 -0
  154. package/dist/types/src/cli/formatters/simple-formatter.d.ts +2 -2
  155. package/dist/types/src/cli/output-manager.d.ts +31 -0
  156. package/dist/types/src/cli/summary-reporter.d.ts +3 -3
  157. package/dist/types/src/cli.d.ts +3 -1
  158. package/dist/types/src/rules/erb-no-empty-tags.d.ts +2 -2
  159. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +2 -2
  160. package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
  161. package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +2 -2
  162. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +2 -2
  163. package/dist/types/src/rules/html-anchor-require-href.d.ts +2 -2
  164. package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +2 -2
  165. package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
  166. package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +2 -2
  167. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +2 -2
  168. package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +2 -2
  169. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +2 -2
  170. package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
  171. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +2 -2
  172. package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
  173. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +2 -2
  174. package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
  175. package/dist/types/src/rules/html-img-require-alt.d.ts +2 -2
  176. package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
  177. package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
  178. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +2 -2
  179. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +2 -2
  180. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +2 -2
  181. package/dist/types/src/rules/html-no-empty-headings.d.ts +2 -2
  182. package/dist/types/src/rules/html-no-nested-links.d.ts +2 -2
  183. package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
  184. package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
  185. package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
  186. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +3 -2
  187. package/dist/types/src/rules/index.d.ts +10 -0
  188. package/dist/types/src/rules/parser-no-errors.d.ts +8 -0
  189. package/dist/types/src/rules/rule-utils.d.ts +107 -13
  190. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +2 -2
  191. package/dist/types/src/types.d.ts +27 -3
  192. package/dist/types/types.d.ts +27 -3
  193. package/docs/rules/README.md +13 -2
  194. package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
  195. package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
  196. package/docs/rules/html-attribute-equals-spacing.md +35 -0
  197. package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
  198. package/docs/rules/html-iframe-has-title.md +43 -0
  199. package/docs/rules/html-navigation-has-label.md +61 -0
  200. package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
  201. package/docs/rules/html-no-positive-tab-index.md +55 -0
  202. package/docs/rules/html-no-self-closing.md +65 -0
  203. package/docs/rules/html-no-title-attribute.md +69 -0
  204. package/docs/rules/html-tag-name-lowercase.md +16 -3
  205. package/docs/rules/parser-no-errors.md +84 -0
  206. package/package.json +4 -4
  207. package/src/cli/argument-parser.ts +33 -24
  208. package/src/cli/file-processor.ts +25 -17
  209. package/src/cli/formatters/base-formatter.ts +2 -2
  210. package/src/cli/formatters/detailed-formatter.ts +9 -9
  211. package/src/cli/formatters/github-actions-formatter.ts +70 -0
  212. package/src/cli/formatters/index.ts +2 -0
  213. package/src/cli/formatters/json-formatter.ts +107 -0
  214. package/src/cli/formatters/simple-formatter.ts +15 -15
  215. package/src/cli/output-manager.ts +143 -0
  216. package/src/cli/summary-reporter.ts +24 -24
  217. package/src/cli.ts +48 -31
  218. package/src/default-rules.ts +22 -0
  219. package/src/linter.ts +30 -4
  220. package/src/rules/erb-no-empty-tags.ts +3 -3
  221. package/src/rules/erb-no-output-control-flow.ts +3 -3
  222. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
  223. package/src/rules/erb-prefer-image-tag-helper.ts +4 -9
  224. package/src/rules/erb-require-whitespace-inside-tags.ts +3 -3
  225. package/src/rules/erb-requires-trailing-newline.ts +2 -0
  226. package/src/rules/html-anchor-require-href.ts +3 -3
  227. package/src/rules/html-aria-attribute-must-be-valid.ts +29 -33
  228. package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
  229. package/src/rules/html-aria-level-must-be-valid.ts +40 -7
  230. package/src/rules/html-aria-role-heading-requires-level.ts +18 -30
  231. package/src/rules/html-aria-role-must-be-valid.ts +7 -7
  232. package/src/rules/html-attribute-double-quotes.ts +23 -8
  233. package/src/rules/html-attribute-equals-spacing.ts +41 -0
  234. package/src/rules/html-attribute-values-require-quotes.ts +32 -12
  235. package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
  236. package/src/rules/html-boolean-attributes-no-value.ts +19 -6
  237. package/src/rules/html-iframe-has-title.ts +62 -0
  238. package/src/rules/html-img-require-alt.ts +4 -9
  239. package/src/rules/html-navigation-has-label.ts +64 -0
  240. package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
  241. package/src/rules/html-no-block-inside-inline.ts +5 -5
  242. package/src/rules/html-no-duplicate-attributes.ts +30 -30
  243. package/src/rules/html-no-duplicate-ids.ts +6 -5
  244. package/src/rules/html-no-empty-headings.ts +4 -33
  245. package/src/rules/html-no-nested-links.ts +3 -3
  246. package/src/rules/html-no-positive-tab-index.ts +33 -0
  247. package/src/rules/html-no-self-closing.ts +36 -0
  248. package/src/rules/html-no-title-attribute.ts +42 -0
  249. package/src/rules/html-tag-name-lowercase.ts +44 -31
  250. package/src/rules/index.ts +10 -0
  251. package/src/rules/parser-no-errors.ts +25 -0
  252. package/src/rules/rule-utils.ts +260 -39
  253. package/src/rules/svg-tag-name-capitalization.ts +4 -11
  254. package/src/types.ts +30 -3
@@ -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 { Node, 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(
@@ -21,10 +21,10 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
21
21
  export class HTMLAriaRoleMustBeValidRule extends ParserRule {
22
22
  name = "html-aria-role-must-be-valid"
23
23
 
24
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
24
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
25
25
  const visitor = new AriaRoleMustBeValid(this.name, context)
26
26
 
27
- visitor.visit(node)
27
+ visitor.visit(result.value)
28
28
 
29
29
  return visitor.offenses
30
30
  }
@@ -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 { Node, 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
  )
@@ -21,9 +34,11 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
21
34
  export class HTMLAttributeDoubleQuotesRule extends ParserRule {
22
35
  name = "html-attribute-double-quotes"
23
36
 
24
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
37
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
25
38
  const visitor = new AttributeDoubleQuotesVisitor(this.name, context)
26
- visitor.visit(node)
39
+
40
+ visitor.visit(result.value)
41
+
27
42
  return visitor.offenses
28
43
  }
29
44
  }
@@ -0,0 +1,41 @@
1
+ import { BaseRuleVisitor } from "./rule-utils.js"
2
+ import { ParserRule } from "../types.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { ParseResult, HTMLAttributeNode } from "@herb-tools/core"
6
+
7
+ class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
8
+ visitHTMLAttributeNode(attribute: HTMLAttributeNode): void {
9
+ if (!attribute.equals || !attribute.name || !attribute.value) {
10
+ return
11
+ }
12
+
13
+ if (attribute.equals.value.startsWith(" ")) {
14
+ this.addOffense(
15
+ "Remove whitespace before `=` in HTML attribute",
16
+ attribute.equals.location,
17
+ "error"
18
+ )
19
+ }
20
+
21
+ if (attribute.equals.value.endsWith(" ")) {
22
+ this.addOffense(
23
+ "Remove whitespace after `=` in HTML attribute",
24
+ attribute.equals.location,
25
+ "error"
26
+ )
27
+ }
28
+ }
29
+ }
30
+
31
+ export class HTMLAttributeEqualsSpacingRule extends ParserRule {
32
+ name = "html-attribute-equals-spacing"
33
+
34
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
35
+ const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context)
36
+
37
+ visitor.visit(result.value)
38
+
39
+ return visitor.offenses
40
+ }
41
+ }
@@ -1,31 +1,51 @@
1
- import { AttributeVisitorMixin } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLAttributeNode, HTMLAttributeValueNode, Node } from "@herb-tools/core"
5
+ import type { HTMLAttributeNode, HTMLAttributeValueNode, ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
8
- protected checkAttribute(attributeName: string, _attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
9
- if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE") return
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
9
+ if (this.hasAttributeValue(attributeNode)) return
10
+ if (this.isQuoted(attributeNode)) return
10
11
 
11
- const valueNode = attributeNode.value as HTMLAttributeValueNode
12
- if (valueNode.quoted) return
12
+ this.addOffense(
13
+ `Attribute value should be quoted: \`${attributeName}="${attributeValue}"\`. Always wrap attribute values in quotes.`,
14
+ attributeNode.value!.location,
15
+ "error"
16
+ )
17
+ }
18
+
19
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }: StaticAttributeDynamicValueParams): void {
20
+ if (this.hasAttributeValue(attributeNode)) return
21
+ if (this.isQuoted(attributeNode)) return
13
22
 
14
23
  this.addOffense(
15
- // TODO: print actual attribute value in message
16
- `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`,
17
- valueNode.location,
24
+ `Attribute value should be quoted: \`${attributeName}="${combinedValue}"\`. Always wrap attribute values in quotes.`,
25
+ attributeNode.value!.location,
18
26
  "error"
19
27
  )
20
28
  }
29
+
30
+ private hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
31
+ return attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE"
32
+ }
33
+
34
+ private isQuoted(attributeNode: HTMLAttributeNode): boolean {
35
+ const valueNode = attributeNode.value as HTMLAttributeValueNode
36
+
37
+ return valueNode.quoted
38
+ }
21
39
  }
22
40
 
23
41
  export class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
24
42
  name = "html-attribute-values-require-quotes"
25
43
 
26
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
44
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
27
45
  const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context)
28
- visitor.visit(node)
46
+
47
+ visitor.visit(result.value)
48
+
29
49
  return visitor.offenses
30
50
  }
31
51
  }
@@ -0,0 +1,66 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute, getAttributes, findAttributeByName } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, HTMLAttributeValueNode, ParseResult, Node } from "@herb-tools/core"
6
+
7
+ const ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT = new Set([
8
+ "button", "fieldset", "input", "optgroup", "option", "select", "textarea"
9
+ ])
10
+
11
+ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
12
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
13
+ this.checkElement(node)
14
+ super.visitHTMLOpenTagNode(node)
15
+ }
16
+
17
+ private checkElement(node: HTMLOpenTagNode): void {
18
+ const tagName = getTagName(node)
19
+
20
+ if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
21
+ return
22
+ }
23
+
24
+ const hasDisabled = hasAttribute(node, "disabled")
25
+ const hasAriaDisabled = hasAttribute(node, "aria-disabled")
26
+
27
+ if ((hasDisabled && this.hasERBContent(node, "disabled")) || (hasAriaDisabled && this.hasERBContent(node, "aria-disabled"))) {
28
+ return
29
+ }
30
+
31
+ if (hasDisabled && hasAriaDisabled) {
32
+ this.addOffense(
33
+ "aria-disabled may be used in place of native HTML disabled to allow tab-focus on an otherwise ignored element. Setting both attributes is contradictory and confusing. Choose either disabled or aria-disabled, not both.",
34
+ node.tag_name!.location,
35
+ "error"
36
+ )
37
+ }
38
+ }
39
+
40
+ private hasERBContent(node: HTMLOpenTagNode, attributeName: string): boolean {
41
+ const attributes = getAttributes(node)
42
+
43
+ const attribute = findAttributeByName(attributes, attributeName)
44
+ if (!attribute) return false
45
+
46
+ const valueNode = attribute.value
47
+ if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE") return false
48
+
49
+ const htmlValueNode = valueNode as HTMLAttributeValueNode
50
+ if (!htmlValueNode.children) return false
51
+
52
+ return htmlValueNode.children.some((child: Node) => child.type === "AST_ERB_CONTENT_NODE")
53
+ }
54
+ }
55
+
56
+ export class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
57
+ name = "html-avoid-both-disabled-and-aria-disabled"
58
+
59
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
60
+ const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context)
61
+
62
+ visitor.visit(result.value)
63
+
64
+ return visitor.offenses
65
+ }
66
+ }
@@ -1,11 +1,11 @@
1
- import { AttributeVisitorMixin, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
2
-
3
1
  import { ParserRule } from "../types.js"
2
+ import { AttributeVisitorMixin, StaticAttributeStaticValueParams, StaticAttributeDynamicValueParams, isBooleanAttribute, hasAttributeValue } from "./rule-utils.js"
3
+
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLAttributeNode, Node } from "@herb-tools/core"
5
+ import type { ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
8
- protected checkAttribute(attributeName: string, _attributeValue: string | null, attributeNode: HTMLAttributeNode): void {
8
+ protected checkStaticAttributeStaticValue({ attributeName, attributeNode }: StaticAttributeStaticValueParams) {
9
9
  if (!isBooleanAttribute(attributeName)) return
10
10
  if (!hasAttributeValue(attributeNode)) return
11
11
 
@@ -15,14 +15,27 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
15
15
  "error"
16
16
  )
17
17
  }
18
+
19
+ protected checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }: StaticAttributeDynamicValueParams) {
20
+ if (!isBooleanAttribute(attributeName)) return
21
+ if (!hasAttributeValue(attributeNode)) return
22
+
23
+ this.addOffense(
24
+ `Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`,
25
+ attributeNode.value!.location,
26
+ "error"
27
+ )
28
+ }
18
29
  }
19
30
 
20
31
  export class HTMLBooleanAttributesNoValueRule extends ParserRule {
21
32
  name = "html-boolean-attributes-no-value"
22
33
 
23
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
34
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
24
35
  const visitor = new BooleanAttributesNoValueVisitor(this.name, context)
25
- visitor.visit(node)
36
+
37
+ visitor.visit(result.value)
38
+
26
39
  return visitor.offenses
27
40
  }
28
41
  }
@@ -0,0 +1,62 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, getAttribute, getAttributeValue } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ class IframeHasTitleVisitor extends BaseRuleVisitor {
8
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
+ this.checkIframeElement(node)
10
+ super.visitHTMLOpenTagNode(node)
11
+ }
12
+
13
+ private checkIframeElement(node: HTMLOpenTagNode): void {
14
+ const tagName = getTagName(node)
15
+
16
+ if (tagName !== "iframe") {
17
+ return
18
+ }
19
+
20
+ const ariaHiddenAttribute = getAttribute(node, "aria-hidden")
21
+ if (ariaHiddenAttribute) {
22
+ const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute)
23
+ if (ariaHiddenValue === "true") {
24
+ return
25
+ }
26
+ }
27
+
28
+ const attribute = getAttribute(node, "title")
29
+
30
+ if (!attribute) {
31
+ this.addOffense(
32
+ "`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.",
33
+ node.location,
34
+ "error"
35
+ )
36
+
37
+ return
38
+ }
39
+
40
+ const value = getAttributeValue(attribute)
41
+
42
+ if (!value || value.trim() === "") {
43
+ this.addOffense(
44
+ "`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.",
45
+ node.location,
46
+ "error"
47
+ )
48
+ }
49
+ }
50
+ }
51
+
52
+ export class HTMLIframeHasTitleRule extends ParserRule {
53
+ name = "html-iframe-has-title"
54
+
55
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
56
+ const visitor = new IframeHasTitleVisitor(this.name, context)
57
+
58
+ visitor.visit(result.value)
59
+
60
+ return visitor.offenses
61
+ }
62
+ }
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, getTagName, hasAttribute } from "./rule-utils.js"
2
2
 
3
3
  import { ParserRule } from "../types.js"
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLOpenTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class ImgRequireAltVisitor extends BaseRuleVisitor {
8
8
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
@@ -10,12 +10,7 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
10
10
  super.visitHTMLOpenTagNode(node)
11
11
  }
12
12
 
13
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
14
- this.checkImgTag(node)
15
- super.visitHTMLSelfCloseTagNode(node)
16
- }
17
-
18
- private checkImgTag(node: HTMLOpenTagNode | HTMLSelfCloseTagNode): void {
13
+ private checkImgTag(node: HTMLOpenTagNode): void {
19
14
  const tagName = getTagName(node)
20
15
 
21
16
  if (tagName !== "img") {
@@ -35,9 +30,9 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
35
30
  export class HTMLImgRequireAltRule extends ParserRule {
36
31
  name = "html-img-require-alt"
37
32
 
38
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
33
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
39
34
  const visitor = new ImgRequireAltVisitor(this.name, context)
40
- visitor.visit(node)
35
+ visitor.visit(result.value)
41
36
  return visitor.offenses
42
37
  }
43
38
  }
@@ -0,0 +1,64 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute, getAttributeValue, findAttributeByName, getAttributes } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ class NavigationHasLabelVisitor extends BaseRuleVisitor {
8
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9
+ this.checkNavigationElement(node)
10
+ super.visitHTMLOpenTagNode(node)
11
+ }
12
+
13
+ private checkNavigationElement(node: HTMLOpenTagNode): void {
14
+ const tagName = getTagName(node)
15
+ const isNavElement = tagName === "nav"
16
+ const hasNavigationRole = this.hasRoleNavigation(node)
17
+
18
+ if (!isNavElement && !hasNavigationRole) {
19
+ return
20
+ }
21
+
22
+ const hasAriaLabel = hasAttribute(node, "aria-label")
23
+ const hasAriaLabelledby = hasAttribute(node, "aria-labelledby")
24
+
25
+ if (!hasAriaLabel && !hasAriaLabelledby) {
26
+ let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`
27
+
28
+ if (hasNavigationRole && !isNavElement) {
29
+ message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`
30
+ }
31
+
32
+ this.addOffense(
33
+ message,
34
+ node.tag_name!.location,
35
+ "error"
36
+ )
37
+ }
38
+ }
39
+
40
+ private hasRoleNavigation(node: HTMLOpenTagNode): boolean {
41
+ const attributes = getAttributes(node)
42
+ const roleAttribute = findAttributeByName(attributes, "role")
43
+
44
+ if (!roleAttribute) {
45
+ return false
46
+ }
47
+
48
+ const roleValue = getAttributeValue(roleAttribute)
49
+
50
+ return roleValue === "navigation"
51
+ }
52
+ }
53
+
54
+ export class HTMLNavigationHasLabelRule extends ParserRule {
55
+ name = "html-navigation-has-label"
56
+
57
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
58
+ const visitor = new NavigationHasLabelVisitor(this.name, context)
59
+
60
+ visitor.visit(result.value)
61
+
62
+ return visitor.offenses
63
+ }
64
+ }
@@ -0,0 +1,90 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor, getTagName, hasAttribute, getAttributeValue, findAttributeByName, getAttributes } from "./rule-utils.js"
3
+
4
+ import type { LintOffense, LintContext } from "../types.js"
5
+ import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
6
+
7
+ const INTERACTIVE_ELEMENTS = new Set([
8
+ "button", "summary", "input", "select", "textarea", "a"
9
+ ])
10
+
11
+ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
12
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
13
+ this.checkAriaHiddenOnFocusable(node)
14
+ super.visitHTMLOpenTagNode(node)
15
+ }
16
+
17
+ private checkAriaHiddenOnFocusable(node: HTMLOpenTagNode): void {
18
+ if (!this.hasAriaHiddenTrue(node)) return
19
+
20
+ if (this.isFocusable(node)) {
21
+ this.addOffense(
22
+ `Elements that are focusable should not have \`aria-hidden="true"\` because it will cause confusion for assistive technology users.`,
23
+ node.tag_name!.location,
24
+ "error"
25
+ )
26
+ }
27
+ }
28
+
29
+ private hasAriaHiddenTrue(node: HTMLOpenTagNode): boolean {
30
+ const attributes = getAttributes(node)
31
+ const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden")
32
+
33
+ if (!ariaHiddenAttr) return false
34
+
35
+ const value = getAttributeValue(ariaHiddenAttr)
36
+
37
+ return value === "true"
38
+ }
39
+
40
+ private isFocusable(node: HTMLOpenTagNode): boolean {
41
+ const tagName = getTagName(node)
42
+ if (!tagName) return false
43
+
44
+ const tabIndexValue = this.getTabIndexValue(node)
45
+
46
+ if (tagName === "a") {
47
+ const hasHref = hasAttribute(node, "href")
48
+
49
+ if (!hasHref) {
50
+ return tabIndexValue !== null && tabIndexValue >= 0
51
+ }
52
+
53
+ return tabIndexValue === null || tabIndexValue >= 0
54
+ }
55
+
56
+ if (INTERACTIVE_ELEMENTS.has(tagName)) {
57
+ // Interactive elements are focusable unless tabindex is negative
58
+ return tabIndexValue === null || tabIndexValue >= 0
59
+ } else {
60
+ // Non-interactive elements are focusable only if tabindex >= 0
61
+ return tabIndexValue !== null && tabIndexValue >= 0
62
+ }
63
+ }
64
+
65
+ private getTabIndexValue(node: HTMLOpenTagNode): number | null {
66
+ const attributes = getAttributes(node)
67
+ const tabIndexAttribute = findAttributeByName(attributes, "tabindex")
68
+
69
+ if (!tabIndexAttribute) return null
70
+
71
+ const value = getAttributeValue(tabIndexAttribute)
72
+ if (!value) return null
73
+
74
+ const parsed = parseInt(value, 10)
75
+
76
+ return isNaN(parsed) ? null : parsed
77
+ }
78
+ }
79
+
80
+ export class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
81
+ name = "html-no-aria-hidden-on-focusable"
82
+
83
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
84
+ const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context)
85
+
86
+ visitor.visit(result.value)
87
+
88
+ return visitor.offenses
89
+ }
90
+ }
@@ -2,7 +2,7 @@ import { BaseRuleVisitor, isInlineElement, isBlockElement } from "./rule-utils.j
2
2
 
3
3
  import { ParserRule } from "../types.js"
4
4
  import type { LintOffense, LintContext } from "../types.js"
5
- import type { HTMLOpenTagNode, HTMLElementNode, Node } from "@herb-tools/core"
5
+ import type { HTMLOpenTagNode, HTMLElementNode, ParseResult } from "@herb-tools/core"
6
6
 
7
7
  class BlockInsideInlineVisitor extends BaseRuleVisitor {
8
8
  private inlineStack: string[] = []
@@ -19,7 +19,7 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
19
19
  return { isInline, isBlock, isUnknown }
20
20
  }
21
21
 
22
- private addViolationMessage(tagName: string, isBlock: boolean, openTag: HTMLOpenTagNode): void {
22
+ private addOffenseMessage(tagName: string, isBlock: boolean, openTag: HTMLOpenTagNode): void {
23
23
  const parentInline = this.inlineStack[this.inlineStack.length - 1]
24
24
  const elementType = isBlock ? "Block-level" : "Unknown"
25
25
 
@@ -62,7 +62,7 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
62
62
  const { isInline, isBlock, isUnknown } = this.getElementType(tagName)
63
63
 
64
64
  if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
65
- this.addViolationMessage(tagName, isBlock, openTag)
65
+ this.addOffenseMessage(tagName, isBlock, openTag)
66
66
  }
67
67
 
68
68
  if (isInline) {
@@ -77,9 +77,9 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
77
77
  export class HTMLNoBlockInsideInlineRule extends ParserRule {
78
78
  name = "html-no-block-inside-inline"
79
79
 
80
- check(node: Node, context?: Partial<LintContext>): LintOffense[] {
80
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
81
81
  const visitor = new BlockInsideInlineVisitor(this.name, context)
82
- visitor.visit(node)
82
+ visitor.visit(result.value)
83
83
  return visitor.offenses
84
84
  }
85
85
  }