@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.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
@@ -253,6 +271,19 @@ const ARIA_ATTRIBUTES = new Set([
253
271
  'aria-valuenow',
254
272
  'aria-valuetext',
255
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
+ }
256
287
  /**
257
288
  * Checks if an element is inline
258
289
  */
@@ -277,6 +308,9 @@ function isBooleanAttribute(attributeName) {
277
308
  * and attribute iteration logic. Provides simplified interface with extracted attribute info.
278
309
  */
279
310
  class AttributeVisitorMixin extends BaseRuleVisitor {
311
+ constructor(ruleName, context) {
312
+ super(ruleName, context);
313
+ }
280
314
  visitHTMLOpenTagNode(node) {
281
315
  this.checkAttributesOnNode(node);
282
316
  super.visitHTMLOpenTagNode(node);
@@ -306,6 +340,56 @@ function forEachAttribute(node, callback) {
306
340
  }
307
341
  }
308
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
+ }
309
393
 
310
394
  class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
311
395
  visitERBContentNode(node) {
@@ -320,10 +404,10 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
320
404
  this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
321
405
  }
322
406
  }
323
- class ERBNoEmptyTagsRule {
407
+ class ERBNoEmptyTagsRule extends ParserRule {
324
408
  name = "erb-no-empty-tags";
325
- check(node) {
326
- const visitor = new ERBNoEmptyTagsVisitor(this.name);
409
+ check(node, context) {
410
+ const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
327
411
  visitor.visit(node);
328
412
  return visitor.offenses;
329
413
  }
@@ -366,15 +450,126 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
366
450
  return;
367
451
  }
368
452
  }
369
- class ERBNoOutputControlFlowRule {
453
+ class ERBNoOutputControlFlowRule extends ParserRule {
370
454
  name = "erb-no-output-control-flow";
371
- check(node) {
372
- 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);
373
548
  visitor.visit(node);
374
549
  return visitor.offenses;
375
550
  }
376
551
  }
377
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
+
378
573
  class RequireWhitespaceInsideTags extends BaseRuleVisitor {
379
574
  visitChildNodes(node) {
380
575
  this.checkWhitespace(node);
@@ -423,10 +618,10 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
423
618
  this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
424
619
  }
425
620
  }
426
- class ERBRequireWhitespaceRule {
621
+ class ERBRequireWhitespaceRule extends ParserRule {
427
622
  name = "erb-require-whitespace-inside-tags";
428
- check(node) {
429
- const visitor = new RequireWhitespaceInsideTags(this.name);
623
+ check(node, context) {
624
+ const visitor = new RequireWhitespaceInsideTags(this.name, context);
430
625
  visitor.visit(node);
431
626
  return visitor.offenses;
432
627
  }
@@ -447,10 +642,10 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
447
642
  }
448
643
  }
449
644
  }
450
- class HTMLAnchorRequireHrefRule {
645
+ class HTMLAnchorRequireHrefRule extends ParserRule {
451
646
  name = "html-anchor-require-href";
452
- check(node) {
453
- const visitor = new AnchorRechireHrefVisitor(this.name);
647
+ check(node, context) {
648
+ const visitor = new AnchorRechireHrefVisitor(this.name, context);
454
649
  visitor.visit(node);
455
650
  return visitor.offenses;
456
651
  }
@@ -470,10 +665,35 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
470
665
  }
471
666
  }
472
667
  }
473
- class HTMLAriaAttributeMustBeValid {
668
+ class HTMLAriaAttributeMustBeValid extends ParserRule {
474
669
  name = "html-aria-attribute-must-be-valid";
475
- check(node) {
476
- 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);
477
697
  visitor.visit(node);
478
698
  return visitor.offenses;
479
699
  }
@@ -495,10 +715,10 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
495
715
  }
496
716
  }
497
717
  }
498
- class HTMLAriaRoleHeadingRequiresLevelRule {
718
+ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
499
719
  name = "html-aria-role-heading-requires-level";
500
- check(node) {
501
- const visitor = new AriaRoleHeadingRequiresLevel(this.name);
720
+ check(node, context) {
721
+ const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
502
722
  visitor.visit(node);
503
723
  return visitor.offenses;
504
724
  }
@@ -515,10 +735,10 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
515
735
  this.addOffense(`The \`role\` attribute must be a valid ARIA role. Role \`${attributeValue}\` is not recognized.`, attributeNode.location, "error");
516
736
  }
517
737
  }
518
- class HTMLAriaRoleMustBeValidRule {
738
+ class HTMLAriaRoleMustBeValidRule extends ParserRule {
519
739
  name = "html-aria-role-must-be-valid";
520
- check(node) {
521
- const visitor = new AriaRoleMustBeValid(this.name);
740
+ check(node, context) {
741
+ const visitor = new AriaRoleMustBeValid(this.name, context);
522
742
  visitor.visit(node);
523
743
  return visitor.offenses;
524
744
  }
@@ -535,10 +755,10 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
535
755
  this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
536
756
  }
537
757
  }
538
- class HTMLAttributeDoubleQuotesRule {
758
+ class HTMLAttributeDoubleQuotesRule extends ParserRule {
539
759
  name = "html-attribute-double-quotes";
540
- check(node) {
541
- const visitor = new AttributeDoubleQuotesVisitor(this.name);
760
+ check(node, context) {
761
+ const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
542
762
  visitor.visit(node);
543
763
  return visitor.offenses;
544
764
  }
@@ -556,10 +776,10 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
556
776
  `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
557
777
  }
558
778
  }
559
- class HTMLAttributeValuesRequireQuotesRule {
779
+ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
560
780
  name = "html-attribute-values-require-quotes";
561
- check(node) {
562
- const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
781
+ check(node, context) {
782
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
563
783
  visitor.visit(node);
564
784
  return visitor.offenses;
565
785
  }
@@ -574,10 +794,10 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
574
794
  this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
575
795
  }
576
796
  }
577
- class HTMLBooleanAttributesNoValueRule {
797
+ class HTMLBooleanAttributesNoValueRule extends ParserRule {
578
798
  name = "html-boolean-attributes-no-value";
579
- check(node) {
580
- const visitor = new BooleanAttributesNoValueVisitor(this.name);
799
+ check(node, context) {
800
+ const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
581
801
  visitor.visit(node);
582
802
  return visitor.offenses;
583
803
  }
@@ -602,10 +822,10 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
602
822
  }
603
823
  }
604
824
  }
605
- class HTMLImgRequireAltRule {
825
+ class HTMLImgRequireAltRule extends ParserRule {
606
826
  name = "html-img-require-alt";
607
- check(node) {
608
- const visitor = new ImgRequireAltVisitor(this.name);
827
+ check(node, context) {
828
+ const visitor = new ImgRequireAltVisitor(this.name, context);
609
829
  visitor.visit(node);
610
830
  return visitor.offenses;
611
831
  }
@@ -644,10 +864,10 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
644
864
  }
645
865
  }
646
866
  }
647
- class HTMLNoDuplicateAttributesRule {
867
+ class HTMLNoDuplicateAttributesRule extends ParserRule {
648
868
  name = "html-no-duplicate-attributes";
649
- check(node) {
650
- const visitor = new NoDuplicateAttributesVisitor(this.name);
869
+ check(node, context) {
870
+ const visitor = new NoDuplicateAttributesVisitor(this.name, context);
651
871
  visitor.visit(node);
652
872
  return visitor.offenses;
653
873
  }
@@ -668,10 +888,10 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
668
888
  this.documentIds.add(id);
669
889
  }
670
890
  }
671
- class HTMLNoDuplicateIdsRule {
891
+ class HTMLNoDuplicateIdsRule extends ParserRule {
672
892
  name = "html-no-duplicate-ids";
673
- check(node) {
674
- const visitor = new NoDuplicateIdsVisitor(this.name);
893
+ check(node, context) {
894
+ const visitor = new NoDuplicateIdsVisitor(this.name, context);
675
895
  visitor.visit(node);
676
896
  return visitor.offenses;
677
897
  }
@@ -815,10 +1035,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
815
1035
  return false;
816
1036
  }
817
1037
  }
818
- class HTMLNoEmptyHeadingsRule {
1038
+ class HTMLNoEmptyHeadingsRule extends ParserRule {
819
1039
  name = "html-no-empty-headings";
820
- check(node) {
821
- const visitor = new NoEmptyHeadingsVisitor(this.name);
1040
+ check(node, context) {
1041
+ const visitor = new NoEmptyHeadingsVisitor(this.name, context);
822
1042
  visitor.visit(node);
823
1043
  return visitor.offenses;
824
1044
  }
@@ -859,10 +1079,10 @@ class NestedLinkVisitor extends BaseRuleVisitor {
859
1079
  super.visitHTMLOpenTagNode(node);
860
1080
  }
861
1081
  }
862
- class HTMLNoNestedLinksRule {
1082
+ class HTMLNoNestedLinksRule extends ParserRule {
863
1083
  name = "html-no-nested-links";
864
- check(node) {
865
- const visitor = new NestedLinkVisitor(this.name);
1084
+ check(node, context) {
1085
+ const visitor = new NestedLinkVisitor(this.name, context);
866
1086
  visitor.visit(node);
867
1087
  return visitor.offenses;
868
1088
  }
@@ -906,10 +1126,10 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
906
1126
  }
907
1127
  }
908
1128
  }
909
- class HTMLTagNameLowercaseRule {
1129
+ class HTMLTagNameLowercaseRule extends ParserRule {
910
1130
  name = "html-tag-name-lowercase";
911
- check(node) {
912
- const visitor = new TagNameLowercaseVisitor(this.name);
1131
+ check(node, context) {
1132
+ const visitor = new TagNameLowercaseVisitor(this.name, context);
913
1133
  visitor.visit(node);
914
1134
  return visitor.offenses;
915
1135
  }
@@ -962,10 +1182,10 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
962
1182
  }
963
1183
  }
964
1184
  }
965
- class SVGTagNameCapitalizationRule {
1185
+ class SVGTagNameCapitalizationRule extends ParserRule {
966
1186
  name = "svg-tag-name-capitalization";
967
- check(node) {
968
- const visitor = new SVGTagNameCapitalizationVisitor(this.name);
1187
+ check(node, context) {
1188
+ const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
969
1189
  visitor.visit(node);
970
1190
  return visitor.offenses;
971
1191
  }
@@ -974,9 +1194,12 @@ class SVGTagNameCapitalizationRule {
974
1194
  const defaultRules = [
975
1195
  ERBNoEmptyTagsRule,
976
1196
  ERBNoOutputControlFlowRule,
1197
+ ERBPreferImageTagHelperRule,
1198
+ ERBRequiresTrailingNewlineRule,
977
1199
  ERBRequireWhitespaceRule,
978
1200
  HTMLAnchorRequireHrefRule,
979
1201
  HTMLAriaAttributeMustBeValid,
1202
+ HTMLAriaLevelMustBeValidRule,
980
1203
  HTMLAriaRoleHeadingRequiresLevelRule,
981
1204
  HTMLAriaRoleMustBeValidRule,
982
1205
  HTMLAttributeDoubleQuotesRule,
@@ -994,12 +1217,15 @@ const defaultRules = [
994
1217
 
995
1218
  class Linter {
996
1219
  rules;
1220
+ herb;
997
1221
  offenses;
998
1222
  /**
999
1223
  * Creates a new Linter instance.
1000
- * @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.
1001
1226
  */
1002
- constructor(rules) {
1227
+ constructor(herb, rules) {
1228
+ this.herb = herb;
1003
1229
  this.rules = rules !== undefined ? rules : this.getDefaultRules();
1004
1230
  this.offenses = [];
1005
1231
  }
@@ -1013,11 +1239,39 @@ class Linter {
1013
1239
  getRuleCount() {
1014
1240
  return this.rules.length;
1015
1241
  }
1016
- 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) {
1017
1260
  this.offenses = [];
1018
- for (const Rule of this.rules) {
1019
- const rule = new Rule();
1020
- 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
+ }
1021
1275
  this.offenses.push(...ruleOffenses);
1022
1276
  }
1023
1277
  const errors = this.offenses.filter(offense => offense.severity === "error").length;
@@ -1079,18 +1333,22 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
1079
1333
  this.visitBlockElement(node);
1080
1334
  }
1081
1335
  }
1082
- class HTMLNoBlockInsideInlineRule {
1336
+ class HTMLNoBlockInsideInlineRule extends ParserRule {
1083
1337
  name = "html-no-block-inside-inline";
1084
- check(node) {
1085
- const visitor = new BlockInsideInlineVisitor(this.name);
1338
+ check(node, context) {
1339
+ const visitor = new BlockInsideInlineVisitor(this.name, context);
1086
1340
  visitor.visit(node);
1087
1341
  return visitor.offenses;
1088
1342
  }
1089
1343
  }
1090
1344
 
1345
+ exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
1091
1346
  exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
1092
1347
  exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
1348
+ exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
1349
+ exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
1093
1350
  exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
1351
+ exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
1094
1352
  exports.HTMLAriaRoleHeadingRequiresLevelRule = HTMLAriaRoleHeadingRequiresLevelRule;
1095
1353
  exports.HTMLAriaRoleMustBeValidRule = HTMLAriaRoleMustBeValidRule;
1096
1354
  exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
@@ -1103,6 +1361,9 @@ exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
1103
1361
  exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
1104
1362
  exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
1105
1363
  exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
1364
+ exports.LexerRule = LexerRule;
1106
1365
  exports.Linter = Linter;
1366
+ exports.ParserRule = ParserRule;
1107
1367
  exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
1368
+ exports.SourceRule = SourceRule;
1108
1369
  //# sourceMappingURL=index.cjs.map