@herb-tools/linter 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +16 -4
  2. package/dist/herb-lint.js +557 -122
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +454 -67
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +448 -69
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +4 -4
  9. package/dist/src/cli/file-processor.js +2 -4
  10. package/dist/src/cli/file-processor.js.map +1 -1
  11. package/dist/src/default-rules.js +8 -0
  12. package/dist/src/default-rules.js.map +1 -1
  13. package/dist/src/linter.js +37 -6
  14. package/dist/src/linter.js.map +1 -1
  15. package/dist/src/rules/erb-no-empty-tags.js +4 -3
  16. package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
  17. package/dist/src/rules/erb-no-output-control-flow.js +4 -3
  18. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
  19. package/dist/src/rules/erb-prefer-image-tag-helper.js +93 -0
  20. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -0
  21. package/dist/src/rules/erb-require-whitespace-inside-tags.js +22 -5
  22. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
  23. package/dist/src/rules/erb-requires-trailing-newline.js +22 -0
  24. package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -0
  25. package/dist/src/rules/html-anchor-require-href.js +4 -3
  26. package/dist/src/rules/html-anchor-require-href.js.map +1 -1
  27. package/dist/src/rules/html-aria-attribute-must-be-valid.js +4 -3
  28. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  29. package/dist/src/rules/html-aria-level-must-be-valid.js +27 -0
  30. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -0
  31. package/dist/src/rules/html-aria-role-heading-requires-level.js +4 -3
  32. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  33. package/dist/src/rules/html-aria-role-must-be-valid.js +4 -3
  34. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  35. package/dist/src/rules/html-attribute-double-quotes.js +4 -3
  36. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  37. package/dist/src/rules/html-attribute-values-require-quotes.js +4 -3
  38. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  39. package/dist/src/rules/html-boolean-attributes-no-value.js +4 -3
  40. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  41. package/dist/src/rules/html-img-require-alt.js +4 -3
  42. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  43. package/dist/src/rules/html-no-block-inside-inline.js +4 -3
  44. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
  45. package/dist/src/rules/html-no-duplicate-attributes.js +4 -3
  46. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  47. package/dist/src/rules/html-no-duplicate-ids.js +4 -3
  48. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  49. package/dist/src/rules/html-no-empty-headings.js +4 -3
  50. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  51. package/dist/src/rules/html-no-nested-links.js +4 -3
  52. package/dist/src/rules/html-no-nested-links.js.map +1 -1
  53. package/dist/src/rules/html-tag-name-lowercase.js +21 -11
  54. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  55. package/dist/src/rules/index.js +4 -0
  56. package/dist/src/rules/index.js.map +1 -1
  57. package/dist/src/rules/rule-utils.js +168 -2
  58. package/dist/src/rules/rule-utils.js.map +1 -1
  59. package/dist/src/rules/svg-tag-name-capitalization.js +58 -0
  60. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -0
  61. package/dist/src/types.js +15 -1
  62. package/dist/src/types.js.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/dist/types/linter.d.ts +20 -5
  65. package/dist/types/rules/erb-no-empty-tags.d.ts +4 -3
  66. package/dist/types/rules/erb-no-output-control-flow.d.ts +4 -3
  67. package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +7 -0
  68. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +4 -3
  69. package/dist/types/rules/erb-requires-trailing-newline.d.ts +6 -0
  70. package/dist/types/rules/html-anchor-require-href.d.ts +4 -3
  71. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +4 -3
  72. package/dist/types/rules/html-aria-level-must-be-valid.d.ts +7 -0
  73. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +4 -3
  74. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +4 -3
  75. package/dist/types/rules/html-attribute-double-quotes.d.ts +4 -3
  76. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +4 -3
  77. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +4 -3
  78. package/dist/types/rules/html-img-require-alt.d.ts +4 -3
  79. package/dist/types/rules/html-no-block-inside-inline.d.ts +4 -3
  80. package/dist/types/rules/html-no-duplicate-attributes.d.ts +4 -3
  81. package/dist/types/rules/html-no-duplicate-ids.d.ts +4 -3
  82. package/dist/types/rules/html-no-empty-headings.d.ts +4 -3
  83. package/dist/types/rules/html-no-nested-links.d.ts +4 -3
  84. package/dist/types/rules/html-tag-name-lowercase.d.ts +4 -3
  85. package/dist/types/rules/index.d.ts +4 -0
  86. package/dist/types/rules/rule-utils.d.ts +82 -4
  87. package/dist/types/rules/svg-tag-name-capitalization.d.ts +7 -0
  88. package/dist/types/src/linter.d.ts +20 -5
  89. package/dist/types/src/rules/erb-no-empty-tags.d.ts +4 -3
  90. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +4 -3
  91. package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +7 -0
  92. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +4 -3
  93. package/dist/types/src/rules/erb-requires-trailing-newline.d.ts +6 -0
  94. package/dist/types/src/rules/html-anchor-require-href.d.ts +4 -3
  95. package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +4 -3
  96. package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +7 -0
  97. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +4 -3
  98. package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +4 -3
  99. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +4 -3
  100. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +4 -3
  101. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +4 -3
  102. package/dist/types/src/rules/html-img-require-alt.d.ts +4 -3
  103. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +4 -3
  104. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +4 -3
  105. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +4 -3
  106. package/dist/types/src/rules/html-no-empty-headings.d.ts +4 -3
  107. package/dist/types/src/rules/html-no-nested-links.d.ts +4 -3
  108. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +4 -3
  109. package/dist/types/src/rules/index.d.ts +4 -0
  110. package/dist/types/src/rules/rule-utils.d.ts +82 -4
  111. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +7 -0
  112. package/dist/types/src/types.d.ts +49 -6
  113. package/dist/types/types.d.ts +49 -6
  114. package/docs/rules/README.md +5 -1
  115. package/docs/rules/erb-prefer-image-tag-helper.md +65 -0
  116. package/docs/rules/erb-requires-trailing-newline.md +37 -0
  117. package/docs/rules/html-anchor-require-href.md +1 -1
  118. package/docs/rules/html-aria-level-must-be-valid.md +37 -0
  119. package/docs/rules/svg-tag-name-capitalization.md +57 -0
  120. package/package.json +4 -4
  121. package/src/cli/file-processor.ts +2 -4
  122. package/src/default-rules.ts +8 -0
  123. package/src/linter.ts +42 -8
  124. package/src/rules/erb-no-empty-tags.ts +5 -4
  125. package/src/rules/erb-no-output-control-flow.ts +6 -4
  126. package/src/rules/erb-prefer-image-tag-helper.ts +124 -0
  127. package/src/rules/erb-require-whitespace-inside-tags.ts +38 -6
  128. package/src/rules/erb-requires-trailing-newline.ts +27 -0
  129. package/src/rules/html-anchor-require-href.ts +5 -4
  130. package/src/rules/html-aria-attribute-must-be-valid.ts +5 -5
  131. package/src/rules/html-aria-level-must-be-valid.ts +42 -0
  132. package/src/rules/html-aria-role-heading-requires-level.ts +5 -4
  133. package/src/rules/html-aria-role-must-be-valid.ts +5 -4
  134. package/src/rules/html-attribute-double-quotes.ts +5 -4
  135. package/src/rules/html-attribute-values-require-quotes.ts +5 -4
  136. package/src/rules/html-boolean-attributes-no-value.ts +5 -4
  137. package/src/rules/html-img-require-alt.ts +5 -4
  138. package/src/rules/html-no-block-inside-inline.ts +5 -4
  139. package/src/rules/html-no-duplicate-attributes.ts +5 -4
  140. package/src/rules/html-no-duplicate-ids.ts +5 -5
  141. package/src/rules/html-no-empty-headings.ts +5 -4
  142. package/src/rules/html-no-nested-links.ts +5 -4
  143. package/src/rules/html-tag-name-lowercase.ts +29 -13
  144. package/src/rules/index.ts +4 -0
  145. package/src/rules/rule-utils.ts +203 -4
  146. package/src/rules/svg-tag-name-capitalization.ts +74 -0
  147. package/src/types.ts +60 -6
package/dist/index.cjs CHANGED
@@ -2,15 +2,33 @@
2
2
 
3
3
  var core = require('@herb-tools/core');
4
4
 
5
+ class ParserRule {
6
+ static type = "parser";
7
+ }
8
+ class LexerRule {
9
+ static type = "lexer";
10
+ }
11
+ /**
12
+ * Default context object with all keys defined but set to undefined
13
+ */
14
+ const DEFAULT_LINT_CONTEXT = {
15
+ fileName: undefined
16
+ };
17
+ class SourceRule {
18
+ static type = "source";
19
+ }
20
+
5
21
  /**
6
22
  * Base visitor class that provides common functionality for rule visitors
7
23
  */
8
24
  class BaseRuleVisitor extends core.Visitor {
9
25
  offenses = [];
10
26
  ruleName;
11
- constructor(ruleName) {
27
+ context;
28
+ constructor(ruleName, context) {
12
29
  super();
13
30
  this.ruleName = ruleName;
31
+ this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
14
32
  }
15
33
  /**
16
34
  * Helper method to create a lint offense
@@ -148,6 +166,49 @@ const HTML_BOOLEAN_ATTRIBUTES = new Set([
148
166
  "noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
149
167
  ]);
150
168
  const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
169
+ /**
170
+ * SVG elements that use camelCase naming
171
+ */
172
+ const SVG_CAMEL_CASE_ELEMENTS = new Set([
173
+ "animateMotion",
174
+ "animateTransform",
175
+ "clipPath",
176
+ "feBlend",
177
+ "feColorMatrix",
178
+ "feComponentTransfer",
179
+ "feComposite",
180
+ "feConvolveMatrix",
181
+ "feDiffuseLighting",
182
+ "feDisplacementMap",
183
+ "feDistantLight",
184
+ "feDropShadow",
185
+ "feFlood",
186
+ "feFuncA",
187
+ "feFuncB",
188
+ "feFuncG",
189
+ "feFuncR",
190
+ "feGaussianBlur",
191
+ "feImage",
192
+ "feMerge",
193
+ "feMergeNode",
194
+ "feMorphology",
195
+ "feOffset",
196
+ "fePointLight",
197
+ "feSpecularLighting",
198
+ "feSpotLight",
199
+ "feTile",
200
+ "feTurbulence",
201
+ "foreignObject",
202
+ "glyphRef",
203
+ "linearGradient",
204
+ "radialGradient",
205
+ "textPath"
206
+ ]);
207
+ /**
208
+ * Mapping from lowercase SVG element names to their correct camelCase versions
209
+ * Generated dynamically from SVG_CAMEL_CASE_ELEMENTS
210
+ */
211
+ const SVG_LOWERCASE_TO_CAMELCASE = new Map(Array.from(SVG_CAMEL_CASE_ELEMENTS).map(element => [element.toLowerCase(), element]));
151
212
  const VALID_ARIA_ROLES = new Set([
152
213
  "banner", "complementary", "contentinfo", "form", "main", "navigation", "region", "search",
153
214
  "article", "cell", "columnheader", "definition", "directory", "document", "feed", "figure",
@@ -210,6 +271,19 @@ const ARIA_ATTRIBUTES = new Set([
210
271
  'aria-valuenow',
211
272
  'aria-valuetext',
212
273
  ]);
274
+ /**
275
+ * Helper function to create a location at the end of the source with a 1-character range
276
+ */
277
+ function createEndOfFileLocation(source) {
278
+ const lines = source.split('\n');
279
+ const lastLineNumber = lines.length;
280
+ const lastLine = lines[lines.length - 1];
281
+ const lastColumnNumber = lastLine.length;
282
+ const startColumn = lastColumnNumber > 0 ? lastColumnNumber - 1 : 0;
283
+ const start = new core.Position(lastLineNumber, startColumn);
284
+ const end = new core.Position(lastLineNumber, lastColumnNumber);
285
+ return new core.Location(start, end);
286
+ }
213
287
  /**
214
288
  * Checks if an element is inline
215
289
  */
@@ -234,6 +308,9 @@ function isBooleanAttribute(attributeName) {
234
308
  * and attribute iteration logic. Provides simplified interface with extracted attribute info.
235
309
  */
236
310
  class AttributeVisitorMixin extends BaseRuleVisitor {
311
+ constructor(ruleName, context) {
312
+ super(ruleName, context);
313
+ }
237
314
  visitHTMLOpenTagNode(node) {
238
315
  this.checkAttributesOnNode(node);
239
316
  super.visitHTMLOpenTagNode(node);
@@ -263,6 +340,56 @@ function forEachAttribute(node, callback) {
263
340
  }
264
341
  }
265
342
  }
343
+ /**
344
+ * Base source visitor class that provides common functionality for source-based rule visitors
345
+ */
346
+ class BaseSourceRuleVisitor {
347
+ offenses = [];
348
+ ruleName;
349
+ context;
350
+ constructor(ruleName, context) {
351
+ this.ruleName = ruleName;
352
+ this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
353
+ }
354
+ /**
355
+ * Helper method to create a lint offense for source rules
356
+ */
357
+ createOffense(message, location, severity = "error") {
358
+ return {
359
+ rule: this.ruleName, // Type assertion for compatibility
360
+ code: this.ruleName,
361
+ source: "Herb Linter",
362
+ message,
363
+ location,
364
+ severity,
365
+ };
366
+ }
367
+ /**
368
+ * Helper method to add an offense to the offenses array
369
+ */
370
+ addOffense(message, location, severity = "error") {
371
+ this.offenses.push(this.createOffense(message, location, severity));
372
+ }
373
+ /**
374
+ * Main entry point for source rule visitors
375
+ * @param source - The raw source code
376
+ */
377
+ visit(source) {
378
+ this.visitSource(source);
379
+ }
380
+ /**
381
+ * Helper method to create a location for a specific position in the source
382
+ */
383
+ createLocationAt(source, position) {
384
+ const beforePosition = source.substring(0, position);
385
+ const lines = beforePosition.split('\n');
386
+ const line = lines.length;
387
+ const column = lines[lines.length - 1].length + 1;
388
+ const start = new core.Position(line, column);
389
+ const end = new core.Position(line, column);
390
+ return new core.Location(start, end);
391
+ }
392
+ }
266
393
 
267
394
  class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
268
395
  visitERBContentNode(node) {
@@ -277,10 +404,10 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
277
404
  this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
278
405
  }
279
406
  }
280
- class ERBNoEmptyTagsRule {
407
+ class ERBNoEmptyTagsRule extends ParserRule {
281
408
  name = "erb-no-empty-tags";
282
- check(node) {
283
- const visitor = new ERBNoEmptyTagsVisitor(this.name);
409
+ check(node, context) {
410
+ const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
284
411
  visitor.visit(node);
285
412
  return visitor.offenses;
286
413
  }
@@ -323,15 +450,126 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
323
450
  return;
324
451
  }
325
452
  }
326
- class ERBNoOutputControlFlowRule {
453
+ class ERBNoOutputControlFlowRule extends ParserRule {
327
454
  name = "erb-no-output-control-flow";
328
- check(node) {
329
- const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name);
455
+ check(node, context) {
456
+ const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context);
457
+ visitor.visit(node);
458
+ return visitor.offenses;
459
+ }
460
+ }
461
+
462
+ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
463
+ visitHTMLOpenTagNode(node) {
464
+ this.checkImgTag(node);
465
+ super.visitHTMLOpenTagNode(node);
466
+ }
467
+ visitHTMLSelfCloseTagNode(node) {
468
+ this.checkImgTag(node);
469
+ super.visitHTMLSelfCloseTagNode(node);
470
+ }
471
+ checkImgTag(node) {
472
+ const tagName = getTagName(node);
473
+ if (tagName !== "img") {
474
+ return;
475
+ }
476
+ const attributes = getAttributes(node);
477
+ const srcAttribute = findAttributeByName(attributes, "src");
478
+ if (!srcAttribute) {
479
+ return;
480
+ }
481
+ if (!srcAttribute.value) {
482
+ return;
483
+ }
484
+ const valueNode = srcAttribute.value;
485
+ const hasERBContent = this.containsERBContent(valueNode);
486
+ if (hasERBContent) {
487
+ const suggestedExpression = this.buildSuggestedExpression(valueNode);
488
+ this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
489
+ }
490
+ }
491
+ containsERBContent(valueNode) {
492
+ if (!valueNode.children)
493
+ return false;
494
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
495
+ }
496
+ buildSuggestedExpression(valueNode) {
497
+ if (!valueNode.children)
498
+ return "expression";
499
+ let hasText = false;
500
+ let hasERB = false;
501
+ for (const child of valueNode.children) {
502
+ if (child.type === "AST_ERB_CONTENT_NODE") {
503
+ hasERB = true;
504
+ }
505
+ else if (child.type === "AST_LITERAL_NODE") {
506
+ const literalNode = child;
507
+ if (literalNode.content && literalNode.content.trim()) {
508
+ hasText = true;
509
+ }
510
+ }
511
+ }
512
+ if (hasText && hasERB) {
513
+ let result = '"';
514
+ for (const child of valueNode.children) {
515
+ if (child.type === "AST_ERB_CONTENT_NODE") {
516
+ const erbNode = child;
517
+ result += `#{${(erbNode.content?.value || "").trim()}}`;
518
+ }
519
+ else if (child.type === "AST_LITERAL_NODE") {
520
+ const literalNode = child;
521
+ result += literalNode.content || "";
522
+ }
523
+ }
524
+ result += '"';
525
+ return result;
526
+ }
527
+ if (hasERB && !hasText) {
528
+ const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE");
529
+ if (erbNodes.length === 1) {
530
+ return (erbNodes[0].content?.value || "").trim();
531
+ }
532
+ else if (erbNodes.length > 1) {
533
+ let result = '"';
534
+ for (const erbNode of erbNodes) {
535
+ result += `#{${(erbNode.content?.value || "").trim()}}`;
536
+ }
537
+ result += '"';
538
+ return result;
539
+ }
540
+ }
541
+ return "expression";
542
+ }
543
+ }
544
+ class ERBPreferImageTagHelperRule extends ParserRule {
545
+ name = "erb-prefer-image-tag-helper";
546
+ check(node, context) {
547
+ const visitor = new ERBPreferImageTagHelperVisitor(this.name, context);
330
548
  visitor.visit(node);
331
549
  return visitor.offenses;
332
550
  }
333
551
  }
334
552
 
553
+ class ERBRequiresTrailingNewlineVisitor extends BaseSourceRuleVisitor {
554
+ visitSource(source) {
555
+ if (source.length === 0)
556
+ return;
557
+ if (source.endsWith('\n'))
558
+ return;
559
+ if (!this.context.fileName)
560
+ return;
561
+ this.addOffense("File must end with trailing newline", createEndOfFileLocation(source), "error");
562
+ }
563
+ }
564
+ class ERBRequiresTrailingNewlineRule extends SourceRule {
565
+ name = "erb-requires-trailing-newline";
566
+ check(source, context) {
567
+ const visitor = new ERBRequiresTrailingNewlineVisitor(this.name, context);
568
+ visitor.visit(source);
569
+ return visitor.offenses;
570
+ }
571
+ }
572
+
335
573
  class RequireWhitespaceInsideTags extends BaseRuleVisitor {
336
574
  visitChildNodes(node) {
337
575
  this.checkWhitespace(node);
@@ -348,8 +586,24 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
348
586
  return;
349
587
  }
350
588
  const value = content.value;
351
- this.checkOpenTagWhitespace(openTag, value);
352
- this.checkCloseTagWhitespace(closeTag, value);
589
+ if (openTag.value === "<%#") {
590
+ this.checkCommentTagWhitespace(openTag, closeTag, value);
591
+ }
592
+ else {
593
+ this.checkOpenTagWhitespace(openTag, value);
594
+ this.checkCloseTagWhitespace(closeTag, value);
595
+ }
596
+ }
597
+ checkCommentTagWhitespace(openTag, closeTag, content) {
598
+ if (!content.startsWith(" ") && !content.startsWith("\n") && !content.startsWith("=")) {
599
+ this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, "error");
600
+ }
601
+ else if (content.startsWith("=") && content.length > 1 && !content[1].match(/\s/)) {
602
+ this.addOffense(`Add whitespace after \`<%#=\`.`, openTag.location, "error");
603
+ }
604
+ if (!content.endsWith(" ") && !content.endsWith("\n")) {
605
+ this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
606
+ }
353
607
  }
354
608
  checkOpenTagWhitespace(openTag, content) {
355
609
  if (content.startsWith(" ") || content.startsWith("\n")) {
@@ -364,10 +618,10 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
364
618
  this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
365
619
  }
366
620
  }
367
- class ERBRequireWhitespaceRule {
621
+ class ERBRequireWhitespaceRule extends ParserRule {
368
622
  name = "erb-require-whitespace-inside-tags";
369
- check(node) {
370
- const visitor = new RequireWhitespaceInsideTags(this.name);
623
+ check(node, context) {
624
+ const visitor = new RequireWhitespaceInsideTags(this.name, context);
371
625
  visitor.visit(node);
372
626
  return visitor.offenses;
373
627
  }
@@ -388,10 +642,10 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
388
642
  }
389
643
  }
390
644
  }
391
- class HTMLAnchorRequireHrefRule {
645
+ class HTMLAnchorRequireHrefRule extends ParserRule {
392
646
  name = "html-anchor-require-href";
393
- check(node) {
394
- const visitor = new AnchorRechireHrefVisitor(this.name);
647
+ check(node, context) {
648
+ const visitor = new AnchorRechireHrefVisitor(this.name, context);
395
649
  visitor.visit(node);
396
650
  return visitor.offenses;
397
651
  }
@@ -411,10 +665,35 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
411
665
  }
412
666
  }
413
667
  }
414
- class HTMLAriaAttributeMustBeValid {
668
+ class HTMLAriaAttributeMustBeValid extends ParserRule {
415
669
  name = "html-aria-attribute-must-be-valid";
416
- check(node) {
417
- const visitor = new AriaAttributeMustBeValid(this.name);
670
+ check(node, context) {
671
+ const visitor = new AriaAttributeMustBeValid(this.name, context);
672
+ visitor.visit(node);
673
+ return visitor.offenses;
674
+ }
675
+ }
676
+
677
+ class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
678
+ checkAttribute(attributeName, attributeValue, attributeNode, _parentNode) {
679
+ if (attributeName !== "aria-level")
680
+ return;
681
+ if (attributeValue !== null && attributeValue.includes("<%"))
682
+ return;
683
+ if (attributeValue === null || attributeValue === "") {
684
+ this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
685
+ return;
686
+ }
687
+ const number = parseInt(attributeValue);
688
+ if (isNaN(number) || number < 1 || number > 6 || attributeValue !== number.toString()) {
689
+ this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${attributeValue}\`.`, attributeNode.location);
690
+ }
691
+ }
692
+ }
693
+ class HTMLAriaLevelMustBeValidRule extends ParserRule {
694
+ name = "html-aria-level-must-be-valid";
695
+ check(node, context) {
696
+ const visitor = new HTMLAriaLevelMustBeValidVisitor(this.name, context);
418
697
  visitor.visit(node);
419
698
  return visitor.offenses;
420
699
  }
@@ -436,10 +715,10 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
436
715
  }
437
716
  }
438
717
  }
439
- class HTMLAriaRoleHeadingRequiresLevelRule {
718
+ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
440
719
  name = "html-aria-role-heading-requires-level";
441
- check(node) {
442
- const visitor = new AriaRoleHeadingRequiresLevel(this.name);
720
+ check(node, context) {
721
+ const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
443
722
  visitor.visit(node);
444
723
  return visitor.offenses;
445
724
  }
@@ -456,10 +735,10 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
456
735
  this.addOffense(`The \`role\` attribute must be a valid ARIA role. Role \`${attributeValue}\` is not recognized.`, attributeNode.location, "error");
457
736
  }
458
737
  }
459
- class HTMLAriaRoleMustBeValidRule {
738
+ class HTMLAriaRoleMustBeValidRule extends ParserRule {
460
739
  name = "html-aria-role-must-be-valid";
461
- check(node) {
462
- const visitor = new AriaRoleMustBeValid(this.name);
740
+ check(node, context) {
741
+ const visitor = new AriaRoleMustBeValid(this.name, context);
463
742
  visitor.visit(node);
464
743
  return visitor.offenses;
465
744
  }
@@ -476,10 +755,10 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
476
755
  this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
477
756
  }
478
757
  }
479
- class HTMLAttributeDoubleQuotesRule {
758
+ class HTMLAttributeDoubleQuotesRule extends ParserRule {
480
759
  name = "html-attribute-double-quotes";
481
- check(node) {
482
- const visitor = new AttributeDoubleQuotesVisitor(this.name);
760
+ check(node, context) {
761
+ const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
483
762
  visitor.visit(node);
484
763
  return visitor.offenses;
485
764
  }
@@ -497,10 +776,10 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
497
776
  `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
498
777
  }
499
778
  }
500
- class HTMLAttributeValuesRequireQuotesRule {
779
+ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
501
780
  name = "html-attribute-values-require-quotes";
502
- check(node) {
503
- const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
781
+ check(node, context) {
782
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
504
783
  visitor.visit(node);
505
784
  return visitor.offenses;
506
785
  }
@@ -515,10 +794,10 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
515
794
  this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
516
795
  }
517
796
  }
518
- class HTMLBooleanAttributesNoValueRule {
797
+ class HTMLBooleanAttributesNoValueRule extends ParserRule {
519
798
  name = "html-boolean-attributes-no-value";
520
- check(node) {
521
- const visitor = new BooleanAttributesNoValueVisitor(this.name);
799
+ check(node, context) {
800
+ const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
522
801
  visitor.visit(node);
523
802
  return visitor.offenses;
524
803
  }
@@ -543,10 +822,10 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
543
822
  }
544
823
  }
545
824
  }
546
- class HTMLImgRequireAltRule {
825
+ class HTMLImgRequireAltRule extends ParserRule {
547
826
  name = "html-img-require-alt";
548
- check(node) {
549
- const visitor = new ImgRequireAltVisitor(this.name);
827
+ check(node, context) {
828
+ const visitor = new ImgRequireAltVisitor(this.name, context);
550
829
  visitor.visit(node);
551
830
  return visitor.offenses;
552
831
  }
@@ -585,10 +864,10 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
585
864
  }
586
865
  }
587
866
  }
588
- class HTMLNoDuplicateAttributesRule {
867
+ class HTMLNoDuplicateAttributesRule extends ParserRule {
589
868
  name = "html-no-duplicate-attributes";
590
- check(node) {
591
- const visitor = new NoDuplicateAttributesVisitor(this.name);
869
+ check(node, context) {
870
+ const visitor = new NoDuplicateAttributesVisitor(this.name, context);
592
871
  visitor.visit(node);
593
872
  return visitor.offenses;
594
873
  }
@@ -609,10 +888,10 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
609
888
  this.documentIds.add(id);
610
889
  }
611
890
  }
612
- class HTMLNoDuplicateIdsRule {
891
+ class HTMLNoDuplicateIdsRule extends ParserRule {
613
892
  name = "html-no-duplicate-ids";
614
- check(node) {
615
- const visitor = new NoDuplicateIdsVisitor(this.name);
893
+ check(node, context) {
894
+ const visitor = new NoDuplicateIdsVisitor(this.name, context);
616
895
  visitor.visit(node);
617
896
  return visitor.offenses;
618
897
  }
@@ -756,10 +1035,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
756
1035
  return false;
757
1036
  }
758
1037
  }
759
- class HTMLNoEmptyHeadingsRule {
1038
+ class HTMLNoEmptyHeadingsRule extends ParserRule {
760
1039
  name = "html-no-empty-headings";
761
- check(node) {
762
- const visitor = new NoEmptyHeadingsVisitor(this.name);
1040
+ check(node, context) {
1041
+ const visitor = new NoEmptyHeadingsVisitor(this.name, context);
763
1042
  visitor.visit(node);
764
1043
  return visitor.offenses;
765
1044
  }
@@ -800,33 +1079,98 @@ class NestedLinkVisitor extends BaseRuleVisitor {
800
1079
  super.visitHTMLOpenTagNode(node);
801
1080
  }
802
1081
  }
803
- class HTMLNoNestedLinksRule {
1082
+ class HTMLNoNestedLinksRule extends ParserRule {
804
1083
  name = "html-no-nested-links";
805
- check(node) {
806
- const visitor = new NestedLinkVisitor(this.name);
1084
+ check(node, context) {
1085
+ const visitor = new NestedLinkVisitor(this.name, context);
807
1086
  visitor.visit(node);
808
1087
  return visitor.offenses;
809
1088
  }
810
1089
  }
811
1090
 
812
1091
  class TagNameLowercaseVisitor extends BaseRuleVisitor {
813
- visitHTMLOpenTagNode(node) {
814
- this.checkTagName(node);
1092
+ visitHTMLElementNode(node) {
1093
+ const tagName = node.tag_name?.value;
1094
+ if (node.open_tag) {
1095
+ this.checkTagName(node.open_tag);
1096
+ }
1097
+ if (tagName && ["svg"].includes(tagName.toLowerCase())) {
1098
+ if (node.close_tag) {
1099
+ this.checkTagName(node.close_tag);
1100
+ }
1101
+ return;
1102
+ }
815
1103
  this.visitChildNodes(node);
1104
+ if (node.close_tag) {
1105
+ this.checkTagName(node.close_tag);
1106
+ }
816
1107
  }
817
- visitHTMLCloseTagNode(node) {
1108
+ visitHTMLSelfCloseTagNode(node) {
818
1109
  this.checkTagName(node);
819
1110
  this.visitChildNodes(node);
820
1111
  }
1112
+ checkTagName(node) {
1113
+ const tagName = node.tag_name?.value;
1114
+ if (!tagName)
1115
+ return;
1116
+ const lowercaseTagName = tagName.toLowerCase();
1117
+ if (tagName !== lowercaseTagName) {
1118
+ let type = node.type;
1119
+ if (node.type == "AST_HTML_OPEN_TAG_NODE")
1120
+ type = "Opening";
1121
+ if (node.type == "AST_HTML_CLOSE_TAG_NODE")
1122
+ type = "Closing";
1123
+ if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
1124
+ type = "Self-closing";
1125
+ this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`, node.tag_name.location, "error");
1126
+ }
1127
+ }
1128
+ }
1129
+ class HTMLTagNameLowercaseRule extends ParserRule {
1130
+ name = "html-tag-name-lowercase";
1131
+ check(node, context) {
1132
+ const visitor = new TagNameLowercaseVisitor(this.name, context);
1133
+ visitor.visit(node);
1134
+ return visitor.offenses;
1135
+ }
1136
+ }
1137
+
1138
+ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1139
+ insideSVG = false;
1140
+ visitHTMLElementNode(node) {
1141
+ const tagName = node.tag_name?.value;
1142
+ if (tagName && ["svg"].includes(tagName.toLowerCase())) {
1143
+ const wasInsideSVG = this.insideSVG;
1144
+ this.insideSVG = true;
1145
+ this.visitChildNodes(node);
1146
+ this.insideSVG = wasInsideSVG;
1147
+ return;
1148
+ }
1149
+ if (this.insideSVG) {
1150
+ if (node.open_tag) {
1151
+ this.checkTagName(node.open_tag);
1152
+ }
1153
+ if (node.close_tag) {
1154
+ this.checkTagName(node.close_tag);
1155
+ }
1156
+ }
1157
+ this.visitChildNodes(node);
1158
+ }
821
1159
  visitHTMLSelfCloseTagNode(node) {
822
- this.checkTagName(node);
1160
+ if (this.insideSVG) {
1161
+ this.checkTagName(node);
1162
+ }
823
1163
  this.visitChildNodes(node);
824
1164
  }
825
1165
  checkTagName(node) {
826
1166
  const tagName = node.tag_name?.value;
827
1167
  if (!tagName)
828
1168
  return;
829
- if (tagName !== tagName.toLowerCase()) {
1169
+ if (SVG_CAMEL_CASE_ELEMENTS.has(tagName))
1170
+ return;
1171
+ const lowercaseTagName = tagName.toLowerCase();
1172
+ const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
1173
+ if (correctCamelCase && tagName !== correctCamelCase) {
830
1174
  let type = node.type;
831
1175
  if (node.type == "AST_HTML_OPEN_TAG_NODE")
832
1176
  type = "Opening";
@@ -834,14 +1178,14 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
834
1178
  type = "Closing";
835
1179
  if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
836
1180
  type = "Self-closing";
837
- this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`, node.tag_name.location, "error");
1181
+ this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
838
1182
  }
839
1183
  }
840
1184
  }
841
- class HTMLTagNameLowercaseRule {
842
- name = "html-tag-name-lowercase";
843
- check(node) {
844
- const visitor = new TagNameLowercaseVisitor(this.name);
1185
+ class SVGTagNameCapitalizationRule extends ParserRule {
1186
+ name = "svg-tag-name-capitalization";
1187
+ check(node, context) {
1188
+ const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
845
1189
  visitor.visit(node);
846
1190
  return visitor.offenses;
847
1191
  }
@@ -850,9 +1194,12 @@ class HTMLTagNameLowercaseRule {
850
1194
  const defaultRules = [
851
1195
  ERBNoEmptyTagsRule,
852
1196
  ERBNoOutputControlFlowRule,
1197
+ ERBPreferImageTagHelperRule,
1198
+ ERBRequiresTrailingNewlineRule,
853
1199
  ERBRequireWhitespaceRule,
854
1200
  HTMLAnchorRequireHrefRule,
855
1201
  HTMLAriaAttributeMustBeValid,
1202
+ HTMLAriaLevelMustBeValidRule,
856
1203
  HTMLAriaRoleHeadingRequiresLevelRule,
857
1204
  HTMLAriaRoleMustBeValidRule,
858
1205
  HTMLAttributeDoubleQuotesRule,
@@ -865,16 +1212,20 @@ const defaultRules = [
865
1212
  HTMLNoEmptyHeadingsRule,
866
1213
  HTMLNoNestedLinksRule,
867
1214
  HTMLTagNameLowercaseRule,
1215
+ SVGTagNameCapitalizationRule,
868
1216
  ];
869
1217
 
870
1218
  class Linter {
871
1219
  rules;
1220
+ herb;
872
1221
  offenses;
873
1222
  /**
874
1223
  * Creates a new Linter instance.
875
- * @param rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
1224
+ * @param herb - The Herb backend instance for parsing and lexing
1225
+ * @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
876
1226
  */
877
- constructor(rules) {
1227
+ constructor(herb, rules) {
1228
+ this.herb = herb;
878
1229
  this.rules = rules !== undefined ? rules : this.getDefaultRules();
879
1230
  this.offenses = [];
880
1231
  }
@@ -888,11 +1239,39 @@ class Linter {
888
1239
  getRuleCount() {
889
1240
  return this.rules.length;
890
1241
  }
891
- lint(document) {
1242
+ /**
1243
+ * Type guard to check if a rule is a LexerRule
1244
+ */
1245
+ isLexerRule(rule) {
1246
+ return rule.constructor.type === "lexer";
1247
+ }
1248
+ /**
1249
+ * Type guard to check if a rule is a SourceRule
1250
+ */
1251
+ isSourceRule(rule) {
1252
+ return rule.constructor.type === "source";
1253
+ }
1254
+ /**
1255
+ * Lint source code using Parser/AST, Lexer, and Source rules.
1256
+ * @param source - The source code to lint
1257
+ * @param context - Optional context for linting (e.g., fileName for distinguishing files vs snippets)
1258
+ */
1259
+ lint(source, context) {
892
1260
  this.offenses = [];
893
- for (const Rule of this.rules) {
894
- const rule = new Rule();
895
- const ruleOffenses = rule.check(document);
1261
+ const parseResult = this.herb.parse(source);
1262
+ const lexResult = this.herb.lex(source);
1263
+ for (const RuleClass of this.rules) {
1264
+ const rule = new RuleClass();
1265
+ let ruleOffenses;
1266
+ if (this.isLexerRule(rule)) {
1267
+ ruleOffenses = rule.check(lexResult, context);
1268
+ }
1269
+ else if (this.isSourceRule(rule)) {
1270
+ ruleOffenses = rule.check(source, context);
1271
+ }
1272
+ else {
1273
+ ruleOffenses = rule.check(parseResult.value, context);
1274
+ }
896
1275
  this.offenses.push(...ruleOffenses);
897
1276
  }
898
1277
  const errors = this.offenses.filter(offense => offense.severity === "error").length;
@@ -954,18 +1333,22 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
954
1333
  this.visitBlockElement(node);
955
1334
  }
956
1335
  }
957
- class HTMLNoBlockInsideInlineRule {
1336
+ class HTMLNoBlockInsideInlineRule extends ParserRule {
958
1337
  name = "html-no-block-inside-inline";
959
- check(node) {
960
- const visitor = new BlockInsideInlineVisitor(this.name);
1338
+ check(node, context) {
1339
+ const visitor = new BlockInsideInlineVisitor(this.name, context);
961
1340
  visitor.visit(node);
962
1341
  return visitor.offenses;
963
1342
  }
964
1343
  }
965
1344
 
1345
+ exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
966
1346
  exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
967
1347
  exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
1348
+ exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
1349
+ exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
968
1350
  exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
1351
+ exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
969
1352
  exports.HTMLAriaRoleHeadingRequiresLevelRule = HTMLAriaRoleHeadingRequiresLevelRule;
970
1353
  exports.HTMLAriaRoleMustBeValidRule = HTMLAriaRoleMustBeValidRule;
971
1354
  exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
@@ -978,5 +1361,9 @@ exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
978
1361
  exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
979
1362
  exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
980
1363
  exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
1364
+ exports.LexerRule = LexerRule;
981
1365
  exports.Linter = Linter;
1366
+ exports.ParserRule = ParserRule;
1367
+ exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
1368
+ exports.SourceRule = SourceRule;
982
1369
  //# sourceMappingURL=index.cjs.map