@herb-tools/linter 0.4.2 → 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 (146) hide show
  1. package/README.md +16 -2
  2. package/dist/herb-lint.js +426 -116
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +322 -61
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +317 -63
  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 +6 -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 +4 -3
  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 +4 -3
  54. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  55. package/dist/src/rules/index.js +3 -0
  56. package/dist/src/rules/index.js.map +1 -1
  57. package/dist/src/rules/rule-utils.js +125 -2
  58. package/dist/src/rules/rule-utils.js.map +1 -1
  59. package/dist/src/rules/svg-tag-name-capitalization.js +4 -3
  60. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  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 +3 -0
  86. package/dist/types/rules/rule-utils.d.ts +73 -4
  87. package/dist/types/rules/svg-tag-name-capitalization.d.ts +4 -3
  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 +3 -0
  110. package/dist/types/src/rules/rule-utils.d.ts +73 -4
  111. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +4 -3
  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 +4 -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/package.json +4 -4
  120. package/src/cli/file-processor.ts +2 -4
  121. package/src/default-rules.ts +6 -0
  122. package/src/linter.ts +42 -8
  123. package/src/rules/erb-no-empty-tags.ts +5 -4
  124. package/src/rules/erb-no-output-control-flow.ts +6 -4
  125. package/src/rules/erb-prefer-image-tag-helper.ts +124 -0
  126. package/src/rules/erb-require-whitespace-inside-tags.ts +5 -4
  127. package/src/rules/erb-requires-trailing-newline.ts +27 -0
  128. package/src/rules/html-anchor-require-href.ts +5 -4
  129. package/src/rules/html-aria-attribute-must-be-valid.ts +5 -5
  130. package/src/rules/html-aria-level-must-be-valid.ts +42 -0
  131. package/src/rules/html-aria-role-heading-requires-level.ts +5 -4
  132. package/src/rules/html-aria-role-must-be-valid.ts +5 -4
  133. package/src/rules/html-attribute-double-quotes.ts +5 -4
  134. package/src/rules/html-attribute-values-require-quotes.ts +5 -4
  135. package/src/rules/html-boolean-attributes-no-value.ts +5 -4
  136. package/src/rules/html-img-require-alt.ts +5 -4
  137. package/src/rules/html-no-block-inside-inline.ts +5 -4
  138. package/src/rules/html-no-duplicate-attributes.ts +5 -4
  139. package/src/rules/html-no-duplicate-ids.ts +5 -5
  140. package/src/rules/html-no-empty-headings.ts +5 -4
  141. package/src/rules/html-no-nested-links.ts +5 -4
  142. package/src/rules/html-tag-name-lowercase.ts +5 -4
  143. package/src/rules/index.ts +3 -0
  144. package/src/rules/rule-utils.ts +156 -4
  145. package/src/rules/svg-tag-name-capitalization.ts +5 -4
  146. package/src/types.ts +60 -6
package/dist/index.js CHANGED
@@ -1,4 +1,20 @@
1
- import { Visitor, isERBNode } from '@herb-tools/core';
1
+ import { Visitor, Position, Location, isERBNode } from '@herb-tools/core';
2
+
3
+ class ParserRule {
4
+ static type = "parser";
5
+ }
6
+ class LexerRule {
7
+ static type = "lexer";
8
+ }
9
+ /**
10
+ * Default context object with all keys defined but set to undefined
11
+ */
12
+ const DEFAULT_LINT_CONTEXT = {
13
+ fileName: undefined
14
+ };
15
+ class SourceRule {
16
+ static type = "source";
17
+ }
2
18
 
3
19
  /**
4
20
  * Base visitor class that provides common functionality for rule visitors
@@ -6,9 +22,11 @@ import { Visitor, isERBNode } from '@herb-tools/core';
6
22
  class BaseRuleVisitor extends Visitor {
7
23
  offenses = [];
8
24
  ruleName;
9
- constructor(ruleName) {
25
+ context;
26
+ constructor(ruleName, context) {
10
27
  super();
11
28
  this.ruleName = ruleName;
29
+ this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
12
30
  }
13
31
  /**
14
32
  * Helper method to create a lint offense
@@ -251,6 +269,19 @@ const ARIA_ATTRIBUTES = new Set([
251
269
  'aria-valuenow',
252
270
  'aria-valuetext',
253
271
  ]);
272
+ /**
273
+ * Helper function to create a location at the end of the source with a 1-character range
274
+ */
275
+ function createEndOfFileLocation(source) {
276
+ const lines = source.split('\n');
277
+ const lastLineNumber = lines.length;
278
+ const lastLine = lines[lines.length - 1];
279
+ const lastColumnNumber = lastLine.length;
280
+ const startColumn = lastColumnNumber > 0 ? lastColumnNumber - 1 : 0;
281
+ const start = new Position(lastLineNumber, startColumn);
282
+ const end = new Position(lastLineNumber, lastColumnNumber);
283
+ return new Location(start, end);
284
+ }
254
285
  /**
255
286
  * Checks if an element is inline
256
287
  */
@@ -275,6 +306,9 @@ function isBooleanAttribute(attributeName) {
275
306
  * and attribute iteration logic. Provides simplified interface with extracted attribute info.
276
307
  */
277
308
  class AttributeVisitorMixin extends BaseRuleVisitor {
309
+ constructor(ruleName, context) {
310
+ super(ruleName, context);
311
+ }
278
312
  visitHTMLOpenTagNode(node) {
279
313
  this.checkAttributesOnNode(node);
280
314
  super.visitHTMLOpenTagNode(node);
@@ -304,6 +338,56 @@ function forEachAttribute(node, callback) {
304
338
  }
305
339
  }
306
340
  }
341
+ /**
342
+ * Base source visitor class that provides common functionality for source-based rule visitors
343
+ */
344
+ class BaseSourceRuleVisitor {
345
+ offenses = [];
346
+ ruleName;
347
+ context;
348
+ constructor(ruleName, context) {
349
+ this.ruleName = ruleName;
350
+ this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
351
+ }
352
+ /**
353
+ * Helper method to create a lint offense for source rules
354
+ */
355
+ createOffense(message, location, severity = "error") {
356
+ return {
357
+ rule: this.ruleName, // Type assertion for compatibility
358
+ code: this.ruleName,
359
+ source: "Herb Linter",
360
+ message,
361
+ location,
362
+ severity,
363
+ };
364
+ }
365
+ /**
366
+ * Helper method to add an offense to the offenses array
367
+ */
368
+ addOffense(message, location, severity = "error") {
369
+ this.offenses.push(this.createOffense(message, location, severity));
370
+ }
371
+ /**
372
+ * Main entry point for source rule visitors
373
+ * @param source - The raw source code
374
+ */
375
+ visit(source) {
376
+ this.visitSource(source);
377
+ }
378
+ /**
379
+ * Helper method to create a location for a specific position in the source
380
+ */
381
+ createLocationAt(source, position) {
382
+ const beforePosition = source.substring(0, position);
383
+ const lines = beforePosition.split('\n');
384
+ const line = lines.length;
385
+ const column = lines[lines.length - 1].length + 1;
386
+ const start = new Position(line, column);
387
+ const end = new Position(line, column);
388
+ return new Location(start, end);
389
+ }
390
+ }
307
391
 
308
392
  class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
309
393
  visitERBContentNode(node) {
@@ -318,10 +402,10 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
318
402
  this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
319
403
  }
320
404
  }
321
- class ERBNoEmptyTagsRule {
405
+ class ERBNoEmptyTagsRule extends ParserRule {
322
406
  name = "erb-no-empty-tags";
323
- check(node) {
324
- const visitor = new ERBNoEmptyTagsVisitor(this.name);
407
+ check(node, context) {
408
+ const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
325
409
  visitor.visit(node);
326
410
  return visitor.offenses;
327
411
  }
@@ -364,15 +448,126 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
364
448
  return;
365
449
  }
366
450
  }
367
- class ERBNoOutputControlFlowRule {
451
+ class ERBNoOutputControlFlowRule extends ParserRule {
368
452
  name = "erb-no-output-control-flow";
369
- check(node) {
370
- const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name);
453
+ check(node, context) {
454
+ const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context);
455
+ visitor.visit(node);
456
+ return visitor.offenses;
457
+ }
458
+ }
459
+
460
+ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
461
+ visitHTMLOpenTagNode(node) {
462
+ this.checkImgTag(node);
463
+ super.visitHTMLOpenTagNode(node);
464
+ }
465
+ visitHTMLSelfCloseTagNode(node) {
466
+ this.checkImgTag(node);
467
+ super.visitHTMLSelfCloseTagNode(node);
468
+ }
469
+ checkImgTag(node) {
470
+ const tagName = getTagName(node);
471
+ if (tagName !== "img") {
472
+ return;
473
+ }
474
+ const attributes = getAttributes(node);
475
+ const srcAttribute = findAttributeByName(attributes, "src");
476
+ if (!srcAttribute) {
477
+ return;
478
+ }
479
+ if (!srcAttribute.value) {
480
+ return;
481
+ }
482
+ const valueNode = srcAttribute.value;
483
+ const hasERBContent = this.containsERBContent(valueNode);
484
+ if (hasERBContent) {
485
+ const suggestedExpression = this.buildSuggestedExpression(valueNode);
486
+ this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
487
+ }
488
+ }
489
+ containsERBContent(valueNode) {
490
+ if (!valueNode.children)
491
+ return false;
492
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
493
+ }
494
+ buildSuggestedExpression(valueNode) {
495
+ if (!valueNode.children)
496
+ return "expression";
497
+ let hasText = false;
498
+ let hasERB = false;
499
+ for (const child of valueNode.children) {
500
+ if (child.type === "AST_ERB_CONTENT_NODE") {
501
+ hasERB = true;
502
+ }
503
+ else if (child.type === "AST_LITERAL_NODE") {
504
+ const literalNode = child;
505
+ if (literalNode.content && literalNode.content.trim()) {
506
+ hasText = true;
507
+ }
508
+ }
509
+ }
510
+ if (hasText && hasERB) {
511
+ let result = '"';
512
+ for (const child of valueNode.children) {
513
+ if (child.type === "AST_ERB_CONTENT_NODE") {
514
+ const erbNode = child;
515
+ result += `#{${(erbNode.content?.value || "").trim()}}`;
516
+ }
517
+ else if (child.type === "AST_LITERAL_NODE") {
518
+ const literalNode = child;
519
+ result += literalNode.content || "";
520
+ }
521
+ }
522
+ result += '"';
523
+ return result;
524
+ }
525
+ if (hasERB && !hasText) {
526
+ const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE");
527
+ if (erbNodes.length === 1) {
528
+ return (erbNodes[0].content?.value || "").trim();
529
+ }
530
+ else if (erbNodes.length > 1) {
531
+ let result = '"';
532
+ for (const erbNode of erbNodes) {
533
+ result += `#{${(erbNode.content?.value || "").trim()}}`;
534
+ }
535
+ result += '"';
536
+ return result;
537
+ }
538
+ }
539
+ return "expression";
540
+ }
541
+ }
542
+ class ERBPreferImageTagHelperRule extends ParserRule {
543
+ name = "erb-prefer-image-tag-helper";
544
+ check(node, context) {
545
+ const visitor = new ERBPreferImageTagHelperVisitor(this.name, context);
371
546
  visitor.visit(node);
372
547
  return visitor.offenses;
373
548
  }
374
549
  }
375
550
 
551
+ class ERBRequiresTrailingNewlineVisitor extends BaseSourceRuleVisitor {
552
+ visitSource(source) {
553
+ if (source.length === 0)
554
+ return;
555
+ if (source.endsWith('\n'))
556
+ return;
557
+ if (!this.context.fileName)
558
+ return;
559
+ this.addOffense("File must end with trailing newline", createEndOfFileLocation(source), "error");
560
+ }
561
+ }
562
+ class ERBRequiresTrailingNewlineRule extends SourceRule {
563
+ name = "erb-requires-trailing-newline";
564
+ check(source, context) {
565
+ const visitor = new ERBRequiresTrailingNewlineVisitor(this.name, context);
566
+ visitor.visit(source);
567
+ return visitor.offenses;
568
+ }
569
+ }
570
+
376
571
  class RequireWhitespaceInsideTags extends BaseRuleVisitor {
377
572
  visitChildNodes(node) {
378
573
  this.checkWhitespace(node);
@@ -421,10 +616,10 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
421
616
  this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
422
617
  }
423
618
  }
424
- class ERBRequireWhitespaceRule {
619
+ class ERBRequireWhitespaceRule extends ParserRule {
425
620
  name = "erb-require-whitespace-inside-tags";
426
- check(node) {
427
- const visitor = new RequireWhitespaceInsideTags(this.name);
621
+ check(node, context) {
622
+ const visitor = new RequireWhitespaceInsideTags(this.name, context);
428
623
  visitor.visit(node);
429
624
  return visitor.offenses;
430
625
  }
@@ -445,10 +640,10 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
445
640
  }
446
641
  }
447
642
  }
448
- class HTMLAnchorRequireHrefRule {
643
+ class HTMLAnchorRequireHrefRule extends ParserRule {
449
644
  name = "html-anchor-require-href";
450
- check(node) {
451
- const visitor = new AnchorRechireHrefVisitor(this.name);
645
+ check(node, context) {
646
+ const visitor = new AnchorRechireHrefVisitor(this.name, context);
452
647
  visitor.visit(node);
453
648
  return visitor.offenses;
454
649
  }
@@ -468,10 +663,35 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
468
663
  }
469
664
  }
470
665
  }
471
- class HTMLAriaAttributeMustBeValid {
666
+ class HTMLAriaAttributeMustBeValid extends ParserRule {
472
667
  name = "html-aria-attribute-must-be-valid";
473
- check(node) {
474
- const visitor = new AriaAttributeMustBeValid(this.name);
668
+ check(node, context) {
669
+ const visitor = new AriaAttributeMustBeValid(this.name, context);
670
+ visitor.visit(node);
671
+ return visitor.offenses;
672
+ }
673
+ }
674
+
675
+ class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
676
+ checkAttribute(attributeName, attributeValue, attributeNode, _parentNode) {
677
+ if (attributeName !== "aria-level")
678
+ return;
679
+ if (attributeValue !== null && attributeValue.includes("<%"))
680
+ return;
681
+ if (attributeValue === null || attributeValue === "") {
682
+ this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
683
+ return;
684
+ }
685
+ const number = parseInt(attributeValue);
686
+ if (isNaN(number) || number < 1 || number > 6 || attributeValue !== number.toString()) {
687
+ this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${attributeValue}\`.`, attributeNode.location);
688
+ }
689
+ }
690
+ }
691
+ class HTMLAriaLevelMustBeValidRule extends ParserRule {
692
+ name = "html-aria-level-must-be-valid";
693
+ check(node, context) {
694
+ const visitor = new HTMLAriaLevelMustBeValidVisitor(this.name, context);
475
695
  visitor.visit(node);
476
696
  return visitor.offenses;
477
697
  }
@@ -493,10 +713,10 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
493
713
  }
494
714
  }
495
715
  }
496
- class HTMLAriaRoleHeadingRequiresLevelRule {
716
+ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
497
717
  name = "html-aria-role-heading-requires-level";
498
- check(node) {
499
- const visitor = new AriaRoleHeadingRequiresLevel(this.name);
718
+ check(node, context) {
719
+ const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
500
720
  visitor.visit(node);
501
721
  return visitor.offenses;
502
722
  }
@@ -513,10 +733,10 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
513
733
  this.addOffense(`The \`role\` attribute must be a valid ARIA role. Role \`${attributeValue}\` is not recognized.`, attributeNode.location, "error");
514
734
  }
515
735
  }
516
- class HTMLAriaRoleMustBeValidRule {
736
+ class HTMLAriaRoleMustBeValidRule extends ParserRule {
517
737
  name = "html-aria-role-must-be-valid";
518
- check(node) {
519
- const visitor = new AriaRoleMustBeValid(this.name);
738
+ check(node, context) {
739
+ const visitor = new AriaRoleMustBeValid(this.name, context);
520
740
  visitor.visit(node);
521
741
  return visitor.offenses;
522
742
  }
@@ -533,10 +753,10 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
533
753
  this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
534
754
  }
535
755
  }
536
- class HTMLAttributeDoubleQuotesRule {
756
+ class HTMLAttributeDoubleQuotesRule extends ParserRule {
537
757
  name = "html-attribute-double-quotes";
538
- check(node) {
539
- const visitor = new AttributeDoubleQuotesVisitor(this.name);
758
+ check(node, context) {
759
+ const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
540
760
  visitor.visit(node);
541
761
  return visitor.offenses;
542
762
  }
@@ -554,10 +774,10 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
554
774
  `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
555
775
  }
556
776
  }
557
- class HTMLAttributeValuesRequireQuotesRule {
777
+ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
558
778
  name = "html-attribute-values-require-quotes";
559
- check(node) {
560
- const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
779
+ check(node, context) {
780
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
561
781
  visitor.visit(node);
562
782
  return visitor.offenses;
563
783
  }
@@ -572,10 +792,10 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
572
792
  this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
573
793
  }
574
794
  }
575
- class HTMLBooleanAttributesNoValueRule {
795
+ class HTMLBooleanAttributesNoValueRule extends ParserRule {
576
796
  name = "html-boolean-attributes-no-value";
577
- check(node) {
578
- const visitor = new BooleanAttributesNoValueVisitor(this.name);
797
+ check(node, context) {
798
+ const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
579
799
  visitor.visit(node);
580
800
  return visitor.offenses;
581
801
  }
@@ -600,10 +820,10 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
600
820
  }
601
821
  }
602
822
  }
603
- class HTMLImgRequireAltRule {
823
+ class HTMLImgRequireAltRule extends ParserRule {
604
824
  name = "html-img-require-alt";
605
- check(node) {
606
- const visitor = new ImgRequireAltVisitor(this.name);
825
+ check(node, context) {
826
+ const visitor = new ImgRequireAltVisitor(this.name, context);
607
827
  visitor.visit(node);
608
828
  return visitor.offenses;
609
829
  }
@@ -642,10 +862,10 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
642
862
  }
643
863
  }
644
864
  }
645
- class HTMLNoDuplicateAttributesRule {
865
+ class HTMLNoDuplicateAttributesRule extends ParserRule {
646
866
  name = "html-no-duplicate-attributes";
647
- check(node) {
648
- const visitor = new NoDuplicateAttributesVisitor(this.name);
867
+ check(node, context) {
868
+ const visitor = new NoDuplicateAttributesVisitor(this.name, context);
649
869
  visitor.visit(node);
650
870
  return visitor.offenses;
651
871
  }
@@ -666,10 +886,10 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
666
886
  this.documentIds.add(id);
667
887
  }
668
888
  }
669
- class HTMLNoDuplicateIdsRule {
889
+ class HTMLNoDuplicateIdsRule extends ParserRule {
670
890
  name = "html-no-duplicate-ids";
671
- check(node) {
672
- const visitor = new NoDuplicateIdsVisitor(this.name);
891
+ check(node, context) {
892
+ const visitor = new NoDuplicateIdsVisitor(this.name, context);
673
893
  visitor.visit(node);
674
894
  return visitor.offenses;
675
895
  }
@@ -813,10 +1033,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
813
1033
  return false;
814
1034
  }
815
1035
  }
816
- class HTMLNoEmptyHeadingsRule {
1036
+ class HTMLNoEmptyHeadingsRule extends ParserRule {
817
1037
  name = "html-no-empty-headings";
818
- check(node) {
819
- const visitor = new NoEmptyHeadingsVisitor(this.name);
1038
+ check(node, context) {
1039
+ const visitor = new NoEmptyHeadingsVisitor(this.name, context);
820
1040
  visitor.visit(node);
821
1041
  return visitor.offenses;
822
1042
  }
@@ -857,10 +1077,10 @@ class NestedLinkVisitor extends BaseRuleVisitor {
857
1077
  super.visitHTMLOpenTagNode(node);
858
1078
  }
859
1079
  }
860
- class HTMLNoNestedLinksRule {
1080
+ class HTMLNoNestedLinksRule extends ParserRule {
861
1081
  name = "html-no-nested-links";
862
- check(node) {
863
- const visitor = new NestedLinkVisitor(this.name);
1082
+ check(node, context) {
1083
+ const visitor = new NestedLinkVisitor(this.name, context);
864
1084
  visitor.visit(node);
865
1085
  return visitor.offenses;
866
1086
  }
@@ -904,10 +1124,10 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
904
1124
  }
905
1125
  }
906
1126
  }
907
- class HTMLTagNameLowercaseRule {
1127
+ class HTMLTagNameLowercaseRule extends ParserRule {
908
1128
  name = "html-tag-name-lowercase";
909
- check(node) {
910
- const visitor = new TagNameLowercaseVisitor(this.name);
1129
+ check(node, context) {
1130
+ const visitor = new TagNameLowercaseVisitor(this.name, context);
911
1131
  visitor.visit(node);
912
1132
  return visitor.offenses;
913
1133
  }
@@ -960,10 +1180,10 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
960
1180
  }
961
1181
  }
962
1182
  }
963
- class SVGTagNameCapitalizationRule {
1183
+ class SVGTagNameCapitalizationRule extends ParserRule {
964
1184
  name = "svg-tag-name-capitalization";
965
- check(node) {
966
- const visitor = new SVGTagNameCapitalizationVisitor(this.name);
1185
+ check(node, context) {
1186
+ const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
967
1187
  visitor.visit(node);
968
1188
  return visitor.offenses;
969
1189
  }
@@ -972,9 +1192,12 @@ class SVGTagNameCapitalizationRule {
972
1192
  const defaultRules = [
973
1193
  ERBNoEmptyTagsRule,
974
1194
  ERBNoOutputControlFlowRule,
1195
+ ERBPreferImageTagHelperRule,
1196
+ ERBRequiresTrailingNewlineRule,
975
1197
  ERBRequireWhitespaceRule,
976
1198
  HTMLAnchorRequireHrefRule,
977
1199
  HTMLAriaAttributeMustBeValid,
1200
+ HTMLAriaLevelMustBeValidRule,
978
1201
  HTMLAriaRoleHeadingRequiresLevelRule,
979
1202
  HTMLAriaRoleMustBeValidRule,
980
1203
  HTMLAttributeDoubleQuotesRule,
@@ -992,12 +1215,15 @@ const defaultRules = [
992
1215
 
993
1216
  class Linter {
994
1217
  rules;
1218
+ herb;
995
1219
  offenses;
996
1220
  /**
997
1221
  * Creates a new Linter instance.
998
- * @param rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
1222
+ * @param herb - The Herb backend instance for parsing and lexing
1223
+ * @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
999
1224
  */
1000
- constructor(rules) {
1225
+ constructor(herb, rules) {
1226
+ this.herb = herb;
1001
1227
  this.rules = rules !== undefined ? rules : this.getDefaultRules();
1002
1228
  this.offenses = [];
1003
1229
  }
@@ -1011,11 +1237,39 @@ class Linter {
1011
1237
  getRuleCount() {
1012
1238
  return this.rules.length;
1013
1239
  }
1014
- lint(document) {
1240
+ /**
1241
+ * Type guard to check if a rule is a LexerRule
1242
+ */
1243
+ isLexerRule(rule) {
1244
+ return rule.constructor.type === "lexer";
1245
+ }
1246
+ /**
1247
+ * Type guard to check if a rule is a SourceRule
1248
+ */
1249
+ isSourceRule(rule) {
1250
+ return rule.constructor.type === "source";
1251
+ }
1252
+ /**
1253
+ * Lint source code using Parser/AST, Lexer, and Source rules.
1254
+ * @param source - The source code to lint
1255
+ * @param context - Optional context for linting (e.g., fileName for distinguishing files vs snippets)
1256
+ */
1257
+ lint(source, context) {
1015
1258
  this.offenses = [];
1016
- for (const Rule of this.rules) {
1017
- const rule = new Rule();
1018
- const ruleOffenses = rule.check(document);
1259
+ const parseResult = this.herb.parse(source);
1260
+ const lexResult = this.herb.lex(source);
1261
+ for (const RuleClass of this.rules) {
1262
+ const rule = new RuleClass();
1263
+ let ruleOffenses;
1264
+ if (this.isLexerRule(rule)) {
1265
+ ruleOffenses = rule.check(lexResult, context);
1266
+ }
1267
+ else if (this.isSourceRule(rule)) {
1268
+ ruleOffenses = rule.check(source, context);
1269
+ }
1270
+ else {
1271
+ ruleOffenses = rule.check(parseResult.value, context);
1272
+ }
1019
1273
  this.offenses.push(...ruleOffenses);
1020
1274
  }
1021
1275
  const errors = this.offenses.filter(offense => offense.severity === "error").length;
@@ -1077,14 +1331,14 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
1077
1331
  this.visitBlockElement(node);
1078
1332
  }
1079
1333
  }
1080
- class HTMLNoBlockInsideInlineRule {
1334
+ class HTMLNoBlockInsideInlineRule extends ParserRule {
1081
1335
  name = "html-no-block-inside-inline";
1082
- check(node) {
1083
- const visitor = new BlockInsideInlineVisitor(this.name);
1336
+ check(node, context) {
1337
+ const visitor = new BlockInsideInlineVisitor(this.name, context);
1084
1338
  visitor.visit(node);
1085
1339
  return visitor.offenses;
1086
1340
  }
1087
1341
  }
1088
1342
 
1089
- export { ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, HTMLAnchorRequireHrefRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeValuesRequireQuotesRule, HTMLBooleanAttributesNoValueRule, HTMLImgRequireAltRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLTagNameLowercaseRule, Linter, SVGTagNameCapitalizationRule };
1343
+ export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeValuesRequireQuotesRule, HTMLBooleanAttributesNoValueRule, HTMLImgRequireAltRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
1090
1344
  //# sourceMappingURL=index.js.map