@herb-tools/linter 0.8.9 → 0.9.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 (573) hide show
  1. package/README.md +5 -5
  2. package/dist/{src/cli → cli}/argument-parser.js +15 -2
  3. package/dist/cli/argument-parser.js.map +1 -0
  4. package/dist/{src/cli → cli}/file-processor.js +155 -9
  5. package/dist/cli/file-processor.js.map +1 -0
  6. package/dist/cli/file-url.js +6 -0
  7. package/dist/cli/file-url.js.map +1 -0
  8. package/dist/cli/formatters/base-formatter.js.map +1 -0
  9. package/dist/{src/cli → cli}/formatters/detailed-formatter.js +16 -19
  10. package/dist/cli/formatters/detailed-formatter.js.map +1 -0
  11. package/dist/cli/formatters/github-actions-formatter.js.map +1 -0
  12. package/dist/cli/formatters/index.js.map +1 -0
  13. package/dist/cli/formatters/json-formatter.js.map +1 -0
  14. package/dist/cli/formatters/simple-formatter.js +54 -0
  15. package/dist/cli/formatters/simple-formatter.js.map +1 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/lint-worker.js +143 -0
  18. package/dist/cli/lint-worker.js.map +1 -0
  19. package/dist/cli/output-manager.js.map +1 -0
  20. package/dist/{src/cli → cli}/summary-reporter.js +13 -16
  21. package/dist/cli/summary-reporter.js.map +1 -0
  22. package/dist/{src/cli.js → cli.js} +5 -3
  23. package/dist/cli.js.map +1 -0
  24. package/dist/{src/custom-rule-loader.js → custom-rule-loader.js} +20 -4
  25. package/dist/custom-rule-loader.js.map +1 -0
  26. package/dist/herb-disable-comment-utils.js.map +1 -0
  27. package/dist/herb-lint.js +60648 -17513
  28. package/dist/herb-lint.js.map +1 -1
  29. package/dist/index.cjs +2621 -934
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +2554 -873
  32. package/dist/index.js.map +1 -1
  33. package/dist/lint-worker.js +71462 -0
  34. package/dist/lint-worker.js.map +1 -0
  35. package/dist/linter-ignore.js.map +1 -0
  36. package/dist/{src/linter.js → linter.js} +89 -74
  37. package/dist/linter.js.map +1 -0
  38. package/dist/loader.cjs +31206 -7834
  39. package/dist/loader.cjs.map +1 -1
  40. package/dist/loader.js +31168 -7802
  41. package/dist/loader.js.map +1 -1
  42. package/dist/parse-cache.js +30 -0
  43. package/dist/parse-cache.js.map +1 -0
  44. package/dist/rules/actionview-no-silent-helper.js +45 -0
  45. package/dist/rules/actionview-no-silent-helper.js.map +1 -0
  46. package/dist/{src/rules → rules}/erb-comment-syntax.js +2 -2
  47. package/dist/rules/erb-comment-syntax.js.map +1 -0
  48. package/dist/{src/rules → rules}/erb-no-case-node-children.js +2 -2
  49. package/dist/rules/erb-no-case-node-children.js.map +1 -0
  50. package/dist/rules/erb-no-conditional-html-element.js +38 -0
  51. package/dist/rules/erb-no-conditional-html-element.js.map +1 -0
  52. package/dist/rules/erb-no-conditional-open-tag.js +24 -0
  53. package/dist/rules/erb-no-conditional-open-tag.js.map +1 -0
  54. package/dist/rules/erb-no-duplicate-branch-elements.js +245 -0
  55. package/dist/rules/erb-no-duplicate-branch-elements.js.map +1 -0
  56. package/dist/{src/rules → rules}/erb-no-empty-tags.js +2 -2
  57. package/dist/rules/erb-no-empty-tags.js.map +1 -0
  58. package/dist/{src/rules → rules}/erb-no-extra-newline.js +4 -21
  59. package/dist/rules/erb-no-extra-newline.js.map +1 -0
  60. package/dist/{src/rules → rules}/erb-no-extra-whitespace-inside-tags.js +39 -13
  61. package/dist/rules/erb-no-extra-whitespace-inside-tags.js.map +1 -0
  62. package/dist/rules/erb-no-inline-case-conditions.js +40 -0
  63. package/dist/rules/erb-no-inline-case-conditions.js.map +1 -0
  64. package/dist/rules/erb-no-instance-variables-in-partials.js +67 -0
  65. package/dist/rules/erb-no-instance-variables-in-partials.js.map +1 -0
  66. package/dist/rules/erb-no-interpolated-class-names.js +47 -0
  67. package/dist/rules/erb-no-interpolated-class-names.js.map +1 -0
  68. package/dist/rules/erb-no-javascript-tag-helper.js +34 -0
  69. package/dist/rules/erb-no-javascript-tag-helper.js.map +1 -0
  70. package/dist/{src/rules → rules}/erb-no-output-control-flow.js +9 -12
  71. package/dist/rules/erb-no-output-control-flow.js.map +1 -0
  72. package/dist/rules/erb-no-output-in-attribute-name.js +30 -0
  73. package/dist/rules/erb-no-output-in-attribute-name.js.map +1 -0
  74. package/dist/rules/erb-no-output-in-attribute-position.js +30 -0
  75. package/dist/rules/erb-no-output-in-attribute-position.js.map +1 -0
  76. package/dist/rules/erb-no-raw-output-in-attribute-value.js +35 -0
  77. package/dist/rules/erb-no-raw-output-in-attribute-value.js.map +1 -0
  78. package/dist/{src/rules → rules}/erb-no-silent-tag-in-attribute-name.js +2 -2
  79. package/dist/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
  80. package/dist/rules/erb-no-statement-in-script.js +58 -0
  81. package/dist/rules/erb-no-statement-in-script.js.map +1 -0
  82. package/dist/rules/erb-no-then-in-control-flow.js +45 -0
  83. package/dist/rules/erb-no-then-in-control-flow.js.map +1 -0
  84. package/dist/rules/erb-no-trailing-whitespace.js +138 -0
  85. package/dist/rules/erb-no-trailing-whitespace.js.map +1 -0
  86. package/dist/rules/erb-no-unsafe-js-attribute.js +36 -0
  87. package/dist/rules/erb-no-unsafe-js-attribute.js.map +1 -0
  88. package/dist/rules/erb-no-unsafe-raw.js +63 -0
  89. package/dist/rules/erb-no-unsafe-raw.js.map +1 -0
  90. package/dist/rules/erb-no-unsafe-script-interpolation.js +54 -0
  91. package/dist/rules/erb-no-unsafe-script-interpolation.js.map +1 -0
  92. package/dist/{src/rules → rules}/erb-prefer-image-tag-helper.js +5 -4
  93. package/dist/rules/erb-prefer-image-tag-helper.js.map +1 -0
  94. package/dist/{src/rules → rules}/erb-require-trailing-newline.js +2 -2
  95. package/dist/rules/erb-require-trailing-newline.js.map +1 -0
  96. package/dist/{src/rules → rules}/erb-require-whitespace-inside-tags.js +39 -15
  97. package/dist/rules/erb-require-whitespace-inside-tags.js.map +1 -0
  98. package/dist/{src/rules → rules}/erb-right-trim.js +2 -2
  99. package/dist/rules/erb-right-trim.js.map +1 -0
  100. package/dist/{src/rules → rules}/erb-strict-locals-comment-syntax.js +5 -5
  101. package/dist/rules/erb-strict-locals-comment-syntax.js.map +1 -0
  102. package/dist/{src/rules → rules}/erb-strict-locals-required.js +2 -2
  103. package/dist/rules/erb-strict-locals-required.js.map +1 -0
  104. package/dist/rules/file-utils.js.map +1 -0
  105. package/dist/rules/herb-disable-comment-base.js.map +1 -0
  106. package/dist/{src/rules → rules}/herb-disable-comment-malformed.js +2 -2
  107. package/dist/rules/herb-disable-comment-malformed.js.map +1 -0
  108. package/dist/{src/rules → rules}/herb-disable-comment-missing-rules.js +2 -2
  109. package/dist/rules/herb-disable-comment-missing-rules.js.map +1 -0
  110. package/dist/{src/rules → rules}/herb-disable-comment-no-duplicate-rules.js +2 -2
  111. package/dist/rules/herb-disable-comment-no-duplicate-rules.js.map +1 -0
  112. package/dist/{src/rules → rules}/herb-disable-comment-no-redundant-all.js +2 -2
  113. package/dist/rules/herb-disable-comment-no-redundant-all.js.map +1 -0
  114. package/dist/{src/rules → rules}/herb-disable-comment-unnecessary.js +2 -2
  115. package/dist/rules/herb-disable-comment-unnecessary.js.map +1 -0
  116. package/dist/{src/rules → rules}/herb-disable-comment-valid-rule-name.js +2 -2
  117. package/dist/rules/herb-disable-comment-valid-rule-name.js.map +1 -0
  118. package/dist/rules/html-allowed-script-type.js +57 -0
  119. package/dist/rules/html-allowed-script-type.js.map +1 -0
  120. package/dist/rules/html-anchor-require-href.js +68 -0
  121. package/dist/rules/html-anchor-require-href.js.map +1 -0
  122. package/dist/{src/rules → rules}/html-aria-attribute-must-be-valid.js +3 -3
  123. package/dist/rules/html-aria-attribute-must-be-valid.js.map +1 -0
  124. package/dist/{src/rules → rules}/html-aria-label-is-well-formatted.js +3 -3
  125. package/dist/rules/html-aria-label-is-well-formatted.js.map +1 -0
  126. package/dist/{src/rules → rules}/html-aria-level-must-be-valid.js +3 -3
  127. package/dist/rules/html-aria-level-must-be-valid.js.map +1 -0
  128. package/dist/{src/rules → rules}/html-aria-role-heading-requires-level.js +5 -4
  129. package/dist/rules/html-aria-role-heading-requires-level.js.map +1 -0
  130. package/dist/{src/rules → rules}/html-aria-role-must-be-valid.js +3 -3
  131. package/dist/rules/html-aria-role-must-be-valid.js.map +1 -0
  132. package/dist/{src/rules → rules}/html-attribute-double-quotes.js +4 -4
  133. package/dist/rules/html-attribute-double-quotes.js.map +1 -0
  134. package/dist/{src/rules → rules}/html-attribute-equals-spacing.js +2 -2
  135. package/dist/rules/html-attribute-equals-spacing.js.map +1 -0
  136. package/dist/{src/rules → rules}/html-attribute-values-require-quotes.js +2 -2
  137. package/dist/rules/html-attribute-values-require-quotes.js.map +1 -0
  138. package/dist/{src/rules → rules}/html-avoid-both-disabled-and-aria-disabled.js +9 -9
  139. package/dist/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
  140. package/dist/{src/rules → rules}/html-body-only-elements.js +5 -4
  141. package/dist/rules/html-body-only-elements.js.map +1 -0
  142. package/dist/{src/rules → rules}/html-boolean-attributes-no-value.js +4 -3
  143. package/dist/rules/html-boolean-attributes-no-value.js.map +1 -0
  144. package/dist/rules/html-details-has-summary.js +52 -0
  145. package/dist/rules/html-details-has-summary.js.map +1 -0
  146. package/dist/{src/rules → rules}/html-head-only-elements.js +6 -5
  147. package/dist/rules/html-head-only-elements.js.map +1 -0
  148. package/dist/{src/rules → rules}/html-iframe-has-title.js +8 -11
  149. package/dist/rules/html-iframe-has-title.js.map +1 -0
  150. package/dist/{src/rules → rules}/html-img-require-alt.js +11 -5
  151. package/dist/rules/html-img-require-alt.js.map +1 -0
  152. package/dist/{src/rules → rules}/html-input-require-autocomplete.js +7 -10
  153. package/dist/rules/html-input-require-autocomplete.js.map +1 -0
  154. package/dist/{src/rules → rules}/html-navigation-has-label.js +6 -5
  155. package/dist/rules/html-navigation-has-label.js.map +1 -0
  156. package/dist/rules/html-no-abstract-roles.js +29 -0
  157. package/dist/rules/html-no-abstract-roles.js.map +1 -0
  158. package/dist/rules/html-no-aria-hidden-on-body.js +42 -0
  159. package/dist/rules/html-no-aria-hidden-on-body.js.map +1 -0
  160. package/dist/{src/rules → rules}/html-no-aria-hidden-on-focusable.js +6 -5
  161. package/dist/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
  162. package/dist/{src/rules → rules}/html-no-block-inside-inline.js +6 -9
  163. package/dist/rules/html-no-block-inside-inline.js.map +1 -0
  164. package/dist/{src/rules → rules}/html-no-duplicate-attributes.js +4 -3
  165. package/dist/rules/html-no-duplicate-attributes.js.map +1 -0
  166. package/dist/{src/rules → rules}/html-no-duplicate-ids.js +14 -11
  167. package/dist/rules/html-no-duplicate-ids.js.map +1 -0
  168. package/dist/{src/rules → rules}/html-no-duplicate-meta-names.js +22 -20
  169. package/dist/rules/html-no-duplicate-meta-names.js.map +1 -0
  170. package/dist/{src/rules → rules}/html-no-empty-attributes.js +2 -2
  171. package/dist/rules/html-no-empty-attributes.js.map +1 -0
  172. package/dist/rules/html-no-empty-headings.js +98 -0
  173. package/dist/rules/html-no-empty-headings.js.map +1 -0
  174. package/dist/{src/rules → rules}/html-no-nested-links.js +23 -15
  175. package/dist/rules/html-no-nested-links.js.map +1 -0
  176. package/dist/{src/rules → rules}/html-no-positive-tab-index.js +3 -3
  177. package/dist/rules/html-no-positive-tab-index.js.map +1 -0
  178. package/dist/{src/rules → rules}/html-no-self-closing.js +4 -4
  179. package/dist/rules/html-no-self-closing.js.map +1 -0
  180. package/dist/{src/rules → rules}/html-no-space-in-tag.js +4 -6
  181. package/dist/rules/html-no-space-in-tag.js.map +1 -0
  182. package/dist/{src/rules → rules}/html-no-title-attribute.js +6 -5
  183. package/dist/rules/html-no-title-attribute.js.map +1 -0
  184. package/dist/{src/rules → rules}/html-no-underscores-in-attribute-names.js +2 -2
  185. package/dist/rules/html-no-underscores-in-attribute-names.js.map +1 -0
  186. package/dist/rules/html-require-closing-tags.js +29 -0
  187. package/dist/rules/html-require-closing-tags.js.map +1 -0
  188. package/dist/{src/rules → rules}/html-tag-name-lowercase.js +13 -9
  189. package/dist/rules/html-tag-name-lowercase.js.map +1 -0
  190. package/dist/{src/rules → rules}/index.js +19 -0
  191. package/dist/rules/index.js.map +1 -0
  192. package/dist/{src/rules → rules}/parser-no-errors.js +3 -3
  193. package/dist/rules/parser-no-errors.js.map +1 -0
  194. package/dist/{src/rules → rules}/rule-utils.js +141 -219
  195. package/dist/rules/rule-utils.js.map +1 -0
  196. package/dist/rules/string-utils.js.map +1 -0
  197. package/dist/{src/rules → rules}/svg-tag-name-capitalization.js +7 -6
  198. package/dist/rules/svg-tag-name-capitalization.js.map +1 -0
  199. package/dist/rules/turbo-permanent-require-id.js +34 -0
  200. package/dist/rules/turbo-permanent-require-id.js.map +1 -0
  201. package/dist/{src/rules.js → rules.js} +56 -10
  202. package/dist/rules.js.map +1 -0
  203. package/dist/types/cli/argument-parser.d.ts +1 -0
  204. package/dist/types/cli/file-processor.d.ts +13 -0
  205. package/dist/types/cli/file-url.d.ts +1 -0
  206. package/dist/types/cli/index.d.ts +1 -0
  207. package/dist/types/cli/lint-worker.d.ts +34 -0
  208. package/dist/types/custom-rule-loader.d.ts +4 -0
  209. package/dist/types/index.d.ts +1 -0
  210. package/dist/types/linter.d.ts +13 -6
  211. package/dist/types/parse-cache.d.ts +9 -0
  212. package/dist/types/{src/rules/html-aria-level-must-be-valid.d.ts → rules/actionview-no-silent-helper.d.ts} +4 -3
  213. package/dist/types/rules/erb-comment-syntax.d.ts +1 -1
  214. package/dist/types/rules/erb-no-case-node-children.d.ts +1 -1
  215. package/dist/types/{src/rules/herb-disable-comment-malformed.d.ts → rules/erb-no-conditional-html-element.d.ts} +3 -3
  216. package/dist/types/{src/rules/erb-prefer-image-tag-helper.d.ts → rules/erb-no-conditional-open-tag.d.ts} +3 -3
  217. package/dist/types/rules/erb-no-duplicate-branch-elements.d.ts +17 -0
  218. package/dist/types/rules/erb-no-empty-tags.d.ts +1 -1
  219. package/dist/types/rules/erb-no-extra-newline.d.ts +1 -1
  220. package/dist/types/rules/erb-no-extra-whitespace-inside-tags.d.ts +1 -1
  221. package/dist/types/{src/rules/html-no-duplicate-attributes.d.ts → rules/erb-no-inline-case-conditions.d.ts} +4 -3
  222. package/dist/types/rules/erb-no-instance-variables-in-partials.d.ts +10 -0
  223. package/dist/types/{src/rules/html-no-aria-hidden-on-focusable.d.ts → rules/erb-no-interpolated-class-names.d.ts} +2 -2
  224. package/dist/types/{src/rules/html-aria-attribute-must-be-valid.d.ts → rules/erb-no-javascript-tag-helper.d.ts} +2 -2
  225. package/dist/types/rules/erb-no-output-control-flow.d.ts +1 -1
  226. package/dist/types/{src/rules/erb-no-silent-tag-in-attribute-name.d.ts → rules/erb-no-output-in-attribute-name.d.ts} +2 -2
  227. package/dist/types/{src/rules/herb-disable-comment-missing-rules.d.ts → rules/erb-no-output-in-attribute-position.d.ts} +2 -2
  228. package/dist/types/{src/rules/erb-no-empty-tags.d.ts → rules/erb-no-raw-output-in-attribute-value.d.ts} +2 -2
  229. package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +1 -1
  230. package/dist/types/{src/rules/html-navigation-has-label.d.ts → rules/erb-no-statement-in-script.d.ts} +2 -2
  231. package/dist/types/rules/erb-no-then-in-control-flow.d.ts +9 -0
  232. package/dist/types/rules/erb-no-trailing-whitespace.d.ts +19 -0
  233. package/dist/types/{src/rules/html-no-positive-tab-index.d.ts → rules/erb-no-unsafe-js-attribute.d.ts} +2 -2
  234. package/dist/types/{src/rules/erb-no-case-node-children.d.ts → rules/erb-no-unsafe-raw.d.ts} +2 -2
  235. package/dist/types/rules/erb-no-unsafe-script-interpolation.d.ts +8 -0
  236. package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +1 -1
  237. package/dist/types/rules/erb-require-trailing-newline.d.ts +1 -1
  238. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +1 -1
  239. package/dist/types/rules/erb-right-trim.d.ts +1 -1
  240. package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +1 -1
  241. package/dist/types/rules/erb-strict-locals-required.d.ts +1 -1
  242. package/dist/types/rules/herb-disable-comment-malformed.d.ts +1 -1
  243. package/dist/types/rules/herb-disable-comment-missing-rules.d.ts +1 -1
  244. package/dist/types/rules/herb-disable-comment-no-duplicate-rules.d.ts +1 -1
  245. package/dist/types/rules/herb-disable-comment-no-redundant-all.d.ts +1 -1
  246. package/dist/types/rules/herb-disable-comment-unnecessary.d.ts +1 -1
  247. package/dist/types/rules/herb-disable-comment-valid-rule-name.d.ts +1 -1
  248. package/dist/types/{src/rules/html-anchor-require-href.d.ts → rules/html-allowed-script-type.d.ts} +2 -2
  249. package/dist/types/rules/html-anchor-require-href.d.ts +3 -2
  250. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +1 -1
  251. package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +1 -1
  252. package/dist/types/rules/html-aria-level-must-be-valid.d.ts +1 -1
  253. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +1 -1
  254. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +1 -1
  255. package/dist/types/rules/html-attribute-double-quotes.d.ts +1 -1
  256. package/dist/types/rules/html-attribute-equals-spacing.d.ts +1 -1
  257. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +1 -1
  258. package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +1 -1
  259. package/dist/types/rules/html-body-only-elements.d.ts +1 -1
  260. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +1 -1
  261. package/dist/types/{src/rules/html-no-empty-attributes.d.ts → rules/html-details-has-summary.d.ts} +4 -3
  262. package/dist/types/rules/html-head-only-elements.d.ts +1 -1
  263. package/dist/types/rules/html-iframe-has-title.d.ts +1 -1
  264. package/dist/types/rules/html-img-require-alt.d.ts +1 -1
  265. package/dist/types/rules/html-input-require-autocomplete.d.ts +1 -1
  266. package/dist/types/rules/html-navigation-has-label.d.ts +1 -1
  267. package/dist/types/{src/rules/html-no-empty-headings.d.ts → rules/html-no-abstract-roles.d.ts} +2 -2
  268. package/dist/types/{src/rules/erb-no-output-control-flow.d.ts → rules/html-no-aria-hidden-on-body.d.ts} +3 -3
  269. package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +1 -1
  270. package/dist/types/rules/html-no-block-inside-inline.d.ts +1 -1
  271. package/dist/types/rules/html-no-duplicate-attributes.d.ts +1 -1
  272. package/dist/types/rules/html-no-duplicate-ids.d.ts +1 -1
  273. package/dist/types/rules/html-no-duplicate-meta-names.d.ts +1 -1
  274. package/dist/types/rules/html-no-empty-attributes.d.ts +1 -1
  275. package/dist/types/rules/html-no-empty-headings.d.ts +1 -1
  276. package/dist/types/rules/html-no-nested-links.d.ts +1 -1
  277. package/dist/types/rules/html-no-positive-tab-index.d.ts +1 -1
  278. package/dist/types/rules/html-no-self-closing.d.ts +1 -1
  279. package/dist/types/rules/html-no-space-in-tag.d.ts +1 -1
  280. package/dist/types/rules/html-no-title-attribute.d.ts +1 -1
  281. package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +1 -1
  282. package/dist/types/{src/rules/html-body-only-elements.d.ts → rules/html-require-closing-tags.d.ts} +4 -3
  283. package/dist/types/rules/html-tag-name-lowercase.d.ts +1 -1
  284. package/dist/types/rules/index.d.ts +19 -0
  285. package/dist/types/rules/parser-no-errors.d.ts +1 -1
  286. package/dist/types/rules/rule-utils.d.ts +35 -88
  287. package/dist/types/rules/svg-tag-name-capitalization.d.ts +1 -1
  288. package/dist/types/{src/rules/html-aria-role-must-be-valid.d.ts → rules/turbo-permanent-require-id.d.ts} +2 -2
  289. package/dist/types/types.d.ts +25 -7
  290. package/dist/types/urls.d.ts +1 -0
  291. package/dist/{src/types.js → types.js} +53 -0
  292. package/dist/types.js.map +1 -0
  293. package/dist/urls.js +5 -0
  294. package/dist/urls.js.map +1 -0
  295. package/docs/rules/README.md +23 -2
  296. package/docs/rules/actionview-no-silent-helper.md +57 -0
  297. package/docs/rules/erb-no-conditional-html-element.md +90 -0
  298. package/docs/rules/erb-no-conditional-open-tag.md +130 -0
  299. package/docs/rules/erb-no-duplicate-branch-elements.md +98 -0
  300. package/docs/rules/erb-no-inline-case-conditions.md +85 -0
  301. package/docs/rules/erb-no-instance-variables-in-partials.md +43 -0
  302. package/docs/rules/erb-no-interpolated-class-names.md +57 -0
  303. package/docs/rules/erb-no-javascript-tag-helper.md +33 -0
  304. package/docs/rules/erb-no-output-in-attribute-name.md +38 -0
  305. package/docs/rules/erb-no-output-in-attribute-position.md +60 -0
  306. package/docs/rules/erb-no-raw-output-in-attribute-value.md +37 -0
  307. package/docs/rules/erb-no-statement-in-script.md +68 -0
  308. package/docs/rules/erb-no-then-in-control-flow.md +86 -0
  309. package/docs/rules/erb-no-trailing-whitespace.md +69 -0
  310. package/docs/rules/erb-no-unsafe-js-attribute.md +41 -0
  311. package/docs/rules/erb-no-unsafe-raw.md +47 -0
  312. package/docs/rules/erb-no-unsafe-script-interpolation.md +73 -0
  313. package/docs/rules/html-allowed-script-type.md +59 -0
  314. package/docs/rules/html-anchor-require-href.md +19 -6
  315. package/docs/rules/html-details-has-summary.md +46 -0
  316. package/docs/rules/html-img-require-alt.md +5 -3
  317. package/docs/rules/html-no-abstract-roles.md +74 -0
  318. package/docs/rules/html-no-aria-hidden-on-body.md +44 -0
  319. package/docs/rules/html-require-closing-tags.md +142 -0
  320. package/docs/rules/parser-no-errors.md +4 -17
  321. package/docs/rules/turbo-permanent-require-id.md +41 -0
  322. package/package.json +12 -11
  323. package/src/cli/argument-parser.ts +20 -2
  324. package/src/cli/file-processor.ts +189 -10
  325. package/src/cli/file-url.ts +6 -0
  326. package/src/cli/formatters/detailed-formatter.ts +19 -21
  327. package/src/cli/formatters/simple-formatter.ts +23 -13
  328. package/src/cli/index.ts +2 -0
  329. package/src/cli/lint-worker.ts +208 -0
  330. package/src/cli/summary-reporter.ts +14 -15
  331. package/src/cli.ts +5 -3
  332. package/src/custom-rule-loader.ts +20 -5
  333. package/src/herb-disable-comment-utils.ts +0 -3
  334. package/src/index.ts +1 -0
  335. package/src/linter.ts +98 -79
  336. package/src/parse-cache.ts +39 -0
  337. package/src/rules/actionview-no-silent-helper.ts +58 -0
  338. package/src/rules/erb-comment-syntax.ts +2 -2
  339. package/src/rules/erb-no-case-node-children.ts +2 -2
  340. package/src/rules/erb-no-conditional-html-element.ts +53 -0
  341. package/src/rules/erb-no-conditional-open-tag.ts +37 -0
  342. package/src/rules/erb-no-duplicate-branch-elements.ts +320 -0
  343. package/src/rules/erb-no-empty-tags.ts +2 -2
  344. package/src/rules/erb-no-extra-newline.ts +5 -25
  345. package/src/rules/erb-no-extra-whitespace-inside-tags.ts +45 -15
  346. package/src/rules/erb-no-inline-case-conditions.ts +54 -0
  347. package/src/rules/erb-no-instance-variables-in-partials.ts +101 -0
  348. package/src/rules/erb-no-interpolated-class-names.ts +65 -0
  349. package/src/rules/erb-no-javascript-tag-helper.ts +47 -0
  350. package/src/rules/erb-no-output-control-flow.ts +10 -10
  351. package/src/rules/erb-no-output-in-attribute-name.ts +39 -0
  352. package/src/rules/erb-no-output-in-attribute-position.ts +39 -0
  353. package/src/rules/erb-no-raw-output-in-attribute-value.ts +47 -0
  354. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +2 -2
  355. package/src/rules/erb-no-statement-in-script.ts +82 -0
  356. package/src/rules/erb-no-then-in-control-flow.ts +62 -0
  357. package/src/rules/erb-no-trailing-whitespace.ts +187 -0
  358. package/src/rules/erb-no-unsafe-js-attribute.ts +47 -0
  359. package/src/rules/erb-no-unsafe-raw.ts +83 -0
  360. package/src/rules/erb-no-unsafe-script-interpolation.ts +76 -0
  361. package/src/rules/erb-prefer-image-tag-helper.ts +5 -4
  362. package/src/rules/erb-require-trailing-newline.ts +2 -2
  363. package/src/rules/erb-require-whitespace-inside-tags.ts +42 -18
  364. package/src/rules/erb-right-trim.ts +2 -2
  365. package/src/rules/erb-strict-locals-comment-syntax.ts +5 -5
  366. package/src/rules/erb-strict-locals-required.ts +2 -2
  367. package/src/rules/herb-disable-comment-malformed.ts +2 -2
  368. package/src/rules/herb-disable-comment-missing-rules.ts +2 -2
  369. package/src/rules/herb-disable-comment-no-duplicate-rules.ts +2 -2
  370. package/src/rules/herb-disable-comment-no-redundant-all.ts +2 -2
  371. package/src/rules/herb-disable-comment-unnecessary.ts +2 -2
  372. package/src/rules/herb-disable-comment-valid-rule-name.ts +2 -2
  373. package/src/rules/html-allowed-script-type.ts +84 -0
  374. package/src/rules/html-anchor-require-href.ts +73 -11
  375. package/src/rules/html-aria-attribute-must-be-valid.ts +3 -3
  376. package/src/rules/html-aria-label-is-well-formatted.ts +3 -3
  377. package/src/rules/html-aria-level-must-be-valid.ts +3 -3
  378. package/src/rules/html-aria-role-heading-requires-level.ts +5 -4
  379. package/src/rules/html-aria-role-must-be-valid.ts +3 -3
  380. package/src/rules/html-attribute-double-quotes.ts +4 -4
  381. package/src/rules/html-attribute-equals-spacing.ts +2 -2
  382. package/src/rules/html-attribute-values-require-quotes.ts +2 -2
  383. package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +10 -11
  384. package/src/rules/html-body-only-elements.ts +5 -4
  385. package/src/rules/html-boolean-attributes-no-value.ts +4 -3
  386. package/src/rules/html-details-has-summary.ts +69 -0
  387. package/src/rules/html-head-only-elements.ts +6 -5
  388. package/src/rules/html-iframe-has-title.ts +8 -11
  389. package/src/rules/html-img-require-alt.ts +16 -5
  390. package/src/rules/html-input-require-autocomplete.ts +7 -10
  391. package/src/rules/html-navigation-has-label.ts +6 -5
  392. package/src/rules/html-no-abstract-roles.ts +40 -0
  393. package/src/rules/html-no-aria-hidden-on-body.ts +58 -0
  394. package/src/rules/html-no-aria-hidden-on-focusable.ts +6 -5
  395. package/src/rules/html-no-block-inside-inline.ts +7 -13
  396. package/src/rules/html-no-duplicate-attributes.ts +4 -3
  397. package/src/rules/html-no-duplicate-ids.ts +16 -13
  398. package/src/rules/html-no-duplicate-meta-names.ts +20 -19
  399. package/src/rules/html-no-empty-attributes.ts +2 -2
  400. package/src/rules/html-no-empty-headings.ts +44 -58
  401. package/src/rules/html-no-nested-links.ts +25 -16
  402. package/src/rules/html-no-positive-tab-index.ts +3 -3
  403. package/src/rules/html-no-self-closing.ts +5 -5
  404. package/src/rules/html-no-space-in-tag.ts +5 -8
  405. package/src/rules/html-no-title-attribute.ts +6 -5
  406. package/src/rules/html-no-underscores-in-attribute-names.ts +2 -2
  407. package/src/rules/html-require-closing-tags.ts +41 -0
  408. package/src/rules/html-tag-name-lowercase.ts +14 -9
  409. package/src/rules/index.ts +19 -0
  410. package/src/rules/parser-no-errors.ts +3 -3
  411. package/src/rules/rule-utils.ts +162 -279
  412. package/src/rules/svg-tag-name-capitalization.ts +10 -10
  413. package/src/rules/turbo-permanent-require-id.ts +49 -0
  414. package/src/rules.ts +60 -10
  415. package/src/types.ts +76 -7
  416. package/src/urls.ts +5 -0
  417. package/dist/package.json +0 -65
  418. package/dist/src/cli/argument-parser.js.map +0 -1
  419. package/dist/src/cli/file-processor.js.map +0 -1
  420. package/dist/src/cli/formatters/base-formatter.js.map +0 -1
  421. package/dist/src/cli/formatters/detailed-formatter.js.map +0 -1
  422. package/dist/src/cli/formatters/github-actions-formatter.js.map +0 -1
  423. package/dist/src/cli/formatters/index.js.map +0 -1
  424. package/dist/src/cli/formatters/json-formatter.js.map +0 -1
  425. package/dist/src/cli/formatters/simple-formatter.js +0 -44
  426. package/dist/src/cli/formatters/simple-formatter.js.map +0 -1
  427. package/dist/src/cli/index.js.map +0 -1
  428. package/dist/src/cli/output-manager.js.map +0 -1
  429. package/dist/src/cli/summary-reporter.js.map +0 -1
  430. package/dist/src/cli.js.map +0 -1
  431. package/dist/src/custom-rule-loader.js.map +0 -1
  432. package/dist/src/herb-disable-comment-utils.js.map +0 -1
  433. package/dist/src/herb-lint.js +0 -5
  434. package/dist/src/herb-lint.js.map +0 -1
  435. package/dist/src/index.js +0 -5
  436. package/dist/src/index.js.map +0 -1
  437. package/dist/src/linter-ignore.js.map +0 -1
  438. package/dist/src/linter.js.map +0 -1
  439. package/dist/src/loader.js +0 -17
  440. package/dist/src/loader.js.map +0 -1
  441. package/dist/src/rules/erb-comment-syntax.js.map +0 -1
  442. package/dist/src/rules/erb-no-case-node-children.js.map +0 -1
  443. package/dist/src/rules/erb-no-empty-tags.js.map +0 -1
  444. package/dist/src/rules/erb-no-extra-newline.js.map +0 -1
  445. package/dist/src/rules/erb-no-extra-whitespace-inside-tags.js.map +0 -1
  446. package/dist/src/rules/erb-no-output-control-flow.js.map +0 -1
  447. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +0 -1
  448. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +0 -1
  449. package/dist/src/rules/erb-require-trailing-newline.js.map +0 -1
  450. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +0 -1
  451. package/dist/src/rules/erb-right-trim.js.map +0 -1
  452. package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +0 -1
  453. package/dist/src/rules/erb-strict-locals-required.js.map +0 -1
  454. package/dist/src/rules/file-utils.js.map +0 -1
  455. package/dist/src/rules/herb-disable-comment-base.js.map +0 -1
  456. package/dist/src/rules/herb-disable-comment-malformed.js.map +0 -1
  457. package/dist/src/rules/herb-disable-comment-missing-rules.js.map +0 -1
  458. package/dist/src/rules/herb-disable-comment-no-duplicate-rules.js.map +0 -1
  459. package/dist/src/rules/herb-disable-comment-no-redundant-all.js.map +0 -1
  460. package/dist/src/rules/herb-disable-comment-unnecessary.js.map +0 -1
  461. package/dist/src/rules/herb-disable-comment-valid-rule-name.js.map +0 -1
  462. package/dist/src/rules/html-anchor-require-href.js +0 -32
  463. package/dist/src/rules/html-anchor-require-href.js.map +0 -1
  464. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +0 -1
  465. package/dist/src/rules/html-aria-label-is-well-formatted.js.map +0 -1
  466. package/dist/src/rules/html-aria-level-must-be-valid.js.map +0 -1
  467. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +0 -1
  468. package/dist/src/rules/html-aria-role-must-be-valid.js.map +0 -1
  469. package/dist/src/rules/html-attribute-double-quotes.js.map +0 -1
  470. package/dist/src/rules/html-attribute-equals-spacing.js.map +0 -1
  471. package/dist/src/rules/html-attribute-values-require-quotes.js.map +0 -1
  472. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +0 -1
  473. package/dist/src/rules/html-body-only-elements.js.map +0 -1
  474. package/dist/src/rules/html-boolean-attributes-no-value.js.map +0 -1
  475. package/dist/src/rules/html-head-only-elements.js.map +0 -1
  476. package/dist/src/rules/html-iframe-has-title.js.map +0 -1
  477. package/dist/src/rules/html-img-require-alt.js.map +0 -1
  478. package/dist/src/rules/html-input-require-autocomplete.js.map +0 -1
  479. package/dist/src/rules/html-navigation-has-label.js.map +0 -1
  480. package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +0 -1
  481. package/dist/src/rules/html-no-block-inside-inline.js.map +0 -1
  482. package/dist/src/rules/html-no-duplicate-attributes.js.map +0 -1
  483. package/dist/src/rules/html-no-duplicate-ids.js.map +0 -1
  484. package/dist/src/rules/html-no-duplicate-meta-names.js.map +0 -1
  485. package/dist/src/rules/html-no-empty-attributes.js.map +0 -1
  486. package/dist/src/rules/html-no-empty-headings.js +0 -115
  487. package/dist/src/rules/html-no-empty-headings.js.map +0 -1
  488. package/dist/src/rules/html-no-nested-links.js.map +0 -1
  489. package/dist/src/rules/html-no-positive-tab-index.js.map +0 -1
  490. package/dist/src/rules/html-no-self-closing.js.map +0 -1
  491. package/dist/src/rules/html-no-space-in-tag.js.map +0 -1
  492. package/dist/src/rules/html-no-title-attribute.js.map +0 -1
  493. package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +0 -1
  494. package/dist/src/rules/html-tag-name-lowercase.js.map +0 -1
  495. package/dist/src/rules/index.js.map +0 -1
  496. package/dist/src/rules/parser-no-errors.js.map +0 -1
  497. package/dist/src/rules/rule-utils.js.map +0 -1
  498. package/dist/src/rules/string-utils.js.map +0 -1
  499. package/dist/src/rules/svg-tag-name-capitalization.js.map +0 -1
  500. package/dist/src/rules.js.map +0 -1
  501. package/dist/src/types.js.map +0 -1
  502. package/dist/tsconfig.tsbuildinfo +0 -1
  503. package/dist/types/src/cli/argument-parser.d.ts +0 -25
  504. package/dist/types/src/cli/file-processor.d.ts +0 -43
  505. package/dist/types/src/cli/formatters/base-formatter.d.ts +0 -6
  506. package/dist/types/src/cli/formatters/detailed-formatter.d.ts +0 -13
  507. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +0 -17
  508. package/dist/types/src/cli/formatters/index.d.ts +0 -5
  509. package/dist/types/src/cli/formatters/json-formatter.d.ts +0 -48
  510. package/dist/types/src/cli/formatters/simple-formatter.d.ts +0 -8
  511. package/dist/types/src/cli/index.d.ts +0 -5
  512. package/dist/types/src/cli/output-manager.d.ts +0 -32
  513. package/dist/types/src/cli/summary-reporter.d.ts +0 -28
  514. package/dist/types/src/cli.d.ts +0 -28
  515. package/dist/types/src/custom-rule-loader.d.ts +0 -62
  516. package/dist/types/src/herb-disable-comment-utils.d.ts +0 -69
  517. package/dist/types/src/herb-lint.d.ts +0 -2
  518. package/dist/types/src/index.d.ts +0 -4
  519. package/dist/types/src/linter-ignore.d.ts +0 -12
  520. package/dist/types/src/linter.d.ts +0 -133
  521. package/dist/types/src/loader.d.ts +0 -20
  522. package/dist/types/src/rules/erb-comment-syntax.d.ts +0 -14
  523. package/dist/types/src/rules/erb-no-extra-newline.d.ts +0 -14
  524. package/dist/types/src/rules/erb-no-extra-whitespace-inside-tags.d.ts +0 -18
  525. package/dist/types/src/rules/erb-require-trailing-newline.d.ts +0 -9
  526. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +0 -18
  527. package/dist/types/src/rules/erb-right-trim.d.ts +0 -14
  528. package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +0 -9
  529. package/dist/types/src/rules/erb-strict-locals-required.d.ts +0 -9
  530. package/dist/types/src/rules/file-utils.d.ts +0 -13
  531. package/dist/types/src/rules/herb-disable-comment-base.d.ts +0 -37
  532. package/dist/types/src/rules/herb-disable-comment-no-duplicate-rules.d.ts +0 -8
  533. package/dist/types/src/rules/herb-disable-comment-no-redundant-all.d.ts +0 -8
  534. package/dist/types/src/rules/herb-disable-comment-unnecessary.d.ts +0 -8
  535. package/dist/types/src/rules/herb-disable-comment-valid-rule-name.d.ts +0 -8
  536. package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +0 -8
  537. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +0 -8
  538. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +0 -15
  539. package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +0 -14
  540. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +0 -15
  541. package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +0 -8
  542. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +0 -14
  543. package/dist/types/src/rules/html-head-only-elements.d.ts +0 -9
  544. package/dist/types/src/rules/html-iframe-has-title.d.ts +0 -8
  545. package/dist/types/src/rules/html-img-require-alt.d.ts +0 -8
  546. package/dist/types/src/rules/html-input-require-autocomplete.d.ts +0 -8
  547. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +0 -8
  548. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +0 -8
  549. package/dist/types/src/rules/html-no-duplicate-meta-names.d.ts +0 -9
  550. package/dist/types/src/rules/html-no-nested-links.d.ts +0 -8
  551. package/dist/types/src/rules/html-no-self-closing.d.ts +0 -16
  552. package/dist/types/src/rules/html-no-space-in-tag.d.ts +0 -16
  553. package/dist/types/src/rules/html-no-title-attribute.d.ts +0 -8
  554. package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +0 -8
  555. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +0 -18
  556. package/dist/types/src/rules/index.d.ts +0 -54
  557. package/dist/types/src/rules/parser-no-errors.d.ts +0 -9
  558. package/dist/types/src/rules/rule-utils.d.ts +0 -351
  559. package/dist/types/src/rules/string-utils.d.ts +0 -15
  560. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +0 -16
  561. package/dist/types/src/rules.d.ts +0 -2
  562. package/dist/types/src/types.d.ts +0 -190
  563. /package/dist/{src/cli → cli}/formatters/base-formatter.js +0 -0
  564. /package/dist/{src/cli → cli}/formatters/github-actions-formatter.js +0 -0
  565. /package/dist/{src/cli → cli}/formatters/index.js +0 -0
  566. /package/dist/{src/cli → cli}/formatters/json-formatter.js +0 -0
  567. /package/dist/{src/cli → cli}/index.js +0 -0
  568. /package/dist/{src/cli → cli}/output-manager.js +0 -0
  569. /package/dist/{src/herb-disable-comment-utils.js → herb-disable-comment-utils.js} +0 -0
  570. /package/dist/{src/linter-ignore.js → linter-ignore.js} +0 -0
  571. /package/dist/{src/rules → rules}/file-utils.js +0 -0
  572. /package/dist/{src/rules → rules}/herb-disable-comment-base.js +0 -0
  573. /package/dist/{src/rules → rules}/string-utils.js +0 -0
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { getNodesBeforePosition, getNodesAfterPosition, filterNodes, ERBContentNode, isERBOutputNode, Visitor, isToken, isParseResult, getStaticAttributeName, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, hasStaticContent, getStaticContentFromNodes, Location, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, isERBNode, isWhitespaceNode, isCommentNode, isLiteralNode, isHTMLTextNode, Position, filterERBContentNodes, isNode, LiteralNode, didyoumean, filterLiteralNodes, Token, getTagName as getTagName$1, isHTMLElementNode, isHTMLOpenTagNode, HTMLCloseTagNode, WhitespaceNode, filterWhitespaceNodes, HTMLOpenTagNode, isERBCommentNode } from '@herb-tools/core';
2
1
  import picomatch from 'picomatch';
2
+ import { getNodesBeforePosition, getNodesAfterPosition, filterNodes, ERBContentNode, isERBOutputNode, isERBIfNode, isERBUnlessNode, isERBElseNode, isHTMLTextNode, Visitor, isToken, isParseResult, forEachAttribute, getAttributeName, hasDynamicAttributeNameOnAttribute, getStaticAttributeValue, getAttributeValueNodes, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, getAttributeValue, getCombinedAttributeNameString, Location, Position, isERBOpenTagNode, isERBNode, isWhitespaceNode, isCommentNode, isLiteralNode, isERBCaseNode, isPureWhitespaceNode, isERBWhenNode, isHTMLElementNode, isEquivalentElement, findParentArray, removeNodeFromArray, replaceNodeWithBody, createLiteral, HTMLElementNode, PrismVisitor, splitLiteralsAtWhitespace, groupNodesByClass, filterERBContentNodes, isHTMLOpenTagNode, getTagLocalName, getAttribute, isERBCommentNode, getAttributes, findAttributeByName, isNode, LiteralNode, didyoumean, hasAttributeValue, isRubyLiteralNode, filterHTMLAttributeNodes, filterLiteralNodes, getAttributeValueQuoteType, Token, hasAttribute, isHTMLAttributeValueNode, isERBContentNode, getStaticAttributeName, isERBControlFlowNode, HTMLCloseTagNode, getTagName, createWhitespaceNode, filterWhitespaceNodes, getStaticContentFromNodes, getOpenTag, isHTMLCloseTagNode, HTMLOpenTagNode, DEFAULT_PARSER_OPTIONS } from '@herb-tools/core';
3
3
 
4
4
  class PrintContext {
5
5
  output = "";
@@ -141,7 +141,9 @@ class Printer extends Visitor {
141
141
  return this.context.getOutput();
142
142
  }
143
143
  write(content) {
144
- this.context.write(content);
144
+ if (content !== null && content !== undefined) {
145
+ this.context.write(content);
146
+ }
145
147
  }
146
148
  }
147
149
 
@@ -201,6 +203,12 @@ class IdentityPrinter extends Printer {
201
203
  this.write(node.tag_closing.value);
202
204
  }
203
205
  }
206
+ visitHTMLVirtualCloseTagNode(_node) {
207
+ // Virtual closing tags don't print anything (they are synthetic)
208
+ }
209
+ visitHTMLOmittedCloseTagNode(_node) {
210
+ // Omitted closing tags don't print anything
211
+ }
204
212
  visitHTMLElementNode(node) {
205
213
  const tagName = node.tag_name?.value;
206
214
  if (tagName) {
@@ -219,6 +227,24 @@ class IdentityPrinter extends Printer {
219
227
  this.context.exitTag();
220
228
  }
221
229
  }
230
+ visitHTMLConditionalElementNode(node) {
231
+ const tagName = node.tag_name?.value;
232
+ if (tagName) {
233
+ this.context.enterTag(tagName);
234
+ }
235
+ if (node.open_conditional) {
236
+ this.visit(node.open_conditional);
237
+ }
238
+ if (node.body) {
239
+ node.body.forEach(child => this.visit(child));
240
+ }
241
+ if (node.close_conditional) {
242
+ this.visit(node.close_conditional);
243
+ }
244
+ if (tagName) {
245
+ this.context.exitTag();
246
+ }
247
+ }
222
248
  visitHTMLAttributeNode(node) {
223
249
  if (node.name) {
224
250
  this.visit(node.name);
@@ -242,6 +268,12 @@ class IdentityPrinter extends Printer {
242
268
  this.write(node.close_quote.value);
243
269
  }
244
270
  }
271
+ visitRubyLiteralNode(node) {
272
+ this.write(node.content);
273
+ }
274
+ visitRubyHTMLAttributesSplatNode(node) {
275
+ this.write(node.content);
276
+ }
245
277
  visitHTMLCommentNode(node) {
246
278
  if (node.comment_start) {
247
279
  this.write(node.comment_start.value);
@@ -278,6 +310,9 @@ class IdentityPrinter extends Printer {
278
310
  this.write(node.tag_closing.value);
279
311
  }
280
312
  }
313
+ visitERBOpenTagNode(node) {
314
+ this.printERBNode(node);
315
+ }
281
316
  visitERBContentNode(node) {
282
317
  this.printERBNode(node);
283
318
  }
@@ -444,6 +479,259 @@ class IdentityPrinter extends Printer {
444
479
  }
445
480
  }
446
481
 
482
+ /**
483
+ * IndentPrinter - Re-indentation printer that preserves content but adjusts indentation
484
+ *
485
+ * Extends IdentityPrinter to preserve all content as-is while replacing
486
+ * leading whitespace on each line with the correct indentation based on
487
+ * the AST nesting depth.
488
+ */
489
+ class IndentPrinter extends IdentityPrinter {
490
+ indentLevel = 0;
491
+ indentWidth;
492
+ pendingIndent = false;
493
+ constructor(indentWidth = 2) {
494
+ super();
495
+ this.indentWidth = indentWidth;
496
+ }
497
+ get indent() {
498
+ return " ".repeat(this.indentLevel * this.indentWidth);
499
+ }
500
+ write(content) {
501
+ if (this.pendingIndent && content.length > 0) {
502
+ this.pendingIndent = false;
503
+ this.context.write(this.indent + content);
504
+ }
505
+ else {
506
+ this.context.write(content);
507
+ }
508
+ }
509
+ visitLiteralNode(node) {
510
+ this.writeWithIndent(node.content);
511
+ }
512
+ visitHTMLTextNode(node) {
513
+ this.writeWithIndent(node.content);
514
+ }
515
+ visitHTMLElementNode(node) {
516
+ const tagName = node.tag_name?.value;
517
+ if (tagName) {
518
+ this.context.enterTag(tagName);
519
+ }
520
+ if (node.open_tag) {
521
+ this.visit(node.open_tag);
522
+ }
523
+ if (node.body) {
524
+ this.indentLevel++;
525
+ node.body.forEach(child => this.visit(child));
526
+ this.indentLevel--;
527
+ }
528
+ if (node.close_tag) {
529
+ this.visit(node.close_tag);
530
+ }
531
+ if (tagName) {
532
+ this.context.exitTag();
533
+ }
534
+ }
535
+ visitERBIfNode(node) {
536
+ this.printERBNode(node);
537
+ if (node.statements) {
538
+ this.indentLevel++;
539
+ node.statements.forEach(statement => this.visit(statement));
540
+ this.indentLevel--;
541
+ }
542
+ if (node.subsequent) {
543
+ this.visit(node.subsequent);
544
+ }
545
+ if (node.end_node) {
546
+ this.visit(node.end_node);
547
+ }
548
+ }
549
+ visitERBElseNode(node) {
550
+ this.printERBNode(node);
551
+ if (node.statements) {
552
+ this.indentLevel++;
553
+ node.statements.forEach(statement => this.visit(statement));
554
+ this.indentLevel--;
555
+ }
556
+ }
557
+ visitERBBlockNode(node) {
558
+ this.printERBNode(node);
559
+ if (node.body) {
560
+ this.indentLevel++;
561
+ node.body.forEach(child => this.visit(child));
562
+ this.indentLevel--;
563
+ }
564
+ if (node.end_node) {
565
+ this.visit(node.end_node);
566
+ }
567
+ }
568
+ visitERBCaseNode(node) {
569
+ this.printERBNode(node);
570
+ if (node.children) {
571
+ this.indentLevel++;
572
+ node.children.forEach(child => this.visit(child));
573
+ this.indentLevel--;
574
+ }
575
+ if (node.conditions) {
576
+ this.indentLevel++;
577
+ node.conditions.forEach(condition => this.visit(condition));
578
+ this.indentLevel--;
579
+ }
580
+ if (node.else_clause) {
581
+ this.indentLevel++;
582
+ this.visit(node.else_clause);
583
+ this.indentLevel--;
584
+ }
585
+ if (node.end_node) {
586
+ this.visit(node.end_node);
587
+ }
588
+ }
589
+ visitERBWhenNode(node) {
590
+ this.printERBNode(node);
591
+ if (node.statements) {
592
+ this.indentLevel++;
593
+ node.statements.forEach(statement => this.visit(statement));
594
+ this.indentLevel--;
595
+ }
596
+ }
597
+ visitERBWhileNode(node) {
598
+ this.printERBNode(node);
599
+ if (node.statements) {
600
+ this.indentLevel++;
601
+ node.statements.forEach(statement => this.visit(statement));
602
+ this.indentLevel--;
603
+ }
604
+ if (node.end_node) {
605
+ this.visit(node.end_node);
606
+ }
607
+ }
608
+ visitERBUntilNode(node) {
609
+ this.printERBNode(node);
610
+ if (node.statements) {
611
+ this.indentLevel++;
612
+ node.statements.forEach(statement => this.visit(statement));
613
+ this.indentLevel--;
614
+ }
615
+ if (node.end_node) {
616
+ this.visit(node.end_node);
617
+ }
618
+ }
619
+ visitERBForNode(node) {
620
+ this.printERBNode(node);
621
+ if (node.statements) {
622
+ this.indentLevel++;
623
+ node.statements.forEach(statement => this.visit(statement));
624
+ this.indentLevel--;
625
+ }
626
+ if (node.end_node) {
627
+ this.visit(node.end_node);
628
+ }
629
+ }
630
+ visitERBBeginNode(node) {
631
+ this.printERBNode(node);
632
+ if (node.statements) {
633
+ this.indentLevel++;
634
+ node.statements.forEach(statement => this.visit(statement));
635
+ this.indentLevel--;
636
+ }
637
+ if (node.rescue_clause) {
638
+ this.visit(node.rescue_clause);
639
+ }
640
+ if (node.else_clause) {
641
+ this.visit(node.else_clause);
642
+ }
643
+ if (node.ensure_clause) {
644
+ this.visit(node.ensure_clause);
645
+ }
646
+ if (node.end_node) {
647
+ this.visit(node.end_node);
648
+ }
649
+ }
650
+ visitERBRescueNode(node) {
651
+ this.printERBNode(node);
652
+ if (node.statements) {
653
+ this.indentLevel++;
654
+ node.statements.forEach(statement => this.visit(statement));
655
+ this.indentLevel--;
656
+ }
657
+ if (node.subsequent) {
658
+ this.visit(node.subsequent);
659
+ }
660
+ }
661
+ visitERBEnsureNode(node) {
662
+ this.printERBNode(node);
663
+ if (node.statements) {
664
+ this.indentLevel++;
665
+ node.statements.forEach(statement => this.visit(statement));
666
+ this.indentLevel--;
667
+ }
668
+ }
669
+ visitERBUnlessNode(node) {
670
+ this.printERBNode(node);
671
+ if (node.statements) {
672
+ this.indentLevel++;
673
+ node.statements.forEach(statement => this.visit(statement));
674
+ this.indentLevel--;
675
+ }
676
+ if (node.else_clause) {
677
+ this.visit(node.else_clause);
678
+ }
679
+ if (node.end_node) {
680
+ this.visit(node.end_node);
681
+ }
682
+ }
683
+ /**
684
+ * Write content, replacing leading whitespace on each line with the current indent.
685
+ *
686
+ * Uses a pendingIndent mechanism: when content ends with a newline followed by
687
+ * whitespace-only, sets pendingIndent=true instead of writing the indent immediately.
688
+ * The indent is then applied at the correct level when the next node writes content
689
+ * (via the overridden write() method).
690
+ */
691
+ writeWithIndent(content) {
692
+ if (!content.includes("\n")) {
693
+ if (this.pendingIndent) {
694
+ this.pendingIndent = false;
695
+ const trimmed = content.replace(/^[ \t]+/, "");
696
+ if (trimmed.length > 0) {
697
+ this.context.write(this.indent + trimmed);
698
+ }
699
+ }
700
+ else {
701
+ this.context.write(content);
702
+ }
703
+ return;
704
+ }
705
+ const lines = content.split("\n");
706
+ const lastIndex = lines.length - 1;
707
+ for (let i = 0; i < lines.length; i++) {
708
+ if (i > 0) {
709
+ this.context.write("\n");
710
+ }
711
+ const line = lines[i];
712
+ const trimmed = line.replace(/^[ \t]+/, "");
713
+ if (i === 0) {
714
+ if (this.pendingIndent) {
715
+ this.pendingIndent = false;
716
+ if (trimmed.length > 0) {
717
+ this.context.write(this.indent + trimmed);
718
+ }
719
+ }
720
+ else {
721
+ this.context.write(line);
722
+ }
723
+ }
724
+ else if (i === lastIndex && trimmed.length === 0) {
725
+ this.pendingIndent = true;
726
+ }
727
+ else if (trimmed.length === 0) ;
728
+ else {
729
+ this.context.write(this.indent + trimmed);
730
+ }
731
+ }
732
+ }
733
+ }
734
+
447
735
  const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
448
736
  ...DEFAULT_PRINT_OPTIONS,
449
737
  forceQuotes: false
@@ -465,7 +753,6 @@ const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
465
753
  * - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
466
754
  */
467
755
  class ERBToRubyStringPrinter extends IdentityPrinter {
468
- // TODO: cleanup `.type === "AST_*" checks`
469
756
  static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
470
757
  const erbNodes = filterNodes([node], ERBContentNode);
471
758
  if (erbNodes.length === 1 && isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
@@ -477,19 +764,18 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
477
764
  if (hasOnlyERBContent && childErbNodes.length === 1 && isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
478
765
  return (childErbNodes[0].content?.value || "").trim();
479
766
  }
480
- if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
481
- const ifNode = node.children[0];
767
+ const firstChild = node.children[0];
768
+ if (node.children.length === 1 && isERBIfNode(firstChild) && !options.forceQuotes) {
482
769
  const printer = new ERBToRubyStringPrinter();
483
- if (printer.canConvertToTernary(ifNode)) {
484
- printer.convertToTernaryWithoutWrapper(ifNode);
770
+ if (printer.canConvertToTernary(firstChild)) {
771
+ printer.convertToTernaryWithoutWrapper(firstChild);
485
772
  return printer.context.getOutput();
486
773
  }
487
774
  }
488
- if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
489
- const unlessNode = node.children[0];
775
+ if (node.children.length === 1 && isERBUnlessNode(firstChild) && !options.forceQuotes) {
490
776
  const printer = new ERBToRubyStringPrinter();
491
- if (printer.canConvertUnlessToTernary(unlessNode)) {
492
- printer.convertUnlessToTernaryWithoutWrapper(unlessNode);
777
+ if (printer.canConvertUnlessToTernary(firstChild)) {
778
+ printer.convertUnlessToTernaryWithoutWrapper(firstChild);
493
779
  return printer.context.getOutput();
494
780
  }
495
781
  }
@@ -529,15 +815,15 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
529
815
  this.visitChildNodes(node);
530
816
  }
531
817
  canConvertToTernary(node) {
532
- if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
818
+ if (node.subsequent && !isERBElseNode(node.subsequent)) {
533
819
  return false;
534
820
  }
535
- const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
821
+ const ifOnlyText = node.statements ? node.statements.every(isHTMLTextNode) : true;
536
822
  if (!ifOnlyText)
537
823
  return false;
538
- if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
824
+ if (isERBElseNode(node.subsequent)) {
539
825
  return node.subsequent.statements
540
- ? node.subsequent.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
826
+ ? node.subsequent.statements.every(isHTMLTextNode)
541
827
  : true;
542
828
  }
543
829
  return true;
@@ -564,14 +850,14 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
564
850
  this.context.write('"');
565
851
  this.context.write(" : ");
566
852
  this.context.write('"');
567
- if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
853
+ if (isERBElseNode(node.subsequent) && node.subsequent.statements) {
568
854
  node.subsequent.statements.forEach(statement => this.visit(statement));
569
855
  }
570
856
  this.context.write('"');
571
857
  this.context.write("}");
572
858
  }
573
859
  convertToTernaryWithoutWrapper(node) {
574
- if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
860
+ if (node.subsequent && !isERBElseNode(node.subsequent)) {
575
861
  return false;
576
862
  }
577
863
  if (node.content?.value) {
@@ -594,18 +880,18 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
594
880
  this.context.write('"');
595
881
  this.context.write(" : ");
596
882
  this.context.write('"');
597
- if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
883
+ if (isERBElseNode(node.subsequent) && node.subsequent.statements) {
598
884
  node.subsequent.statements.forEach(statement => this.visit(statement));
599
885
  }
600
886
  this.context.write('"');
601
887
  }
602
888
  canConvertUnlessToTernary(node) {
603
- const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
889
+ const unlessOnlyText = node.statements ? node.statements.every(isHTMLTextNode) : true;
604
890
  if (!unlessOnlyText)
605
891
  return false;
606
- if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
892
+ if (isERBElseNode(node.else_clause)) {
607
893
  return node.else_clause.statements
608
- ? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
894
+ ? node.else_clause.statements.every(isHTMLTextNode)
609
895
  : true;
610
896
  }
611
897
  return true;
@@ -634,7 +920,7 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
634
920
  this.context.write('"');
635
921
  this.context.write(" : ");
636
922
  this.context.write('"');
637
- if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
923
+ if (isERBElseNode(node.else_clause)) {
638
924
  node.else_clause.statements.forEach(statement => this.visit(statement));
639
925
  }
640
926
  this.context.write('"');
@@ -663,13 +949,16 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
663
949
  this.context.write('"');
664
950
  this.context.write(" : ");
665
951
  this.context.write('"');
666
- if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
952
+ if (isERBElseNode(node.else_clause)) {
667
953
  node.else_clause.statements.forEach(statement => this.visit(statement));
668
954
  }
669
955
  this.context.write('"');
670
956
  }
671
957
  }
672
958
 
959
+ const DEFAULT_LINTER_PARSER_OPTIONS = {
960
+ track_whitespace: true,
961
+ };
673
962
  /**
674
963
  * Default configuration for rules when defaultConfig is not specified.
675
964
  * Custom rules can omit defaultConfig and will use these defaults.
@@ -684,26 +973,61 @@ const DEFAULT_RULE_CONFIG = {
684
973
  */
685
974
  class ParserRule {
686
975
  static type = "parser";
976
+ static ruleName;
687
977
  /** Indicates whether this rule supports autofix. Defaults to false. */
688
978
  static autocorrectable = false;
689
979
  /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
690
980
  static unsafeAutocorrectable = false;
981
+ /** Indicates whether the source should be re-indented after autofix. Defaults to false. */
982
+ static reindentAfterAutofix = false;
983
+ get ruleName() {
984
+ return this.constructor.ruleName;
985
+ }
691
986
  get defaultConfig() {
692
987
  return DEFAULT_RULE_CONFIG;
693
988
  }
989
+ get parserOptions() {
990
+ return DEFAULT_LINTER_PARSER_OPTIONS;
991
+ }
992
+ createOffense(message, location, autofixContext, severity) {
993
+ return {
994
+ rule: this.ruleName,
995
+ code: this.ruleName,
996
+ source: "Herb Linter",
997
+ message,
998
+ location,
999
+ autofixContext,
1000
+ severity,
1001
+ };
1002
+ }
694
1003
  }
695
1004
  /**
696
1005
  * Base class for lexer rules.
697
1006
  */
698
1007
  class LexerRule {
699
1008
  static type = "lexer";
1009
+ static ruleName;
700
1010
  /** Indicates whether this rule supports autofix. Defaults to false. */
701
1011
  static autocorrectable = false;
702
1012
  /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
703
1013
  static unsafeAutocorrectable = false;
1014
+ get ruleName() {
1015
+ return this.constructor.ruleName;
1016
+ }
704
1017
  get defaultConfig() {
705
1018
  return DEFAULT_RULE_CONFIG;
706
1019
  }
1020
+ createOffense(message, location, autofixContext, severity) {
1021
+ return {
1022
+ rule: this.ruleName,
1023
+ code: this.ruleName,
1024
+ source: "Herb Linter",
1025
+ message,
1026
+ location,
1027
+ autofixContext,
1028
+ severity,
1029
+ };
1030
+ }
707
1031
  }
708
1032
  /**
709
1033
  * Default context object with all keys defined but set to undefined
@@ -716,13 +1040,28 @@ const DEFAULT_LINT_CONTEXT = {
716
1040
  };
717
1041
  class SourceRule {
718
1042
  static type = "source";
1043
+ static ruleName;
719
1044
  /** Indicates whether this rule supports autofix. Defaults to false. */
720
1045
  static autocorrectable = false;
721
1046
  /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
722
1047
  static unsafeAutocorrectable = false;
1048
+ get ruleName() {
1049
+ return this.constructor.ruleName;
1050
+ }
723
1051
  get defaultConfig() {
724
1052
  return DEFAULT_RULE_CONFIG;
725
1053
  }
1054
+ createOffense(message, location, autofixContext, severity) {
1055
+ return {
1056
+ rule: this.ruleName,
1057
+ code: this.ruleName,
1058
+ source: "Herb Linter",
1059
+ message,
1060
+ location,
1061
+ autofixContext,
1062
+ severity,
1063
+ };
1064
+ }
726
1065
  }
727
1066
 
728
1067
  var ControlFlowType;
@@ -746,7 +1085,7 @@ class BaseRuleVisitor extends Visitor {
746
1085
  * Helper method to create an unbound lint offense (without severity).
747
1086
  * The Linter will bind severity based on the rule's config.
748
1087
  */
749
- createOffense(message, location, autofixContext) {
1088
+ createOffense(message, location, autofixContext, severity) {
750
1089
  return {
751
1090
  rule: this.ruleName,
752
1091
  code: this.ruleName,
@@ -754,13 +1093,14 @@ class BaseRuleVisitor extends Visitor {
754
1093
  message,
755
1094
  location,
756
1095
  autofixContext,
1096
+ severity,
757
1097
  };
758
1098
  }
759
1099
  /**
760
1100
  * Helper method to add an offense to the offenses array
761
1101
  */
762
- addOffense(message, location, autofixContext) {
763
- this.offenses.push(this.createOffense(message, location, autofixContext));
1102
+ addOffense(message, location, autofixContext, severity) {
1103
+ this.offenses.push(this.createOffense(message, location, autofixContext, severity));
764
1104
  }
765
1105
  }
766
1106
  /**
@@ -829,195 +1169,7 @@ class ControlFlowTrackingVisitor extends BaseRuleVisitor {
829
1169
  }
830
1170
  }
831
1171
  /**
832
- * Gets attributes from an HTMLOpenTagNode
833
- */
834
- function getAttributes(node) {
835
- return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE");
836
- }
837
- /**
838
- * Gets the tag name from an HTML tag node (lowercased)
839
- */
840
- function getTagName(node) {
841
- if (!node)
842
- return null;
843
- return node.tag_name?.value.toLowerCase() || null;
844
- }
845
- /**
846
- * Gets the attribute name from an HTMLAttributeNode (lowercased)
847
- * Returns null if the attribute name contains dynamic content (ERB)
848
- */
849
- function getAttributeName(attributeNode, lowercase = true) {
850
- if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
851
- const nameNode = attributeNode.name;
852
- const staticName = getStaticAttributeName(nameNode);
853
- if (!lowercase)
854
- return staticName;
855
- return staticName ? staticName.toLowerCase() : null;
856
- }
857
- return null;
858
- }
859
- /**
860
- * Checks if an attribute has a dynamic (ERB-containing) name
861
- */
862
- function hasDynamicAttributeName(attributeNode) {
863
- if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
864
- const nameNode = attributeNode.name;
865
- return hasDynamicAttributeName$1(nameNode);
866
- }
867
- return false;
868
- }
869
- /**
870
- * Gets the combined string representation of an attribute name (for debugging)
871
- * This includes both static content and ERB syntax
872
- */
873
- function getCombinedAttributeNameString(attributeNode) {
874
- if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
875
- const nameNode = attributeNode.name;
876
- return getCombinedAttributeName(nameNode);
877
- }
878
- return "";
879
- }
880
- /**
881
- * Checks if an attribute value contains only static content (no ERB)
882
- */
883
- function hasStaticAttributeValue(attributeNode) {
884
- const valueNode = attributeNode.value;
885
- if (!valueNode?.children)
886
- return false;
887
- return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
888
- }
889
- /**
890
- * Checks if an attribute value contains dynamic content (ERB)
891
- */
892
- function hasDynamicAttributeValue(attributeNode) {
893
- const valueNode = attributeNode.value;
894
- if (!valueNode?.children)
895
- return false;
896
- return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
897
- }
898
- /**
899
- * Gets the static string value of an attribute (returns null if it contains ERB)
900
- */
901
- function getStaticAttributeValue(attributeNode) {
902
- if (!hasStaticAttributeValue(attributeNode))
903
- return null;
904
- const valueNode = attributeNode.value;
905
- const result = valueNode.children
906
- ?.filter(child => child.type === "AST_LITERAL_NODE")
907
- .map(child => child.content)
908
- .join("") || "";
909
- return result;
910
- }
911
- /**
912
- * Gets the value nodes array for dynamic inspection
913
- */
914
- function getAttributeValueNodes(attributeNode) {
915
- const valueNode = attributeNode.value;
916
- return valueNode?.children || [];
917
- }
918
- /**
919
- * Checks if an attribute value contains any static content (for validation purposes)
920
- */
921
- function hasStaticAttributeValueContent(attributeNode) {
922
- const valueNodes = getAttributeValueNodes(attributeNode);
923
- return hasStaticContent(valueNodes);
924
- }
925
- /**
926
- * Gets the static content of an attribute value (all literal parts combined)
927
- * Returns the concatenated literal content, or null if no literal nodes exist
928
- */
929
- function getStaticAttributeValueContent(attributeNode) {
930
- const valueNodes = getAttributeValueNodes(attributeNode);
931
- return getStaticContentFromNodes(valueNodes);
932
- }
933
- /**
934
- * Gets the attribute value content from an HTMLAttributeValueNode
935
- */
936
- function getAttributeValue(attributeNode) {
937
- const valueNode = attributeNode.value;
938
- if (valueNode === null)
939
- return null;
940
- if (valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) {
941
- return null;
942
- }
943
- let result = "";
944
- for (const child of valueNode.children) {
945
- switch (child.type) {
946
- case "AST_ERB_CONTENT_NODE": {
947
- const erbNode = child;
948
- if (erbNode.content) {
949
- result += `${erbNode.tag_opening?.value}${erbNode.content.value}${erbNode.tag_closing?.value}`;
950
- }
951
- break;
952
- }
953
- case "AST_LITERAL_NODE": {
954
- result += child.content;
955
- break;
956
- }
957
- }
958
- }
959
- return result;
960
- }
961
- /**
962
- * Checks if an attribute has a value
963
- */
964
- function hasAttributeValue(attributeNode) {
965
- return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE";
966
- }
967
- /**
968
- * Gets the quote type used for an attribute value
969
- */
970
- function getAttributeValueQuoteType(nodeOrAttribute) {
971
- let valueNode;
972
- if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
973
- const attributeNode = nodeOrAttribute;
974
- if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
975
- valueNode = attributeNode.value;
976
- }
977
- }
978
- else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
979
- valueNode = nodeOrAttribute;
980
- }
981
- if (valueNode) {
982
- if (valueNode.quoted && valueNode.open_quote) {
983
- return valueNode.open_quote.value === '"' ? "double" : "single";
984
- }
985
- return "none";
986
- }
987
- return null;
988
- }
989
- /**
990
- * Finds an attribute by name in a list of attributes
991
- */
992
- function findAttributeByName(attributes, attributeName) {
993
- for (const child of attributes) {
994
- if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
995
- const attributeNode = child;
996
- const name = getAttributeName(attributeNode);
997
- if (name === attributeName.toLowerCase()) {
998
- return attributeNode;
999
- }
1000
- }
1001
- }
1002
- return null;
1003
- }
1004
- /**
1005
- * Checks if a tag has a specific attribute
1006
- */
1007
- function hasAttribute(node, attributeName) {
1008
- if (!node)
1009
- return false;
1010
- return getAttribute(node, attributeName) !== null;
1011
- }
1012
- /**
1013
- * Checks if a tag has a specific attribute
1014
- */
1015
- function getAttribute(node, attributeName) {
1016
- const attributes = getAttributes(node);
1017
- return findAttributeByName(attributes, attributeName);
1018
- }
1019
- /**
1020
- * Common HTML element categorization
1172
+ * Common HTML element categorization
1021
1173
  */
1022
1174
  const HTML_INLINE_ELEMENTS = new Set([
1023
1175
  "a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code",
@@ -1098,6 +1250,25 @@ const VALID_ARIA_ROLES = new Set([
1098
1250
  "treegrid", "treeitem",
1099
1251
  "log", "marquee"
1100
1252
  ]);
1253
+ /**
1254
+ * Abstract ARIA roles used to support the WAI-ARIA Roles Model.
1255
+ * Authors MUST NOT use abstract roles in content.
1256
+ * @see https://www.w3.org/TR/wai-aria-1.0/roles#abstract_roles
1257
+ */
1258
+ const ABSTRACT_ARIA_ROLES = new Set([
1259
+ "command",
1260
+ "composite",
1261
+ "input",
1262
+ "landmark",
1263
+ "range",
1264
+ "roletype",
1265
+ "section",
1266
+ "sectionhead",
1267
+ "select",
1268
+ "structure",
1269
+ "widget",
1270
+ "window"
1271
+ ]);
1101
1272
  const ARIA_ATTRIBUTES = new Set([
1102
1273
  'aria-activedescendant',
1103
1274
  'aria-atomic',
@@ -1205,7 +1376,7 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
1205
1376
  forEachAttribute(node, (attributeNode) => {
1206
1377
  const staticAttributeName = getAttributeName(attributeNode);
1207
1378
  const originalAttributeName = getAttributeName(attributeNode, false) || "";
1208
- const isDynamicName = hasDynamicAttributeName(attributeNode);
1379
+ const isDynamicName = hasDynamicAttributeNameOnAttribute(attributeNode);
1209
1380
  const staticAttributeValue = getStaticAttributeValue(attributeNode);
1210
1381
  const valueNodes = getAttributeValueNodes(attributeNode);
1211
1382
  const hasOutputERB = hasERBOutput(valueNodes);
@@ -1267,27 +1438,6 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
1267
1438
  // Default implementation does nothing
1268
1439
  }
1269
1440
  }
1270
- /**
1271
- * Checks if an attribute value is quoted
1272
- */
1273
- function isAttributeValueQuoted(attributeNode) {
1274
- if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
1275
- const valueNode = attributeNode.value;
1276
- return !!valueNode.quoted;
1277
- }
1278
- return false;
1279
- }
1280
- /**
1281
- * Iterates over all attributes of a tag node, calling the callback for each attribute
1282
- */
1283
- function forEachAttribute(node, callback) {
1284
- const attributes = getAttributes(node);
1285
- for (const child of attributes) {
1286
- if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
1287
- callback(child);
1288
- }
1289
- }
1290
- }
1291
1441
  /**
1292
1442
  * Base lexer visitor class that provides common functionality for lexer-based rule visitors
1293
1443
  */
@@ -1303,7 +1453,7 @@ class BaseLexerRuleVisitor {
1303
1453
  * Helper method to create an unbound lint offense (without severity).
1304
1454
  * The Linter will bind severity based on the rule's config.
1305
1455
  */
1306
- createOffense(message, location, autofixContext) {
1456
+ createOffense(message, location, autofixContext, severity) {
1307
1457
  return {
1308
1458
  rule: this.ruleName,
1309
1459
  code: this.ruleName,
@@ -1311,13 +1461,14 @@ class BaseLexerRuleVisitor {
1311
1461
  message,
1312
1462
  location,
1313
1463
  autofixContext,
1464
+ severity,
1314
1465
  };
1315
1466
  }
1316
1467
  /**
1317
1468
  * Helper method to add an offense to the offenses array
1318
1469
  */
1319
- addOffense(message, location, autofixContext) {
1320
- this.offenses.push(this.createOffense(message, location, autofixContext));
1470
+ addOffense(message, location, autofixContext, severity) {
1471
+ this.offenses.push(this.createOffense(message, location, autofixContext, severity));
1321
1472
  }
1322
1473
  /**
1323
1474
  * Main entry point for lexer rule visitors
@@ -1358,7 +1509,7 @@ class BaseSourceRuleVisitor {
1358
1509
  * Helper method to create an unbound lint offense (without severity).
1359
1510
  * The Linter will bind severity based on the rule's config.
1360
1511
  */
1361
- createOffense(message, location, autofixContext) {
1512
+ createOffense(message, location, autofixContext, severity) {
1362
1513
  return {
1363
1514
  rule: this.ruleName,
1364
1515
  code: this.ruleName,
@@ -1366,13 +1517,14 @@ class BaseSourceRuleVisitor {
1366
1517
  message,
1367
1518
  location,
1368
1519
  autofixContext,
1520
+ severity,
1369
1521
  };
1370
1522
  }
1371
1523
  /**
1372
1524
  * Helper method to add an offense to the offenses array
1373
1525
  */
1374
- addOffense(message, location, autofixContext) {
1375
- this.offenses.push(this.createOffense(message, location, autofixContext));
1526
+ addOffense(message, location, autofixContext, severity) {
1527
+ this.offenses.push(this.createOffense(message, location, autofixContext, severity));
1376
1528
  }
1377
1529
  /**
1378
1530
  * Main entry point for source rule visitors
@@ -1570,6 +1722,157 @@ function isHeadTag(tagName) {
1570
1722
  !isHtmlOnlyTag(tag) &&
1571
1723
  (isHeadOnlyTag(tag) || isHeadAndBodyTag(tag)));
1572
1724
  }
1725
+ /**
1726
+ * Converts a character offset in a source string to a Position (line, column).
1727
+ * Lines are 1-based, columns are 0-based.
1728
+ */
1729
+ function positionFromOffset(source, offset) {
1730
+ let line = 1;
1731
+ let column = 0;
1732
+ let currentOffset = 0;
1733
+ for (let i = 0; i < source.length && currentOffset < offset; i++) {
1734
+ const char = source[i];
1735
+ currentOffset++;
1736
+ if (char === "\n") {
1737
+ line++;
1738
+ column = 0;
1739
+ }
1740
+ else {
1741
+ column++;
1742
+ }
1743
+ }
1744
+ return new Position(line, column);
1745
+ }
1746
+ /**
1747
+ * Creates a Location from a source string, a start offset, and a length.
1748
+ */
1749
+ function locationFromOffset(source, startOffset, length) {
1750
+ const start = positionFromOffset(source, startOffset);
1751
+ const end = positionFromOffset(source, startOffset + length);
1752
+ return Location.from(start.line, start.column, end.line, end.column);
1753
+ }
1754
+ /**
1755
+ * Checks if a position (line, column) is within a node's location range.
1756
+ * @param node - The node to check
1757
+ * @param line - Line number (1-based)
1758
+ * @param column - Column number (0-based)
1759
+ * @returns true if the position is within the node's location
1760
+ */
1761
+ function isPositionInNode(node, line, column) {
1762
+ if (!node.location)
1763
+ return false;
1764
+ const { start, end } = node.location;
1765
+ if (line < start.line)
1766
+ return false;
1767
+ if (line === start.line && column < start.column)
1768
+ return false;
1769
+ if (line > end.line)
1770
+ return false;
1771
+ if (line === end.line && column >= end.column)
1772
+ return false;
1773
+ return true;
1774
+ }
1775
+ /**
1776
+ * Finds a node in the AST that contains a specific position.
1777
+ * Returns the deepest (most specific) node that matches the position and optional predicate.
1778
+ *
1779
+ * @param root - The root node to search from
1780
+ * @param line - Line number (1-based)
1781
+ * @param column - Column number (0-based)
1782
+ * @param predicate - Optional predicate function to filter nodes
1783
+ * @returns The matching node or null if not found
1784
+ */
1785
+ function findNodeAtPosition(root, line, column, predicate) {
1786
+ let bestMatch = null;
1787
+ const visited = new Set();
1788
+ function search(node) {
1789
+ if (!node || visited.has(node))
1790
+ return;
1791
+ visited.add(node);
1792
+ if (isPositionInNode(node, line, column)) {
1793
+ if (!predicate || predicate(node)) {
1794
+ if (!bestMatch || isMoreSpecific(node, bestMatch)) {
1795
+ bestMatch = node;
1796
+ }
1797
+ }
1798
+ }
1799
+ const nodeAny = node;
1800
+ if (typeof nodeAny.compactChildNodes === 'function') {
1801
+ for (const child of nodeAny.compactChildNodes()) {
1802
+ search(child);
1803
+ }
1804
+ }
1805
+ else {
1806
+ if (nodeAny.children && Array.isArray(nodeAny.children)) {
1807
+ for (const child of nodeAny.children) {
1808
+ if (child)
1809
+ search(child);
1810
+ }
1811
+ }
1812
+ if (nodeAny.body && Array.isArray(nodeAny.body)) {
1813
+ for (const child of nodeAny.body) {
1814
+ if (child)
1815
+ search(child);
1816
+ }
1817
+ }
1818
+ }
1819
+ }
1820
+ function isMoreSpecific(nodeA, nodeB) {
1821
+ if (!nodeA.location || !nodeB.location)
1822
+ return false;
1823
+ const aStart = nodeA.location.start;
1824
+ const aEnd = nodeA.location.end;
1825
+ const bStart = nodeB.location.start;
1826
+ const bEnd = nodeB.location.end;
1827
+ const startsAtOrAfter = aStart.line > bStart.line || (aStart.line === bStart.line && aStart.column >= bStart.column);
1828
+ const endsAtOrBefore = aEnd.line < bEnd.line || (aEnd.line === bEnd.line && aEnd.column <= bEnd.column);
1829
+ return startsAtOrAfter && endsAtOrBefore;
1830
+ }
1831
+ search(root);
1832
+ return bestMatch;
1833
+ }
1834
+
1835
+ class ActionViewNoSilentHelperVisitor extends BaseRuleVisitor {
1836
+ visitHTMLElementNode(node) {
1837
+ this.checkActionViewHelper(node);
1838
+ super.visitHTMLElementNode(node);
1839
+ }
1840
+ checkActionViewHelper(node) {
1841
+ if (!node.element_source || node.element_source === "HTML")
1842
+ return;
1843
+ if (!isERBOpenTagNode(node.open_tag))
1844
+ return;
1845
+ if (isERBOutputNode(node.open_tag))
1846
+ return;
1847
+ const tagOpening = node.open_tag.tag_opening?.value;
1848
+ if (!tagOpening)
1849
+ return;
1850
+ const helperName = node.element_source.includes("#")
1851
+ ? node.element_source.split("#").pop()
1852
+ : node.element_source;
1853
+ this.addOffense(`Avoid using \`${tagOpening} %>\` with \`${helperName}\`. Use \`<%= %>\` to ensure the helper's output is rendered.`, node.open_tag.location);
1854
+ }
1855
+ }
1856
+ class ActionViewNoSilentHelperRule extends ParserRule {
1857
+ static ruleName = "actionview-no-silent-helper";
1858
+ get defaultConfig() {
1859
+ return {
1860
+ enabled: true,
1861
+ severity: "error"
1862
+ };
1863
+ }
1864
+ get parserOptions() {
1865
+ return {
1866
+ track_whitespace: true,
1867
+ action_view_helpers: true,
1868
+ };
1869
+ }
1870
+ check(result, context) {
1871
+ const visitor = new ActionViewNoSilentHelperVisitor(this.ruleName, context);
1872
+ visitor.visit(result.value);
1873
+ return visitor.offenses;
1874
+ }
1875
+ }
1573
1876
 
1574
1877
  class ERBCommentSyntaxVisitor extends BaseRuleVisitor {
1575
1878
  visitERBContentNode(node) {
@@ -1587,7 +1890,7 @@ class ERBCommentSyntaxVisitor extends BaseRuleVisitor {
1587
1890
  }
1588
1891
  class ERBCommentSyntax extends ParserRule {
1589
1892
  static autocorrectable = true;
1590
- name = "erb-comment-syntax";
1893
+ static ruleName = "erb-comment-syntax";
1591
1894
  get defaultConfig() {
1592
1895
  return {
1593
1896
  enabled: true,
@@ -1595,7 +1898,7 @@ class ERBCommentSyntax extends ParserRule {
1595
1898
  };
1596
1899
  }
1597
1900
  check(result, context) {
1598
- const visitor = new ERBCommentSyntaxVisitor(this.name, context);
1901
+ const visitor = new ERBCommentSyntaxVisitor(this.ruleName, context);
1599
1902
  visitor.visit(result.value);
1600
1903
  return visitor.offenses;
1601
1904
  }
@@ -1651,7 +1954,7 @@ class ERBNoCaseNodeChildrenVisitor extends BaseRuleVisitor {
1651
1954
  }
1652
1955
  }
1653
1956
  class ERBNoCaseNodeChildrenRule extends ParserRule {
1654
- name = "erb-no-case-node-children";
1957
+ static ruleName = "erb-no-case-node-children";
1655
1958
  get defaultConfig() {
1656
1959
  return {
1657
1960
  enabled: true,
@@ -1659,27 +1962,132 @@ class ERBNoCaseNodeChildrenRule extends ParserRule {
1659
1962
  };
1660
1963
  }
1661
1964
  check(result, context) {
1662
- const visitor = new ERBNoCaseNodeChildrenVisitor(this.name, context);
1965
+ const visitor = new ERBNoCaseNodeChildrenVisitor(this.ruleName, context);
1663
1966
  visitor.visit(result.value);
1664
1967
  return visitor.offenses;
1665
1968
  }
1666
1969
  }
1667
1970
 
1668
- class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
1669
- visitERBContentNode(node) {
1971
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
1972
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
1973
+ function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
1974
+ function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
1975
+ function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
1976
+ const dedent = createDedent({});
1977
+ function createDedent(options) {
1978
+ dedent.withOptions = newOptions => createDedent(_objectSpread(_objectSpread({}, options), newOptions));
1979
+ return dedent;
1980
+ function dedent(strings, ...values) {
1981
+ const raw = typeof strings === "string" ? [strings] : strings.raw;
1982
+ const {
1983
+ alignValues = false,
1984
+ escapeSpecialCharacters = Array.isArray(strings),
1985
+ trimWhitespace = true
1986
+ } = options;
1987
+
1988
+ // first, perform interpolation
1989
+ let result = "";
1990
+ for (let i = 0; i < raw.length; i++) {
1991
+ let next = raw[i];
1992
+ if (escapeSpecialCharacters) {
1993
+ // handle escaped newlines, backticks, and interpolation characters
1994
+ next = next.replace(/\\\n[ \t]*/g, "").replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\\{/g, "{");
1995
+ }
1996
+ result += next;
1997
+ if (i < values.length) {
1998
+ const value = alignValues ? alignValue(values[i], result) : values[i];
1999
+
2000
+ // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
2001
+ result += value;
2002
+ }
2003
+ }
2004
+
2005
+ // now strip indentation
2006
+ const lines = result.split("\n");
2007
+ let mindent = null;
2008
+ for (const l of lines) {
2009
+ const m = l.match(/^(\s+)\S+/);
2010
+ if (m) {
2011
+ const indent = m[1].length;
2012
+ if (!mindent) {
2013
+ // this is the first indented line
2014
+ mindent = indent;
2015
+ } else {
2016
+ mindent = Math.min(mindent, indent);
2017
+ }
2018
+ }
2019
+ }
2020
+ if (mindent !== null) {
2021
+ const m = mindent; // appease TypeScript
2022
+ result = lines
2023
+ // https://github.com/typescript-eslint/typescript-eslint/issues/7140
2024
+ // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
2025
+ .map(l => l[0] === " " || l[0] === "\t" ? l.slice(m) : l).join("\n");
2026
+ }
2027
+
2028
+ // dedent eats leading and trailing whitespace too
2029
+ if (trimWhitespace) {
2030
+ result = result.trim();
2031
+ }
2032
+
2033
+ // handle escaped newlines at the end to ensure they don't get stripped too
2034
+ if (escapeSpecialCharacters) {
2035
+ result = result.replace(/\\n/g, "\n");
2036
+ }
2037
+
2038
+ // Workaround for Bun issue with Unicode characters
2039
+ // https://github.com/oven-sh/bun/issues/8745
2040
+ if (typeof Bun !== "undefined") {
2041
+ result = result.replace(
2042
+ // Matches e.g. \\u{1f60a} or \\u5F1F
2043
+ /\\u(?:\{([\da-fA-F]{1,6})\}|([\da-fA-F]{4}))/g, (_, braced, unbraced) => {
2044
+ var _ref;
2045
+ const hex = (_ref = braced !== null && braced !== void 0 ? braced : unbraced) !== null && _ref !== void 0 ? _ref : "";
2046
+ return String.fromCodePoint(parseInt(hex, 16));
2047
+ });
2048
+ }
2049
+ return result;
2050
+ }
2051
+ }
2052
+
2053
+ /**
2054
+ * Adjusts the indentation of a multi-line interpolated value to match the current line.
2055
+ */
2056
+ function alignValue(value, precedingText) {
2057
+ if (typeof value !== "string" || !value.includes("\n")) {
2058
+ return value;
2059
+ }
2060
+ const currentLine = precedingText.slice(precedingText.lastIndexOf("\n") + 1);
2061
+ const indentMatch = currentLine.match(/^(\s+)/);
2062
+ if (indentMatch) {
2063
+ const indent = indentMatch[1];
2064
+ return value.replace(/\n/g, `\n${indent}`);
2065
+ }
2066
+ return value;
2067
+ }
2068
+
2069
+ class ERBNoConditionalHTMLElementRuleVisitor extends BaseRuleVisitor {
2070
+ visitHTMLConditionalElementNode(node) {
2071
+ const tagName = node.tag_name?.value || "element";
2072
+ const condition = node.condition || "condition";
2073
+ const suggestion = dedent `
2074
+ Consider using a \`capture\` block instead:
2075
+
2076
+ <% content = capture do %>
2077
+ ... your content here ...
2078
+ <% end %>
2079
+
2080
+ <%= ${condition} ? content_tag(:${tagName}, content) : content %>
2081
+ `;
2082
+ this.addOffense(dedent `
2083
+ Avoid opening and closing \`<${tagName}>\` tags in separate conditional blocks with the same condition. \
2084
+ This pattern is difficult to read and maintain. ${suggestion}
2085
+ `, node.location);
1670
2086
  this.visitChildNodes(node);
1671
- const { content, tag_closing } = node;
1672
- if (!content)
1673
- return;
1674
- if (tag_closing?.value === "")
1675
- return;
1676
- if (content.value.trim().length > 0)
1677
- return;
1678
- this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location);
1679
2087
  }
1680
2088
  }
1681
- class ERBNoEmptyTagsRule extends ParserRule {
1682
- name = "erb-no-empty-tags";
2089
+ class ERBNoConditionalHTMLElementRule extends ParserRule {
2090
+ static ruleName = "erb-no-conditional-html-element";
1683
2091
  get defaultConfig() {
1684
2092
  return {
1685
2093
  enabled: true,
@@ -1687,37 +2095,311 @@ class ERBNoEmptyTagsRule extends ParserRule {
1687
2095
  };
1688
2096
  }
1689
2097
  check(result, context) {
1690
- const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
2098
+ const visitor = new ERBNoConditionalHTMLElementRuleVisitor(this.ruleName, context);
1691
2099
  visitor.visit(result.value);
1692
2100
  return visitor.offenses;
1693
2101
  }
1694
2102
  }
1695
2103
 
1696
- function positionFromOffset(source, offset) {
1697
- let line = 1;
1698
- let column = 0;
1699
- let currentOffset = 0;
1700
- for (let i = 0; i < source.length && currentOffset < offset; i++) {
1701
- const char = source[i];
1702
- currentOffset++;
1703
- if (char === "\n") {
1704
- line++;
1705
- column = 0;
2104
+ class ERBNoConditionalOpenTagRuleVisitor extends BaseRuleVisitor {
2105
+ visitHTMLConditionalOpenTagNode(node) {
2106
+ const tagName = node.tag_name?.value || "element";
2107
+ this.addOffense(`Avoid using ERB conditionals to split the open and closing tag of \`<${tagName}>\` element.`, node.location);
2108
+ this.visitChildNodes(node);
2109
+ }
2110
+ }
2111
+ class ERBNoConditionalOpenTagRule extends ParserRule {
2112
+ static ruleName = "erb-no-conditional-open-tag";
2113
+ get defaultConfig() {
2114
+ return {
2115
+ enabled: true,
2116
+ severity: "error"
2117
+ };
2118
+ }
2119
+ check(result, context) {
2120
+ const visitor = new ERBNoConditionalOpenTagRuleVisitor(this.ruleName, context);
2121
+ visitor.visit(result.value);
2122
+ return visitor.offenses;
2123
+ }
2124
+ }
2125
+
2126
+ function getSignificantNodes(statements) {
2127
+ return statements.filter(node => !isPureWhitespaceNode(node));
2128
+ }
2129
+ function allEquivalentElements(nodes) {
2130
+ if (nodes.length < 2)
2131
+ return false;
2132
+ if (!nodes.every(node => isHTMLElementNode(node)))
2133
+ return false;
2134
+ const first = nodes[0];
2135
+ return nodes.slice(1).every(node => isEquivalentElement(first, node));
2136
+ }
2137
+ function collectBranchesFromIf(node) {
2138
+ const branches = [];
2139
+ let current = node.subsequent;
2140
+ branches.push(node.statements);
2141
+ while (current) {
2142
+ if (isERBElseNode(current)) {
2143
+ branches.push(current.statements);
2144
+ return branches;
2145
+ }
2146
+ if (isERBIfNode(current)) {
2147
+ branches.push(current.statements);
2148
+ current = current.subsequent;
1706
2149
  }
1707
2150
  else {
1708
- column++;
2151
+ break;
1709
2152
  }
1710
2153
  }
1711
- return new Position(line, column);
2154
+ return null;
1712
2155
  }
1713
- class ERBNoExtraNewLineVisitor extends BaseSourceRuleVisitor {
1714
- visitSource(source) {
1715
- if (source.length === 0)
2156
+ function collectBranchesFromUnless(node) {
2157
+ if (!node.else_clause)
2158
+ return null;
2159
+ return [node.statements, node.else_clause.statements];
2160
+ }
2161
+ function collectBranchesFromCase(node) {
2162
+ if (!node.else_clause)
2163
+ return null;
2164
+ const branches = [];
2165
+ for (const condition of node.conditions) {
2166
+ if (isERBWhenNode(condition)) {
2167
+ branches.push(condition.statements);
2168
+ }
2169
+ }
2170
+ branches.push(node.else_clause.statements);
2171
+ return branches;
2172
+ }
2173
+ function collectBranches(node) {
2174
+ if (isERBIfNode(node))
2175
+ return collectBranchesFromIf(node);
2176
+ if (isERBUnlessNode(node))
2177
+ return collectBranchesFromUnless(node);
2178
+ if (isERBCaseNode(node))
2179
+ return collectBranchesFromCase(node);
2180
+ return null;
2181
+ }
2182
+ function findCommonPrefixCount(branches, minLength) {
2183
+ let count = 0;
2184
+ for (let index = 0; index < minLength; index++) {
2185
+ const nodesAtIndex = branches.map(branch => branch[index]);
2186
+ if (allEquivalentElements(nodesAtIndex)) {
2187
+ count++;
2188
+ }
2189
+ else {
2190
+ break;
2191
+ }
2192
+ }
2193
+ return count;
2194
+ }
2195
+ function findCommonSuffixCount(branches, minLength, prefixCount) {
2196
+ let count = 0;
2197
+ for (let offset = 0; offset < minLength - prefixCount; offset++) {
2198
+ const nodesAtOffset = branches.map(branch => branch[branch.length - 1 - offset]);
2199
+ if (allEquivalentElements(nodesAtOffset)) {
2200
+ count++;
2201
+ }
2202
+ else {
2203
+ break;
2204
+ }
2205
+ }
2206
+ return count;
2207
+ }
2208
+ function createWrapper(template, body) {
2209
+ return new HTMLElementNode({
2210
+ type: "AST_HTML_ELEMENT_NODE",
2211
+ open_tag: template.open_tag,
2212
+ tag_name: template.tag_name,
2213
+ body,
2214
+ close_tag: template.close_tag,
2215
+ is_void: template.is_void,
2216
+ element_source: template.element_source,
2217
+ location: Location.zero,
2218
+ errors: [],
2219
+ });
2220
+ }
2221
+ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor {
2222
+ processedIfNodes = new Set();
2223
+ visitERBIfNode(node) {
2224
+ if (this.processedIfNodes.has(node)) {
2225
+ this.visitChildNodes(node);
1716
2226
  return;
1717
- const regex = /\n{4,}/g;
1718
- let match;
1719
- while ((match = regex.exec(source)) !== null) {
1720
- const startOffset = match.index + 3;
2227
+ }
2228
+ this.checkConditionalNode(node);
2229
+ this.visitChildNodes(node);
2230
+ }
2231
+ visitERBUnlessNode(node) {
2232
+ this.checkConditionalNode(node);
2233
+ this.visitChildNodes(node);
2234
+ }
2235
+ visitERBCaseNode(node) {
2236
+ this.checkConditionalNode(node);
2237
+ this.visitChildNodes(node);
2238
+ }
2239
+ checkConditionalNode(node) {
2240
+ const branches = collectBranches(node);
2241
+ if (!branches)
2242
+ return;
2243
+ if (isERBIfNode(node)) {
2244
+ this.markSubsequentIfNodesAsProcessed(node);
2245
+ }
2246
+ const state = { isFirstOffense: true };
2247
+ this.checkBranches(branches, node, state);
2248
+ }
2249
+ markSubsequentIfNodesAsProcessed(node) {
2250
+ let current = node.subsequent;
2251
+ while (current) {
2252
+ if (isERBIfNode(current)) {
2253
+ this.processedIfNodes.add(current);
2254
+ current = current.subsequent;
2255
+ }
2256
+ else {
2257
+ break;
2258
+ }
2259
+ }
2260
+ }
2261
+ checkBranches(branches, conditionalNode, state) {
2262
+ const significantBranches = branches.map(getSignificantNodes);
2263
+ if (significantBranches.some(branch => branch.length === 0))
2264
+ return;
2265
+ const minLength = Math.min(...significantBranches.map(branch => branch.length));
2266
+ const prefixCount = findCommonPrefixCount(significantBranches, minLength);
2267
+ const suffixCount = findCommonSuffixCount(significantBranches, minLength, prefixCount);
2268
+ for (let index = 0; index < prefixCount; index++) {
2269
+ const elements = significantBranches.map(branch => branch[index]);
2270
+ this.reportAndRecurse(elements, conditionalNode, state);
2271
+ }
2272
+ for (let offset = 0; offset < suffixCount; offset++) {
2273
+ const elements = significantBranches.map(branch => branch[branch.length - 1 - offset]);
2274
+ this.reportAndRecurse(elements, conditionalNode, state);
2275
+ }
2276
+ }
2277
+ reportAndRecurse(elements, conditionalNode, state) {
2278
+ const bodies = elements.map(element => element.body);
2279
+ const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]));
2280
+ for (const element of elements) {
2281
+ const printed = IdentityPrinter.print(element.open_tag);
2282
+ const autofixContext = state.isFirstOffense
2283
+ ? { node: conditionalNode }
2284
+ : undefined;
2285
+ this.addOffense(`The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`, bodiesMatch ? element.location : (element?.open_tag?.location || element.location), autofixContext);
2286
+ state.isFirstOffense = false;
2287
+ }
2288
+ if (!bodiesMatch && bodies.every(body => body.length > 0)) {
2289
+ this.checkBranches(bodies, conditionalNode, state);
2290
+ }
2291
+ }
2292
+ }
2293
+ class ERBNoDuplicateBranchElementsRule extends ParserRule {
2294
+ static ruleName = "erb-no-duplicate-branch-elements";
2295
+ static autocorrectable = true;
2296
+ static reindentAfterAutofix = true;
2297
+ get defaultConfig() {
2298
+ return {
2299
+ enabled: true,
2300
+ severity: "warning",
2301
+ };
2302
+ }
2303
+ check(result, context) {
2304
+ const visitor = new ERBNoDuplicateBranchElementsVisitor(this.ruleName, context);
2305
+ visitor.visit(result.value);
2306
+ return visitor.offenses;
2307
+ }
2308
+ autofix(offense, result) {
2309
+ if (!offense.autofixContext)
2310
+ return null;
2311
+ const conditionalNode = offense.autofixContext.node;
2312
+ const branches = collectBranches(conditionalNode);
2313
+ if (!branches)
2314
+ return null;
2315
+ const significantBranches = branches.map(getSignificantNodes);
2316
+ if (significantBranches.some(branch => branch.length === 0))
2317
+ return null;
2318
+ const minLength = Math.min(...significantBranches.map(branch => branch.length));
2319
+ const prefixCount = findCommonPrefixCount(significantBranches, minLength);
2320
+ const suffixCount = findCommonSuffixCount(significantBranches, minLength, prefixCount);
2321
+ if (prefixCount === 0 && suffixCount === 0)
2322
+ return null;
2323
+ const parentInfo = findParentArray(result.value, conditionalNode);
2324
+ if (!parentInfo)
2325
+ return null;
2326
+ let { array: parentArray, index: conditionalIndex } = parentInfo;
2327
+ let hasWrapped = false;
2328
+ const hoistElement = (elements, position) => {
2329
+ const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]));
2330
+ if (bodiesMatch) {
2331
+ for (let i = 0; i < branches.length; i++) {
2332
+ removeNodeFromArray(branches[i], elements[i]);
2333
+ }
2334
+ if (position === "before") {
2335
+ parentArray.splice(conditionalIndex, 0, elements[0]);
2336
+ conditionalIndex++;
2337
+ }
2338
+ else {
2339
+ parentArray.splice(conditionalIndex + 1, 0, elements[0]);
2340
+ }
2341
+ }
2342
+ else {
2343
+ if (hasWrapped)
2344
+ return;
2345
+ for (let i = 0; i < branches.length; i++) {
2346
+ replaceNodeWithBody(branches[i], elements[i]);
2347
+ }
2348
+ const wrapper = createWrapper(elements[0], [createLiteral("\n"), conditionalNode, createLiteral("\n")]);
2349
+ parentArray[conditionalIndex] = wrapper;
2350
+ parentArray = wrapper.body;
2351
+ conditionalIndex = 1;
2352
+ hasWrapped = true;
2353
+ }
2354
+ };
2355
+ for (let index = 0; index < prefixCount; index++) {
2356
+ const elements = significantBranches.map(branch => branch[index]);
2357
+ hoistElement(elements, "before");
2358
+ }
2359
+ for (let offset = 0; offset < suffixCount; offset++) {
2360
+ const elements = significantBranches.map(branch => branch[branch.length - 1 - offset]);
2361
+ hoistElement(elements, "after");
2362
+ }
2363
+ return result;
2364
+ }
2365
+ }
2366
+
2367
+ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
2368
+ visitERBContentNode(node) {
2369
+ this.visitChildNodes(node);
2370
+ const { content, tag_closing } = node;
2371
+ if (!content)
2372
+ return;
2373
+ if (tag_closing?.value === "")
2374
+ return;
2375
+ if (content.value.trim().length > 0)
2376
+ return;
2377
+ this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location);
2378
+ }
2379
+ }
2380
+ class ERBNoEmptyTagsRule extends ParserRule {
2381
+ static ruleName = "erb-no-empty-tags";
2382
+ get defaultConfig() {
2383
+ return {
2384
+ enabled: true,
2385
+ severity: "error"
2386
+ };
2387
+ }
2388
+ check(result, context) {
2389
+ const visitor = new ERBNoEmptyTagsVisitor(this.ruleName, context);
2390
+ visitor.visit(result.value);
2391
+ return visitor.offenses;
2392
+ }
2393
+ }
2394
+
2395
+ class ERBNoExtraNewLineVisitor extends BaseSourceRuleVisitor {
2396
+ visitSource(source) {
2397
+ if (source.length === 0)
2398
+ return;
2399
+ const regex = /\n{4,}/g;
2400
+ let match;
2401
+ while ((match = regex.exec(source)) !== null) {
2402
+ const startOffset = match.index + 3;
1721
2403
  const endOffset = match.index + match[0].length;
1722
2404
  const start = positionFromOffset(source, startOffset);
1723
2405
  const end = positionFromOffset(source, endOffset);
@@ -1731,161 +2413,820 @@ class ERBNoExtraNewLineVisitor extends BaseSourceRuleVisitor {
1731
2413
  }
1732
2414
  }
1733
2415
  }
1734
- class ERBNoExtraNewLineRule extends SourceRule {
1735
- static autocorrectable = true;
1736
- name = "erb-no-extra-newline";
2416
+ class ERBNoExtraNewLineRule extends SourceRule {
2417
+ static autocorrectable = true;
2418
+ static ruleName = "erb-no-extra-newline";
2419
+ get defaultConfig() {
2420
+ return {
2421
+ enabled: true,
2422
+ severity: "error"
2423
+ };
2424
+ }
2425
+ check(source, context) {
2426
+ const visitor = new ERBNoExtraNewLineVisitor(this.ruleName, context);
2427
+ visitor.visit(source);
2428
+ return visitor.offenses;
2429
+ }
2430
+ autofix(offense, source, _context) {
2431
+ if (!offense.autofixContext)
2432
+ return null;
2433
+ const { startOffset, endOffset } = offense.autofixContext;
2434
+ const before = source.substring(0, startOffset);
2435
+ const after = source.substring(endOffset);
2436
+ return before + after;
2437
+ }
2438
+ }
2439
+
2440
+ class ERBNoExtraWhitespaceInsideTagsVisitor extends BaseRuleVisitor {
2441
+ visitERBNode(node) {
2442
+ const openTag = node.tag_opening;
2443
+ const closeTag = node.tag_closing;
2444
+ const { value } = node.content ?? {};
2445
+ if (!openTag || !closeTag || !value)
2446
+ return;
2447
+ if (this.hasExtraLeadingWhitespace(value)) {
2448
+ this.reportWhitespace(node, openTag, closeTag, value, "start", 0, `Remove extra whitespace after \`${openTag.value}\`.`, "after-open");
2449
+ }
2450
+ if (openTag.value === "<%#") {
2451
+ const prefix = this.getCommentedTagPrefix(value);
2452
+ if (prefix) {
2453
+ const afterPrefix = value.substring(prefix.length);
2454
+ const tag = `<%#${prefix}`;
2455
+ const hasExtraWhitespace = afterPrefix.match(/^\s{2,}/) && !afterPrefix.startsWith(" \n") && !afterPrefix.startsWith("\n");
2456
+ if (hasExtraWhitespace) {
2457
+ this.reportWhitespace(node, openTag, closeTag, value, "start", prefix.length, `Remove extra whitespace after \`${tag}\`. This looks like a temporarily commented ERB tag.`, "after-comment-equals", "info");
2458
+ }
2459
+ else {
2460
+ this.addOffense(`\`${tag}\` looks like a temporarily commented ERB tag.`, openTag.location, { node, openTag, closeTag, content: value, fixType: "after-comment-equals", unsafe: true }, "info");
2461
+ }
2462
+ }
2463
+ }
2464
+ if (this.hasExtraTrailingWhitespace(value)) {
2465
+ this.reportWhitespace(node, openTag, closeTag, value, "end", 0, `Remove extra whitespace before \`${closeTag.value}\`.`, "before-close");
2466
+ }
2467
+ }
2468
+ getCommentedTagPrefix(content) {
2469
+ if (content.startsWith("graphql"))
2470
+ return "graphql";
2471
+ if (content.startsWith("%="))
2472
+ return "%=";
2473
+ if (content.startsWith("=="))
2474
+ return "==";
2475
+ if (content.startsWith("%"))
2476
+ return "%";
2477
+ if (content.startsWith("="))
2478
+ return "=";
2479
+ if (content.startsWith("-"))
2480
+ return "-";
2481
+ return null;
2482
+ }
2483
+ hasExtraLeadingWhitespace(content) {
2484
+ return content.startsWith(" ") && !content.startsWith(" \n");
2485
+ }
2486
+ hasExtraTrailingWhitespace(content) {
2487
+ return !content.includes("\n") && /\s{2,}$/.test(content);
2488
+ }
2489
+ getWhitespaceLocation(node, content, position, offset = 0) {
2490
+ const contentLocation = node.content.location;
2491
+ if (position === "start") {
2492
+ const match = content.substring(offset).match(/^\s+/);
2493
+ const length = match ? match[0].length : 0;
2494
+ const startColumn = contentLocation.start.column + offset;
2495
+ return Location.from(contentLocation.start.line, startColumn, contentLocation.start.line, startColumn + length);
2496
+ }
2497
+ else {
2498
+ const match = content.match(/\s+$/);
2499
+ const length = match ? match[0].length : 0;
2500
+ return Location.from(contentLocation.end.line, contentLocation.end.column - length, contentLocation.end.line, contentLocation.end.column);
2501
+ }
2502
+ }
2503
+ reportWhitespace(node, openTag, closeTag, content, position, offset, message, fixType, severity, unsafe) {
2504
+ const location = this.getWhitespaceLocation(node, content, position, offset);
2505
+ this.addOffense(message, location, {
2506
+ node,
2507
+ openTag,
2508
+ closeTag,
2509
+ content,
2510
+ fixType,
2511
+ unsafe,
2512
+ }, severity);
2513
+ }
2514
+ }
2515
+ class ERBNoExtraWhitespaceRule extends ParserRule {
2516
+ static autocorrectable = true;
2517
+ static ruleName = "erb-no-extra-whitespace-inside-tags";
2518
+ get defaultConfig() {
2519
+ return {
2520
+ enabled: true,
2521
+ severity: "error"
2522
+ };
2523
+ }
2524
+ check(result, context) {
2525
+ const visitor = new ERBNoExtraWhitespaceInsideTagsVisitor(this.ruleName, context);
2526
+ visitor.visit(result.value);
2527
+ return visitor.offenses;
2528
+ }
2529
+ autofix(offense, result, _context) {
2530
+ if (!offense.autofixContext)
2531
+ return null;
2532
+ const { node, fixType } = offense.autofixContext;
2533
+ if (!node.content)
2534
+ return null;
2535
+ const content = node.content.value;
2536
+ switch (fixType) {
2537
+ case "before-close":
2538
+ node.content.value = content.replace(/\s{2,}$/, " ");
2539
+ break;
2540
+ case "after-open":
2541
+ node.content.value = content.replace(/^\s{2,}/, " ");
2542
+ break;
2543
+ case "after-comment-equals": {
2544
+ const prefix = content.startsWith("graphql") ? "graphql" : content.startsWith("%=") ? "%=" : content.startsWith("==") ? "==" : content.startsWith("%") ? "%" : content.startsWith("=") ? "=" : content.startsWith("-") ? "-" : null;
2545
+ if (prefix) {
2546
+ const afterPrefix = content.substring(prefix.length);
2547
+ node.content.value = prefix + " " + afterPrefix.replace(/^\s{2,}/, "");
2548
+ }
2549
+ break;
2550
+ }
2551
+ default:
2552
+ return null;
2553
+ }
2554
+ return result;
2555
+ }
2556
+ }
2557
+
2558
+ class ERBNoInlineCaseConditionsVisitor extends BaseRuleVisitor {
2559
+ visitERBCaseNode(node) {
2560
+ this.checkConditions(node, "when");
2561
+ this.visitChildNodes(node);
2562
+ }
2563
+ visitERBCaseMatchNode(node) {
2564
+ this.checkConditions(node, "in");
2565
+ this.visitChildNodes(node);
2566
+ }
2567
+ checkConditions(node, type) {
2568
+ if (!node.conditions || node.conditions.length === 0)
2569
+ return;
2570
+ for (const condition of node.conditions) {
2571
+ if (condition.tag_opening === null) {
2572
+ this.addOffense(`A \`case\` statement with \`${type}\` conditions in a single ERB tag cannot be reliably parsed, compiled, and formatted. Use separate ERB tags for \`case\` and its conditions (e.g., \`<% case x %>\` followed by \`<% ${type} y %>\`).`, node.location);
2573
+ break;
2574
+ }
2575
+ }
2576
+ }
2577
+ }
2578
+ class ERBNoInlineCaseConditionsRule extends ParserRule {
2579
+ static ruleName = "erb-no-inline-case-conditions";
2580
+ get defaultConfig() {
2581
+ return {
2582
+ enabled: true,
2583
+ severity: "warning",
2584
+ };
2585
+ }
2586
+ get parserOptions() {
2587
+ return { strict: false };
2588
+ }
2589
+ check(result, context) {
2590
+ const visitor = new ERBNoInlineCaseConditionsVisitor(this.ruleName, context);
2591
+ visitor.visit(result.value);
2592
+ return visitor.offenses;
2593
+ }
2594
+ }
2595
+
2596
+ /**
2597
+ * File path and naming utilities for linter rules
2598
+ */
2599
+ /**
2600
+ * Extracts the basename (filename) from a file path
2601
+ * Works with both forward slashes and backslashes
2602
+ */
2603
+ function getBasename(filePath) {
2604
+ const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
2605
+ return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
2606
+ }
2607
+ /**
2608
+ * Checks if a file is a Rails partial (filename starts with `_`)
2609
+ * Returns null if fileName is undefined (unknown context)
2610
+ */
2611
+ function isPartialFile(fileName) {
2612
+ if (!fileName)
2613
+ return null;
2614
+ return getBasename(fileName).startsWith("_");
2615
+ }
2616
+
2617
+ class InstanceVariableCollector extends PrismVisitor {
2618
+ instanceVariables = [];
2619
+ visitInstanceVariableReadNode(node) {
2620
+ this.collect(node, "read");
2621
+ }
2622
+ visitInstanceVariableWriteNode(node) {
2623
+ this.collect(node, "write");
2624
+ }
2625
+ visitInstanceVariableAndWriteNode(node) {
2626
+ this.collect(node, "write");
2627
+ }
2628
+ visitInstanceVariableOrWriteNode(node) {
2629
+ this.collect(node, "write");
2630
+ }
2631
+ visitInstanceVariableOperatorWriteNode(node) {
2632
+ this.collect(node, "write");
2633
+ }
2634
+ visitInstanceVariableTargetNode(node) {
2635
+ this.collect(node, "write");
2636
+ }
2637
+ collect(node, usage) {
2638
+ this.instanceVariables.push({
2639
+ name: node.name,
2640
+ usage,
2641
+ startOffset: node.location.startOffset,
2642
+ length: node.location.length
2643
+ });
2644
+ }
2645
+ }
2646
+ class ERBNoInstanceVariablesInPartialsRule extends ParserRule {
2647
+ static ruleName = "erb-no-instance-variables-in-partials";
2648
+ get defaultConfig() {
2649
+ return {
2650
+ enabled: true,
2651
+ severity: "error",
2652
+ };
2653
+ }
2654
+ get parserOptions() {
2655
+ return {
2656
+ track_whitespace: true,
2657
+ prism_program: true,
2658
+ };
2659
+ }
2660
+ isEnabled(_result, context) {
2661
+ return isPartialFile(context?.fileName) === true;
2662
+ }
2663
+ check(result, _context) {
2664
+ const source = result.value.source;
2665
+ const prismNode = result.value.prismNode;
2666
+ if (!prismNode || !source)
2667
+ return [];
2668
+ const collector = new InstanceVariableCollector();
2669
+ collector.visit(prismNode);
2670
+ return collector.instanceVariables.map(ivar => {
2671
+ const location = locationFromOffset(source, ivar.startOffset, ivar.length);
2672
+ const message = ivar.usage === "read"
2673
+ ? `Avoid using instance variables in partials. Pass \`${ivar.name}\` as a local variable instead.`
2674
+ : `Avoid setting instance variables in partials. Use a local variable instead of \`${ivar.name}\`.`;
2675
+ return this.createOffense(message, location);
2676
+ });
2677
+ }
2678
+ }
2679
+
2680
+ function groupToString(group) {
2681
+ return group.map(node => {
2682
+ if (isLiteralNode(node)) {
2683
+ return node.content;
2684
+ }
2685
+ return IdentityPrinter.print(node, { ignoreErrors: true });
2686
+ }).join("");
2687
+ }
2688
+ class ERBNoInterpolatedClassNamesVisitor extends AttributeVisitorMixin {
2689
+ checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
2690
+ if (attributeName !== "class")
2691
+ return;
2692
+ const splitNodes = splitLiteralsAtWhitespace(valueNodes);
2693
+ const groups = groupNodesByClass(splitNodes);
2694
+ for (const group of groups) {
2695
+ if (group.every(node => isPureWhitespaceNode(node)))
2696
+ continue;
2697
+ const isInterpolated = group.some(node => !isLiteralNode(node));
2698
+ if (!isInterpolated)
2699
+ continue;
2700
+ const hasAttachedLiteral = group.some(node => isLiteralNode(node) && node.content.trim());
2701
+ if (!hasAttachedLiteral)
2702
+ continue;
2703
+ const className = groupToString(group);
2704
+ this.addOffense(`Avoid ERB interpolation inside class names: \`${className}\`. Use standalone ERB expressions that output complete class names instead.`, attributeNode.value.location);
2705
+ }
2706
+ }
2707
+ }
2708
+ class ERBNoInterpolatedClassNamesRule extends ParserRule {
2709
+ static ruleName = "erb-no-interpolated-class-names";
2710
+ get defaultConfig() {
2711
+ return {
2712
+ enabled: true,
2713
+ severity: "warning"
2714
+ };
2715
+ }
2716
+ check(result, context) {
2717
+ const visitor = new ERBNoInterpolatedClassNamesVisitor(this.ruleName, context);
2718
+ visitor.visit(result.value);
2719
+ return visitor.offenses;
2720
+ }
2721
+ }
2722
+
2723
+ const JAVASCRIPT_TAG_PATTERN = /\bjavascript_tag\b/;
2724
+ class ERBNoJavascriptTagHelperVisitor extends BaseRuleVisitor {
2725
+ visitDocumentNode(node) {
2726
+ for (const child of node.children || []) {
2727
+ if (!isERBNode(child))
2728
+ continue;
2729
+ if (!isERBOutputNode(child))
2730
+ continue;
2731
+ const content = child.content?.value || "";
2732
+ if (JAVASCRIPT_TAG_PATTERN.test(content)) {
2733
+ this.addOffense("Avoid `javascript_tag`. Use inline `<script>` tags instead.", child.location);
2734
+ }
2735
+ }
2736
+ super.visitDocumentNode(node);
2737
+ }
2738
+ }
2739
+ class ERBNoJavascriptTagHelperRule extends ParserRule {
2740
+ static ruleName = "erb-no-javascript-tag-helper";
2741
+ get defaultConfig() {
2742
+ return {
2743
+ enabled: true,
2744
+ severity: "warning"
2745
+ };
2746
+ }
2747
+ check(result, context) {
2748
+ const visitor = new ERBNoJavascriptTagHelperVisitor(this.ruleName, context);
2749
+ visitor.visit(result.value);
2750
+ return visitor.offenses;
2751
+ }
2752
+ }
2753
+
2754
+ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
2755
+ visitERBIfNode(node) {
2756
+ this.checkOutputControlFlow(node);
2757
+ this.visitChildNodes(node);
2758
+ }
2759
+ visitERBUnlessNode(node) {
2760
+ this.checkOutputControlFlow(node);
2761
+ this.visitChildNodes(node);
2762
+ }
2763
+ visitERBElseNode(node) {
2764
+ this.checkOutputControlFlow(node);
2765
+ this.visitChildNodes(node);
2766
+ }
2767
+ visitERBEndNode(node) {
2768
+ this.checkOutputControlFlow(node);
2769
+ this.visitChildNodes(node);
2770
+ }
2771
+ static CONTROL_BLOCK_NAMES = {
2772
+ "AST_ERB_IF_NODE": "if",
2773
+ "AST_ERB_ELSE_NODE": "else",
2774
+ "AST_ERB_END_NODE": "end",
2775
+ "AST_ERB_UNLESS_NODE": "unless"
2776
+ };
2777
+ checkOutputControlFlow(controlBlock) {
2778
+ const openTag = controlBlock.tag_opening;
2779
+ if (!openTag) {
2780
+ return;
2781
+ }
2782
+ if (openTag.value === "<%=") {
2783
+ const controlBlockType = ERBNoOutputControlFlowRuleVisitor.CONTROL_BLOCK_NAMES[controlBlock.type] || controlBlock.type;
2784
+ this.addOffense(`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`, openTag.location);
2785
+ }
2786
+ }
2787
+ }
2788
+ class ERBNoOutputControlFlowRule extends ParserRule {
2789
+ static ruleName = "erb-no-output-control-flow";
2790
+ get defaultConfig() {
2791
+ return {
2792
+ enabled: true,
2793
+ severity: "error"
2794
+ };
2795
+ }
2796
+ check(result, context) {
2797
+ const visitor = new ERBNoOutputControlFlowRuleVisitor(this.ruleName, context);
2798
+ visitor.visit(result.value);
2799
+ return visitor.offenses;
2800
+ }
2801
+ }
2802
+
2803
+ class ERBNoOutputInAttributeNameVisitor extends BaseRuleVisitor {
2804
+ visitHTMLAttributeNameNode(node) {
2805
+ for (const child of node.children) {
2806
+ if (!isERBNode(child))
2807
+ continue;
2808
+ if (!isERBOutputNode(child))
2809
+ continue;
2810
+ this.addOffense("Avoid ERB output in attribute names. Use static attribute names with dynamic values instead.", child.location);
2811
+ }
2812
+ super.visitHTMLAttributeNameNode(node);
2813
+ }
2814
+ }
2815
+ class ERBNoOutputInAttributeNameRule extends ParserRule {
2816
+ static ruleName = "erb-no-output-in-attribute-name";
2817
+ get defaultConfig() {
2818
+ return {
2819
+ enabled: true,
2820
+ severity: "error"
2821
+ };
2822
+ }
2823
+ check(result, context) {
2824
+ const visitor = new ERBNoOutputInAttributeNameVisitor(this.ruleName, context);
2825
+ visitor.visit(result.value);
2826
+ return visitor.offenses;
2827
+ }
2828
+ }
2829
+
2830
+ class ERBNoOutputInAttributePositionVisitor extends BaseRuleVisitor {
2831
+ visitHTMLOpenTagNode(node) {
2832
+ for (const child of node.children) {
2833
+ if (!isERBNode(child))
2834
+ continue;
2835
+ if (!isERBOutputNode(child))
2836
+ continue;
2837
+ this.addOffense("Avoid `<%= %>` in attribute position. Use `<% if ... %>` with static attributes instead.", child.location);
2838
+ }
2839
+ super.visitHTMLOpenTagNode(node);
2840
+ }
2841
+ }
2842
+ class ERBNoOutputInAttributePositionRule extends ParserRule {
2843
+ static ruleName = "erb-no-output-in-attribute-position";
2844
+ get defaultConfig() {
2845
+ return {
2846
+ enabled: true,
2847
+ severity: "error"
2848
+ };
2849
+ }
2850
+ check(result, context) {
2851
+ const visitor = new ERBNoOutputInAttributePositionVisitor(this.ruleName, context);
2852
+ visitor.visit(result.value);
2853
+ return visitor.offenses;
2854
+ }
2855
+ }
2856
+
2857
+ class ERBNoRawOutputInAttributeValueVisitor extends AttributeVisitorMixin {
2858
+ checkStaticAttributeDynamicValue({ valueNodes, attributeNode }) {
2859
+ this.checkValueNodes(valueNodes);
2860
+ }
2861
+ checkDynamicAttributeDynamicValue({ valueNodes }) {
2862
+ this.checkValueNodes(valueNodes);
2863
+ }
2864
+ checkValueNodes(nodes) {
2865
+ for (const node of nodes) {
2866
+ if (!isERBNode(node))
2867
+ continue;
2868
+ if (node.tag_opening?.value === "<%==") {
2869
+ this.addOffense("Avoid `<%==` in attribute values. Use `<%= %>` instead to ensure proper HTML escaping.", node.location);
2870
+ }
2871
+ }
2872
+ }
2873
+ }
2874
+ class ERBNoRawOutputInAttributeValueRule extends ParserRule {
2875
+ static ruleName = "erb-no-raw-output-in-attribute-value";
2876
+ get defaultConfig() {
2877
+ return {
2878
+ enabled: true,
2879
+ severity: "error"
2880
+ };
2881
+ }
2882
+ check(result, context) {
2883
+ const visitor = new ERBNoRawOutputInAttributeValueVisitor(this.ruleName, context);
2884
+ visitor.visit(result.value);
2885
+ return visitor.offenses;
2886
+ }
2887
+ }
2888
+
2889
+ class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
2890
+ visitHTMLAttributeNameNode(node) {
2891
+ const erbNodes = filterERBContentNodes(node.children);
2892
+ const silentNodes = erbNodes.filter(this.isSilentERBTag);
2893
+ for (const node of silentNodes) {
2894
+ this.addOffense(`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.`, node.location);
2895
+ }
2896
+ }
2897
+ // TODO: might be worth to extract
2898
+ isSilentERBTag(node) {
2899
+ const silentTags = ["<%", "<%-", "<%#"];
2900
+ return silentTags.includes(node.tag_opening?.value || "");
2901
+ }
2902
+ }
2903
+ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
2904
+ static ruleName = "erb-no-silent-tag-in-attribute-name";
2905
+ get defaultConfig() {
2906
+ return {
2907
+ enabled: true,
2908
+ severity: "error"
2909
+ };
2910
+ }
2911
+ check(result, context) {
2912
+ const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.ruleName, context);
2913
+ visitor.visit(result.value);
2914
+ return visitor.offenses;
2915
+ }
2916
+ }
2917
+
2918
+ const END_PATTERN = /^\s*end\b/;
2919
+ class ERBNoStatementInScriptVisitor extends BaseRuleVisitor {
2920
+ visitHTMLElementNode(node) {
2921
+ if (!isHTMLOpenTagNode(node.open_tag)) {
2922
+ super.visitHTMLElementNode(node);
2923
+ return;
2924
+ }
2925
+ if (getTagLocalName(node.open_tag) === "script") {
2926
+ this.checkScriptElement(node);
2927
+ }
2928
+ super.visitHTMLElementNode(node);
2929
+ }
2930
+ checkScriptElement(node) {
2931
+ if (!isHTMLOpenTagNode(node.open_tag))
2932
+ return;
2933
+ const typeAttribute = getAttribute(node.open_tag, "type");
2934
+ const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null;
2935
+ if (typeValue === "text/html") {
2936
+ return;
2937
+ }
2938
+ if (!node.body || node.body.length === 0) {
2939
+ return;
2940
+ }
2941
+ this.checkNodesForStatements(node.body);
2942
+ }
2943
+ checkNodesForStatements(nodes) {
2944
+ for (const child of nodes) {
2945
+ if (!isERBNode(child))
2946
+ continue;
2947
+ if (isERBOutputNode(child))
2948
+ continue;
2949
+ if (isERBCommentNode(child))
2950
+ continue;
2951
+ const content = child.content?.value || "";
2952
+ if (END_PATTERN.test(content))
2953
+ continue;
2954
+ this.addOffense("Avoid `<% %>` tags inside `<script>`. Use `<%= %>` to interpolate values into JavaScript.", child.location);
2955
+ }
2956
+ }
2957
+ }
2958
+ class ERBNoStatementInScriptRule extends ParserRule {
2959
+ static ruleName = "erb-no-statement-in-script";
2960
+ get defaultConfig() {
2961
+ return {
2962
+ enabled: true,
2963
+ severity: "warning"
2964
+ };
2965
+ }
2966
+ check(result, context) {
2967
+ const visitor = new ERBNoStatementInScriptVisitor(this.ruleName, context);
2968
+ visitor.visit(result.value);
2969
+ return visitor.offenses;
2970
+ }
2971
+ }
2972
+
2973
+ class ERBNoThenInControlFlowVisitor extends BaseRuleVisitor {
2974
+ visitERBIfNode(node) {
2975
+ const content = node.content?.value?.trim() ?? "";
2976
+ const keyword = content.startsWith("elsif") ? "elsif" : "if";
2977
+ this.checkThenKeyword(keyword, node.then_keyword);
2978
+ this.visitChildNodes(node);
2979
+ }
2980
+ visitERBUnlessNode(node) {
2981
+ this.checkThenKeyword("unless", node.then_keyword);
2982
+ this.visitChildNodes(node);
2983
+ }
2984
+ visitERBWhenNode(node) {
2985
+ this.checkThenKeyword("when", node.then_keyword);
2986
+ this.visitChildNodes(node);
2987
+ }
2988
+ visitERBInNode(node) {
2989
+ this.checkThenKeyword("in", node.then_keyword);
2990
+ this.visitChildNodes(node);
2991
+ }
2992
+ checkThenKeyword(keyword, thenKeyword) {
2993
+ if (thenKeyword === null)
2994
+ return;
2995
+ this.addOffense(`Avoid using \`then\` in \`${keyword}\` expressions inside ERB templates. Use the multiline block form instead.`, thenKeyword);
2996
+ }
2997
+ }
2998
+ class ERBNoThenInControlFlowRule extends ParserRule {
2999
+ static ruleName = "erb-no-then-in-control-flow";
1737
3000
  get defaultConfig() {
1738
3001
  return {
1739
3002
  enabled: true,
1740
- severity: "error"
3003
+ severity: "warning",
1741
3004
  };
1742
3005
  }
1743
- check(source, context) {
1744
- const visitor = new ERBNoExtraNewLineVisitor(this.name, context);
1745
- visitor.visit(source);
1746
- return visitor.offenses;
3006
+ get parserOptions() {
3007
+ return { strict: true };
1747
3008
  }
1748
- autofix(offense, source, _context) {
1749
- if (!offense.autofixContext)
1750
- return null;
1751
- const { startOffset, endOffset } = offense.autofixContext;
1752
- const before = source.substring(0, startOffset);
1753
- const after = source.substring(endOffset);
1754
- return before + after;
3009
+ check(result, context) {
3010
+ const visitor = new ERBNoThenInControlFlowVisitor(this.ruleName, context);
3011
+ visitor.visit(result.value);
3012
+ return visitor.offenses;
1755
3013
  }
1756
3014
  }
1757
3015
 
1758
- class ERBNoExtraWhitespaceInsideTagsVisitor extends BaseRuleVisitor {
1759
- visitERBNode(node) {
1760
- const openTag = node.tag_opening;
1761
- const closeTag = node.tag_closing;
1762
- const { value } = node.content ?? {};
1763
- if (!openTag || !closeTag || !value)
1764
- return;
1765
- if (this.hasExtraLeadingWhitespace(value)) {
1766
- this.reportWhitespace(node, openTag, closeTag, value, "start", 0, `Remove extra whitespace after \`${openTag.value}\`.`, "after-open");
1767
- }
1768
- if (openTag.value === "<%#" && value.startsWith("=") && value.length > 1) {
1769
- const afterEquals = value.substring(1);
1770
- if (afterEquals.match(/^\s{2,}/) && !afterEquals.startsWith(" \n") && !afterEquals.startsWith("\n")) {
1771
- this.reportWhitespace(node, openTag, closeTag, value, "start", 1, `Remove extra whitespace after \`<%#=\`.`, "after-comment-equals");
3016
+ const TRAILING_WHITESPACE = /[ \t\r\v\f\u00A0]+$/;
3017
+ const TRAILING_WHITESPACE_BEFORE_NEWLINE = /[ \t\r\v\f\u00A0]+(?=\n)/g;
3018
+ const ONLY_WHITESPACE = /^[ \t\r\v\f\u00A0]+$/;
3019
+ class SkipZoneCollector extends Visitor {
3020
+ skipZones = [];
3021
+ SKIP_TAGS = new Set(["pre", "textarea", "script", "style"]);
3022
+ visitHTMLElementNode(node) {
3023
+ if (isHTMLOpenTagNode(node.open_tag)) {
3024
+ const tagName = getTagLocalName(node.open_tag);
3025
+ if (tagName && this.SKIP_TAGS.has(tagName)) {
3026
+ this.skipZones.push({
3027
+ startLine: node.location.start.line,
3028
+ startColumn: node.location.start.column,
3029
+ endLine: node.location.end.line,
3030
+ endColumn: node.location.end.column
3031
+ });
3032
+ return;
1772
3033
  }
1773
3034
  }
1774
- if (this.hasExtraTrailingWhitespace(value)) {
1775
- this.reportWhitespace(node, openTag, closeTag, value, "end", 0, `Remove extra whitespace before \`${closeTag.value}\`.`, "before-close");
1776
- }
1777
- }
1778
- hasExtraLeadingWhitespace(content) {
1779
- return content.startsWith(" ") && !content.startsWith(" \n");
1780
- }
1781
- hasExtraTrailingWhitespace(content) {
1782
- return !content.includes("\n") && /\s{2,}$/.test(content);
1783
- }
1784
- getWhitespaceLocation(node, content, position, offset = 0) {
1785
- const contentLocation = node.content.location;
1786
- if (position === "start") {
1787
- const match = content.substring(offset).match(/^\s+/);
1788
- const length = match ? match[0].length : 0;
1789
- const startColumn = contentLocation.start.column + offset;
1790
- return Location.from(contentLocation.start.line, startColumn, contentLocation.start.line, startColumn + length);
1791
- }
1792
- else {
1793
- const match = content.match(/\s+$/);
1794
- const length = match ? match[0].length : 0;
1795
- return Location.from(contentLocation.end.line, contentLocation.end.column - length, contentLocation.end.line, contentLocation.end.column);
1796
- }
3035
+ super.visitHTMLElementNode(node);
1797
3036
  }
1798
- reportWhitespace(node, openTag, closeTag, content, position, offset, message, fixType) {
1799
- const location = this.getWhitespaceLocation(node, content, position, offset);
1800
- this.addOffense(message, location, {
1801
- node,
1802
- openTag,
1803
- closeTag,
1804
- content,
1805
- fixType
3037
+ visitERBNode(node) {
3038
+ if (!node.tag_opening)
3039
+ return;
3040
+ if (!node.tag_closing)
3041
+ return;
3042
+ this.skipZones.push({
3043
+ startLine: node.tag_opening.location.start.line,
3044
+ startColumn: node.tag_opening.location.start.column,
3045
+ endLine: node.tag_closing.location.end.line,
3046
+ endColumn: node.tag_closing.location.end.column
1806
3047
  });
1807
3048
  }
1808
3049
  }
1809
- class ERBNoExtraWhitespaceRule extends ParserRule {
3050
+ class ERBNoTrailingWhitespaceRule extends ParserRule {
1810
3051
  static autocorrectable = true;
1811
- name = "erb-no-extra-whitespace-inside-tags";
3052
+ static ruleName = "erb-no-trailing-whitespace";
1812
3053
  get defaultConfig() {
1813
3054
  return {
1814
3055
  enabled: true,
1815
- severity: "error"
3056
+ severity: "error",
1816
3057
  };
1817
3058
  }
1818
- check(result, context) {
1819
- const visitor = new ERBNoExtraWhitespaceInsideTagsVisitor(this.name, context);
1820
- visitor.visit(result.value);
1821
- return visitor.offenses;
3059
+ check(result, _context) {
3060
+ const offenses = [];
3061
+ const lines = result.source.split("\n");
3062
+ const candidates = this.findTrailingWhitespaceCandidates(lines);
3063
+ if (candidates.length === 0)
3064
+ return offenses;
3065
+ const skipZones = this.collectSkipZones(result.value);
3066
+ for (const candidate of candidates) {
3067
+ if (!this.isInSkipZone(candidate, skipZones)) {
3068
+ const location = Location.from(candidate.line, candidate.column, candidate.line, candidate.column + candidate.length);
3069
+ const node = findNodeAtPosition(result.value, candidate.line, candidate.column, (n) => isHTMLTextNode(n) || isLiteralNode(n));
3070
+ offenses.push({
3071
+ rule: this.ruleName,
3072
+ message: "Extra whitespace detected at end of line.",
3073
+ location,
3074
+ autofixContext: node ? { node } : undefined
3075
+ });
3076
+ }
3077
+ }
3078
+ return offenses;
3079
+ }
3080
+ findTrailingWhitespaceCandidates(lines) {
3081
+ const candidates = [];
3082
+ for (let i = 0; i < lines.length; i++) {
3083
+ const line = lines[i];
3084
+ const match = line.match(TRAILING_WHITESPACE);
3085
+ if (match && match.index !== undefined) {
3086
+ candidates.push({
3087
+ line: i + 1,
3088
+ column: match.index,
3089
+ length: match[0].length
3090
+ });
3091
+ }
3092
+ }
3093
+ return candidates;
3094
+ }
3095
+ collectSkipZones(root) {
3096
+ const collector = new SkipZoneCollector();
3097
+ collector.visit(root);
3098
+ return collector.skipZones;
3099
+ }
3100
+ isInSkipZone(candidate, skipZones) {
3101
+ for (const zone of skipZones) {
3102
+ if (candidate.line < zone.startLine || candidate.line > zone.endLine)
3103
+ continue;
3104
+ if (candidate.line === zone.endLine && candidate.column >= zone.endColumn)
3105
+ continue;
3106
+ if (candidate.line === zone.startLine && candidate.column < zone.startColumn)
3107
+ continue;
3108
+ return true;
3109
+ }
3110
+ return false;
1822
3111
  }
1823
3112
  autofix(offense, result, _context) {
1824
3113
  if (!offense.autofixContext)
1825
3114
  return null;
1826
- const { node, fixType } = offense.autofixContext;
1827
- if (!node.content)
1828
- return null;
1829
- const content = node.content.value;
1830
- switch (fixType) {
1831
- case "before-close":
1832
- node.content.value = content.replace(/\s{2,}$/, " ");
1833
- break;
1834
- case "after-open":
1835
- node.content.value = content.replace(/^\s{2,}/, " ");
1836
- break;
1837
- case "after-comment-equals":
1838
- if (content.startsWith("=")) {
1839
- const afterEquals = content.substring(1);
1840
- node.content.value = "= " + afterEquals.replace(/^\s{2,}/, "");
3115
+ const { node } = offense.autofixContext;
3116
+ if (node.type === "AST_HTML_TEXT_NODE" || node.type === "AST_LITERAL_NODE") {
3117
+ let fixedContent = node.content.replace(TRAILING_WHITESPACE_BEFORE_NEWLINE, "");
3118
+ const offenseIsAtEndOfContent = this.isOffenseAtEndOfContent(offense, node);
3119
+ if (offenseIsAtEndOfContent) {
3120
+ if (this.hasTrailingWhitespaceNotIndentation(fixedContent)) {
3121
+ fixedContent = fixedContent.replace(TRAILING_WHITESPACE, "");
1841
3122
  }
1842
- break;
1843
- default:
1844
- return null;
3123
+ if (ONLY_WHITESPACE.test(fixedContent) && node.location.start.column !== 0) {
3124
+ fixedContent = "";
3125
+ }
3126
+ }
3127
+ node.content = fixedContent;
1845
3128
  }
1846
3129
  return result;
1847
3130
  }
3131
+ isOffenseAtEndOfContent(offense, node) {
3132
+ return offense.location.end.line === node.location.end.line && offense.location.end.column === node.location.end.column;
3133
+ }
3134
+ hasTrailingWhitespaceNotIndentation(content) {
3135
+ if (content.endsWith("\n"))
3136
+ return false;
3137
+ const endMatch = content.match(TRAILING_WHITESPACE);
3138
+ if (!endMatch)
3139
+ return false;
3140
+ const whitespaceStart = content.length - endMatch[0].length;
3141
+ if (whitespaceStart === 0)
3142
+ return false;
3143
+ const characterBefore = content[whitespaceStart - 1];
3144
+ if (characterBefore === "\n")
3145
+ return false;
3146
+ return true;
3147
+ }
1848
3148
  }
1849
3149
 
1850
- class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
1851
- visitERBIfNode(node) {
1852
- this.checkOutputControlFlow(node);
1853
- this.visitChildNodes(node);
3150
+ const JS_ATTRIBUTE_PATTERN = /^on/i;
3151
+ const SAFE_PATTERN$1 = /\.to_json\s*$|\bj\s*[\s(]|\bescape_javascript\s*[\s(]/;
3152
+ class ERBNoUnsafeJSAttributeVisitor extends AttributeVisitorMixin {
3153
+ checkStaticAttributeDynamicValue({ attributeName, valueNodes }) {
3154
+ if (!JS_ATTRIBUTE_PATTERN.test(attributeName))
3155
+ return;
3156
+ for (const node of valueNodes) {
3157
+ if (!isERBNode(node))
3158
+ continue;
3159
+ if (!isERBOutputNode(node))
3160
+ continue;
3161
+ const content = node.content?.value?.trim() || "";
3162
+ if (SAFE_PATTERN$1.test(content))
3163
+ continue;
3164
+ this.addOffense(`Unsafe ERB output in \`${attributeName}\` attribute. Use \`.to_json\`, \`j()\`, or \`escape_javascript()\` to safely encode values.`, node.location);
3165
+ }
1854
3166
  }
1855
- visitERBUnlessNode(node) {
1856
- this.checkOutputControlFlow(node);
1857
- this.visitChildNodes(node);
3167
+ }
3168
+ class ERBNoUnsafeJSAttributeRule extends ParserRule {
3169
+ static ruleName = "erb-no-unsafe-js-attribute";
3170
+ get defaultConfig() {
3171
+ return {
3172
+ enabled: true,
3173
+ severity: "error"
3174
+ };
1858
3175
  }
1859
- visitERBElseNode(node) {
1860
- this.checkOutputControlFlow(node);
1861
- this.visitChildNodes(node);
3176
+ check(result, context) {
3177
+ const visitor = new ERBNoUnsafeJSAttributeVisitor(this.ruleName, context);
3178
+ visitor.visit(result.value);
3179
+ return visitor.offenses;
1862
3180
  }
1863
- visitERBEndNode(node) {
1864
- this.checkOutputControlFlow(node);
1865
- this.visitChildNodes(node);
3181
+ }
3182
+
3183
+ const RAW_PATTERN = /\braw[\s(]/;
3184
+ const HTML_SAFE_PATTERN = /\.html_safe\b/;
3185
+ const RAW_TEXT_ELEMENTS = new Set([
3186
+ "title",
3187
+ "textarea",
3188
+ "script",
3189
+ "style",
3190
+ "xmp",
3191
+ "iframe",
3192
+ "noembed",
3193
+ "noframes",
3194
+ "listing",
3195
+ "plaintext",
3196
+ ]);
3197
+ class ERBNoUnsafeRawVisitor extends BaseRuleVisitor {
3198
+ insideRawTextElement = false;
3199
+ visitHTMLElementNode(node) {
3200
+ if (!isHTMLOpenTagNode(node.open_tag)) {
3201
+ super.visitHTMLElementNode(node);
3202
+ return;
3203
+ }
3204
+ const tagName = getTagLocalName(node.open_tag);
3205
+ if (tagName && RAW_TEXT_ELEMENTS.has(tagName)) {
3206
+ const wasInside = this.insideRawTextElement;
3207
+ this.insideRawTextElement = true;
3208
+ super.visitHTMLElementNode(node);
3209
+ this.insideRawTextElement = wasInside;
3210
+ return;
3211
+ }
3212
+ super.visitHTMLElementNode(node);
1866
3213
  }
1867
- checkOutputControlFlow(controlBlock) {
1868
- const openTag = controlBlock.tag_opening;
1869
- if (!openTag) {
3214
+ visitERBContentNode(node) {
3215
+ if (this.insideRawTextElement)
3216
+ return;
3217
+ if (!isERBOutputNode(node))
1870
3218
  return;
3219
+ const content = node.content?.value || "";
3220
+ if (RAW_PATTERN.test(content)) {
3221
+ this.addOffense("Avoid `raw()` in ERB output. It bypasses HTML escaping and can cause cross-site scripting (XSS) vulnerabilities.", node.location);
1871
3222
  }
1872
- if (openTag.value === "<%=") {
1873
- let controlBlockType = controlBlock.type;
1874
- if (controlBlock.type === "AST_ERB_IF_NODE")
1875
- controlBlockType = "if";
1876
- if (controlBlock.type === "AST_ERB_ELSE_NODE")
1877
- controlBlockType = "else";
1878
- if (controlBlock.type === "AST_ERB_END_NODE")
1879
- controlBlockType = "end";
1880
- if (controlBlock.type === "AST_ERB_UNLESS_NODE")
1881
- controlBlockType = "unless";
1882
- this.addOffense(`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`, openTag.location);
3223
+ if (HTML_SAFE_PATTERN.test(content)) {
3224
+ this.addOffense("Avoid `.html_safe` in ERB output. It bypasses HTML escaping and can cause cross-site scripting (XSS) vulnerabilities.", node.location);
1883
3225
  }
1884
- return;
1885
3226
  }
1886
3227
  }
1887
- class ERBNoOutputControlFlowRule extends ParserRule {
1888
- name = "erb-no-output-control-flow";
3228
+ class ERBNoUnsafeRawRule extends ParserRule {
3229
+ static ruleName = "erb-no-unsafe-raw";
1889
3230
  get defaultConfig() {
1890
3231
  return {
1891
3232
  enabled: true,
@@ -1893,28 +3234,50 @@ class ERBNoOutputControlFlowRule extends ParserRule {
1893
3234
  };
1894
3235
  }
1895
3236
  check(result, context) {
1896
- const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context);
3237
+ const visitor = new ERBNoUnsafeRawVisitor(this.ruleName, context);
1897
3238
  visitor.visit(result.value);
1898
3239
  return visitor.offenses;
1899
3240
  }
1900
3241
  }
1901
3242
 
1902
- class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
1903
- visitHTMLAttributeNameNode(node) {
1904
- const erbNodes = filterERBContentNodes(node.children);
1905
- const silentNodes = erbNodes.filter(this.isSilentERBTag);
1906
- for (const node of silentNodes) {
1907
- this.addOffense(`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.`, node.location);
3243
+ const SAFE_PATTERN = /\.to_json\b/;
3244
+ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
3245
+ visitHTMLElementNode(node) {
3246
+ if (!isHTMLOpenTagNode(node.open_tag)) {
3247
+ super.visitHTMLElementNode(node);
3248
+ return;
3249
+ }
3250
+ if (getTagLocalName(node.open_tag) === "script") {
3251
+ this.checkScriptElement(node);
1908
3252
  }
3253
+ super.visitHTMLElementNode(node);
1909
3254
  }
1910
- // TODO: might be worth to extract
1911
- isSilentERBTag(node) {
1912
- const silentTags = ["<%", "<%-", "<%#"];
1913
- return silentTags.includes(node.tag_opening?.value || "");
3255
+ checkScriptElement(node) {
3256
+ if (!isHTMLOpenTagNode(node.open_tag))
3257
+ return;
3258
+ const typeAttribute = getAttribute(node.open_tag, "type");
3259
+ const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null;
3260
+ if (typeValue === "text/html")
3261
+ return;
3262
+ if (!node.body || node.body.length === 0)
3263
+ return;
3264
+ this.checkNodesForUnsafeOutput(node.body);
3265
+ }
3266
+ checkNodesForUnsafeOutput(nodes) {
3267
+ for (const child of nodes) {
3268
+ if (!isERBNode(child))
3269
+ continue;
3270
+ if (!isERBOutputNode(child))
3271
+ continue;
3272
+ const content = child.content?.value?.trim() || "";
3273
+ if (SAFE_PATTERN.test(content))
3274
+ continue;
3275
+ this.addOffense("Unsafe ERB output in `<script>` tag. Use `.to_json` to safely serialize values into JavaScript.", child.location);
3276
+ }
1914
3277
  }
1915
3278
  }
1916
- class ERBNoSilentTagInAttributeNameRule extends ParserRule {
1917
- name = "erb-no-silent-tag-in-attribute-name";
3279
+ class ERBNoUnsafeScriptInterpolationRule extends ParserRule {
3280
+ static ruleName = "erb-no-unsafe-script-interpolation";
1918
3281
  get defaultConfig() {
1919
3282
  return {
1920
3283
  enabled: true,
@@ -1922,7 +3285,7 @@ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
1922
3285
  };
1923
3286
  }
1924
3287
  check(result, context) {
1925
- const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context);
3288
+ const visitor = new ERBNoUnsafeScriptInterpolationVisitor(this.ruleName, context);
1926
3289
  visitor.visit(result.value);
1927
3290
  return visitor.offenses;
1928
3291
  }
@@ -1934,7 +3297,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
1934
3297
  super.visitHTMLOpenTagNode(node);
1935
3298
  }
1936
3299
  checkImgTag(openTag) {
1937
- const tagName = getTagName(openTag);
3300
+ const tagName = getTagLocalName(openTag);
1938
3301
  if (tagName !== "img")
1939
3302
  return;
1940
3303
  const attributes = getAttributes(openTag);
@@ -1995,7 +3358,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
1995
3358
  }
1996
3359
  }
1997
3360
  class ERBPreferImageTagHelperRule extends ParserRule {
1998
- name = "erb-prefer-image-tag-helper";
3361
+ static ruleName = "erb-prefer-image-tag-helper";
1999
3362
  get defaultConfig() {
2000
3363
  return {
2001
3364
  enabled: true,
@@ -2003,7 +3366,7 @@ class ERBPreferImageTagHelperRule extends ParserRule {
2003
3366
  };
2004
3367
  }
2005
3368
  check(result, context) {
2006
- const visitor = new ERBPreferImageTagHelperVisitor(this.name, context);
3369
+ const visitor = new ERBPreferImageTagHelperVisitor(this.ruleName, context);
2007
3370
  visitor.visit(result.value);
2008
3371
  return visitor.offenses;
2009
3372
  }
@@ -2025,7 +3388,7 @@ class ERBRequireTrailingNewlineVisitor extends BaseSourceRuleVisitor {
2025
3388
  }
2026
3389
  class ERBRequireTrailingNewlineRule extends SourceRule {
2027
3390
  static autocorrectable = true;
2028
- name = "erb-require-trailing-newline";
3391
+ static ruleName = "erb-require-trailing-newline";
2029
3392
  get defaultConfig() {
2030
3393
  return {
2031
3394
  enabled: true,
@@ -2033,7 +3396,7 @@ class ERBRequireTrailingNewlineRule extends SourceRule {
2033
3396
  };
2034
3397
  }
2035
3398
  check(source, context) {
2036
- const visitor = new ERBRequireTrailingNewlineVisitor(this.name, context);
3399
+ const visitor = new ERBRequireTrailingNewlineVisitor(this.ruleName, context);
2037
3400
  visitor.visit(source);
2038
3401
  return visitor.offenses;
2039
3402
  }
@@ -2060,7 +3423,22 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
2060
3423
  }
2061
3424
  }
2062
3425
  checkCommentTagWhitespace(node, openTag, closeTag, content) {
2063
- if (!content.startsWith(" ") && !content.startsWith("\n") && !content.startsWith("=")) {
3426
+ const commentedTagPrefix = this.getCommentedTagPrefix(content);
3427
+ if (commentedTagPrefix) {
3428
+ const afterPrefix = content.substring(commentedTagPrefix.length);
3429
+ const tag = `<%#${commentedTagPrefix}`;
3430
+ if (afterPrefix.length > 0 && !afterPrefix[0].match(/\s/)) {
3431
+ this.addOffense(`Add whitespace after \`${tag}\`. This looks like a temporarily commented ERB tag.`, openTag.location, {
3432
+ node,
3433
+ openTag,
3434
+ closeTag,
3435
+ content,
3436
+ fixType: "after-comment-equals",
3437
+ unsafe: true,
3438
+ }, "info");
3439
+ }
3440
+ }
3441
+ else if (!content.startsWith(" ") && !content.startsWith("\n")) {
2064
3442
  this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, {
2065
3443
  node,
2066
3444
  openTag,
@@ -2069,15 +3447,6 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
2069
3447
  fixType: "after-open"
2070
3448
  });
2071
3449
  }
2072
- else if (content.startsWith("=") && content.length > 1 && !content[1].match(/\s/)) {
2073
- this.addOffense(`Add whitespace after \`<%#=\`.`, openTag.location, {
2074
- node,
2075
- openTag,
2076
- closeTag,
2077
- content,
2078
- fixType: "after-comment-equals"
2079
- });
2080
- }
2081
3450
  if (!content.endsWith(" ") && !content.endsWith("\n")) {
2082
3451
  this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, {
2083
3452
  node,
@@ -2100,6 +3469,21 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
2100
3469
  fixType: "after-open"
2101
3470
  });
2102
3471
  }
3472
+ getCommentedTagPrefix(content) {
3473
+ if (content.startsWith("graphql"))
3474
+ return "graphql";
3475
+ if (content.startsWith("%="))
3476
+ return "%=";
3477
+ if (content.startsWith("=="))
3478
+ return "==";
3479
+ if (content.startsWith("%"))
3480
+ return "%";
3481
+ if (content.startsWith("="))
3482
+ return "=";
3483
+ if (content.startsWith("-"))
3484
+ return "-";
3485
+ return null;
3486
+ }
2103
3487
  checkCloseTagWhitespace(node, openTag, closeTag, content) {
2104
3488
  if (content.endsWith(" ") || content.endsWith("\n")) {
2105
3489
  return;
@@ -2115,7 +3499,7 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
2115
3499
  }
2116
3500
  class ERBRequireWhitespaceRule extends ParserRule {
2117
3501
  static autocorrectable = true;
2118
- name = "erb-require-whitespace-inside-tags";
3502
+ static ruleName = "erb-require-whitespace-inside-tags";
2119
3503
  get defaultConfig() {
2120
3504
  return {
2121
3505
  enabled: true,
@@ -2123,7 +3507,7 @@ class ERBRequireWhitespaceRule extends ParserRule {
2123
3507
  };
2124
3508
  }
2125
3509
  check(result, context) {
2126
- const visitor = new RequireWhitespaceInsideTags(this.name, context);
3510
+ const visitor = new RequireWhitespaceInsideTags(this.ruleName, context);
2127
3511
  visitor.visit(result.value);
2128
3512
  return visitor.offenses;
2129
3513
  }
@@ -2142,9 +3526,12 @@ class ERBRequireWhitespaceRule extends ParserRule {
2142
3526
  node.content.value = " " + content;
2143
3527
  return result;
2144
3528
  }
2145
- if (fixType === "after-comment-equals" && content.startsWith("=")) {
2146
- node.content.value = "= " + content.substring(1);
2147
- return result;
3529
+ if (fixType === "after-comment-equals") {
3530
+ const prefix = content.startsWith("graphql") ? "graphql" : content.startsWith("%=") ? "%=" : content.startsWith("==") ? "==" : content.startsWith("%") ? "%" : content.startsWith("=") ? "=" : content.startsWith("-") ? "-" : null;
3531
+ if (prefix) {
3532
+ node.content.value = prefix + " " + content.substring(prefix.length);
3533
+ return result;
3534
+ }
2148
3535
  }
2149
3536
  return null;
2150
3537
  }
@@ -2162,7 +3549,7 @@ class ERBRightTrimVisitor extends BaseRuleVisitor {
2162
3549
  }
2163
3550
  class ERBRightTrimRule extends ParserRule {
2164
3551
  static autocorrectable = true;
2165
- name = "erb-right-trim";
3552
+ static ruleName = "erb-right-trim";
2166
3553
  get defaultConfig() {
2167
3554
  return {
2168
3555
  enabled: true,
@@ -2170,7 +3557,7 @@ class ERBRightTrimRule extends ParserRule {
2170
3557
  };
2171
3558
  }
2172
3559
  check(result, context) {
2173
- const visitor = new ERBRightTrimVisitor(this.name, context);
3560
+ const visitor = new ERBRightTrimVisitor(this.ruleName, context);
2174
3561
  visitor.visit(result.value);
2175
3562
  return visitor.offenses;
2176
3563
  }
@@ -2193,27 +3580,6 @@ class ERBRightTrimRule extends ParserRule {
2193
3580
  }
2194
3581
  }
2195
3582
 
2196
- /**
2197
- * File path and naming utilities for linter rules
2198
- */
2199
- /**
2200
- * Extracts the basename (filename) from a file path
2201
- * Works with both forward slashes and backslashes
2202
- */
2203
- function getBasename(filePath) {
2204
- const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
2205
- return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
2206
- }
2207
- /**
2208
- * Checks if a file is a Rails partial (filename starts with `_`)
2209
- * Returns null if fileName is undefined (unknown context)
2210
- */
2211
- function isPartialFile(fileName) {
2212
- if (!fileName)
2213
- return null;
2214
- return getBasename(fileName).startsWith("_");
2215
- }
2216
-
2217
3583
  /**
2218
3584
  * Checks if parentheses in a string are balanced
2219
3585
  * Returns false if there are more closing parens than opening at any point
@@ -2286,7 +3652,7 @@ function splitByTopLevelComma(str) {
2286
3652
  return result;
2287
3653
  }
2288
3654
 
2289
- const STRICT_LOCALS_PATTERN = /^locals:\s+\([^)]*\)\s*$/;
3655
+ const STRICT_LOCALS_PATTERN = /^locals:\s+\(.*\)\s*$/s;
2290
3656
  function isValidStrictLocalsFormat(content) {
2291
3657
  return STRICT_LOCALS_PATTERN.test(content);
2292
3658
  }
@@ -2311,13 +3677,13 @@ function detectLocalsWithoutColon(content) {
2311
3677
  return /^locals?\(/.test(content);
2312
3678
  }
2313
3679
  function detectSingularLocal(content) {
2314
- return /^local:/.test(content);
3680
+ return content.startsWith('local:');
2315
3681
  }
2316
3682
  function detectMissingColonBeforeParens(content) {
2317
3683
  return /^locals\s+\(/.test(content);
2318
3684
  }
2319
3685
  function detectMissingSpaceAfterColon(content) {
2320
- return /^locals:\(/.test(content);
3686
+ return content.startsWith('locals:(');
2321
3687
  }
2322
3688
  function detectMissingParentheses(content) {
2323
3689
  return /^locals:\s*[^(]/.test(content);
@@ -2481,7 +3847,7 @@ class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
2481
3847
  }
2482
3848
  }
2483
3849
  class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
2484
- name = "erb-strict-locals-comment-syntax";
3850
+ static ruleName = "erb-strict-locals-comment-syntax";
2485
3851
  get defaultConfig() {
2486
3852
  return {
2487
3853
  enabled: true,
@@ -2489,7 +3855,7 @@ class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
2489
3855
  };
2490
3856
  }
2491
3857
  check(result, context) {
2492
- const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context);
3858
+ const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.ruleName, context);
2493
3859
  visitor.visit(result.value);
2494
3860
  return visitor.offenses;
2495
3861
  }
@@ -2512,7 +3878,7 @@ class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
2512
3878
  }
2513
3879
  class ERBStrictLocalsRequiredRule extends SourceRule {
2514
3880
  static unsafeAutocorrectable = true;
2515
- name = "erb-strict-locals-required";
3881
+ static ruleName = "erb-strict-locals-required";
2516
3882
  get defaultConfig() {
2517
3883
  return {
2518
3884
  enabled: false,
@@ -2520,7 +3886,7 @@ class ERBStrictLocalsRequiredRule extends SourceRule {
2520
3886
  };
2521
3887
  }
2522
3888
  check(source, context) {
2523
- const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context);
3889
+ const visitor = new ERBStrictLocalsRequiredVisitor(this.ruleName, context);
2524
3890
  visitor.visit(source);
2525
3891
  return visitor.offenses;
2526
3892
  }
@@ -2688,71 +4054,75 @@ class HerbDisableCommentParsedVisitor extends HerbDisableCommentBaseVisitor {
2688
4054
  }
2689
4055
  }
2690
4056
 
2691
- class HerbDisableCommentValidRuleNameVisitor extends HerbDisableCommentParsedVisitor {
2692
- validRuleNames = new Set();
2693
- validRuleNamesList = [];
2694
- constructor(ruleName, validRuleNames, context) {
2695
- super(ruleName, context);
2696
- this.validRuleNames = new Set([...validRuleNames, "all"]);
2697
- this.validRuleNamesList = Array.from(this.validRuleNames);
2698
- }
2699
- checkParsedHerbDisable(node, _content, herbDisable) {
2700
- herbDisable.ruleNameDetails.forEach(ruleDetail => {
2701
- if (this.validRuleNames.has(ruleDetail.name))
2702
- return;
2703
- const suggestion = didyoumean(ruleDetail.name, this.validRuleNamesList);
2704
- const message = suggestion
2705
- ? `Unknown rule \`${ruleDetail.name}\`. Did you mean \`${suggestion}\`?`
2706
- : `Unknown rule \`${ruleDetail.name}\`.`;
2707
- const location = this.createRuleNameLocation(node, ruleDetail);
2708
- this.addOffenseWithFallback(message, location, node);
2709
- });
4057
+ class HerbDisableCommentMalformedVisitor extends HerbDisableCommentBaseVisitor {
4058
+ checkHerbDisableComment(node, content) {
4059
+ const trimmed = content.trim();
4060
+ const looksLikeHerbDisable = trimmed.startsWith("herb:disable");
4061
+ if (!looksLikeHerbDisable)
4062
+ return;
4063
+ if (trimmed.length > "herb:disable".length) {
4064
+ const charAfterPrefix = trimmed["herb:disable".length];
4065
+ if (charAfterPrefix !== ' ' && charAfterPrefix !== '\t' && charAfterPrefix !== '\n') {
4066
+ this.addOffense("`herb:disable` comment is missing a space after `herb:disable`. Add a space before the rule names.", node.location);
4067
+ return;
4068
+ }
4069
+ }
4070
+ const afterPrefix = trimmed.substring("herb:disable".length).trim();
4071
+ if (afterPrefix.length === 0)
4072
+ return;
4073
+ const parsed = parseHerbDisableContent(content);
4074
+ if (parsed !== null)
4075
+ return;
4076
+ let message = "`herb:disable` comment is malformed.";
4077
+ const rulesString = afterPrefix.trim();
4078
+ if (rulesString.endsWith(',')) {
4079
+ message = "`herb:disable` comment has a trailing comma. Remove the trailing comma.";
4080
+ }
4081
+ else if (rulesString.includes(',,') || rulesString.match(/,\s*,/)) {
4082
+ message = "`herb:disable` comment has consecutive commas. Remove extra commas.";
4083
+ }
4084
+ else if (rulesString.startsWith(',')) {
4085
+ message = "`herb:disable` comment starts with a comma. Remove the leading comma.";
4086
+ }
4087
+ this.addOffense(message, node.location);
2710
4088
  }
2711
4089
  }
2712
- class HerbDisableCommentValidRuleNameRule extends ParserRule {
2713
- name = "herb-disable-comment-valid-rule-name";
4090
+ class HerbDisableCommentMalformedRule extends ParserRule {
4091
+ static ruleName = "herb-disable-comment-malformed";
2714
4092
  get defaultConfig() {
2715
4093
  return {
2716
4094
  enabled: true,
2717
- severity: "warning"
4095
+ severity: "error"
2718
4096
  };
2719
4097
  }
2720
4098
  check(result, context) {
2721
- const validRuleNames = context?.validRuleNames;
2722
- if (!validRuleNames)
2723
- return [];
2724
- if (validRuleNames.length === 0)
2725
- return [];
2726
- const visitor = new HerbDisableCommentValidRuleNameVisitor(this.name, validRuleNames, context);
4099
+ const visitor = new HerbDisableCommentMalformedVisitor(this.ruleName, context);
2727
4100
  visitor.visit(result.value);
2728
4101
  return visitor.offenses;
2729
4102
  }
2730
4103
  }
2731
4104
 
2732
- class HerbDisableCommentNoRedundantAllVisitor extends HerbDisableCommentParsedVisitor {
2733
- checkParsedHerbDisable(node, _content, herbDisable) {
2734
- if (!herbDisable.ruleNames.includes("all"))
2735
- return;
2736
- if (herbDisable.ruleNames.length <= 1)
4105
+ class HerbDisableCommentMissingRulesVisitor extends HerbDisableCommentBaseVisitor {
4106
+ checkHerbDisableComment(node, content) {
4107
+ const herbDisable = parseHerbDisableContent(content);
4108
+ if (herbDisable)
2737
4109
  return;
2738
- const allDetail = herbDisable.ruleNameDetails.find(detail => detail.name === "all");
2739
- if (!allDetail)
4110
+ const emptyFormat = /^\s*herb:disable\s*$/;
4111
+ if (!emptyFormat.test(content))
2740
4112
  return;
2741
- const location = this.createRuleNameLocation(node, allDetail);
2742
- const message = `Using \`all\` with specific rules is redundant. Use \`herb:disable all\` by itself or list only specific rules.`;
2743
- this.addOffenseWithFallback(message, location, node);
4113
+ this.addOffense(`\`herb:disable\` comment is missing rule names. Specify \`all\` or list specific rules to disable.`, node.location);
2744
4114
  }
2745
4115
  }
2746
- class HerbDisableCommentNoRedundantAllRule extends ParserRule {
2747
- name = "herb-disable-comment-no-redundant-all";
4116
+ class HerbDisableCommentMissingRulesRule extends ParserRule {
4117
+ static ruleName = "herb-disable-comment-missing-rules";
2748
4118
  get defaultConfig() {
2749
4119
  return {
2750
4120
  enabled: true,
2751
- severity: "warning"
4121
+ severity: "error"
2752
4122
  };
2753
4123
  }
2754
4124
  check(result, context) {
2755
- const visitor = new HerbDisableCommentNoRedundantAllVisitor(this.name, context);
4125
+ const visitor = new HerbDisableCommentMissingRulesVisitor(this.ruleName, context);
2756
4126
  visitor.visit(result.value);
2757
4127
  return visitor.offenses;
2758
4128
  }
@@ -2774,7 +4144,7 @@ class HerbDisableCommentNoDuplicateRulesVisitor extends HerbDisableCommentParsed
2774
4144
  }
2775
4145
  }
2776
4146
  class HerbDisableCommentNoDuplicateRulesRule extends ParserRule {
2777
- name = "herb-disable-comment-no-duplicate-rules";
4147
+ static ruleName = "herb-disable-comment-no-duplicate-rules";
2778
4148
  get defaultConfig() {
2779
4149
  return {
2780
4150
  enabled: true,
@@ -2782,81 +4152,36 @@ class HerbDisableCommentNoDuplicateRulesRule extends ParserRule {
2782
4152
  };
2783
4153
  }
2784
4154
  check(result, context) {
2785
- const visitor = new HerbDisableCommentNoDuplicateRulesVisitor(this.name, context);
2786
- visitor.visit(result.value);
2787
- return visitor.offenses;
2788
- }
2789
- }
2790
-
2791
- class HerbDisableCommentMissingRulesVisitor extends HerbDisableCommentBaseVisitor {
2792
- checkHerbDisableComment(node, content) {
2793
- const herbDisable = parseHerbDisableContent(content);
2794
- if (herbDisable)
2795
- return;
2796
- const emptyFormat = /^\s*herb:disable\s*$/;
2797
- if (!emptyFormat.test(content))
2798
- return;
2799
- this.addOffense(`\`herb:disable\` comment is missing rule names. Specify \`all\` or list specific rules to disable.`, node.location);
2800
- }
2801
- }
2802
- class HerbDisableCommentMissingRulesRule extends ParserRule {
2803
- name = "herb-disable-comment-missing-rules";
2804
- get defaultConfig() {
2805
- return {
2806
- enabled: true,
2807
- severity: "error"
2808
- };
2809
- }
2810
- check(result, context) {
2811
- const visitor = new HerbDisableCommentMissingRulesVisitor(this.name, context);
4155
+ const visitor = new HerbDisableCommentNoDuplicateRulesVisitor(this.ruleName, context);
2812
4156
  visitor.visit(result.value);
2813
4157
  return visitor.offenses;
2814
4158
  }
2815
4159
  }
2816
4160
 
2817
- class HerbDisableCommentMalformedVisitor extends HerbDisableCommentBaseVisitor {
2818
- checkHerbDisableComment(node, content) {
2819
- const trimmed = content.trim();
2820
- const looksLikeHerbDisable = trimmed.startsWith("herb:disable");
2821
- if (!looksLikeHerbDisable)
4161
+ class HerbDisableCommentNoRedundantAllVisitor extends HerbDisableCommentParsedVisitor {
4162
+ checkParsedHerbDisable(node, _content, herbDisable) {
4163
+ if (!herbDisable.ruleNames.includes("all"))
2822
4164
  return;
2823
- if (trimmed.length > "herb:disable".length) {
2824
- const charAfterPrefix = trimmed["herb:disable".length];
2825
- if (charAfterPrefix !== ' ' && charAfterPrefix !== '\t' && charAfterPrefix !== '\n') {
2826
- this.addOffense("`herb:disable` comment is missing a space after `herb:disable`. Add a space before the rule names.", node.location);
2827
- return;
2828
- }
2829
- }
2830
- const afterPrefix = trimmed.substring("herb:disable".length).trim();
2831
- if (afterPrefix.length === 0)
4165
+ if (herbDisable.ruleNames.length <= 1)
2832
4166
  return;
2833
- const parsed = parseHerbDisableContent(content);
2834
- if (parsed !== null)
4167
+ const allDetail = herbDisable.ruleNameDetails.find(detail => detail.name === "all");
4168
+ if (!allDetail)
2835
4169
  return;
2836
- let message = "`herb:disable` comment is malformed.";
2837
- const rulesString = afterPrefix.trim();
2838
- if (rulesString.endsWith(',')) {
2839
- message = "`herb:disable` comment has a trailing comma. Remove the trailing comma.";
2840
- }
2841
- else if (rulesString.includes(',,') || rulesString.match(/,\s*,/)) {
2842
- message = "`herb:disable` comment has consecutive commas. Remove extra commas.";
2843
- }
2844
- else if (rulesString.startsWith(',')) {
2845
- message = "`herb:disable` comment starts with a comma. Remove the leading comma.";
2846
- }
2847
- this.addOffense(message, node.location);
4170
+ const location = this.createRuleNameLocation(node, allDetail);
4171
+ const message = `Using \`all\` with specific rules is redundant. Use \`herb:disable all\` by itself or list only specific rules.`;
4172
+ this.addOffenseWithFallback(message, location, node);
2848
4173
  }
2849
4174
  }
2850
- class HerbDisableCommentMalformedRule extends ParserRule {
2851
- name = "herb-disable-comment-malformed";
4175
+ class HerbDisableCommentNoRedundantAllRule extends ParserRule {
4176
+ static ruleName = "herb-disable-comment-no-redundant-all";
2852
4177
  get defaultConfig() {
2853
4178
  return {
2854
4179
  enabled: true,
2855
- severity: "error"
4180
+ severity: "warning"
2856
4181
  };
2857
4182
  }
2858
4183
  check(result, context) {
2859
- const visitor = new HerbDisableCommentMalformedVisitor(this.name, context);
4184
+ const visitor = new HerbDisableCommentNoRedundantAllVisitor(this.ruleName, context);
2860
4185
  visitor.visit(result.value);
2861
4186
  return visitor.offenses;
2862
4187
  }
@@ -2903,7 +4228,7 @@ class HerbDisableCommentUnnecessaryVisitor extends HerbDisableCommentParsedVisit
2903
4228
  }
2904
4229
  }
2905
4230
  class HerbDisableCommentUnnecessaryRule extends ParserRule {
2906
- name = "herb-disable-comment-unnecessary";
4231
+ static ruleName = "herb-disable-comment-unnecessary";
2907
4232
  get defaultConfig() {
2908
4233
  return {
2909
4234
  enabled: true,
@@ -2919,37 +4244,161 @@ class HerbDisableCommentUnnecessaryRule extends ParserRule {
2919
4244
  return [];
2920
4245
  if (!ignoredOffensesByLine)
2921
4246
  return [];
2922
- const visitor = new HerbDisableCommentUnnecessaryVisitor(this.name, ignoredOffensesByLine, validRuleNames, context);
4247
+ const visitor = new HerbDisableCommentUnnecessaryVisitor(this.ruleName, ignoredOffensesByLine, validRuleNames, context);
4248
+ visitor.visit(result.value);
4249
+ return visitor.offenses;
4250
+ }
4251
+ }
4252
+
4253
+ class HerbDisableCommentValidRuleNameVisitor extends HerbDisableCommentParsedVisitor {
4254
+ validRuleNames = new Set();
4255
+ validRuleNamesList = [];
4256
+ constructor(ruleName, validRuleNames, context) {
4257
+ super(ruleName, context);
4258
+ this.validRuleNames = new Set([...validRuleNames, "all"]);
4259
+ this.validRuleNamesList = Array.from(this.validRuleNames);
4260
+ }
4261
+ checkParsedHerbDisable(node, _content, herbDisable) {
4262
+ herbDisable.ruleNameDetails.forEach(ruleDetail => {
4263
+ if (this.validRuleNames.has(ruleDetail.name))
4264
+ return;
4265
+ const suggestion = didyoumean(ruleDetail.name, this.validRuleNamesList);
4266
+ const message = suggestion
4267
+ ? `Unknown rule \`${ruleDetail.name}\`. Did you mean \`${suggestion}\`?`
4268
+ : `Unknown rule \`${ruleDetail.name}\`.`;
4269
+ const location = this.createRuleNameLocation(node, ruleDetail);
4270
+ this.addOffenseWithFallback(message, location, node);
4271
+ });
4272
+ }
4273
+ }
4274
+ class HerbDisableCommentValidRuleNameRule extends ParserRule {
4275
+ static ruleName = "herb-disable-comment-valid-rule-name";
4276
+ get defaultConfig() {
4277
+ return {
4278
+ enabled: true,
4279
+ severity: "warning"
4280
+ };
4281
+ }
4282
+ check(result, context) {
4283
+ const validRuleNames = context?.validRuleNames;
4284
+ if (!validRuleNames)
4285
+ return [];
4286
+ if (validRuleNames.length === 0)
4287
+ return [];
4288
+ const visitor = new HerbDisableCommentValidRuleNameVisitor(this.ruleName, validRuleNames, context);
2923
4289
  visitor.visit(result.value);
2924
4290
  return visitor.offenses;
2925
4291
  }
2926
4292
  }
2927
4293
 
2928
- class AnchorRechireHrefVisitor extends BaseRuleVisitor {
4294
+ const ALLOWED_TYPES = ["text/javascript"];
4295
+ class AllowedScriptTypeVisitor extends BaseRuleVisitor {
2929
4296
  visitHTMLOpenTagNode(node) {
4297
+ if (getTagLocalName(node) === "script") {
4298
+ this.visitScriptNode(node);
4299
+ }
4300
+ }
4301
+ visitScriptNode(node) {
4302
+ const typeAttribute = getAttribute(node, "type");
4303
+ if (!typeAttribute) {
4304
+ return;
4305
+ }
4306
+ if (!hasAttributeValue(typeAttribute)) {
4307
+ this.addOffense("Avoid using an empty `type` attribute on the `<script>` tag. Either set a valid type or remove the attribute entirely.", typeAttribute.location);
4308
+ return;
4309
+ }
4310
+ this.validateTypeAttribute(typeAttribute);
4311
+ }
4312
+ validateTypeAttribute(typeAttribute) {
4313
+ const typeValue = getStaticAttributeValue(typeAttribute);
4314
+ if (typeValue === null)
4315
+ return;
4316
+ if (typeValue === "") {
4317
+ this.addOffense("Avoid using an empty `type` attribute on the `<script>` tag. Either set a valid type or remove the attribute entirely.", typeAttribute.location);
4318
+ return;
4319
+ }
4320
+ if (ALLOWED_TYPES.includes(typeValue))
4321
+ return;
4322
+ this.addOffense(`Avoid using \`${typeValue}\` as the \`type\` attribute for the \`<script>\` tag. ` +
4323
+ `Must be one of: ${ALLOWED_TYPES.map(t => `\`${t}\``).join(", ")}` +
4324
+ `${" or blank" }.`, typeAttribute.location);
4325
+ }
4326
+ }
4327
+ class HTMLAllowedScriptTypeRule extends ParserRule {
4328
+ static ruleName = "html-allowed-script-type";
4329
+ get defaultConfig() {
4330
+ return {
4331
+ enabled: true,
4332
+ severity: "error"
4333
+ };
4334
+ }
4335
+ check(result, context) {
4336
+ const visitor = new AllowedScriptTypeVisitor(this.ruleName, context);
4337
+ visitor.visit(result.value);
4338
+ return visitor.offenses;
4339
+ }
4340
+ }
4341
+
4342
+ class AnchorRequireHrefVisitor extends BaseRuleVisitor {
4343
+ visitHTMLElementNode(node) {
2930
4344
  this.checkATag(node);
2931
- super.visitHTMLOpenTagNode(node);
4345
+ super.visitHTMLElementNode(node);
2932
4346
  }
2933
4347
  checkATag(node) {
2934
- const tagName = getTagName(node);
4348
+ const tagName = getTagLocalName(node);
2935
4349
  if (tagName !== "a") {
2936
4350
  return;
2937
4351
  }
2938
- if (!hasAttribute(node, "href")) {
2939
- this.addOffense("Add an `href` attribute to `<a>` to ensure it is focusable and accessible.", node.tag_name.location);
4352
+ const hrefAttribute = this.getHrefAttribute(node);
4353
+ if (!hrefAttribute) {
4354
+ this.addOffense("Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.", node.tag_name.location);
4355
+ return;
4356
+ }
4357
+ const hrefValue = getStaticAttributeValue(hrefAttribute);
4358
+ if (hrefValue === "#") {
4359
+ this.addOffense('Avoid `href="#"` on `<a>`. `href="#"` does not navigate anywhere, scrolls the page to the top, and adds `#` to the URL. If you need a clickable element without navigation, use a `<button>` instead.', hrefAttribute.location);
4360
+ return;
4361
+ }
4362
+ if (hrefValue !== null && hrefValue.startsWith("javascript:void")) {
4363
+ this.addOffense('Avoid `javascript:void(0)` in `href` on `<a>`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.', hrefAttribute.location);
4364
+ return;
4365
+ }
4366
+ if (this.hasNilHrefValue(hrefAttribute)) {
4367
+ this.addOffense("Avoid passing `nil` as the URL for `link_to`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.", hrefAttribute.location);
4368
+ }
4369
+ }
4370
+ hasNilHrefValue(hrefAttribute) {
4371
+ const valueNode = hrefAttribute.value;
4372
+ if (!valueNode)
4373
+ return false;
4374
+ return valueNode.children.some(child => isRubyLiteralNode(child) && child.content === "url_for(nil)");
4375
+ }
4376
+ getHrefAttribute(node) {
4377
+ const openTag = node.open_tag;
4378
+ if (isHTMLOpenTagNode(openTag)) {
4379
+ return getAttribute(openTag, "href");
4380
+ }
4381
+ if (isERBOpenTagNode(openTag)) {
4382
+ return findAttributeByName(filterHTMLAttributeNodes(openTag.children), "href");
2940
4383
  }
4384
+ return null;
2941
4385
  }
2942
4386
  }
2943
4387
  class HTMLAnchorRequireHrefRule extends ParserRule {
2944
- name = "html-anchor-require-href";
4388
+ static ruleName = "html-anchor-require-href";
2945
4389
  get defaultConfig() {
2946
4390
  return {
2947
4391
  enabled: true,
2948
4392
  severity: "error"
2949
4393
  };
2950
4394
  }
4395
+ get parserOptions() {
4396
+ return {
4397
+ action_view_helpers: true,
4398
+ };
4399
+ }
2951
4400
  check(result, context) {
2952
- const visitor = new AnchorRechireHrefVisitor(this.name, context);
4401
+ const visitor = new AnchorRequireHrefVisitor(this.ruleName, context);
2953
4402
  visitor.visit(result.value);
2954
4403
  return visitor.offenses;
2955
4404
  }
@@ -2971,15 +4420,15 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
2971
4420
  }
2972
4421
  }
2973
4422
  class HTMLAriaAttributeMustBeValid extends ParserRule {
2974
- name = "html-aria-attribute-must-be-valid";
4423
+ static ruleName = "html-aria-attribute-must-be-valid";
2975
4424
  get defaultConfig() {
2976
4425
  return {
2977
4426
  enabled: true,
2978
- severity: "error"
4427
+ severity: "warning"
2979
4428
  };
2980
4429
  }
2981
4430
  check(result, context) {
2982
- const visitor = new AriaAttributeMustBeValid(this.name, context);
4431
+ const visitor = new AriaAttributeMustBeValid(this.ruleName, context);
2983
4432
  visitor.visit(result.value);
2984
4433
  return visitor.offenses;
2985
4434
  }
@@ -3008,15 +4457,15 @@ class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
3008
4457
  }
3009
4458
  }
3010
4459
  class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
3011
- name = "html-aria-label-is-well-formatted";
4460
+ static ruleName = "html-aria-label-is-well-formatted";
3012
4461
  get defaultConfig() {
3013
4462
  return {
3014
4463
  enabled: true,
3015
- severity: "error"
4464
+ severity: "warning"
3016
4465
  };
3017
4466
  }
3018
4467
  check(result, context) {
3019
- const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context);
4468
+ const visitor = new AriaLabelIsWellFormattedVisitor(this.ruleName, context);
3020
4469
  visitor.visit(result.value);
3021
4470
  return visitor.offenses;
3022
4471
  }
@@ -3060,15 +4509,15 @@ class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
3060
4509
  }
3061
4510
  }
3062
4511
  class HTMLAriaLevelMustBeValidRule extends ParserRule {
3063
- name = "html-aria-level-must-be-valid";
4512
+ static ruleName = "html-aria-level-must-be-valid";
3064
4513
  get defaultConfig() {
3065
4514
  return {
3066
4515
  enabled: true,
3067
- severity: "error"
4516
+ severity: "warning"
3068
4517
  };
3069
4518
  }
3070
4519
  check(result, context) {
3071
- const visitor = new HTMLAriaLevelMustBeValidVisitor(this.name, context);
4520
+ const visitor = new HTMLAriaLevelMustBeValidVisitor(this.ruleName, context);
3072
4521
  visitor.visit(result.value);
3073
4522
  return visitor.offenses;
3074
4523
  }
@@ -3085,15 +4534,15 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
3085
4534
  }
3086
4535
  }
3087
4536
  class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
3088
- name = "html-aria-role-heading-requires-level";
4537
+ static ruleName = "html-aria-role-heading-requires-level";
3089
4538
  get defaultConfig() {
3090
4539
  return {
3091
4540
  enabled: true,
3092
- severity: "error"
4541
+ severity: "warning"
3093
4542
  };
3094
4543
  }
3095
4544
  check(result, context) {
3096
- const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
4545
+ const visitor = new AriaRoleHeadingRequiresLevel(this.ruleName, context);
3097
4546
  visitor.visit(result.value);
3098
4547
  return visitor.offenses;
3099
4548
  }
@@ -3111,15 +4560,15 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
3111
4560
  }
3112
4561
  }
3113
4562
  class HTMLAriaRoleMustBeValidRule extends ParserRule {
3114
- name = "html-aria-role-must-be-valid";
4563
+ static ruleName = "html-aria-role-must-be-valid";
3115
4564
  get defaultConfig() {
3116
4565
  return {
3117
4566
  enabled: true,
3118
- severity: "error"
4567
+ severity: "warning"
3119
4568
  };
3120
4569
  }
3121
4570
  check(result, context) {
3122
- const visitor = new AriaRoleMustBeValid(this.name, context);
4571
+ const visitor = new AriaRoleMustBeValid(this.ruleName, context);
3123
4572
  visitor.visit(result.value);
3124
4573
  return visitor.offenses;
3125
4574
  }
@@ -3153,7 +4602,7 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
3153
4602
  }
3154
4603
  class HTMLAttributeDoubleQuotesRule extends ParserRule {
3155
4604
  static autocorrectable = true;
3156
- name = "html-attribute-double-quotes";
4605
+ static ruleName = "html-attribute-double-quotes";
3157
4606
  get defaultConfig() {
3158
4607
  return {
3159
4608
  enabled: true,
@@ -3161,7 +4610,7 @@ class HTMLAttributeDoubleQuotesRule extends ParserRule {
3161
4610
  };
3162
4611
  }
3163
4612
  check(result, context) {
3164
- const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
4613
+ const visitor = new AttributeDoubleQuotesVisitor(this.ruleName, context);
3165
4614
  visitor.visit(result.value);
3166
4615
  return visitor.offenses;
3167
4616
  }
@@ -3196,7 +4645,7 @@ class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
3196
4645
  }
3197
4646
  class HTMLAttributeEqualsSpacingRule extends ParserRule {
3198
4647
  static autocorrectable = true;
3199
- name = "html-attribute-equals-spacing";
4648
+ static ruleName = "html-attribute-equals-spacing";
3200
4649
  get defaultConfig() {
3201
4650
  return {
3202
4651
  enabled: true,
@@ -3204,7 +4653,7 @@ class HTMLAttributeEqualsSpacingRule extends ParserRule {
3204
4653
  };
3205
4654
  }
3206
4655
  check(result, context) {
3207
- const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context);
4656
+ const visitor = new HTMLAttributeEqualsSpacingVisitor(this.ruleName, context);
3208
4657
  visitor.visit(result.value);
3209
4658
  return visitor.offenses;
3210
4659
  }
@@ -3250,7 +4699,7 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
3250
4699
  }
3251
4700
  class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
3252
4701
  static autocorrectable = true;
3253
- name = "html-attribute-values-require-quotes";
4702
+ static ruleName = "html-attribute-values-require-quotes";
3254
4703
  get defaultConfig() {
3255
4704
  return {
3256
4705
  enabled: true,
@@ -3258,7 +4707,7 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
3258
4707
  };
3259
4708
  }
3260
4709
  check(result, context) {
3261
- const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
4710
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.ruleName, context);
3262
4711
  visitor.visit(result.value);
3263
4712
  return visitor.offenses;
3264
4713
  }
@@ -3295,7 +4744,7 @@ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
3295
4744
  super.visitHTMLOpenTagNode(node);
3296
4745
  }
3297
4746
  checkElement(node) {
3298
- const tagName = getTagName(node);
4747
+ const tagName = getTagLocalName(node);
3299
4748
  if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
3300
4749
  return;
3301
4750
  }
@@ -3314,24 +4763,23 @@ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
3314
4763
  if (!attribute)
3315
4764
  return false;
3316
4765
  const valueNode = attribute.value;
3317
- if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
4766
+ if (!isHTMLAttributeValueNode(valueNode))
3318
4767
  return false;
3319
- const htmlValueNode = valueNode;
3320
- if (!htmlValueNode.children)
4768
+ if (!valueNode.children)
3321
4769
  return false;
3322
- return htmlValueNode.children.some((child) => child.type === "AST_ERB_CONTENT_NODE");
4770
+ return valueNode.children.some(isERBContentNode);
3323
4771
  }
3324
4772
  }
3325
4773
  class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
3326
- name = "html-avoid-both-disabled-and-aria-disabled";
4774
+ static ruleName = "html-avoid-both-disabled-and-aria-disabled";
3327
4775
  get defaultConfig() {
3328
4776
  return {
3329
4777
  enabled: true,
3330
- severity: "error"
4778
+ severity: "warning"
3331
4779
  };
3332
4780
  }
3333
4781
  check(result, context) {
3334
- const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context);
4782
+ const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.ruleName, context);
3335
4783
  visitor.visit(result.value);
3336
4784
  return visitor.offenses;
3337
4785
  }
@@ -3340,7 +4788,7 @@ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
3340
4788
  class HTMLBodyOnlyElementsVisitor extends BaseRuleVisitor {
3341
4789
  elementStack = [];
3342
4790
  visitHTMLElementNode(node) {
3343
- const tagName = getTagName(node.open_tag)?.toLowerCase();
4791
+ const tagName = getTagLocalName(node);
3344
4792
  if (!tagName)
3345
4793
  return;
3346
4794
  this.checkBodyOnlyElement(node, tagName);
@@ -3366,7 +4814,7 @@ class HTMLBodyOnlyElementsVisitor extends BaseRuleVisitor {
3366
4814
  }
3367
4815
  class HTMLBodyOnlyElementsRule extends ParserRule {
3368
4816
  static autocorrectable = false;
3369
- name = "html-body-only-elements";
4817
+ static ruleName = "html-body-only-elements";
3370
4818
  get defaultConfig() {
3371
4819
  return {
3372
4820
  enabled: true,
@@ -3375,7 +4823,56 @@ class HTMLBodyOnlyElementsRule extends ParserRule {
3375
4823
  };
3376
4824
  }
3377
4825
  check(result, context) {
3378
- const visitor = new HTMLBodyOnlyElementsVisitor(this.name, context);
4826
+ const visitor = new HTMLBodyOnlyElementsVisitor(this.ruleName, context);
4827
+ visitor.visit(result.value);
4828
+ return visitor.offenses;
4829
+ }
4830
+ }
4831
+
4832
+ class DetailsHasSummaryVisitor extends BaseRuleVisitor {
4833
+ visitHTMLElementNode(node) {
4834
+ this.checkDetailsElement(node);
4835
+ super.visitHTMLElementNode(node);
4836
+ }
4837
+ checkDetailsElement(node) {
4838
+ const tagName = getTagLocalName(node);
4839
+ if (tagName !== "details") {
4840
+ return;
4841
+ }
4842
+ if (!this.hasDirectSummaryChild(node)) {
4843
+ this.addOffense("`<details>` element must have a direct `<summary>` child element.", node.location);
4844
+ }
4845
+ }
4846
+ hasDirectSummaryChild(node) {
4847
+ if (!node.body || node.body.length === 0) {
4848
+ return false;
4849
+ }
4850
+ for (const child of node.body) {
4851
+ if (isHTMLElementNode(child)) {
4852
+ const childTagName = getTagLocalName(child);
4853
+ if (childTagName === "summary") {
4854
+ return true;
4855
+ }
4856
+ }
4857
+ }
4858
+ return false;
4859
+ }
4860
+ }
4861
+ class HTMLDetailsHasSummaryRule extends ParserRule {
4862
+ static ruleName = "html-details-has-summary";
4863
+ get defaultConfig() {
4864
+ return {
4865
+ enabled: true,
4866
+ severity: "warning"
4867
+ };
4868
+ }
4869
+ get parserOptions() {
4870
+ return {
4871
+ action_view_helpers: true,
4872
+ };
4873
+ }
4874
+ check(result, context) {
4875
+ const visitor = new DetailsHasSummaryVisitor(this.ruleName, context);
3379
4876
  visitor.visit(result.value);
3380
4877
  return visitor.offenses;
3381
4878
  }
@@ -3400,7 +4897,7 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
3400
4897
  }
3401
4898
  class HTMLBooleanAttributesNoValueRule extends ParserRule {
3402
4899
  static autocorrectable = true;
3403
- name = "html-boolean-attributes-no-value";
4900
+ static ruleName = "html-boolean-attributes-no-value";
3404
4901
  get defaultConfig() {
3405
4902
  return {
3406
4903
  enabled: true,
@@ -3408,7 +4905,7 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
3408
4905
  };
3409
4906
  }
3410
4907
  check(result, context) {
3411
- const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
4908
+ const visitor = new BooleanAttributesNoValueVisitor(this.ruleName, context);
3412
4909
  visitor.visit(result.value);
3413
4910
  return visitor.offenses;
3414
4911
  }
@@ -3425,7 +4922,7 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
3425
4922
  class HeadOnlyElementsVisitor extends BaseRuleVisitor {
3426
4923
  elementStack = [];
3427
4924
  visitHTMLElementNode(node) {
3428
- const tagName = getTagName(node)?.toLowerCase();
4925
+ const tagName = getTagLocalName(node);
3429
4926
  if (!tagName)
3430
4927
  return;
3431
4928
  this.checkHeadOnlyElement(node, tagName);
@@ -3449,7 +4946,7 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
3449
4946
  this.addOffense(`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`, node.location);
3450
4947
  }
3451
4948
  hasItempropAttribute(node) {
3452
- return hasAttribute(node.open_tag, "itemprop");
4949
+ return hasAttribute(node, "itemprop");
3453
4950
  }
3454
4951
  get insideHead() {
3455
4952
  return this.elementStack.includes("head");
@@ -3463,7 +4960,7 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
3463
4960
  }
3464
4961
  class HTMLHeadOnlyElementsRule extends ParserRule {
3465
4962
  static autocorrectable = false;
3466
- name = "html-head-only-elements";
4963
+ static ruleName = "html-head-only-elements";
3467
4964
  get defaultConfig() {
3468
4965
  return {
3469
4966
  enabled: true,
@@ -3472,7 +4969,7 @@ class HTMLHeadOnlyElementsRule extends ParserRule {
3472
4969
  };
3473
4970
  }
3474
4971
  check(result, context) {
3475
- const visitor = new HeadOnlyElementsVisitor(this.name, context);
4972
+ const visitor = new HeadOnlyElementsVisitor(this.ruleName, context);
3476
4973
  visitor.visit(result.value);
3477
4974
  return visitor.offenses;
3478
4975
  }
@@ -3484,16 +4981,12 @@ class IframeHasTitleVisitor extends BaseRuleVisitor {
3484
4981
  super.visitHTMLOpenTagNode(node);
3485
4982
  }
3486
4983
  checkIframeElement(node) {
3487
- const tagName = getTagName(node);
4984
+ const tagName = getTagLocalName(node);
3488
4985
  if (tagName !== "iframe") {
3489
4986
  return;
3490
4987
  }
3491
- const ariaHiddenAttribute = getAttribute(node, "aria-hidden");
3492
- if (ariaHiddenAttribute) {
3493
- const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
3494
- if (ariaHiddenValue === "true") {
3495
- return;
3496
- }
4988
+ if (getStaticAttributeValue(node, "aria-hidden") === "true") {
4989
+ return;
3497
4990
  }
3498
4991
  const attribute = getAttribute(node, "title");
3499
4992
  if (!attribute) {
@@ -3507,15 +5000,15 @@ class IframeHasTitleVisitor extends BaseRuleVisitor {
3507
5000
  }
3508
5001
  }
3509
5002
  class HTMLIframeHasTitleRule extends ParserRule {
3510
- name = "html-iframe-has-title";
5003
+ static ruleName = "html-iframe-has-title";
3511
5004
  get defaultConfig() {
3512
5005
  return {
3513
5006
  enabled: true,
3514
- severity: "error"
5007
+ severity: "warning"
3515
5008
  };
3516
5009
  }
3517
5010
  check(result, context) {
3518
- const visitor = new IframeHasTitleVisitor(this.name, context);
5011
+ const visitor = new IframeHasTitleVisitor(this.ruleName, context);
3519
5012
  visitor.visit(result.value);
3520
5013
  return visitor.offenses;
3521
5014
  }
@@ -3527,25 +5020,30 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
3527
5020
  super.visitHTMLOpenTagNode(node);
3528
5021
  }
3529
5022
  checkImgTag(node) {
3530
- const tagName = getTagName(node);
5023
+ const tagName = getTagLocalName(node);
3531
5024
  if (tagName !== "img") {
3532
5025
  return;
3533
5026
  }
3534
5027
  if (!hasAttribute(node, "alt")) {
3535
5028
  this.addOffense('Missing required `alt` attribute on `<img>` tag. Add `alt=""` for decorative images or `alt="description"` for informative images.', node.tag_name.location);
5029
+ return;
5030
+ }
5031
+ const altAttribute = getAttribute(node, "alt");
5032
+ if (altAttribute && !hasAttributeValue(altAttribute)) {
5033
+ this.addOffense('The `alt` attribute has no value. Add `alt=""` for decorative images or `alt="description"` for informative images.', altAttribute.location);
3536
5034
  }
3537
5035
  }
3538
5036
  }
3539
5037
  class HTMLImgRequireAltRule extends ParserRule {
3540
- name = "html-img-require-alt";
5038
+ static ruleName = "html-img-require-alt";
3541
5039
  get defaultConfig() {
3542
5040
  return {
3543
5041
  enabled: true,
3544
- severity: "error"
5042
+ severity: "warning"
3545
5043
  };
3546
5044
  }
3547
5045
  check(result, context) {
3548
- const visitor = new ImgRequireAltVisitor(this.name, context);
5046
+ const visitor = new ImgRequireAltVisitor(this.ruleName, context);
3549
5047
  visitor.visit(result.value);
3550
5048
  return visitor.offenses;
3551
5049
  }
@@ -3574,10 +5072,7 @@ class HTMLInputRequireAutocompleteVisitor extends BaseRuleVisitor {
3574
5072
  checkInputTag(node) {
3575
5073
  if (!this.isInputTag(node) || this.hasAutocomplete(node))
3576
5074
  return;
3577
- const typeAttribute = getAttribute(node, "type");
3578
- if (!typeAttribute)
3579
- return;
3580
- const typeValue = getStaticAttributeValueContent(typeAttribute);
5075
+ const typeValue = getStaticAttributeValue(node, "type");
3581
5076
  if (!typeValue)
3582
5077
  return;
3583
5078
  if (!this.HTML_INPUT_TYPES_REQUIRING_AUTOCOMPLETE.has(typeValue))
@@ -3594,7 +5089,7 @@ class HTMLInputRequireAutocompleteVisitor extends BaseRuleVisitor {
3594
5089
  return true;
3595
5090
  }
3596
5091
  isInputTag(node) {
3597
- const tagName = getTagName$1(node);
5092
+ const tagName = getTagLocalName(node);
3598
5093
  if (tagName === "input") {
3599
5094
  return true;
3600
5095
  }
@@ -3604,15 +5099,15 @@ class HTMLInputRequireAutocompleteVisitor extends BaseRuleVisitor {
3604
5099
  }
3605
5100
  }
3606
5101
  class HTMLInputRequireAutocompleteRule extends ParserRule {
3607
- name = "html-input-require-autocomplete";
5102
+ static ruleName = "html-input-require-autocomplete";
3608
5103
  get defaultConfig() {
3609
5104
  return {
3610
5105
  enabled: true,
3611
- severity: "error"
5106
+ severity: "warning"
3612
5107
  };
3613
5108
  }
3614
5109
  check(result, context) {
3615
- const visitor = new HTMLInputRequireAutocompleteVisitor(this.name, context);
5110
+ const visitor = new HTMLInputRequireAutocompleteVisitor(this.ruleName, context);
3616
5111
  visitor.visit(result.value);
3617
5112
  return visitor.offenses;
3618
5113
  }
@@ -3624,7 +5119,7 @@ class NavigationHasLabelVisitor extends BaseRuleVisitor {
3624
5119
  super.visitHTMLOpenTagNode(node);
3625
5120
  }
3626
5121
  checkNavigationElement(node) {
3627
- const tagName = getTagName(node);
5122
+ const tagName = getTagLocalName(node);
3628
5123
  const isNavElement = tagName === "nav";
3629
5124
  const hasNavigationRole = this.hasRoleNavigation(node);
3630
5125
  if (!isNavElement && !hasNavigationRole) {
@@ -3651,15 +5146,81 @@ class NavigationHasLabelVisitor extends BaseRuleVisitor {
3651
5146
  }
3652
5147
  }
3653
5148
  class HTMLNavigationHasLabelRule extends ParserRule {
3654
- name = "html-navigation-has-label";
5149
+ static ruleName = "html-navigation-has-label";
3655
5150
  get defaultConfig() {
3656
5151
  return {
3657
5152
  enabled: false,
3658
- severity: "error"
5153
+ severity: "warning"
5154
+ };
5155
+ }
5156
+ check(result, context) {
5157
+ const visitor = new NavigationHasLabelVisitor(this.ruleName, context);
5158
+ visitor.visit(result.value);
5159
+ return visitor.offenses;
5160
+ }
5161
+ }
5162
+
5163
+ class NoAbstractRolesVisitor extends AttributeVisitorMixin {
5164
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
5165
+ if (attributeName !== "role")
5166
+ return;
5167
+ if (!attributeValue)
5168
+ return;
5169
+ const normalizedValue = attributeValue.toLowerCase();
5170
+ if (!ABSTRACT_ARIA_ROLES.has(normalizedValue))
5171
+ return;
5172
+ this.addOffense(`The \`role\` attribute must not use abstract ARIA role \`${attributeValue}\`. Abstract roles are not meant to be used directly.`, attributeNode.location);
5173
+ }
5174
+ }
5175
+ class HTMLNoAbstractRolesRule extends ParserRule {
5176
+ static ruleName = "html-no-abstract-roles";
5177
+ get defaultConfig() {
5178
+ return {
5179
+ enabled: true,
5180
+ severity: "warning"
5181
+ };
5182
+ }
5183
+ check(result, context) {
5184
+ const visitor = new NoAbstractRolesVisitor(this.ruleName, context);
5185
+ visitor.visit(result.value);
5186
+ return visitor.offenses;
5187
+ }
5188
+ }
5189
+
5190
+ class NoAriaHiddenBodyVisitor extends BaseRuleVisitor {
5191
+ visitHTMLOpenTagNode(node) {
5192
+ this.checkAriaHiddenOnBody(node);
5193
+ super.visitHTMLOpenTagNode(node);
5194
+ }
5195
+ checkAriaHiddenOnBody(node) {
5196
+ const tagName = getTagLocalName(node);
5197
+ if (tagName !== "body")
5198
+ return;
5199
+ if (this.hasAriaHidden(node)) {
5200
+ this.addOffense("The `aria-hidden` attribute should never be present on the `<body>` element, as it hides the entire document from assistive technology users.", node.tag_name.location);
5201
+ }
5202
+ }
5203
+ hasAriaHidden(node) {
5204
+ if (!hasAttribute(node, "aria-hidden"))
5205
+ return false;
5206
+ const attributes = getAttributes(node);
5207
+ const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden");
5208
+ if (!ariaHiddenAttr)
5209
+ return false;
5210
+ const value = getAttributeValue(ariaHiddenAttr);
5211
+ return value === null || value === "" || value === "true";
5212
+ }
5213
+ }
5214
+ class HTMLNoAriaHiddenOnBodyRule extends ParserRule {
5215
+ static ruleName = "html-no-aria-hidden-on-body";
5216
+ get defaultConfig() {
5217
+ return {
5218
+ enabled: true,
5219
+ severity: "warning"
3659
5220
  };
3660
5221
  }
3661
5222
  check(result, context) {
3662
- const visitor = new NavigationHasLabelVisitor(this.name, context);
5223
+ const visitor = new NoAriaHiddenBodyVisitor(this.ruleName, context);
3663
5224
  visitor.visit(result.value);
3664
5225
  return visitor.offenses;
3665
5226
  }
@@ -3689,7 +5250,7 @@ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
3689
5250
  return value === "true";
3690
5251
  }
3691
5252
  isFocusable(node) {
3692
- const tagName = getTagName(node);
5253
+ const tagName = getTagLocalName(node);
3693
5254
  if (!tagName)
3694
5255
  return false;
3695
5256
  const tabIndexValue = this.getTabIndexValue(node);
@@ -3722,15 +5283,15 @@ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
3722
5283
  }
3723
5284
  }
3724
5285
  class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
3725
- name = "html-no-aria-hidden-on-focusable";
5286
+ static ruleName = "html-no-aria-hidden-on-focusable";
3726
5287
  get defaultConfig() {
3727
5288
  return {
3728
5289
  enabled: true,
3729
- severity: "error"
5290
+ severity: "warning"
3730
5291
  };
3731
5292
  }
3732
5293
  check(result, context) {
3733
- const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context);
5294
+ const visitor = new NoAriaHiddenOnFocusableVisitor(this.ruleName, context);
3734
5295
  visitor.visit(result.value);
3735
5296
  return visitor.offenses;
3736
5297
  }
@@ -3738,9 +5299,6 @@ class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
3738
5299
 
3739
5300
  class BlockInsideInlineVisitor extends BaseRuleVisitor {
3740
5301
  inlineStack = [];
3741
- isValidHTMLOpenTag(node) {
3742
- return !!(node.open_tag && node.open_tag.type === "AST_HTML_OPEN_TAG_NODE");
3743
- }
3744
5302
  getElementType(tagName) {
3745
5303
  const isInline = isInlineElement(tagName);
3746
5304
  const isBlock = isBlockElement(tagName);
@@ -3764,19 +5322,18 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
3764
5322
  this.inlineStack = savedStack;
3765
5323
  }
3766
5324
  visitHTMLElementNode(node) {
3767
- if (!this.isValidHTMLOpenTag(node)) {
5325
+ if (!isHTMLOpenTagNode(node.open_tag)) {
3768
5326
  super.visitHTMLElementNode(node);
3769
5327
  return;
3770
5328
  }
3771
- const openTag = node.open_tag;
3772
- const tagName = openTag.tag_name?.value.toLowerCase();
5329
+ const tagName = node.open_tag.tag_name?.value.toLowerCase();
3773
5330
  if (!tagName) {
3774
5331
  super.visitHTMLElementNode(node);
3775
5332
  return;
3776
5333
  }
3777
5334
  const { isInline, isBlock, isUnknown } = this.getElementType(tagName);
3778
5335
  if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
3779
- this.addOffenseMessage(tagName, isBlock, openTag);
5336
+ this.addOffenseMessage(tagName, isBlock, node.open_tag);
3780
5337
  }
3781
5338
  if (isInline) {
3782
5339
  this.visitInlineElement(node, tagName);
@@ -3786,7 +5343,7 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
3786
5343
  }
3787
5344
  }
3788
5345
  class HTMLNoBlockInsideInlineRule extends ParserRule {
3789
- name = "html-no-block-inside-inline";
5346
+ static ruleName = "html-no-block-inside-inline";
3790
5347
  get defaultConfig() {
3791
5348
  return {
3792
5349
  enabled: false,
@@ -3794,7 +5351,7 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
3794
5351
  };
3795
5352
  }
3796
5353
  check(result, context) {
3797
- const visitor = new BlockInsideInlineVisitor(this.name, context);
5354
+ const visitor = new BlockInsideInlineVisitor(this.ruleName, context);
3798
5355
  visitor.visit(result.value);
3799
5356
  return visitor.offenses;
3800
5357
  }
@@ -3901,7 +5458,7 @@ class NoDuplicateAttributesVisitor extends ControlFlowTrackingVisitor {
3901
5458
  }
3902
5459
  }
3903
5460
  class HTMLNoDuplicateAttributesRule extends ParserRule {
3904
- name = "html-no-duplicate-attributes";
5461
+ static ruleName = "html-no-duplicate-attributes";
3905
5462
  get defaultConfig() {
3906
5463
  return {
3907
5464
  enabled: true,
@@ -3909,7 +5466,7 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
3909
5466
  };
3910
5467
  }
3911
5468
  check(result, context) {
3912
- const visitor = new NoDuplicateAttributesVisitor(this.name, context);
5469
+ const visitor = new NoDuplicateAttributesVisitor(this.ruleName, context);
3913
5470
  visitor.visit(result.value);
3914
5471
  return visitor.offenses;
3915
5472
  }
@@ -3977,34 +5534,35 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
3977
5534
  }
3978
5535
  extractIdValue(attributeNode) {
3979
5536
  const valueNodes = attributeNode.value?.children || [];
3980
- if (hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
5537
+ const isDynamic = hasERBOutput(valueNodes);
5538
+ if (isDynamic && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
3981
5539
  return null;
3982
5540
  }
3983
5541
  const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
3984
5542
  if (!identifier)
3985
5543
  return null;
3986
- return { identifier, shouldTrackDuplicates: true };
5544
+ return { identifier, shouldTrackDuplicates: true, isDynamic };
3987
5545
  }
3988
5546
  isWhitespaceOnlyId(identifier) {
3989
5547
  return identifier !== '' && identifier.trim() === '';
3990
5548
  }
3991
5549
  processIdDuplicate(idValue, attributeNode) {
3992
- const { identifier, shouldTrackDuplicates } = idValue;
5550
+ const { identifier, shouldTrackDuplicates, isDynamic } = idValue;
3993
5551
  if (!shouldTrackDuplicates)
3994
5552
  return;
3995
5553
  if (this.isInControlFlow) {
3996
- this.handleControlFlowId(identifier, attributeNode);
5554
+ this.handleControlFlowId(identifier, attributeNode, isDynamic);
3997
5555
  }
3998
5556
  else {
3999
5557
  this.handleGlobalId(identifier, attributeNode);
4000
5558
  }
4001
5559
  }
4002
- handleControlFlowId(identifier, attributeNode) {
5560
+ handleControlFlowId(identifier, attributeNode, isDynamic) {
4003
5561
  if (this.currentControlFlowType === ControlFlowType.LOOP) {
4004
5562
  this.handleLoopId(identifier, attributeNode);
4005
5563
  }
4006
5564
  else {
4007
- this.handleConditionalId(identifier, attributeNode);
5565
+ this.handleConditionalId(identifier, attributeNode, isDynamic);
4008
5566
  }
4009
5567
  this.currentBranchIds.add(identifier);
4010
5568
  }
@@ -4018,16 +5576,18 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
4018
5576
  this.addSameLoopIterationOffense(identifier, attributeNode.location);
4019
5577
  }
4020
5578
  }
4021
- handleConditionalId(identifier, attributeNode) {
5579
+ handleConditionalId(identifier, attributeNode, isDynamic) {
4022
5580
  if (this.currentBranchIds.has(identifier)) {
4023
5581
  this.addSameBranchOffense(identifier, attributeNode.location);
4024
5582
  return;
4025
5583
  }
4026
- if (this.documentIds.has(identifier)) {
5584
+ if (!isDynamic && this.documentIds.has(identifier)) {
4027
5585
  this.addDuplicateIdOffense(identifier, attributeNode.location);
4028
5586
  return;
4029
5587
  }
4030
- this.controlFlowIds.add(identifier);
5588
+ if (!isDynamic) {
5589
+ this.controlFlowIds.add(identifier);
5590
+ }
4031
5591
  }
4032
5592
  handleGlobalId(identifier, attributeNode) {
4033
5593
  if (this.documentIds.has(identifier)) {
@@ -4053,7 +5613,7 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
4053
5613
  }
4054
5614
  }
4055
5615
  class HTMLNoDuplicateIdsRule extends ParserRule {
4056
- name = "html-no-duplicate-ids";
5616
+ static ruleName = "html-no-duplicate-ids";
4057
5617
  get defaultConfig() {
4058
5618
  return {
4059
5619
  enabled: true,
@@ -4061,7 +5621,7 @@ class HTMLNoDuplicateIdsRule extends ParserRule {
4061
5621
  };
4062
5622
  }
4063
5623
  check(result, context) {
4064
- const visitor = new NoDuplicateIdsVisitor(this.name, context);
5624
+ const visitor = new NoDuplicateIdsVisitor(this.ruleName, context);
4065
5625
  visitor.visit(result.value);
4066
5626
  return visitor.offenses;
4067
5627
  }
@@ -4073,7 +5633,7 @@ class HTMLNoDuplicateMetaNamesVisitor extends ControlFlowTrackingVisitor {
4073
5633
  currentBranchMetas = [];
4074
5634
  controlFlowMetas = [];
4075
5635
  visitHTMLElementNode(node) {
4076
- const tagName = getTagName(node)?.toLowerCase();
5636
+ const tagName = getTagLocalName(node);
4077
5637
  if (!tagName)
4078
5638
  return;
4079
5639
  if (tagName === "head") {
@@ -4133,21 +5693,23 @@ class HTMLNoDuplicateMetaNamesVisitor extends ControlFlowTrackingVisitor {
4133
5693
  this.currentBranchMetas.push(metaTag);
4134
5694
  }
4135
5695
  extractAttributes(node, metaTag) {
4136
- if (isHTMLElementNode(node) && node.open_tag) {
4137
- forEachAttribute(node.open_tag, (attributeNode) => {
4138
- const name = getAttributeName(attributeNode);
4139
- const value = getAttributeValue(attributeNode)?.trim();
4140
- if (name === "name" && value) {
4141
- metaTag.nameValue = value;
4142
- }
4143
- else if (name === "http-equiv" && value) {
4144
- metaTag.httpEquivValue = value;
4145
- }
4146
- else if (name === "media" && value) {
4147
- metaTag.mediaValue = value;
4148
- }
4149
- });
4150
- }
5696
+ if (!isHTMLElementNode(node))
5697
+ return;
5698
+ if (!isHTMLOpenTagNode(node.open_tag))
5699
+ return;
5700
+ forEachAttribute(node.open_tag, (attributeNode) => {
5701
+ const name = getAttributeName(attributeNode);
5702
+ const value = getAttributeValue(attributeNode)?.trim();
5703
+ if (name === "name" && value) {
5704
+ metaTag.nameValue = value;
5705
+ }
5706
+ else if (name === "http-equiv" && value) {
5707
+ metaTag.httpEquivValue = value;
5708
+ }
5709
+ else if (name === "media" && value) {
5710
+ metaTag.mediaValue = value;
5711
+ }
5712
+ });
4151
5713
  }
4152
5714
  handleControlFlowMeta(metaTag) {
4153
5715
  if (this.currentControlFlowType === ControlFlowType.LOOP) {
@@ -4196,7 +5758,7 @@ class HTMLNoDuplicateMetaNamesVisitor extends ControlFlowTrackingVisitor {
4196
5758
  }
4197
5759
  class HTMLNoDuplicateMetaNamesRule extends ParserRule {
4198
5760
  static autocorrectable = false;
4199
- name = "html-no-duplicate-meta-names";
5761
+ static ruleName = "html-no-duplicate-meta-names";
4200
5762
  get defaultConfig() {
4201
5763
  return {
4202
5764
  enabled: true,
@@ -4204,7 +5766,7 @@ class HTMLNoDuplicateMetaNamesRule extends ParserRule {
4204
5766
  };
4205
5767
  }
4206
5768
  check(result, context) {
4207
- const visitor = new HTMLNoDuplicateMetaNamesVisitor(this.name, context);
5769
+ const visitor = new HTMLNoDuplicateMetaNamesVisitor(this.ruleName, context);
4208
5770
  visitor.visit(result.value);
4209
5771
  return visitor.offenses;
4210
5772
  }
@@ -4296,7 +5858,7 @@ class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
4296
5858
  }
4297
5859
  }
4298
5860
  class HTMLNoEmptyAttributesRule extends ParserRule {
4299
- name = "html-no-empty-attributes";
5861
+ static ruleName = "html-no-empty-attributes";
4300
5862
  get defaultConfig() {
4301
5863
  return {
4302
5864
  enabled: true,
@@ -4304,7 +5866,7 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
4304
5866
  };
4305
5867
  }
4306
5868
  check(result, context) {
4307
- const visitor = new NoEmptyAttributesVisitor(this.name, context);
5869
+ const visitor = new NoEmptyAttributesVisitor(this.ruleName, context);
4308
5870
  visitor.visit(result.value);
4309
5871
  return visitor.offenses;
4310
5872
  }
@@ -4312,22 +5874,18 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
4312
5874
 
4313
5875
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4314
5876
  visitHTMLElementNode(node) {
4315
- const tagName = getTagName(node.open_tag)?.toLowerCase();
5877
+ const tagName = getTagLocalName(node);
4316
5878
  if (tagName === "template")
4317
5879
  return;
4318
5880
  this.checkHeadingElement(node);
4319
5881
  super.visitHTMLElementNode(node);
4320
5882
  }
4321
5883
  checkHeadingElement(node) {
4322
- if (!node.open_tag)
4323
- return;
4324
- if (!isHTMLOpenTagNode(node.open_tag))
4325
- return;
4326
- const tagName = getTagName(node.open_tag);
5884
+ const tagName = getTagLocalName(node);
4327
5885
  if (!tagName)
4328
5886
  return;
4329
5887
  const isStandardHeading = HEADING_TAGS.has(tagName);
4330
- const isAriaHeading = this.hasHeadingRole(node.open_tag);
5888
+ const isAriaHeading = this.hasHeadingRole(node);
4331
5889
  if (!isStandardHeading && !isAriaHeading) {
4332
5890
  return;
4333
5891
  }
@@ -4342,81 +5900,67 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4342
5900
  if (!node.body || node.body.length === 0) {
4343
5901
  return true;
4344
5902
  }
4345
- let hasAccessibleContent = false;
4346
- for (const child of node.body) {
5903
+ return !this.hasAccessibleContent(node.body);
5904
+ }
5905
+ hasAccessibleContent(nodes) {
5906
+ for (const child of nodes) {
4347
5907
  if (isLiteralNode(child) || isHTMLTextNode(child)) {
4348
5908
  if (child.content.trim().length > 0) {
4349
- hasAccessibleContent = true;
4350
- break;
5909
+ return true;
4351
5910
  }
4352
5911
  }
4353
5912
  else if (isHTMLElementNode(child)) {
4354
5913
  if (this.isElementAccessible(child)) {
4355
- hasAccessibleContent = true;
4356
- break;
5914
+ return true;
4357
5915
  }
4358
5916
  }
4359
- else {
4360
- hasAccessibleContent = true;
4361
- break;
5917
+ else if (isERBOutputNode(child)) {
5918
+ return true;
5919
+ }
5920
+ else if (isERBControlFlowNode(child)) {
5921
+ if (this.hasAccessibleContentInControlFlow(child)) {
5922
+ return true;
5923
+ }
4362
5924
  }
4363
5925
  }
4364
- return !hasAccessibleContent;
5926
+ return false;
4365
5927
  }
4366
- hasHeadingRole(node) {
4367
- const attributes = getAttributes(node);
4368
- const roleAttribute = findAttributeByName(attributes, "role");
4369
- if (!roleAttribute) {
4370
- return false;
5928
+ hasAccessibleContentInControlFlow(node) {
5929
+ const nodeWithStatements = node;
5930
+ if (nodeWithStatements.statements && this.hasAccessibleContent(nodeWithStatements.statements)) {
5931
+ return true;
4371
5932
  }
4372
- const roleValue = getAttributeValue(roleAttribute);
4373
- return roleValue === "heading";
5933
+ if (nodeWithStatements.body && this.hasAccessibleContent(nodeWithStatements.body)) {
5934
+ return true;
5935
+ }
5936
+ if (nodeWithStatements.subsequent) {
5937
+ return this.hasAccessibleContentInControlFlow(nodeWithStatements.subsequent);
5938
+ }
5939
+ return false;
5940
+ }
5941
+ hasHeadingRole(node) {
5942
+ return getStaticAttributeValue(node, "role") === "heading";
4374
5943
  }
4375
5944
  isElementAccessible(node) {
4376
- if (!node.open_tag)
4377
- return true;
4378
- if (!isHTMLOpenTagNode(node.open_tag))
4379
- return true;
4380
- const attributes = getAttributes(node.open_tag);
4381
- const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
4382
- if (ariaHiddenAttribute) {
4383
- const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
4384
- if (ariaHiddenValue === "true") {
4385
- return false;
4386
- }
5945
+ if (getStaticAttributeValue(node, "aria-hidden") === "true") {
5946
+ return false;
4387
5947
  }
4388
5948
  if (!node.body || node.body.length === 0) {
4389
5949
  return false;
4390
5950
  }
4391
- for (const child of node.body) {
4392
- if (isLiteralNode(child) || isHTMLTextNode(child)) {
4393
- if (child.content.trim().length > 0) {
4394
- return true;
4395
- }
4396
- }
4397
- else if (isHTMLElementNode(child)) {
4398
- if (this.isElementAccessible(child)) {
4399
- return true;
4400
- }
4401
- }
4402
- else {
4403
- // If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
4404
- return true;
4405
- }
4406
- }
4407
- return false;
5951
+ return this.hasAccessibleContent(node.body);
4408
5952
  }
4409
5953
  }
4410
5954
  class HTMLNoEmptyHeadingsRule extends ParserRule {
4411
- name = "html-no-empty-headings";
5955
+ static ruleName = "html-no-empty-headings";
4412
5956
  get defaultConfig() {
4413
5957
  return {
4414
5958
  enabled: true,
4415
- severity: "error"
5959
+ severity: "warning"
4416
5960
  };
4417
5961
  }
4418
5962
  check(result, context) {
4419
- const visitor = new NoEmptyHeadingsVisitor(this.name, context);
5963
+ const visitor = new NoEmptyHeadingsVisitor(this.ruleName, context);
4420
5964
  visitor.visit(result.value);
4421
5965
  return visitor.offenses;
4422
5966
  }
@@ -4432,25 +5976,32 @@ class NestedLinkVisitor extends BaseRuleVisitor {
4432
5976
  return false;
4433
5977
  }
4434
5978
  visitHTMLElementNode(node) {
4435
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
5979
+ if (!node.open_tag) {
4436
5980
  super.visitHTMLElementNode(node);
4437
5981
  return;
4438
5982
  }
4439
- const openTag = node.open_tag;
4440
- const tagName = getTagName(openTag);
4441
- if (tagName !== "a") {
4442
- super.visitHTMLElementNode(node);
4443
- return;
5983
+ switch (node.open_tag.type) {
5984
+ case "AST_HTML_OPEN_TAG_NODE": {
5985
+ const openTag = node.open_tag;
5986
+ const tagName = getTagLocalName(openTag);
5987
+ if (tagName !== "a") {
5988
+ super.visitHTMLElementNode(node);
5989
+ return;
5990
+ }
5991
+ this.checkNestedLink(openTag);
5992
+ this.linkStack.push(openTag);
5993
+ super.visitHTMLElementNode(node);
5994
+ this.linkStack.pop();
5995
+ break;
5996
+ }
5997
+ case "AST_HTML_CONDITIONAL_OPEN_TAG_NODE":
5998
+ super.visitHTMLElementNode(node);
5999
+ break;
4444
6000
  }
4445
- // If we're already inside a link, this is a nested link
4446
- this.checkNestedLink(openTag);
4447
- this.linkStack.push(openTag);
4448
- super.visitHTMLElementNode(node);
4449
- this.linkStack.pop();
4450
6001
  }
4451
6002
  // Handle self-closing <a> tags (though they're not valid HTML, they might exist)
4452
6003
  visitHTMLOpenTagNode(node) {
4453
- const tagName = getTagName(node);
6004
+ const tagName = getTagLocalName(node);
4454
6005
  if (tagName === "a" && node.is_void) {
4455
6006
  this.checkNestedLink(node);
4456
6007
  }
@@ -4458,7 +6009,7 @@ class NestedLinkVisitor extends BaseRuleVisitor {
4458
6009
  }
4459
6010
  }
4460
6011
  class HTMLNoNestedLinksRule extends ParserRule {
4461
- name = "html-no-nested-links";
6012
+ static ruleName = "html-no-nested-links";
4462
6013
  get defaultConfig() {
4463
6014
  return {
4464
6015
  enabled: true,
@@ -4466,7 +6017,7 @@ class HTMLNoNestedLinksRule extends ParserRule {
4466
6017
  };
4467
6018
  }
4468
6019
  check(result, context) {
4469
- const visitor = new NestedLinkVisitor(this.name, context);
6020
+ const visitor = new NestedLinkVisitor(this.ruleName, context);
4470
6021
  visitor.visit(result.value);
4471
6022
  return visitor.offenses;
4472
6023
  }
@@ -4483,15 +6034,15 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
4483
6034
  }
4484
6035
  }
4485
6036
  class HTMLNoPositiveTabIndexRule extends ParserRule {
4486
- name = "html-no-positive-tab-index";
6037
+ static ruleName = "html-no-positive-tab-index";
4487
6038
  get defaultConfig() {
4488
6039
  return {
4489
6040
  enabled: true,
4490
- severity: "error"
6041
+ severity: "warning"
4491
6042
  };
4492
6043
  }
4493
6044
  check(result, context) {
4494
- const visitor = new NoPositiveTabIndexVisitor(this.name, context);
6045
+ const visitor = new NoPositiveTabIndexVisitor(this.ruleName, context);
4495
6046
  visitor.visit(result.value);
4496
6047
  return visitor.offenses;
4497
6048
  }
@@ -4499,7 +6050,7 @@ class HTMLNoPositiveTabIndexRule extends ParserRule {
4499
6050
 
4500
6051
  class NoSelfClosingVisitor extends BaseRuleVisitor {
4501
6052
  visitHTMLElementNode(node) {
4502
- if (getTagName$1(node) === "svg") {
6053
+ if (getTagLocalName(node) === "svg") {
4503
6054
  this.visit(node.open_tag);
4504
6055
  }
4505
6056
  else {
@@ -4508,7 +6059,7 @@ class NoSelfClosingVisitor extends BaseRuleVisitor {
4508
6059
  }
4509
6060
  visitHTMLOpenTagNode(node) {
4510
6061
  if (node.tag_closing?.value === "/>") {
4511
- const tagName = getTagName$1(node);
6062
+ const tagName = getTagName(node);
4512
6063
  const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
4513
6064
  this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, {
4514
6065
  node,
@@ -4520,7 +6071,7 @@ class NoSelfClosingVisitor extends BaseRuleVisitor {
4520
6071
  }
4521
6072
  class HTMLNoSelfClosingRule extends ParserRule {
4522
6073
  static autocorrectable = true;
4523
- name = "html-no-self-closing";
6074
+ static ruleName = "html-no-self-closing";
4524
6075
  get defaultConfig() {
4525
6076
  return {
4526
6077
  enabled: true,
@@ -4529,7 +6080,7 @@ class HTMLNoSelfClosingRule extends ParserRule {
4529
6080
  };
4530
6081
  }
4531
6082
  check(result, context) {
4532
- const visitor = new NoSelfClosingVisitor(this.name, context);
6083
+ const visitor = new NoSelfClosingVisitor(this.ruleName, context);
4533
6084
  visitor.visit(result.value);
4534
6085
  return visitor.offenses;
4535
6086
  }
@@ -4677,7 +6228,7 @@ class HTMLNoSpaceInTagVisitor extends BaseRuleVisitor {
4677
6228
  class HTMLNoSpaceInTagRule extends ParserRule {
4678
6229
  // TODO: enable and fix autofix
4679
6230
  static autocorrectable = false;
4680
- name = "html-no-space-in-tag";
6231
+ static ruleName = "html-no-space-in-tag";
4681
6232
  get defaultConfig() {
4682
6233
  return {
4683
6234
  enabled: false,
@@ -4685,7 +6236,7 @@ class HTMLNoSpaceInTagRule extends ParserRule {
4685
6236
  };
4686
6237
  }
4687
6238
  check(result, context) {
4688
- const visitor = new HTMLNoSpaceInTagVisitor(this.name, context);
6239
+ const visitor = new HTMLNoSpaceInTagVisitor(this.ruleName, context);
4689
6240
  visitor.visit(result.value);
4690
6241
  return visitor.offenses;
4691
6242
  }
@@ -4696,9 +6247,7 @@ class HTMLNoSpaceInTagRule extends ParserRule {
4696
6247
  if (!node)
4697
6248
  return null;
4698
6249
  if (isHTMLOpenTagNode(node)) {
4699
- const token = Token.from({ type: "TOKEN_WHITESPACE", value: " ", range: [0, 0], location: Location.zero });
4700
- const whitespace = new WhitespaceNode({ type: "AST_WHITESPACE_NODE", value: token, location: Location.zero, errors: [] });
4701
- node.children.push(whitespace);
6250
+ node.children.push(createWhitespaceNode());
4702
6251
  return result;
4703
6252
  }
4704
6253
  if (!isWhitespaceNode(node))
@@ -4744,7 +6293,7 @@ class NoTitleAttributeVisitor extends BaseRuleVisitor {
4744
6293
  super.visitHTMLOpenTagNode(node);
4745
6294
  }
4746
6295
  checkTitleAttribute(node) {
4747
- const tagName = getTagName(node);
6296
+ const tagName = getTagLocalName(node);
4748
6297
  if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
4749
6298
  return;
4750
6299
  }
@@ -4754,15 +6303,15 @@ class NoTitleAttributeVisitor extends BaseRuleVisitor {
4754
6303
  }
4755
6304
  }
4756
6305
  class HTMLNoTitleAttributeRule extends ParserRule {
4757
- name = "html-no-title-attribute";
6306
+ static ruleName = "html-no-title-attribute";
4758
6307
  get defaultConfig() {
4759
6308
  return {
4760
6309
  enabled: false,
4761
- severity: "error"
6310
+ severity: "warning"
4762
6311
  };
4763
6312
  }
4764
6313
  check(result, context) {
4765
- const visitor = new NoTitleAttributeVisitor(this.name, context);
6314
+ const visitor = new NoTitleAttributeVisitor(this.ruleName, context);
4766
6315
  visitor.visit(result.value);
4767
6316
  return visitor.offenses;
4768
6317
  }
@@ -4792,7 +6341,7 @@ class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
4792
6341
  }
4793
6342
  }
4794
6343
  class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
4795
- name = "html-no-underscores-in-attribute-names";
6344
+ static ruleName = "html-no-underscores-in-attribute-names";
4796
6345
  get defaultConfig() {
4797
6346
  return {
4798
6347
  enabled: true,
@@ -4800,7 +6349,34 @@ class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
4800
6349
  };
4801
6350
  }
4802
6351
  check(result, context) {
4803
- const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.name, context);
6352
+ const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.ruleName, context);
6353
+ visitor.visit(result.value);
6354
+ return visitor.offenses;
6355
+ }
6356
+ }
6357
+
6358
+ class RequireClosingTagsVisitor extends BaseRuleVisitor {
6359
+ visitHTMLOmittedCloseTagNode(node) {
6360
+ const tagName = node.tag_name?.value;
6361
+ if (!tagName)
6362
+ return;
6363
+ this.addOffense(`Missing explicit closing tag for \`<${tagName}>\`. Use \`</${tagName}>\` instead of relying on implicit tag closing.`, node.location);
6364
+ }
6365
+ }
6366
+ class HTMLRequireClosingTagsRule extends ParserRule {
6367
+ static autocorrectable = false;
6368
+ static ruleName = "html-require-closing-tags";
6369
+ get defaultConfig() {
6370
+ return {
6371
+ enabled: true,
6372
+ severity: "error",
6373
+ };
6374
+ }
6375
+ get parserOptions() {
6376
+ return { strict: false };
6377
+ }
6378
+ check(result, context) {
6379
+ const visitor = new RequireClosingTagsVisitor(this.ruleName, context);
4804
6380
  visitor.visit(result.value);
4805
6381
  return visitor.offenses;
4806
6382
  }
@@ -4819,9 +6395,11 @@ class XMLDeclarationChecker extends BaseRuleVisitor {
4819
6395
  }
4820
6396
  class TagNameLowercaseVisitor extends BaseRuleVisitor {
4821
6397
  visitHTMLElementNode(node) {
4822
- if (getTagName$1(node).toLowerCase() === "svg") {
4823
- this.checkTagName(node.open_tag);
4824
- this.checkTagName(node.close_tag);
6398
+ if (getTagLocalName(node) === "svg") {
6399
+ this.checkTagName(getOpenTag(node));
6400
+ if (node.close_tag && isHTMLCloseTagNode(node.close_tag)) {
6401
+ this.checkTagName(node.close_tag);
6402
+ }
4825
6403
  }
4826
6404
  else {
4827
6405
  super.visitHTMLElementNode(node);
@@ -4836,7 +6414,7 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
4836
6414
  checkTagName(node) {
4837
6415
  if (!node)
4838
6416
  return;
4839
- const tagName = getTagName$1(node);
6417
+ const tagName = getTagName(node);
4840
6418
  if (!tagName)
4841
6419
  return;
4842
6420
  const lowercaseTagName = tagName.toLowerCase();
@@ -4853,7 +6431,7 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
4853
6431
  }
4854
6432
  class HTMLTagNameLowercaseRule extends ParserRule {
4855
6433
  static autocorrectable = true;
4856
- name = "html-tag-name-lowercase";
6434
+ static ruleName = "html-tag-name-lowercase";
4857
6435
  get defaultConfig() {
4858
6436
  return {
4859
6437
  enabled: true,
@@ -4862,12 +6440,12 @@ class HTMLTagNameLowercaseRule extends ParserRule {
4862
6440
  };
4863
6441
  }
4864
6442
  isEnabled(result, _context) {
4865
- const checker = new XMLDeclarationChecker(this.name);
6443
+ const checker = new XMLDeclarationChecker(this.ruleName);
4866
6444
  checker.visit(result.value);
4867
6445
  return !checker.hasXMLDeclaration;
4868
6446
  }
4869
6447
  check(result, context) {
4870
- const visitor = new TagNameLowercaseVisitor(this.name, context);
6448
+ const visitor = new TagNameLowercaseVisitor(this.ruleName, context);
4871
6449
  visitor.visit(result.value);
4872
6450
  return visitor.offenses;
4873
6451
  }
@@ -4889,14 +6467,39 @@ class HTMLTagNameLowercaseRule extends ParserRule {
4889
6467
  closeTag.tag_name.value = correctedTagName;
4890
6468
  break;
4891
6469
  case "AST_HTML_CLOSE_TAG_NODE":
4892
- const openTag = parentElement.open_tag;
4893
- openTag.tag_name.value = correctedTagName;
6470
+ const openTag = getOpenTag(parentElement);
6471
+ if (openTag?.tag_name) {
6472
+ openTag.tag_name.value = correctedTagName;
6473
+ }
4894
6474
  break;
4895
6475
  }
4896
6476
  return result;
4897
6477
  }
4898
6478
  }
4899
6479
 
6480
+ class ParserNoErrorsRule extends ParserRule {
6481
+ static ruleName = "parser-no-errors";
6482
+ get defaultConfig() {
6483
+ return {
6484
+ enabled: true,
6485
+ severity: "error"
6486
+ };
6487
+ }
6488
+ check(result) {
6489
+ return result.recursiveErrors().map(error => this.herbErrorToLintOffense(error));
6490
+ }
6491
+ herbErrorToLintOffense(error) {
6492
+ return {
6493
+ message: `${error.message} (\`${error.type}\`)`,
6494
+ location: error.location,
6495
+ severity: error.severity,
6496
+ rule: this.ruleName,
6497
+ code: this.ruleName,
6498
+ source: "linter"
6499
+ };
6500
+ }
6501
+ }
6502
+
4900
6503
  class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
4901
6504
  insideSVG = false;
4902
6505
  visitHTMLElementNode(node) {
@@ -4909,10 +6512,10 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
4909
6512
  return;
4910
6513
  }
4911
6514
  if (this.insideSVG) {
4912
- if (node.open_tag) {
6515
+ if (isHTMLOpenTagNode(node.open_tag)) {
4913
6516
  this.checkTagName(node.open_tag);
4914
6517
  }
4915
- if (node.close_tag) {
6518
+ if (node.close_tag && isHTMLCloseTagNode(node.close_tag)) {
4916
6519
  this.checkTagName(node.close_tag);
4917
6520
  }
4918
6521
  }
@@ -4928,9 +6531,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
4928
6531
  const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
4929
6532
  if (correctCamelCase && tagName !== correctCamelCase) {
4930
6533
  let type = node.type;
4931
- if (node.type === "AST_HTML_OPEN_TAG_NODE")
6534
+ if (isHTMLOpenTagNode(node))
4932
6535
  type = "Opening";
4933
- if (node.type === "AST_HTML_CLOSE_TAG_NODE")
6536
+ if (isHTMLCloseTagNode(node))
4934
6537
  type = "Closing";
4935
6538
  this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, {
4936
6539
  node,
@@ -4942,7 +6545,7 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
4942
6545
  }
4943
6546
  class SVGTagNameCapitalizationRule extends ParserRule {
4944
6547
  static autocorrectable = true;
4945
- name = "svg-tag-name-capitalization";
6548
+ static ruleName = "svg-tag-name-capitalization";
4946
6549
  get defaultConfig() {
4947
6550
  return {
4948
6551
  enabled: true,
@@ -4950,7 +6553,7 @@ class SVGTagNameCapitalizationRule extends ParserRule {
4950
6553
  };
4951
6554
  }
4952
6555
  check(result, context) {
4953
- const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
6556
+ const visitor = new SVGTagNameCapitalizationVisitor(this.ruleName, context);
4954
6557
  visitor.visit(result.value);
4955
6558
  return visitor.offenses;
4956
6559
  }
@@ -4965,49 +6568,75 @@ class SVGTagNameCapitalizationRule extends ParserRule {
4965
6568
  }
4966
6569
  }
4967
6570
 
4968
- class ParserNoErrorsRule extends ParserRule {
4969
- name = "parser-no-errors";
6571
+ class TurboPermanentRequireIdVisitor extends BaseRuleVisitor {
6572
+ visitHTMLOpenTagNode(node) {
6573
+ this.checkTurboPermanent(node);
6574
+ super.visitHTMLOpenTagNode(node);
6575
+ }
6576
+ checkTurboPermanent(node) {
6577
+ const turboPermanentAttribute = getAttribute(node, "data-turbo-permanent");
6578
+ if (!turboPermanentAttribute) {
6579
+ return;
6580
+ }
6581
+ const idAttribute = getAttribute(node, "id");
6582
+ if (!idAttribute) {
6583
+ this.addOffense("Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected.", turboPermanentAttribute.location);
6584
+ }
6585
+ }
6586
+ }
6587
+ class TurboPermanentRequireIdRule extends ParserRule {
6588
+ static ruleName = "turbo-permanent-require-id";
4970
6589
  get defaultConfig() {
4971
6590
  return {
4972
6591
  enabled: true,
4973
6592
  severity: "error"
4974
6593
  };
4975
6594
  }
4976
- check(result) {
4977
- return result.recursiveErrors().map(error => this.herbErrorToLintOffense(error));
4978
- }
4979
- herbErrorToLintOffense(error) {
4980
- return {
4981
- message: `${error.message} (\`${error.type}\`)`,
4982
- location: error.location,
4983
- severity: error.severity,
4984
- rule: this.name,
4985
- code: this.name,
4986
- source: "linter"
4987
- };
6595
+ check(result, context) {
6596
+ const visitor = new TurboPermanentRequireIdVisitor(this.ruleName, context);
6597
+ visitor.visit(result.value);
6598
+ return visitor.offenses;
4988
6599
  }
4989
6600
  }
4990
6601
 
4991
6602
  const rules = [
6603
+ ActionViewNoSilentHelperRule,
4992
6604
  ERBCommentSyntax,
4993
6605
  ERBNoCaseNodeChildrenRule,
6606
+ ERBNoConditionalHTMLElementRule,
6607
+ ERBNoConditionalOpenTagRule,
6608
+ ERBNoDuplicateBranchElementsRule,
4994
6609
  ERBNoEmptyTagsRule,
4995
6610
  ERBNoExtraNewLineRule,
4996
6611
  ERBNoExtraWhitespaceRule,
6612
+ ERBNoInlineCaseConditionsRule,
6613
+ ERBNoInstanceVariablesInPartialsRule,
6614
+ ERBNoInterpolatedClassNamesRule,
6615
+ ERBNoJavascriptTagHelperRule,
4997
6616
  ERBNoOutputControlFlowRule,
6617
+ ERBNoOutputInAttributeNameRule,
6618
+ ERBNoOutputInAttributePositionRule,
6619
+ ERBNoRawOutputInAttributeValueRule,
4998
6620
  ERBNoSilentTagInAttributeNameRule,
6621
+ ERBNoStatementInScriptRule,
6622
+ ERBNoThenInControlFlowRule,
6623
+ ERBNoTrailingWhitespaceRule,
6624
+ ERBNoUnsafeJSAttributeRule,
6625
+ ERBNoUnsafeRawRule,
6626
+ ERBNoUnsafeScriptInterpolationRule,
4999
6627
  ERBPreferImageTagHelperRule,
5000
6628
  ERBRequireTrailingNewlineRule,
5001
6629
  ERBRequireWhitespaceRule,
5002
6630
  ERBRightTrimRule,
5003
6631
  ERBStrictLocalsCommentSyntaxRule,
5004
6632
  ERBStrictLocalsRequiredRule,
5005
- HerbDisableCommentValidRuleNameRule,
5006
- HerbDisableCommentNoRedundantAllRule,
5007
- HerbDisableCommentNoDuplicateRulesRule,
5008
- HerbDisableCommentMissingRulesRule,
5009
6633
  HerbDisableCommentMalformedRule,
6634
+ HerbDisableCommentMissingRulesRule,
6635
+ HerbDisableCommentNoDuplicateRulesRule,
6636
+ HerbDisableCommentNoRedundantAllRule,
5010
6637
  HerbDisableCommentUnnecessaryRule,
6638
+ HerbDisableCommentValidRuleNameRule,
6639
+ HTMLAllowedScriptTypeRule,
5011
6640
  HTMLAnchorRequireHrefRule,
5012
6641
  HTMLAriaAttributeMustBeValid,
5013
6642
  HTMLAriaLabelIsWellFormattedRule,
@@ -5019,12 +6648,15 @@ const rules = [
5019
6648
  HTMLAttributeValuesRequireQuotesRule,
5020
6649
  HTMLAvoidBothDisabledAndAriaDisabledRule,
5021
6650
  HTMLBodyOnlyElementsRule,
6651
+ HTMLDetailsHasSummaryRule,
5022
6652
  HTMLBooleanAttributesNoValueRule,
5023
6653
  HTMLHeadOnlyElementsRule,
5024
6654
  HTMLIframeHasTitleRule,
5025
6655
  HTMLImgRequireAltRule,
5026
6656
  HTMLInputRequireAutocompleteRule,
5027
6657
  HTMLNavigationHasLabelRule,
6658
+ HTMLNoAbstractRolesRule,
6659
+ HTMLNoAriaHiddenOnBodyRule,
5028
6660
  HTMLNoAriaHiddenOnFocusableRule,
5029
6661
  HTMLNoBlockInsideInlineRule,
5030
6662
  HTMLNoDuplicateAttributesRule,
@@ -5038,9 +6670,11 @@ const rules = [
5038
6670
  HTMLNoSpaceInTagRule,
5039
6671
  HTMLNoTitleAttributeRule,
5040
6672
  HTMLNoUnderscoresInAttributeNamesRule,
6673
+ HTMLRequireClosingTagsRule,
5041
6674
  HTMLTagNameLowercaseRule,
5042
- SVGTagNameCapitalizationRule,
5043
6675
  ParserNoErrorsRule,
6676
+ SVGTagNameCapitalizationRule,
6677
+ TurboPermanentRequireIdRule,
5044
6678
  ];
5045
6679
 
5046
6680
  const HERB_LINTER_PREFIX = "herb:linter";
@@ -5083,10 +6717,39 @@ class LinterIgnoreDetector extends Visitor {
5083
6717
  }
5084
6718
  }
5085
6719
 
6720
+ class ParseCache {
6721
+ herb;
6722
+ cache = new Map();
6723
+ constructor(herb) {
6724
+ this.herb = herb;
6725
+ }
6726
+ get(source, parserOptions = {}) {
6727
+ const effectiveOptions = this.resolveOptions(parserOptions);
6728
+ const key = source + JSON.stringify(effectiveOptions);
6729
+ let result = this.cache.get(key);
6730
+ if (!result) {
6731
+ result = this.herb.parse(source, effectiveOptions);
6732
+ this.cache.set(key, result);
6733
+ }
6734
+ return result;
6735
+ }
6736
+ clear() {
6737
+ this.cache.clear();
6738
+ }
6739
+ resolveOptions(parserOptions) {
6740
+ return {
6741
+ ...DEFAULT_PARSER_OPTIONS,
6742
+ ...DEFAULT_LINTER_PARSER_OPTIONS,
6743
+ ...parserOptions
6744
+ };
6745
+ }
6746
+ }
6747
+
5086
6748
  class Linter {
5087
6749
  rules;
5088
6750
  allAvailableRules;
5089
6751
  herb;
6752
+ parseCache;
5090
6753
  offenses;
5091
6754
  config;
5092
6755
  /**
@@ -5117,6 +6780,7 @@ class Linter {
5117
6780
  */
5118
6781
  constructor(herb, rules, config, allAvailableRules) {
5119
6782
  this.herb = herb;
6783
+ this.parseCache = new ParseCache(herb);
5120
6784
  this.config = config;
5121
6785
  this.rules = rules !== undefined ? rules : this.getDefaultRules();
5122
6786
  this.allAvailableRules = allAvailableRules !== undefined ? allAvailableRules : this.rules;
@@ -5136,9 +6800,8 @@ class Linter {
5136
6800
  static filterRulesByConfig(allRules, userRulesConfig) {
5137
6801
  return allRules.filter(ruleClass => {
5138
6802
  const instance = new ruleClass();
5139
- const ruleName = instance.name;
5140
6803
  const defaultEnabled = instance.defaultConfig?.enabled ?? DEFAULT_RULE_CONFIG.enabled;
5141
- const userRuleConfig = userRulesConfig?.[ruleName];
6804
+ const userRuleConfig = userRulesConfig?.[ruleClass.ruleName];
5142
6805
  if (userRuleConfig !== undefined) {
5143
6806
  return userRuleConfig.enabled !== false;
5144
6807
  }
@@ -5180,29 +6843,39 @@ class Linter {
5180
6843
  getRuleCount() {
5181
6844
  return this.rules.length;
5182
6845
  }
6846
+ findRuleClass(ruleName) {
6847
+ return this.rules.find(ruleClass => ruleClass.ruleName === ruleName);
6848
+ }
5183
6849
  /**
5184
- * Type guard to check if a rule is a LexerRule
6850
+ * Type guard to check if a rule class is a LexerRule class
5185
6851
  */
5186
- isLexerRule(rule) {
5187
- return rule.constructor.type === "lexer";
6852
+ isLexerRuleClass(ruleClass) {
6853
+ return ruleClass.type === "lexer";
5188
6854
  }
5189
6855
  /**
5190
- * Type guard to check if a rule is a SourceRule
6856
+ * Type guard to check if a rule class is a SourceRule class
5191
6857
  */
5192
- isSourceRule(rule) {
5193
- return rule.constructor.type === "source";
6858
+ isSourceRuleClass(ruleClass) {
6859
+ return ruleClass.type === "source";
6860
+ }
6861
+ /**
6862
+ * Type guard to check if a rule class is a ParserRule class
6863
+ */
6864
+ isParserRuleClass(ruleClass) {
6865
+ return ruleClass.type === "parser" || ruleClass.type === undefined;
5194
6866
  }
5195
6867
  /**
5196
6868
  * Execute a single rule and return its unbound offenses.
5197
6869
  * Handles rule type checking (Lexer/Parser/Source) and isEnabled checks.
5198
6870
  */
5199
- executeRule(rule, parseResult, lexResult, source, context) {
6871
+ executeRule(ruleClass, rule, parseResult, lexResult, source, context) {
6872
+ const ruleName = rule.ruleName;
5200
6873
  if (this.config && context?.fileName) {
5201
- if (!this.config.isRuleEnabledForPath(rule.name, context.fileName)) {
6874
+ if (!this.config.isRuleEnabledForPath(ruleName, context.fileName)) {
5202
6875
  return [];
5203
6876
  }
5204
6877
  }
5205
- if (context?.fileName && !this.config?.linter?.rules?.[rule.name]?.exclude) {
6878
+ if (context?.fileName && !this.config?.linter?.rules?.[ruleName]?.exclude) {
5206
6879
  const defaultExclude = rule.defaultConfig?.exclude ?? DEFAULT_RULE_CONFIG.exclude;
5207
6880
  if (defaultExclude && defaultExclude.length > 0) {
5208
6881
  const isExcluded = defaultExclude.some(pattern => picomatch.isMatch(context.fileName, pattern));
@@ -5213,34 +6886,37 @@ class Linter {
5213
6886
  }
5214
6887
  let isEnabled = true;
5215
6888
  let ruleOffenses;
5216
- if (this.isLexerRule(rule)) {
5217
- if (rule.isEnabled) {
5218
- isEnabled = rule.isEnabled(lexResult, context);
6889
+ if (this.isLexerRuleClass(ruleClass)) {
6890
+ const lexerRule = rule;
6891
+ if (lexerRule.isEnabled) {
6892
+ isEnabled = lexerRule.isEnabled(lexResult, context);
5219
6893
  }
5220
6894
  if (isEnabled) {
5221
- ruleOffenses = rule.check(lexResult, context);
6895
+ ruleOffenses = lexerRule.check(lexResult, context);
5222
6896
  }
5223
6897
  else {
5224
6898
  ruleOffenses = [];
5225
6899
  }
5226
6900
  }
5227
- else if (this.isSourceRule(rule)) {
5228
- if (rule.isEnabled) {
5229
- isEnabled = rule.isEnabled(source, context);
6901
+ else if (this.isSourceRuleClass(ruleClass)) {
6902
+ const sourceRule = rule;
6903
+ if (sourceRule.isEnabled) {
6904
+ isEnabled = sourceRule.isEnabled(source, context);
5230
6905
  }
5231
6906
  if (isEnabled) {
5232
- ruleOffenses = rule.check(source, context);
6907
+ ruleOffenses = sourceRule.check(source, context);
5233
6908
  }
5234
6909
  else {
5235
6910
  ruleOffenses = [];
5236
6911
  }
5237
6912
  }
5238
6913
  else {
5239
- if (rule.isEnabled) {
5240
- isEnabled = rule.isEnabled(parseResult, context);
6914
+ const parserRule = rule;
6915
+ if (parserRule.isEnabled) {
6916
+ isEnabled = parserRule.isEnabled(parseResult, context);
5241
6917
  }
5242
6918
  if (isEnabled) {
5243
- ruleOffenses = rule.check(parseResult, context);
6919
+ ruleOffenses = parserRule.check(parseResult, context);
5244
6920
  }
5245
6921
  else {
5246
6922
  ruleOffenses = [];
@@ -5292,7 +6968,7 @@ class Linter {
5292
6968
  this.offenses = [];
5293
6969
  let ignoredCount = 0;
5294
6970
  let wouldBeIgnoredCount = 0;
5295
- const parseResult = this.herb.parse(source, { track_whitespace: true });
6971
+ const parseResult = this.parseCache.get(source);
5296
6972
  // Check for file-level ignore directive using visitor
5297
6973
  if (hasLinterIgnoreDirective(parseResult)) {
5298
6974
  return {
@@ -5310,20 +6986,12 @@ class Linter {
5310
6986
  const ignoredOffensesByLine = new Map();
5311
6987
  const herbDisableCache = new Map();
5312
6988
  if (hasParserErrors) {
5313
- const hasParserRule = this.rules.find(RuleClass => (new RuleClass()).name === "parser-no-errors");
6989
+ const hasParserRule = this.findRuleClass("parser-no-errors");
5314
6990
  if (hasParserRule) {
5315
6991
  const rule = new ParserNoErrorsRule();
5316
6992
  const offenses = rule.check(parseResult);
5317
6993
  this.offenses.push(...offenses);
5318
6994
  }
5319
- return {
5320
- offenses: this.offenses,
5321
- errors: this.offenses.filter(o => o.severity === "error").length,
5322
- warnings: this.offenses.filter(o => o.severity === "warning").length,
5323
- info: this.offenses.filter(o => o.severity === "info").length,
5324
- hints: this.offenses.filter(o => o.severity === "hint").length,
5325
- ignored: 0
5326
- };
5327
6995
  }
5328
6996
  for (let i = 0; i < sourceLines.length; i++) {
5329
6997
  const line = sourceLines[i];
@@ -5334,30 +7002,36 @@ class Linter {
5334
7002
  }
5335
7003
  context = {
5336
7004
  ...context,
5337
- validRuleNames: this.getAvailableRules().map(RuleClass => new RuleClass().name),
7005
+ validRuleNames: this.getAvailableRules().map(ruleClass => ruleClass.ruleName),
5338
7006
  ignoredOffensesByLine
5339
7007
  };
5340
- const regularRules = this.rules.filter(RuleClass => {
5341
- const rule = new RuleClass();
5342
- return rule.name !== "herb-disable-comment-unnecessary";
5343
- });
5344
- for (const RuleClass of regularRules) {
5345
- const rule = new RuleClass();
5346
- const unboundOffenses = this.executeRule(rule, parseResult, lexResult, source, context);
5347
- const boundOffenses = this.bindSeverity(unboundOffenses, rule.name);
5348
- const { kept, ignored, wouldBeIgnored } = this.filterOffenses(boundOffenses, rule.name, ignoredOffensesByLine, herbDisableCache, context?.ignoreDisableComments);
7008
+ const regularRules = this.rules.filter(ruleClass => ruleClass.ruleName !== "herb-disable-comment-unnecessary");
7009
+ for (const ruleClass of regularRules) {
7010
+ const rule = new ruleClass();
7011
+ const parserOptions = this.isParserRuleClass(ruleClass) ? rule.parserOptions : {};
7012
+ const parseResult = this.parseCache.get(source, parserOptions);
7013
+ // Skip parser rules whose parse result has errors (parser-no-errors handled above)
7014
+ // Skip lexer/source rules when the default parse has errors
7015
+ if (this.isParserRuleClass(ruleClass)) {
7016
+ if (parseResult.recursiveErrors().length > 0)
7017
+ continue;
7018
+ }
7019
+ else if (hasParserErrors) {
7020
+ continue;
7021
+ }
7022
+ const unboundOffenses = this.executeRule(ruleClass, rule, parseResult, lexResult, source, context);
7023
+ const boundOffenses = this.bindSeverity(unboundOffenses, ruleClass.ruleName);
7024
+ const { kept, ignored, wouldBeIgnored } = this.filterOffenses(boundOffenses, ruleClass.ruleName, ignoredOffensesByLine, herbDisableCache, context?.ignoreDisableComments);
5349
7025
  ignoredCount += ignored.length;
5350
7026
  wouldBeIgnoredCount += wouldBeIgnored.length;
5351
7027
  this.offenses.push(...kept);
5352
7028
  }
5353
- const unnecessaryRuleClass = this.rules.find(RuleClass => {
5354
- const rule = new RuleClass();
5355
- return rule.name === "herb-disable-comment-unnecessary";
5356
- });
7029
+ const unnecessaryRuleClass = this.findRuleClass("herb-disable-comment-unnecessary");
5357
7030
  if (unnecessaryRuleClass) {
5358
7031
  const unnecessaryRule = new unnecessaryRuleClass();
7032
+ const parseResult = this.parseCache.get(source, unnecessaryRule.parserOptions);
5359
7033
  const unboundOffenses = unnecessaryRule.check(parseResult, context);
5360
- const boundOffenses = this.bindSeverity(unboundOffenses, unnecessaryRule.name);
7034
+ const boundOffenses = this.bindSeverity(unboundOffenses, unnecessaryRuleClass.ruleName);
5361
7035
  this.offenses.push(...boundOffenses);
5362
7036
  }
5363
7037
  const finalOffenses = this.offenses;
@@ -5390,23 +7064,20 @@ class Linter {
5390
7064
  * @returns Array of offenses with severity bound
5391
7065
  */
5392
7066
  bindSeverity(unboundOffenses, ruleName) {
5393
- const RuleClass = this.rules.find(rule => {
5394
- const instance = new rule();
5395
- return instance.name === ruleName;
5396
- });
5397
- if (!RuleClass) {
7067
+ const ruleClass = this.findRuleClass(ruleName);
7068
+ if (!ruleClass) {
5398
7069
  return unboundOffenses.map(offense => ({
5399
7070
  ...offense,
5400
7071
  severity: "error"
5401
7072
  }));
5402
7073
  }
5403
- const ruleInstance = new RuleClass();
7074
+ const ruleInstance = new ruleClass();
5404
7075
  const defaultSeverity = ruleInstance.defaultConfig?.severity ?? DEFAULT_RULE_CONFIG.severity;
5405
7076
  const userRuleConfig = this.config?.linter?.rules?.[ruleName];
5406
7077
  const severity = userRuleConfig?.severity ?? defaultSeverity;
5407
7078
  return unboundOffenses.map(offense => ({
5408
7079
  ...offense,
5409
- severity
7080
+ severity: offense.severity ?? severity
5410
7081
  }));
5411
7082
  }
5412
7083
  /**
@@ -5425,14 +7096,11 @@ class Linter {
5425
7096
  const parserOffenses = [];
5426
7097
  const sourceOffenses = [];
5427
7098
  for (const offense of lintResult.offenses) {
5428
- const RuleClass = this.rules.find(rule => {
5429
- const instance = new rule();
5430
- return instance.name === offense.rule;
5431
- });
5432
- if (!RuleClass)
7099
+ const ruleClass = this.findRuleClass(offense.rule);
7100
+ if (!ruleClass)
5433
7101
  continue;
5434
- if (RuleClass.type === "lexer") ;
5435
- else if (RuleClass.type === "source") {
7102
+ if (this.isLexerRuleClass(ruleClass)) ;
7103
+ else if (this.isSourceRuleClass(ruleClass)) {
5436
7104
  sourceOffenses.push(offense);
5437
7105
  }
5438
7106
  else {
@@ -5443,15 +7111,16 @@ class Linter {
5443
7111
  const fixed = [];
5444
7112
  const unfixed = [];
5445
7113
  if (parserOffenses.length > 0) {
5446
- const parseResult = this.herb.parse(currentSource, { track_whitespace: true });
7114
+ const parseResult = this.parseCache.get(currentSource);
7115
+ let needsReindent = false;
5447
7116
  for (const offense of parserOffenses) {
5448
- const RuleClass = this.rules.find(rule => new rule().name === offense.rule);
5449
- if (!RuleClass) {
7117
+ const ruleClass = this.findRuleClass(offense.rule);
7118
+ if (!ruleClass) {
5450
7119
  unfixed.push(offense);
5451
7120
  continue;
5452
7121
  }
5453
- const rule = new RuleClass();
5454
- const isUnsafe = RuleClass.unsafeAutocorrectable === true;
7122
+ const rule = new ruleClass();
7123
+ const isUnsafe = ruleClass.unsafeAutocorrectable === true || offense.autofixContext?.unsafe === true;
5455
7124
  if (!rule.autofix) {
5456
7125
  unfixed.push(offense);
5457
7126
  continue;
@@ -5475,14 +7144,21 @@ class Linter {
5475
7144
  const fixedResult = rule.autofix(offense, parseResult, context);
5476
7145
  if (fixedResult) {
5477
7146
  fixed.push(offense);
7147
+ if (this.isParserRuleClass(ruleClass) && ruleClass.reindentAfterAutofix === true) {
7148
+ needsReindent = true;
7149
+ }
5478
7150
  }
5479
7151
  else {
5480
7152
  unfixed.push(offense);
5481
7153
  }
5482
7154
  }
5483
7155
  if (fixed.length > 0) {
5484
- const printer = new IdentityPrinter();
5485
- currentSource = printer.print(parseResult.value);
7156
+ if (needsReindent) {
7157
+ currentSource = new IndentPrinter().print(parseResult.value);
7158
+ }
7159
+ else {
7160
+ currentSource = new IdentityPrinter().print(parseResult.value);
7161
+ }
5486
7162
  }
5487
7163
  }
5488
7164
  if (sourceOffenses.length > 0) {
@@ -5493,13 +7169,13 @@ class Linter {
5493
7169
  return b.location.start.column - a.location.start.column;
5494
7170
  });
5495
7171
  for (const offense of sortedSourceOffenses) {
5496
- const RuleClass = this.rules.find(rule => new rule().name === offense.rule);
5497
- if (!RuleClass) {
7172
+ const ruleClass = this.findRuleClass(offense.rule);
7173
+ if (!ruleClass) {
5498
7174
  unfixed.push(offense);
5499
7175
  continue;
5500
7176
  }
5501
- const rule = new RuleClass();
5502
- const isUnsafe = RuleClass.unsafeAutocorrectable === true;
7177
+ const rule = new ruleClass();
7178
+ const isUnsafe = ruleClass.unsafeAutocorrectable === true || offense.autofixContext?.unsafe === true;
5503
7179
  if (!rule.autofix) {
5504
7180
  unfixed.push(offense);
5505
7181
  continue;
@@ -5526,5 +7202,10 @@ class Linter {
5526
7202
  }
5527
7203
  }
5528
7204
 
5529
- export { ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINT_CONTEXT, DEFAULT_RULE_CONFIG, DOCUMENT_ONLY_TAG_NAMES, ERBCommentSyntax, ERBNoCaseNodeChildrenRule, ERBNoEmptyTagsRule, ERBNoExtraNewLineRule, ERBNoExtraWhitespaceRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, ERBStrictLocalsCommentSyntaxRule, ERBStrictLocalsRequiredRule, HEADING_TAGS, HEAD_AND_BODY_TAG_NAMES, HEAD_ONLY_TAG_NAMES, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBodyOnlyElementsRule, HTMLBooleanAttributesNoValueRule, HTMLHeadOnlyElementsRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLInputRequireAutocompleteRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoDuplicateMetaNamesRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoSpaceInTagRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_ONLY_TAG_NAMES, HTML_VOID_ELEMENTS, HerbDisableCommentBaseVisitor, HerbDisableCommentMalformedRule, HerbDisableCommentMissingRulesRule, HerbDisableCommentNoDuplicateRulesRule, HerbDisableCommentNoRedundantAllRule, HerbDisableCommentParsedVisitor, HerbDisableCommentUnnecessaryRule, HerbDisableCommentValidRuleNameRule, LexerRule, Linter, ParserRule, STRICT_LOCALS_PATTERN, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findAttributeByName, findNodeByLocation, findParent, forEachAttribute, getAttribute, getAttributeName, getAttributeValue, getAttributeValueNodes, getAttributeValueQuoteType, getAttributes, getBasename, getCombinedAttributeNameString, getStaticAttributeValue, getStaticAttributeValueContent, getTagName, hasAttribute, hasAttributeValue, hasBalancedParentheses, hasDynamicAttributeName, hasDynamicAttributeValue, hasStaticAttributeValue, hasStaticAttributeValueContent, isAttributeValueQuoted, isBlockElement, isBodyOnlyTag, isBodyTag, isBooleanAttribute, isDocumentOnlyTag, isHeadAndBodyTag, isHeadOnlyTag, isHeadTag, isHtmlOnlyTag, isInlineElement, isPartialFile, isVoidElement, locationsEqual, rules, splitByTopLevelComma };
7205
+ const DOCS_BASE_URL = "https://herb-tools.dev/linter/rules";
7206
+ function ruleDocumentationUrl(ruleId) {
7207
+ return `${DOCS_BASE_URL}/${ruleId}`;
7208
+ }
7209
+
7210
+ export { ABSTRACT_ARIA_ROLES, ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINTER_PARSER_OPTIONS, DEFAULT_LINT_CONTEXT, DEFAULT_RULE_CONFIG, DOCUMENT_ONLY_TAG_NAMES, ERBCommentSyntax, ERBNoCaseNodeChildrenRule, ERBNoConditionalOpenTagRule, ERBNoDuplicateBranchElementsRule, ERBNoEmptyTagsRule, ERBNoExtraNewLineRule, ERBNoExtraWhitespaceRule, ERBNoInlineCaseConditionsRule, ERBNoInstanceVariablesInPartialsRule, ERBNoJavascriptTagHelperRule, ERBNoOutputControlFlowRule, ERBNoOutputInAttributeNameRule, ERBNoOutputInAttributePositionRule, ERBNoRawOutputInAttributeValueRule, ERBNoSilentTagInAttributeNameRule, ERBNoStatementInScriptRule, ERBNoThenInControlFlowRule, ERBNoTrailingWhitespaceRule, ERBNoUnsafeJSAttributeRule, ERBNoUnsafeRawRule, ERBNoUnsafeScriptInterpolationRule, ERBPreferImageTagHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, ERBStrictLocalsCommentSyntaxRule, ERBStrictLocalsRequiredRule, HEADING_TAGS, HEAD_AND_BODY_TAG_NAMES, HEAD_ONLY_TAG_NAMES, HTMLAllowedScriptTypeRule, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBodyOnlyElementsRule, HTMLBooleanAttributesNoValueRule, HTMLDetailsHasSummaryRule, HTMLHeadOnlyElementsRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLInputRequireAutocompleteRule, HTMLNavigationHasLabelRule, HTMLNoAbstractRolesRule, HTMLNoAriaHiddenOnBodyRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoDuplicateMetaNamesRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoSpaceInTagRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLRequireClosingTagsRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_ONLY_TAG_NAMES, HTML_VOID_ELEMENTS, HerbDisableCommentBaseVisitor, HerbDisableCommentMalformedRule, HerbDisableCommentMissingRulesRule, HerbDisableCommentNoDuplicateRulesRule, HerbDisableCommentNoRedundantAllRule, HerbDisableCommentParsedVisitor, HerbDisableCommentUnnecessaryRule, HerbDisableCommentValidRuleNameRule, LexerRule, Linter, ParserRule, STRICT_LOCALS_PATTERN, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findNodeAtPosition, findNodeByLocation, findParent, getBasename, hasBalancedParentheses, isBlockElement, isBodyOnlyTag, isBodyTag, isBooleanAttribute, isDocumentOnlyTag, isHeadAndBodyTag, isHeadOnlyTag, isHeadTag, isHtmlOnlyTag, isInlineElement, isPartialFile, isVoidElement, locationFromOffset, locationsEqual, positionFromOffset, ruleDocumentationUrl, rules, splitByTopLevelComma };
5530
7211
  //# sourceMappingURL=index.js.map