@herb-tools/linter 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +34 -0
  2. package/bin/herb-lint +3 -0
  3. package/dist/herb-lint.js +16505 -0
  4. package/dist/herb-lint.js.map +1 -0
  5. package/dist/index.cjs +834 -0
  6. package/dist/index.cjs.map +1 -0
  7. package/dist/index.js +820 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/package.json +49 -0
  10. package/dist/src/cli/argument-parser.js +96 -0
  11. package/dist/src/cli/argument-parser.js.map +1 -0
  12. package/dist/src/cli/file-processor.js +58 -0
  13. package/dist/src/cli/file-processor.js.map +1 -0
  14. package/dist/src/cli/formatters/base-formatter.js +3 -0
  15. package/dist/src/cli/formatters/base-formatter.js.map +1 -0
  16. package/dist/src/cli/formatters/detailed-formatter.js +62 -0
  17. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -0
  18. package/dist/src/cli/formatters/index.js +4 -0
  19. package/dist/src/cli/formatters/index.js.map +1 -0
  20. package/dist/src/cli/formatters/simple-formatter.js +31 -0
  21. package/dist/src/cli/formatters/simple-formatter.js.map +1 -0
  22. package/dist/src/cli/index.js +5 -0
  23. package/dist/src/cli/index.js.map +1 -0
  24. package/dist/src/cli/summary-reporter.js +96 -0
  25. package/dist/src/cli/summary-reporter.js.map +1 -0
  26. package/dist/src/cli.js +50 -0
  27. package/dist/src/cli.js.map +1 -0
  28. package/dist/src/default-rules.js +31 -0
  29. package/dist/src/default-rules.js.map +1 -0
  30. package/dist/src/herb-lint.js +5 -0
  31. package/dist/src/herb-lint.js.map +1 -0
  32. package/dist/src/index.js +4 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/linter.js +39 -0
  35. package/dist/src/linter.js.map +1 -0
  36. package/dist/src/rules/erb-no-empty-tags.js +23 -0
  37. package/dist/src/rules/erb-no-empty-tags.js.map +1 -0
  38. package/dist/src/rules/erb-no-output-control-flow.js +47 -0
  39. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -0
  40. package/dist/src/rules/erb-require-whitespace-inside-tags.js +43 -0
  41. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -0
  42. package/dist/src/rules/html-anchor-require-href.js +25 -0
  43. package/dist/src/rules/html-anchor-require-href.js.map +1 -0
  44. package/dist/src/rules/html-aria-role-heading-requires-level.js +26 -0
  45. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -0
  46. package/dist/src/rules/html-attribute-double-quotes.js +21 -0
  47. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -0
  48. package/dist/src/rules/html-attribute-values-require-quotes.js +22 -0
  49. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -0
  50. package/dist/src/rules/html-boolean-attributes-no-value.js +19 -0
  51. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -0
  52. package/dist/src/rules/html-img-require-alt.js +29 -0
  53. package/dist/src/rules/html-img-require-alt.js.map +1 -0
  54. package/dist/src/rules/html-no-block-inside-inline.js +59 -0
  55. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -0
  56. package/dist/src/rules/html-no-duplicate-attributes.js +43 -0
  57. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -0
  58. package/dist/src/rules/html-no-empty-headings.js +148 -0
  59. package/dist/src/rules/html-no-empty-headings.js.map +1 -0
  60. package/dist/src/rules/html-no-nested-links.js +45 -0
  61. package/dist/src/rules/html-no-nested-links.js.map +1 -0
  62. package/dist/src/rules/html-tag-name-lowercase.js +39 -0
  63. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -0
  64. package/dist/src/rules/index.js +13 -0
  65. package/dist/src/rules/index.js.map +1 -0
  66. package/dist/src/rules/rule-utils.js +198 -0
  67. package/dist/src/rules/rule-utils.js.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/tsconfig.tsbuildinfo +1 -0
  71. package/dist/types/cli/argument-parser.d.ts +14 -0
  72. package/dist/types/cli/file-processor.d.ts +21 -0
  73. package/dist/types/cli/formatters/base-formatter.d.ts +6 -0
  74. package/dist/types/cli/formatters/detailed-formatter.d.ts +13 -0
  75. package/dist/types/cli/formatters/index.d.ts +3 -0
  76. package/dist/types/cli/formatters/simple-formatter.d.ts +7 -0
  77. package/dist/types/cli/summary-reporter.d.ts +22 -0
  78. package/dist/types/cli.d.ts +6 -0
  79. package/dist/types/default-rules.d.ts +2 -0
  80. package/dist/types/herb-lint.d.ts +2 -0
  81. package/dist/types/index.d.ts +3 -0
  82. package/dist/types/linter.d.ts +18 -0
  83. package/dist/types/rules/erb-no-empty-tags.d.ts +6 -0
  84. package/dist/types/rules/erb-no-output-control-flow.d.ts +6 -0
  85. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
  86. package/dist/types/rules/html-anchor-require-href.d.ts +6 -0
  87. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +6 -0
  88. package/dist/types/rules/html-attribute-double-quotes.d.ts +6 -0
  89. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +6 -0
  90. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +6 -0
  91. package/dist/types/rules/html-img-require-alt.d.ts +6 -0
  92. package/dist/types/rules/html-no-block-inside-inline.d.ts +6 -0
  93. package/dist/types/rules/html-no-duplicate-attributes.d.ts +6 -0
  94. package/dist/types/rules/html-no-empty-headings.d.ts +6 -0
  95. package/dist/types/rules/html-no-nested-links.d.ts +6 -0
  96. package/dist/types/rules/html-tag-name-lowercase.d.ts +6 -0
  97. package/dist/types/rules/index.d.ts +12 -0
  98. package/dist/types/rules/rule-utils.d.ts +89 -0
  99. package/dist/types/src/cli/argument-parser.d.ts +14 -0
  100. package/dist/types/src/cli/file-processor.d.ts +21 -0
  101. package/dist/types/src/cli/formatters/base-formatter.d.ts +6 -0
  102. package/dist/types/src/cli/formatters/detailed-formatter.d.ts +13 -0
  103. package/dist/types/src/cli/formatters/index.d.ts +3 -0
  104. package/dist/types/src/cli/formatters/simple-formatter.d.ts +7 -0
  105. package/dist/types/src/cli/index.d.ts +4 -0
  106. package/dist/types/src/cli/summary-reporter.d.ts +22 -0
  107. package/dist/types/src/cli.d.ts +6 -0
  108. package/dist/types/src/default-rules.d.ts +2 -0
  109. package/dist/types/src/herb-lint.d.ts +2 -0
  110. package/dist/types/src/index.d.ts +3 -0
  111. package/dist/types/src/linter.d.ts +18 -0
  112. package/dist/types/src/rules/erb-no-empty-tags.d.ts +6 -0
  113. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +6 -0
  114. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
  115. package/dist/types/src/rules/html-anchor-require-href.d.ts +6 -0
  116. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +6 -0
  117. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +6 -0
  118. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +6 -0
  119. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +6 -0
  120. package/dist/types/src/rules/html-img-require-alt.d.ts +6 -0
  121. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +6 -0
  122. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +6 -0
  123. package/dist/types/src/rules/html-no-empty-headings.d.ts +6 -0
  124. package/dist/types/src/rules/html-no-nested-links.d.ts +6 -0
  125. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +6 -0
  126. package/dist/types/src/rules/index.d.ts +12 -0
  127. package/dist/types/src/rules/rule-utils.d.ts +89 -0
  128. package/dist/types/src/types.d.ts +26 -0
  129. package/dist/types/types.d.ts +26 -0
  130. package/docs/rules/README.md +39 -0
  131. package/docs/rules/erb-no-empty-tags.md +38 -0
  132. package/docs/rules/erb-no-output-control-flow.md +45 -0
  133. package/docs/rules/erb-require-whitespace-inside-tags.md +43 -0
  134. package/docs/rules/html-anchor-require-href.md +32 -0
  135. package/docs/rules/html-aria-role-heading-requires-level.md +34 -0
  136. package/docs/rules/html-attribute-double-quotes.md +43 -0
  137. package/docs/rules/html-attribute-values-require-quotes.md +43 -0
  138. package/docs/rules/html-boolean-attributes-no-value.md +39 -0
  139. package/docs/rules/html-img-require-alt.md +44 -0
  140. package/docs/rules/html-no-block-inside-inline.md +66 -0
  141. package/docs/rules/html-no-duplicate-attributes.md +35 -0
  142. package/docs/rules/html-no-empty-headings.md +78 -0
  143. package/docs/rules/html-no-nested-links.md +44 -0
  144. package/docs/rules/html-tag-name-lowercase.md +44 -0
  145. package/package.json +49 -0
  146. package/src/cli/argument-parser.ts +125 -0
  147. package/src/cli/file-processor.ts +86 -0
  148. package/src/cli/formatters/base-formatter.ts +11 -0
  149. package/src/cli/formatters/detailed-formatter.ts +74 -0
  150. package/src/cli/formatters/index.ts +3 -0
  151. package/src/cli/formatters/simple-formatter.ts +40 -0
  152. package/src/cli/index.ts +4 -0
  153. package/src/cli/summary-reporter.ts +127 -0
  154. package/src/cli.ts +60 -0
  155. package/src/default-rules.ts +33 -0
  156. package/src/herb-lint.ts +6 -0
  157. package/src/index.ts +3 -0
  158. package/src/linter.ts +50 -0
  159. package/src/rules/erb-no-empty-tags.ts +34 -0
  160. package/src/rules/erb-no-output-control-flow.ts +61 -0
  161. package/src/rules/erb-require-whitespace-inside-tags.ts +61 -0
  162. package/src/rules/html-anchor-require-href.ts +39 -0
  163. package/src/rules/html-aria-role-heading-requires-level.ts +44 -0
  164. package/src/rules/html-attribute-double-quotes.ts +28 -0
  165. package/src/rules/html-attribute-values-require-quotes.ts +30 -0
  166. package/src/rules/html-boolean-attributes-no-value.ts +27 -0
  167. package/src/rules/html-img-require-alt.ts +42 -0
  168. package/src/rules/html-no-block-inside-inline.ts +84 -0
  169. package/src/rules/html-no-duplicate-attributes.ts +59 -0
  170. package/src/rules/html-no-empty-headings.ts +185 -0
  171. package/src/rules/html-no-nested-links.ts +65 -0
  172. package/src/rules/html-tag-name-lowercase.ts +50 -0
  173. package/src/rules/index.ts +12 -0
  174. package/src/rules/rule-utils.ts +257 -0
  175. package/src/types.ts +32 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,834 @@
1
+ 'use strict';
2
+
3
+ var core = require('@herb-tools/core');
4
+
5
+ /**
6
+ * Base visitor class that provides common functionality for rule visitors
7
+ */
8
+ class BaseRuleVisitor extends core.Visitor {
9
+ offenses = [];
10
+ ruleName;
11
+ constructor(ruleName) {
12
+ super();
13
+ this.ruleName = ruleName;
14
+ }
15
+ /**
16
+ * Helper method to create a lint offense
17
+ */
18
+ createOffense(message, location, severity = "error") {
19
+ return {
20
+ rule: this.ruleName,
21
+ code: this.ruleName,
22
+ source: "Herb Linter",
23
+ message,
24
+ location,
25
+ severity,
26
+ };
27
+ }
28
+ /**
29
+ * Helper method to add an offense to the offenses array
30
+ */
31
+ addOffense(message, location, severity = "error") {
32
+ this.offenses.push(this.createOffense(message, location, severity));
33
+ }
34
+ }
35
+ /**
36
+ * Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
37
+ */
38
+ function getAttributes(node) {
39
+ return node.type === "AST_HTML_SELF_CLOSE_TAG_NODE"
40
+ ? node.attributes
41
+ : node.children;
42
+ }
43
+ /**
44
+ * Gets the tag name from an HTML tag node (lowercased)
45
+ */
46
+ function getTagName(node) {
47
+ return node.tag_name?.value.toLowerCase() || null;
48
+ }
49
+ /**
50
+ * Gets the attribute name from an HTMLAttributeNode (lowercased)
51
+ */
52
+ function getAttributeName(attributeNode) {
53
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
54
+ const nameNode = attributeNode.name;
55
+ return nameNode.name?.value.toLowerCase() || null;
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Gets the attribute value content from an HTMLAttributeValueNode
61
+ */
62
+ function getAttributeValue(attributeNode) {
63
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
64
+ const valueNode = attributeNode.value;
65
+ if (valueNode.children && valueNode.children.length > 0) {
66
+ return valueNode.children
67
+ .filter(child => child.type === "AST_LITERAL_NODE")
68
+ .map(child => child.content)
69
+ .join("");
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * Checks if an attribute has a value
76
+ */
77
+ function hasAttributeValue(attributeNode) {
78
+ return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE";
79
+ }
80
+ /**
81
+ * Gets the quote type used for an attribute value
82
+ */
83
+ function getAttributeValueQuoteType(attributeNode) {
84
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
85
+ const valueNode = attributeNode.value;
86
+ if (valueNode.quoted && valueNode.open_quote) {
87
+ return valueNode.open_quote.value === '"' ? "double" : "single";
88
+ }
89
+ return "none";
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Finds an attribute by name in a list of attributes
95
+ */
96
+ function findAttributeByName(attributes, attributeName) {
97
+ for (const child of attributes) {
98
+ if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
99
+ const attributeNode = child;
100
+ const name = getAttributeName(attributeNode);
101
+ if (name === attributeName.toLowerCase()) {
102
+ return attributeNode;
103
+ }
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * Checks if a tag has a specific attribute
110
+ */
111
+ function hasAttribute(node, attributeName) {
112
+ const attributes = getAttributes(node);
113
+ return findAttributeByName(attributes, attributeName) !== null;
114
+ }
115
+ /**
116
+ * Common HTML element categorization
117
+ */
118
+ const HTML_INLINE_ELEMENTS = new Set([
119
+ "a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code",
120
+ "dfn", "em", "i", "img", "input", "kbd", "label", "map", "object", "output",
121
+ "q", "samp", "script", "select", "small", "span", "strong", "sub", "sup",
122
+ "textarea", "time", "tt", "var"
123
+ ]);
124
+ const HTML_BLOCK_ELEMENTS = new Set([
125
+ "address", "article", "aside", "blockquote", "canvas", "dd", "div", "dl",
126
+ "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2",
127
+ "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
128
+ "ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
129
+ ]);
130
+ const HTML_BOOLEAN_ATTRIBUTES = new Set([
131
+ "autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
132
+ "loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
133
+ "open", "default", "formnovalidate", "novalidate", "itemscope", "scoped",
134
+ "seamless", "allowfullscreen", "async", "compact", "declare", "nohref",
135
+ "noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
136
+ ]);
137
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
138
+ /**
139
+ * Checks if an element is inline
140
+ */
141
+ function isInlineElement(tagName) {
142
+ return HTML_INLINE_ELEMENTS.has(tagName.toLowerCase());
143
+ }
144
+ /**
145
+ * Checks if an element is block-level
146
+ */
147
+ function isBlockElement(tagName) {
148
+ return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase());
149
+ }
150
+ /**
151
+ * Checks if an attribute is a boolean attribute
152
+ */
153
+ function isBooleanAttribute(attributeName) {
154
+ return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase());
155
+ }
156
+ /**
157
+ * Abstract base class for rules that need to check individual attributes on HTML tags
158
+ * Eliminates duplication of visitHTMLOpenTagNode/visitHTMLSelfCloseTagNode patterns
159
+ * and attribute iteration logic. Provides simplified interface with extracted attribute info.
160
+ */
161
+ class AttributeVisitorMixin extends BaseRuleVisitor {
162
+ visitHTMLOpenTagNode(node) {
163
+ this.checkAttributesOnNode(node);
164
+ super.visitHTMLOpenTagNode(node);
165
+ }
166
+ visitHTMLSelfCloseTagNode(node) {
167
+ this.checkAttributesOnNode(node);
168
+ super.visitHTMLSelfCloseTagNode(node);
169
+ }
170
+ checkAttributesOnNode(node) {
171
+ forEachAttribute(node, (attributeNode) => {
172
+ const attributeName = getAttributeName(attributeNode);
173
+ const attributeValue = getAttributeValue(attributeNode);
174
+ if (attributeName) {
175
+ this.checkAttribute(attributeName, attributeValue, attributeNode, node);
176
+ }
177
+ });
178
+ }
179
+ }
180
+ /**
181
+ * Iterates over all attributes of a tag node, calling the callback for each attribute
182
+ */
183
+ function forEachAttribute(node, callback) {
184
+ const attributes = getAttributes(node);
185
+ for (const child of attributes) {
186
+ if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
187
+ callback(child);
188
+ }
189
+ }
190
+ }
191
+
192
+ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
193
+ visitERBContentNode(node) {
194
+ this.visitChildNodes(node);
195
+ const { content, tag_closing } = node;
196
+ if (!content)
197
+ return;
198
+ if (tag_closing?.value === "")
199
+ return;
200
+ if (content.value.trim().length > 0)
201
+ return;
202
+ this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
203
+ }
204
+ }
205
+ class ERBNoEmptyTagsRule {
206
+ name = "erb-no-empty-tags";
207
+ check(node) {
208
+ const visitor = new ERBNoEmptyTagsVisitor(this.name);
209
+ visitor.visit(node);
210
+ return visitor.offenses;
211
+ }
212
+ }
213
+
214
+ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
215
+ visitERBIfNode(node) {
216
+ this.checkOutputControlFlow(node);
217
+ this.visitChildNodes(node);
218
+ }
219
+ visitERBUnlessNode(node) {
220
+ this.checkOutputControlFlow(node);
221
+ this.visitChildNodes(node);
222
+ }
223
+ visitERBElseNode(node) {
224
+ this.checkOutputControlFlow(node);
225
+ this.visitChildNodes(node);
226
+ }
227
+ visitERBEndNode(node) {
228
+ this.checkOutputControlFlow(node);
229
+ this.visitChildNodes(node);
230
+ }
231
+ checkOutputControlFlow(controlBlock) {
232
+ const openTag = controlBlock.tag_opening;
233
+ if (!openTag) {
234
+ return;
235
+ }
236
+ if (openTag.value === "<%=") {
237
+ let controlBlockType = controlBlock.type;
238
+ if (controlBlock.type === "AST_ERB_IF_NODE")
239
+ controlBlockType = "if";
240
+ if (controlBlock.type === "AST_ERB_ELSE_NODE")
241
+ controlBlockType = "else";
242
+ if (controlBlock.type === "AST_ERB_END_NODE")
243
+ controlBlockType = "end";
244
+ if (controlBlock.type === "AST_ERB_UNLESS_NODE")
245
+ controlBlockType = "unless";
246
+ this.addOffense(`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`, openTag.location, "error");
247
+ }
248
+ return;
249
+ }
250
+ }
251
+ class ERBNoOutputControlFlowRule {
252
+ name = "erb-no-output-control-flow";
253
+ check(node) {
254
+ const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name);
255
+ visitor.visit(node);
256
+ return visitor.offenses;
257
+ }
258
+ }
259
+
260
+ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
261
+ visitChildNodes(node) {
262
+ this.checkWhitespace(node);
263
+ super.visitChildNodes(node);
264
+ }
265
+ checkWhitespace(node) {
266
+ if (!core.isERBNode(node)) {
267
+ return;
268
+ }
269
+ const openTag = node.tag_opening;
270
+ const closeTag = node.tag_closing;
271
+ const content = node.content;
272
+ if (!openTag || !closeTag || !content) {
273
+ return;
274
+ }
275
+ const value = content.value;
276
+ this.checkOpenTagWhitespace(openTag, value);
277
+ this.checkCloseTagWhitespace(closeTag, value);
278
+ }
279
+ checkOpenTagWhitespace(openTag, content) {
280
+ if (content.startsWith(" ") || content.startsWith("\n")) {
281
+ return;
282
+ }
283
+ this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, "error");
284
+ }
285
+ checkCloseTagWhitespace(closeTag, content) {
286
+ if (content.endsWith(" ") || content.endsWith("\n")) {
287
+ return;
288
+ }
289
+ this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
290
+ }
291
+ }
292
+ class ERBRequireWhitespaceRule {
293
+ name = "erb-require-whitespace-inside-tags";
294
+ check(node) {
295
+ const visitor = new RequireWhitespaceInsideTags(this.name);
296
+ visitor.visit(node);
297
+ return visitor.offenses;
298
+ }
299
+ }
300
+
301
+ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
302
+ visitHTMLOpenTagNode(node) {
303
+ this.checkATag(node);
304
+ super.visitHTMLOpenTagNode(node);
305
+ }
306
+ checkATag(node) {
307
+ const tagName = getTagName(node);
308
+ if (tagName !== "a") {
309
+ return;
310
+ }
311
+ if (!hasAttribute(node, "href")) {
312
+ this.addOffense("Add an `href` attribute to `<a>` to ensure it is focusable and accessible.", node.tag_name.location, "error");
313
+ }
314
+ }
315
+ }
316
+ class HTMLAnchorRequireHrefRule {
317
+ name = "html-anchor-require-href";
318
+ check(node) {
319
+ const visitor = new AnchorRechireHrefVisitor(this.name);
320
+ visitor.visit(node);
321
+ return visitor.offenses;
322
+ }
323
+ }
324
+
325
+ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
326
+ // We want to check 2 attributes here:
327
+ // 1. role="heading"
328
+ // 2. aria-level (which must be present if role="heading")
329
+ checkAttribute(attributeName, attributeValue, attributeNode, parentNode) {
330
+ if (!(attributeName === "role" && attributeValue === "heading")) {
331
+ return;
332
+ }
333
+ const allAttributes = getAttributes(parentNode);
334
+ // If we have a role="heading", we must check for aria-level
335
+ const ariaLevelAttr = allAttributes.find(attr => getAttributeName(attr) === "aria-level");
336
+ if (!ariaLevelAttr) {
337
+ this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
338
+ }
339
+ }
340
+ }
341
+ class HTMLAriaRoleHeadingRequiresLevelRule {
342
+ name = "html-aria-role-heading-requires-level";
343
+ check(node) {
344
+ const visitor = new AriaRoleHeadingRequiresLevel(this.name);
345
+ visitor.visit(node);
346
+ return visitor.offenses;
347
+ }
348
+ }
349
+
350
+ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
351
+ checkAttribute(attributeName, attributeValue, attributeNode) {
352
+ if (!hasAttributeValue(attributeNode))
353
+ return;
354
+ if (getAttributeValueQuoteType(attributeNode) !== "single")
355
+ return;
356
+ if (attributeValue?.includes('"'))
357
+ return; // Single quotes acceptable when value contains double quotes
358
+ this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
359
+ }
360
+ }
361
+ class HTMLAttributeDoubleQuotesRule {
362
+ name = "html-attribute-double-quotes";
363
+ check(node) {
364
+ const visitor = new AttributeDoubleQuotesVisitor(this.name);
365
+ visitor.visit(node);
366
+ return visitor.offenses;
367
+ }
368
+ }
369
+
370
+ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
371
+ checkAttribute(attributeName, _attributeValue, attributeNode) {
372
+ if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
373
+ return;
374
+ const valueNode = attributeNode.value;
375
+ if (valueNode.quoted)
376
+ return;
377
+ this.addOffense(
378
+ // TODO: print actual attribute value in message
379
+ `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
380
+ }
381
+ }
382
+ class HTMLAttributeValuesRequireQuotesRule {
383
+ name = "html-attribute-values-require-quotes";
384
+ check(node) {
385
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
386
+ visitor.visit(node);
387
+ return visitor.offenses;
388
+ }
389
+ }
390
+
391
+ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
392
+ checkAttribute(attributeName, _attributeValue, attributeNode) {
393
+ if (!isBooleanAttribute(attributeName))
394
+ return;
395
+ if (!hasAttributeValue(attributeNode))
396
+ return;
397
+ this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
398
+ }
399
+ }
400
+ class HTMLBooleanAttributesNoValueRule {
401
+ name = "html-boolean-attributes-no-value";
402
+ check(node) {
403
+ const visitor = new BooleanAttributesNoValueVisitor(this.name);
404
+ visitor.visit(node);
405
+ return visitor.offenses;
406
+ }
407
+ }
408
+
409
+ class ImgRequireAltVisitor extends BaseRuleVisitor {
410
+ visitHTMLOpenTagNode(node) {
411
+ this.checkImgTag(node);
412
+ super.visitHTMLOpenTagNode(node);
413
+ }
414
+ visitHTMLSelfCloseTagNode(node) {
415
+ this.checkImgTag(node);
416
+ super.visitHTMLSelfCloseTagNode(node);
417
+ }
418
+ checkImgTag(node) {
419
+ const tagName = getTagName(node);
420
+ if (tagName !== "img") {
421
+ return;
422
+ }
423
+ if (!hasAttribute(node, "alt")) {
424
+ this.addOffense('Missing required `alt` attribute on `<img>` tag. Add `alt=""` for decorative images or `alt="description"` for informative images.', node.tag_name.location, "error");
425
+ }
426
+ }
427
+ }
428
+ class HTMLImgRequireAltRule {
429
+ name = "html-img-require-alt";
430
+ check(node) {
431
+ const visitor = new ImgRequireAltVisitor(this.name);
432
+ visitor.visit(node);
433
+ return visitor.offenses;
434
+ }
435
+ }
436
+
437
+ class BlockInsideInlineVisitor extends BaseRuleVisitor {
438
+ inlineStack = [];
439
+ isValidHTMLOpenTag(node) {
440
+ return !!(node.open_tag && node.open_tag.type === "AST_HTML_OPEN_TAG_NODE");
441
+ }
442
+ getElementType(tagName) {
443
+ const isInline = isInlineElement(tagName);
444
+ const isBlock = isBlockElement(tagName);
445
+ const isUnknown = !isInline && !isBlock;
446
+ return { isInline, isBlock, isUnknown };
447
+ }
448
+ addViolationMessage(tagName, isBlock, openTag) {
449
+ const parentInline = this.inlineStack[this.inlineStack.length - 1];
450
+ const elementType = isBlock ? "Block-level" : "Unknown";
451
+ this.addOffense(`${elementType} element \`<${tagName}>\` cannot be placed inside inline element \`<${parentInline}>\`.`, openTag.tag_name.location, "error");
452
+ }
453
+ visitInlineElement(node, tagName) {
454
+ this.inlineStack.push(tagName);
455
+ super.visitHTMLElementNode(node);
456
+ this.inlineStack.pop();
457
+ }
458
+ visitBlockElement(node) {
459
+ const savedStack = [...this.inlineStack];
460
+ this.inlineStack = [];
461
+ super.visitHTMLElementNode(node);
462
+ this.inlineStack = savedStack;
463
+ }
464
+ visitHTMLElementNode(node) {
465
+ if (!this.isValidHTMLOpenTag(node)) {
466
+ super.visitHTMLElementNode(node);
467
+ return;
468
+ }
469
+ const openTag = node.open_tag;
470
+ const tagName = openTag.tag_name?.value.toLowerCase();
471
+ if (!tagName) {
472
+ super.visitHTMLElementNode(node);
473
+ return;
474
+ }
475
+ const { isInline, isBlock, isUnknown } = this.getElementType(tagName);
476
+ if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
477
+ this.addViolationMessage(tagName, isBlock, openTag);
478
+ }
479
+ if (isInline) {
480
+ this.visitInlineElement(node, tagName);
481
+ return;
482
+ }
483
+ this.visitBlockElement(node);
484
+ }
485
+ }
486
+ class HTMLNoBlockInsideInlineRule {
487
+ name = "html-no-block-inside-inline";
488
+ check(node) {
489
+ const visitor = new BlockInsideInlineVisitor(this.name);
490
+ visitor.visit(node);
491
+ return visitor.offenses;
492
+ }
493
+ }
494
+
495
+ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
496
+ visitHTMLOpenTagNode(node) {
497
+ this.checkDuplicateAttributes(node);
498
+ super.visitHTMLOpenTagNode(node);
499
+ }
500
+ visitHTMLSelfCloseTagNode(node) {
501
+ this.checkDuplicateAttributes(node);
502
+ super.visitHTMLSelfCloseTagNode(node);
503
+ }
504
+ checkDuplicateAttributes(node) {
505
+ const attributeNames = new Map();
506
+ forEachAttribute(node, (attributeNode) => {
507
+ if (attributeNode.name?.type !== "AST_HTML_ATTRIBUTE_NAME_NODE")
508
+ return;
509
+ const nameNode = attributeNode.name;
510
+ if (!nameNode.name)
511
+ return;
512
+ const attributeName = nameNode.name.value.toLowerCase(); // HTML attributes are case-insensitive
513
+ if (!attributeNames.has(attributeName)) {
514
+ attributeNames.set(attributeName, []);
515
+ }
516
+ attributeNames.get(attributeName).push(nameNode);
517
+ });
518
+ for (const [attributeName, nameNodes] of attributeNames) {
519
+ if (nameNodes.length > 1) {
520
+ for (let i = 1; i < nameNodes.length; i++) {
521
+ const nameNode = nameNodes[i];
522
+ this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, nameNode.location, "error");
523
+ }
524
+ }
525
+ }
526
+ }
527
+ }
528
+ class HTMLNoDuplicateAttributesRule {
529
+ name = "html-no-duplicate-attributes";
530
+ check(node) {
531
+ const visitor = new NoDuplicateAttributesVisitor(this.name);
532
+ visitor.visit(node);
533
+ return visitor.offenses;
534
+ }
535
+ }
536
+
537
+ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
538
+ visitHTMLElementNode(node) {
539
+ this.checkHeadingElement(node);
540
+ super.visitHTMLElementNode(node);
541
+ }
542
+ visitHTMLSelfCloseTagNode(node) {
543
+ this.checkSelfClosingHeading(node);
544
+ super.visitHTMLSelfCloseTagNode(node);
545
+ }
546
+ checkHeadingElement(node) {
547
+ if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
548
+ return;
549
+ }
550
+ const openTag = node.open_tag;
551
+ const tagName = getTagName(openTag);
552
+ if (!tagName) {
553
+ return;
554
+ }
555
+ const isStandardHeading = HEADING_TAGS.has(tagName);
556
+ const isAriaHeading = this.hasHeadingRole(openTag);
557
+ if (!isStandardHeading && !isAriaHeading) {
558
+ return;
559
+ }
560
+ if (this.isEmptyHeading(node)) {
561
+ const elementDescription = isStandardHeading
562
+ ? `\`<${tagName}>\``
563
+ : `\`<${tagName} role="heading">\``;
564
+ this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.location, "error");
565
+ }
566
+ }
567
+ checkSelfClosingHeading(node) {
568
+ const tagName = getTagName(node);
569
+ if (!tagName) {
570
+ return;
571
+ }
572
+ // Check if it's a standard heading tag (h1-h6) or has role="heading"
573
+ const isStandardHeading = HEADING_TAGS.has(tagName);
574
+ const isAriaHeading = this.hasHeadingRole(node);
575
+ if (!isStandardHeading && !isAriaHeading) {
576
+ return;
577
+ }
578
+ // Self-closing headings are always empty
579
+ const elementDescription = isStandardHeading
580
+ ? `\`<${tagName}>\``
581
+ : `\`<${tagName} role="heading">\``;
582
+ this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.tag_name.location, "error");
583
+ }
584
+ isEmptyHeading(node) {
585
+ if (!node.body || node.body.length === 0) {
586
+ return true;
587
+ }
588
+ // Check if all content is just whitespace or inaccessible
589
+ let hasAccessibleContent = false;
590
+ for (const child of node.body) {
591
+ if (child.type === "AST_LITERAL_NODE") {
592
+ const literalNode = child;
593
+ if (literalNode.content.trim().length > 0) {
594
+ hasAccessibleContent = true;
595
+ break;
596
+ }
597
+ }
598
+ else if (child.type === "AST_HTML_TEXT_NODE") {
599
+ const textNode = child;
600
+ if (textNode.content.trim().length > 0) {
601
+ hasAccessibleContent = true;
602
+ break;
603
+ }
604
+ }
605
+ else if (child.type === "AST_HTML_ELEMENT_NODE") {
606
+ const elementNode = child;
607
+ // Check if this element is accessible (not aria-hidden="true")
608
+ if (this.isElementAccessible(elementNode)) {
609
+ hasAccessibleContent = true;
610
+ break;
611
+ }
612
+ }
613
+ else {
614
+ // If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
615
+ hasAccessibleContent = true;
616
+ break;
617
+ }
618
+ }
619
+ return !hasAccessibleContent;
620
+ }
621
+ hasHeadingRole(node) {
622
+ const attributes = getAttributes(node);
623
+ const roleAttribute = findAttributeByName(attributes, "role");
624
+ if (!roleAttribute) {
625
+ return false;
626
+ }
627
+ const roleValue = getAttributeValue(roleAttribute);
628
+ return roleValue === "heading";
629
+ }
630
+ isElementAccessible(node) {
631
+ // Check if the element has aria-hidden="true"
632
+ if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
633
+ return true;
634
+ }
635
+ const openTag = node.open_tag;
636
+ const attributes = getAttributes(openTag);
637
+ const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
638
+ if (ariaHiddenAttribute) {
639
+ const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
640
+ if (ariaHiddenValue === "true") {
641
+ return false;
642
+ }
643
+ }
644
+ // Recursively check if the element has any accessible content
645
+ if (!node.body || node.body.length === 0) {
646
+ return false;
647
+ }
648
+ for (const child of node.body) {
649
+ if (child.type === "AST_LITERAL_NODE") {
650
+ const literalNode = child;
651
+ if (literalNode.content.trim().length > 0) {
652
+ return true;
653
+ }
654
+ }
655
+ else if (child.type === "AST_HTML_TEXT_NODE") {
656
+ const textNode = child;
657
+ if (textNode.content.trim().length > 0) {
658
+ return true;
659
+ }
660
+ }
661
+ else if (child.type === "AST_HTML_ELEMENT_NODE") {
662
+ const elementNode = child;
663
+ if (this.isElementAccessible(elementNode)) {
664
+ return true;
665
+ }
666
+ }
667
+ else {
668
+ // If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
669
+ return true;
670
+ }
671
+ }
672
+ return false;
673
+ }
674
+ }
675
+ class HTMLNoEmptyHeadingsRule {
676
+ name = "html-no-empty-headings";
677
+ check(node) {
678
+ const visitor = new NoEmptyHeadingsVisitor(this.name);
679
+ visitor.visit(node);
680
+ return visitor.offenses;
681
+ }
682
+ }
683
+
684
+ class NestedLinkVisitor extends BaseRuleVisitor {
685
+ linkStack = [];
686
+ checkNestedLink(openTag) {
687
+ if (this.linkStack.length > 0) {
688
+ this.addOffense("Nested `<a>` elements are not allowed. Links cannot contain other links.", openTag.tag_name.location, "error");
689
+ return true;
690
+ }
691
+ return false;
692
+ }
693
+ visitHTMLElementNode(node) {
694
+ if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
695
+ super.visitHTMLElementNode(node);
696
+ return;
697
+ }
698
+ const openTag = node.open_tag;
699
+ const tagName = getTagName(openTag);
700
+ if (tagName !== "a") {
701
+ super.visitHTMLElementNode(node);
702
+ return;
703
+ }
704
+ // If we're already inside a link, this is a nested link
705
+ this.checkNestedLink(openTag);
706
+ this.linkStack.push(openTag);
707
+ super.visitHTMLElementNode(node);
708
+ this.linkStack.pop();
709
+ }
710
+ // Handle self-closing <a> tags (though they're not valid HTML, they might exist)
711
+ visitHTMLOpenTagNode(node) {
712
+ const tagName = getTagName(node);
713
+ if (tagName === "a" && node.is_void) {
714
+ this.checkNestedLink(node);
715
+ }
716
+ super.visitHTMLOpenTagNode(node);
717
+ }
718
+ }
719
+ class HTMLNoNestedLinksRule {
720
+ name = "html-no-nested-links";
721
+ check(node) {
722
+ const visitor = new NestedLinkVisitor(this.name);
723
+ visitor.visit(node);
724
+ return visitor.offenses;
725
+ }
726
+ }
727
+
728
+ class TagNameLowercaseVisitor extends BaseRuleVisitor {
729
+ visitHTMLOpenTagNode(node) {
730
+ this.checkTagName(node);
731
+ this.visitChildNodes(node);
732
+ }
733
+ visitHTMLCloseTagNode(node) {
734
+ this.checkTagName(node);
735
+ this.visitChildNodes(node);
736
+ }
737
+ visitHTMLSelfCloseTagNode(node) {
738
+ this.checkTagName(node);
739
+ this.visitChildNodes(node);
740
+ }
741
+ checkTagName(node) {
742
+ const tagName = node.tag_name?.value;
743
+ if (!tagName)
744
+ return;
745
+ if (tagName !== tagName.toLowerCase()) {
746
+ let type = node.type;
747
+ if (node.type == "AST_HTML_OPEN_TAG_NODE")
748
+ type = "Opening";
749
+ if (node.type == "AST_HTML_CLOSE_TAG_NODE")
750
+ type = "Closing";
751
+ if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
752
+ type = "Self-closing";
753
+ this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`, node.tag_name.location, "error");
754
+ }
755
+ }
756
+ }
757
+ class HTMLTagNameLowercaseRule {
758
+ name = "html-tag-name-lowercase";
759
+ check(node) {
760
+ const visitor = new TagNameLowercaseVisitor(this.name);
761
+ visitor.visit(node);
762
+ return visitor.offenses;
763
+ }
764
+ }
765
+
766
+ const defaultRules = [
767
+ ERBNoEmptyTagsRule,
768
+ ERBNoOutputControlFlowRule,
769
+ ERBRequireWhitespaceRule,
770
+ HTMLAnchorRequireHrefRule,
771
+ HTMLAriaRoleHeadingRequiresLevelRule,
772
+ HTMLAttributeDoubleQuotesRule,
773
+ HTMLAttributeValuesRequireQuotesRule,
774
+ HTMLBooleanAttributesNoValueRule,
775
+ HTMLImgRequireAltRule,
776
+ HTMLNoBlockInsideInlineRule,
777
+ HTMLNoDuplicateAttributesRule,
778
+ HTMLNoEmptyHeadingsRule,
779
+ HTMLNoNestedLinksRule,
780
+ HTMLTagNameLowercaseRule,
781
+ ];
782
+
783
+ class Linter {
784
+ rules;
785
+ offenses;
786
+ /**
787
+ * Creates a new Linter instance.
788
+ * @param rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
789
+ */
790
+ constructor(rules) {
791
+ this.rules = rules !== undefined ? rules : this.getDefaultRules();
792
+ this.offenses = [];
793
+ }
794
+ /**
795
+ * Returns the default set of rule classes used by the linter.
796
+ * @returns Array of rule classes
797
+ */
798
+ getDefaultRules() {
799
+ return defaultRules;
800
+ }
801
+ getRuleCount() {
802
+ return this.rules.length;
803
+ }
804
+ lint(document) {
805
+ this.offenses = [];
806
+ for (const Rule of this.rules) {
807
+ const rule = new Rule();
808
+ const ruleOffenses = rule.check(document);
809
+ this.offenses.push(...ruleOffenses);
810
+ }
811
+ const errors = this.offenses.filter(offense => offense.severity === "error").length;
812
+ const warnings = this.offenses.filter(offense => offense.severity === "warning").length;
813
+ return {
814
+ offenses: this.offenses,
815
+ errors,
816
+ warnings
817
+ };
818
+ }
819
+ }
820
+
821
+ exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
822
+ exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
823
+ exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
824
+ exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
825
+ exports.HTMLAttributeValuesRequireQuotesRule = HTMLAttributeValuesRequireQuotesRule;
826
+ exports.HTMLBooleanAttributesNoValueRule = HTMLBooleanAttributesNoValueRule;
827
+ exports.HTMLImgRequireAltRule = HTMLImgRequireAltRule;
828
+ exports.HTMLNoBlockInsideInlineRule = HTMLNoBlockInsideInlineRule;
829
+ exports.HTMLNoDuplicateAttributesRule = HTMLNoDuplicateAttributesRule;
830
+ exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
831
+ exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
832
+ exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
833
+ exports.Linter = Linter;
834
+ //# sourceMappingURL=index.cjs.map