@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.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
@@ -146,6 +164,49 @@ const HTML_BOOLEAN_ATTRIBUTES = new Set([
146
164
  "noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
147
165
  ]);
148
166
  const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
167
+ /**
168
+ * SVG elements that use camelCase naming
169
+ */
170
+ const SVG_CAMEL_CASE_ELEMENTS = new Set([
171
+ "animateMotion",
172
+ "animateTransform",
173
+ "clipPath",
174
+ "feBlend",
175
+ "feColorMatrix",
176
+ "feComponentTransfer",
177
+ "feComposite",
178
+ "feConvolveMatrix",
179
+ "feDiffuseLighting",
180
+ "feDisplacementMap",
181
+ "feDistantLight",
182
+ "feDropShadow",
183
+ "feFlood",
184
+ "feFuncA",
185
+ "feFuncB",
186
+ "feFuncG",
187
+ "feFuncR",
188
+ "feGaussianBlur",
189
+ "feImage",
190
+ "feMerge",
191
+ "feMergeNode",
192
+ "feMorphology",
193
+ "feOffset",
194
+ "fePointLight",
195
+ "feSpecularLighting",
196
+ "feSpotLight",
197
+ "feTile",
198
+ "feTurbulence",
199
+ "foreignObject",
200
+ "glyphRef",
201
+ "linearGradient",
202
+ "radialGradient",
203
+ "textPath"
204
+ ]);
205
+ /**
206
+ * Mapping from lowercase SVG element names to their correct camelCase versions
207
+ * Generated dynamically from SVG_CAMEL_CASE_ELEMENTS
208
+ */
209
+ const SVG_LOWERCASE_TO_CAMELCASE = new Map(Array.from(SVG_CAMEL_CASE_ELEMENTS).map(element => [element.toLowerCase(), element]));
149
210
  const VALID_ARIA_ROLES = new Set([
150
211
  "banner", "complementary", "contentinfo", "form", "main", "navigation", "region", "search",
151
212
  "article", "cell", "columnheader", "definition", "directory", "document", "feed", "figure",
@@ -208,6 +269,19 @@ const ARIA_ATTRIBUTES = new Set([
208
269
  'aria-valuenow',
209
270
  'aria-valuetext',
210
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
+ }
211
285
  /**
212
286
  * Checks if an element is inline
213
287
  */
@@ -232,6 +306,9 @@ function isBooleanAttribute(attributeName) {
232
306
  * and attribute iteration logic. Provides simplified interface with extracted attribute info.
233
307
  */
234
308
  class AttributeVisitorMixin extends BaseRuleVisitor {
309
+ constructor(ruleName, context) {
310
+ super(ruleName, context);
311
+ }
235
312
  visitHTMLOpenTagNode(node) {
236
313
  this.checkAttributesOnNode(node);
237
314
  super.visitHTMLOpenTagNode(node);
@@ -261,6 +338,56 @@ function forEachAttribute(node, callback) {
261
338
  }
262
339
  }
263
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
+ }
264
391
 
265
392
  class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
266
393
  visitERBContentNode(node) {
@@ -275,10 +402,10 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
275
402
  this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
276
403
  }
277
404
  }
278
- class ERBNoEmptyTagsRule {
405
+ class ERBNoEmptyTagsRule extends ParserRule {
279
406
  name = "erb-no-empty-tags";
280
- check(node) {
281
- const visitor = new ERBNoEmptyTagsVisitor(this.name);
407
+ check(node, context) {
408
+ const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
282
409
  visitor.visit(node);
283
410
  return visitor.offenses;
284
411
  }
@@ -321,15 +448,126 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
321
448
  return;
322
449
  }
323
450
  }
324
- class ERBNoOutputControlFlowRule {
451
+ class ERBNoOutputControlFlowRule extends ParserRule {
325
452
  name = "erb-no-output-control-flow";
326
- check(node) {
327
- 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);
328
546
  visitor.visit(node);
329
547
  return visitor.offenses;
330
548
  }
331
549
  }
332
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
+
333
571
  class RequireWhitespaceInsideTags extends BaseRuleVisitor {
334
572
  visitChildNodes(node) {
335
573
  this.checkWhitespace(node);
@@ -346,8 +584,24 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
346
584
  return;
347
585
  }
348
586
  const value = content.value;
349
- this.checkOpenTagWhitespace(openTag, value);
350
- this.checkCloseTagWhitespace(closeTag, value);
587
+ if (openTag.value === "<%#") {
588
+ this.checkCommentTagWhitespace(openTag, closeTag, value);
589
+ }
590
+ else {
591
+ this.checkOpenTagWhitespace(openTag, value);
592
+ this.checkCloseTagWhitespace(closeTag, value);
593
+ }
594
+ }
595
+ checkCommentTagWhitespace(openTag, closeTag, content) {
596
+ if (!content.startsWith(" ") && !content.startsWith("\n") && !content.startsWith("=")) {
597
+ this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, "error");
598
+ }
599
+ else if (content.startsWith("=") && content.length > 1 && !content[1].match(/\s/)) {
600
+ this.addOffense(`Add whitespace after \`<%#=\`.`, openTag.location, "error");
601
+ }
602
+ if (!content.endsWith(" ") && !content.endsWith("\n")) {
603
+ this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
604
+ }
351
605
  }
352
606
  checkOpenTagWhitespace(openTag, content) {
353
607
  if (content.startsWith(" ") || content.startsWith("\n")) {
@@ -362,10 +616,10 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
362
616
  this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
363
617
  }
364
618
  }
365
- class ERBRequireWhitespaceRule {
619
+ class ERBRequireWhitespaceRule extends ParserRule {
366
620
  name = "erb-require-whitespace-inside-tags";
367
- check(node) {
368
- const visitor = new RequireWhitespaceInsideTags(this.name);
621
+ check(node, context) {
622
+ const visitor = new RequireWhitespaceInsideTags(this.name, context);
369
623
  visitor.visit(node);
370
624
  return visitor.offenses;
371
625
  }
@@ -386,10 +640,10 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
386
640
  }
387
641
  }
388
642
  }
389
- class HTMLAnchorRequireHrefRule {
643
+ class HTMLAnchorRequireHrefRule extends ParserRule {
390
644
  name = "html-anchor-require-href";
391
- check(node) {
392
- const visitor = new AnchorRechireHrefVisitor(this.name);
645
+ check(node, context) {
646
+ const visitor = new AnchorRechireHrefVisitor(this.name, context);
393
647
  visitor.visit(node);
394
648
  return visitor.offenses;
395
649
  }
@@ -409,10 +663,35 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
409
663
  }
410
664
  }
411
665
  }
412
- class HTMLAriaAttributeMustBeValid {
666
+ class HTMLAriaAttributeMustBeValid extends ParserRule {
413
667
  name = "html-aria-attribute-must-be-valid";
414
- check(node) {
415
- 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);
416
695
  visitor.visit(node);
417
696
  return visitor.offenses;
418
697
  }
@@ -434,10 +713,10 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
434
713
  }
435
714
  }
436
715
  }
437
- class HTMLAriaRoleHeadingRequiresLevelRule {
716
+ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
438
717
  name = "html-aria-role-heading-requires-level";
439
- check(node) {
440
- const visitor = new AriaRoleHeadingRequiresLevel(this.name);
718
+ check(node, context) {
719
+ const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
441
720
  visitor.visit(node);
442
721
  return visitor.offenses;
443
722
  }
@@ -454,10 +733,10 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
454
733
  this.addOffense(`The \`role\` attribute must be a valid ARIA role. Role \`${attributeValue}\` is not recognized.`, attributeNode.location, "error");
455
734
  }
456
735
  }
457
- class HTMLAriaRoleMustBeValidRule {
736
+ class HTMLAriaRoleMustBeValidRule extends ParserRule {
458
737
  name = "html-aria-role-must-be-valid";
459
- check(node) {
460
- const visitor = new AriaRoleMustBeValid(this.name);
738
+ check(node, context) {
739
+ const visitor = new AriaRoleMustBeValid(this.name, context);
461
740
  visitor.visit(node);
462
741
  return visitor.offenses;
463
742
  }
@@ -474,10 +753,10 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
474
753
  this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
475
754
  }
476
755
  }
477
- class HTMLAttributeDoubleQuotesRule {
756
+ class HTMLAttributeDoubleQuotesRule extends ParserRule {
478
757
  name = "html-attribute-double-quotes";
479
- check(node) {
480
- const visitor = new AttributeDoubleQuotesVisitor(this.name);
758
+ check(node, context) {
759
+ const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
481
760
  visitor.visit(node);
482
761
  return visitor.offenses;
483
762
  }
@@ -495,10 +774,10 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
495
774
  `Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
496
775
  }
497
776
  }
498
- class HTMLAttributeValuesRequireQuotesRule {
777
+ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
499
778
  name = "html-attribute-values-require-quotes";
500
- check(node) {
501
- const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
779
+ check(node, context) {
780
+ const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
502
781
  visitor.visit(node);
503
782
  return visitor.offenses;
504
783
  }
@@ -513,10 +792,10 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
513
792
  this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
514
793
  }
515
794
  }
516
- class HTMLBooleanAttributesNoValueRule {
795
+ class HTMLBooleanAttributesNoValueRule extends ParserRule {
517
796
  name = "html-boolean-attributes-no-value";
518
- check(node) {
519
- const visitor = new BooleanAttributesNoValueVisitor(this.name);
797
+ check(node, context) {
798
+ const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
520
799
  visitor.visit(node);
521
800
  return visitor.offenses;
522
801
  }
@@ -541,10 +820,10 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
541
820
  }
542
821
  }
543
822
  }
544
- class HTMLImgRequireAltRule {
823
+ class HTMLImgRequireAltRule extends ParserRule {
545
824
  name = "html-img-require-alt";
546
- check(node) {
547
- const visitor = new ImgRequireAltVisitor(this.name);
825
+ check(node, context) {
826
+ const visitor = new ImgRequireAltVisitor(this.name, context);
548
827
  visitor.visit(node);
549
828
  return visitor.offenses;
550
829
  }
@@ -583,10 +862,10 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
583
862
  }
584
863
  }
585
864
  }
586
- class HTMLNoDuplicateAttributesRule {
865
+ class HTMLNoDuplicateAttributesRule extends ParserRule {
587
866
  name = "html-no-duplicate-attributes";
588
- check(node) {
589
- const visitor = new NoDuplicateAttributesVisitor(this.name);
867
+ check(node, context) {
868
+ const visitor = new NoDuplicateAttributesVisitor(this.name, context);
590
869
  visitor.visit(node);
591
870
  return visitor.offenses;
592
871
  }
@@ -607,10 +886,10 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
607
886
  this.documentIds.add(id);
608
887
  }
609
888
  }
610
- class HTMLNoDuplicateIdsRule {
889
+ class HTMLNoDuplicateIdsRule extends ParserRule {
611
890
  name = "html-no-duplicate-ids";
612
- check(node) {
613
- const visitor = new NoDuplicateIdsVisitor(this.name);
891
+ check(node, context) {
892
+ const visitor = new NoDuplicateIdsVisitor(this.name, context);
614
893
  visitor.visit(node);
615
894
  return visitor.offenses;
616
895
  }
@@ -754,10 +1033,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
754
1033
  return false;
755
1034
  }
756
1035
  }
757
- class HTMLNoEmptyHeadingsRule {
1036
+ class HTMLNoEmptyHeadingsRule extends ParserRule {
758
1037
  name = "html-no-empty-headings";
759
- check(node) {
760
- const visitor = new NoEmptyHeadingsVisitor(this.name);
1038
+ check(node, context) {
1039
+ const visitor = new NoEmptyHeadingsVisitor(this.name, context);
761
1040
  visitor.visit(node);
762
1041
  return visitor.offenses;
763
1042
  }
@@ -798,33 +1077,98 @@ class NestedLinkVisitor extends BaseRuleVisitor {
798
1077
  super.visitHTMLOpenTagNode(node);
799
1078
  }
800
1079
  }
801
- class HTMLNoNestedLinksRule {
1080
+ class HTMLNoNestedLinksRule extends ParserRule {
802
1081
  name = "html-no-nested-links";
803
- check(node) {
804
- const visitor = new NestedLinkVisitor(this.name);
1082
+ check(node, context) {
1083
+ const visitor = new NestedLinkVisitor(this.name, context);
805
1084
  visitor.visit(node);
806
1085
  return visitor.offenses;
807
1086
  }
808
1087
  }
809
1088
 
810
1089
  class TagNameLowercaseVisitor extends BaseRuleVisitor {
811
- visitHTMLOpenTagNode(node) {
812
- this.checkTagName(node);
1090
+ visitHTMLElementNode(node) {
1091
+ const tagName = node.tag_name?.value;
1092
+ if (node.open_tag) {
1093
+ this.checkTagName(node.open_tag);
1094
+ }
1095
+ if (tagName && ["svg"].includes(tagName.toLowerCase())) {
1096
+ if (node.close_tag) {
1097
+ this.checkTagName(node.close_tag);
1098
+ }
1099
+ return;
1100
+ }
813
1101
  this.visitChildNodes(node);
1102
+ if (node.close_tag) {
1103
+ this.checkTagName(node.close_tag);
1104
+ }
814
1105
  }
815
- visitHTMLCloseTagNode(node) {
1106
+ visitHTMLSelfCloseTagNode(node) {
816
1107
  this.checkTagName(node);
817
1108
  this.visitChildNodes(node);
818
1109
  }
1110
+ checkTagName(node) {
1111
+ const tagName = node.tag_name?.value;
1112
+ if (!tagName)
1113
+ return;
1114
+ const lowercaseTagName = tagName.toLowerCase();
1115
+ if (tagName !== lowercaseTagName) {
1116
+ let type = node.type;
1117
+ if (node.type == "AST_HTML_OPEN_TAG_NODE")
1118
+ type = "Opening";
1119
+ if (node.type == "AST_HTML_CLOSE_TAG_NODE")
1120
+ type = "Closing";
1121
+ if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
1122
+ type = "Self-closing";
1123
+ this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`, node.tag_name.location, "error");
1124
+ }
1125
+ }
1126
+ }
1127
+ class HTMLTagNameLowercaseRule extends ParserRule {
1128
+ name = "html-tag-name-lowercase";
1129
+ check(node, context) {
1130
+ const visitor = new TagNameLowercaseVisitor(this.name, context);
1131
+ visitor.visit(node);
1132
+ return visitor.offenses;
1133
+ }
1134
+ }
1135
+
1136
+ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1137
+ insideSVG = false;
1138
+ visitHTMLElementNode(node) {
1139
+ const tagName = node.tag_name?.value;
1140
+ if (tagName && ["svg"].includes(tagName.toLowerCase())) {
1141
+ const wasInsideSVG = this.insideSVG;
1142
+ this.insideSVG = true;
1143
+ this.visitChildNodes(node);
1144
+ this.insideSVG = wasInsideSVG;
1145
+ return;
1146
+ }
1147
+ if (this.insideSVG) {
1148
+ if (node.open_tag) {
1149
+ this.checkTagName(node.open_tag);
1150
+ }
1151
+ if (node.close_tag) {
1152
+ this.checkTagName(node.close_tag);
1153
+ }
1154
+ }
1155
+ this.visitChildNodes(node);
1156
+ }
819
1157
  visitHTMLSelfCloseTagNode(node) {
820
- this.checkTagName(node);
1158
+ if (this.insideSVG) {
1159
+ this.checkTagName(node);
1160
+ }
821
1161
  this.visitChildNodes(node);
822
1162
  }
823
1163
  checkTagName(node) {
824
1164
  const tagName = node.tag_name?.value;
825
1165
  if (!tagName)
826
1166
  return;
827
- if (tagName !== tagName.toLowerCase()) {
1167
+ if (SVG_CAMEL_CASE_ELEMENTS.has(tagName))
1168
+ return;
1169
+ const lowercaseTagName = tagName.toLowerCase();
1170
+ const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
1171
+ if (correctCamelCase && tagName !== correctCamelCase) {
828
1172
  let type = node.type;
829
1173
  if (node.type == "AST_HTML_OPEN_TAG_NODE")
830
1174
  type = "Opening";
@@ -832,14 +1176,14 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
832
1176
  type = "Closing";
833
1177
  if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
834
1178
  type = "Self-closing";
835
- this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`, node.tag_name.location, "error");
1179
+ this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
836
1180
  }
837
1181
  }
838
1182
  }
839
- class HTMLTagNameLowercaseRule {
840
- name = "html-tag-name-lowercase";
841
- check(node) {
842
- const visitor = new TagNameLowercaseVisitor(this.name);
1183
+ class SVGTagNameCapitalizationRule extends ParserRule {
1184
+ name = "svg-tag-name-capitalization";
1185
+ check(node, context) {
1186
+ const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
843
1187
  visitor.visit(node);
844
1188
  return visitor.offenses;
845
1189
  }
@@ -848,9 +1192,12 @@ class HTMLTagNameLowercaseRule {
848
1192
  const defaultRules = [
849
1193
  ERBNoEmptyTagsRule,
850
1194
  ERBNoOutputControlFlowRule,
1195
+ ERBPreferImageTagHelperRule,
1196
+ ERBRequiresTrailingNewlineRule,
851
1197
  ERBRequireWhitespaceRule,
852
1198
  HTMLAnchorRequireHrefRule,
853
1199
  HTMLAriaAttributeMustBeValid,
1200
+ HTMLAriaLevelMustBeValidRule,
854
1201
  HTMLAriaRoleHeadingRequiresLevelRule,
855
1202
  HTMLAriaRoleMustBeValidRule,
856
1203
  HTMLAttributeDoubleQuotesRule,
@@ -863,16 +1210,20 @@ const defaultRules = [
863
1210
  HTMLNoEmptyHeadingsRule,
864
1211
  HTMLNoNestedLinksRule,
865
1212
  HTMLTagNameLowercaseRule,
1213
+ SVGTagNameCapitalizationRule,
866
1214
  ];
867
1215
 
868
1216
  class Linter {
869
1217
  rules;
1218
+ herb;
870
1219
  offenses;
871
1220
  /**
872
1221
  * Creates a new Linter instance.
873
- * @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.
874
1224
  */
875
- constructor(rules) {
1225
+ constructor(herb, rules) {
1226
+ this.herb = herb;
876
1227
  this.rules = rules !== undefined ? rules : this.getDefaultRules();
877
1228
  this.offenses = [];
878
1229
  }
@@ -886,11 +1237,39 @@ class Linter {
886
1237
  getRuleCount() {
887
1238
  return this.rules.length;
888
1239
  }
889
- 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) {
890
1258
  this.offenses = [];
891
- for (const Rule of this.rules) {
892
- const rule = new Rule();
893
- 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
+ }
894
1273
  this.offenses.push(...ruleOffenses);
895
1274
  }
896
1275
  const errors = this.offenses.filter(offense => offense.severity === "error").length;
@@ -952,14 +1331,14 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
952
1331
  this.visitBlockElement(node);
953
1332
  }
954
1333
  }
955
- class HTMLNoBlockInsideInlineRule {
1334
+ class HTMLNoBlockInsideInlineRule extends ParserRule {
956
1335
  name = "html-no-block-inside-inline";
957
- check(node) {
958
- const visitor = new BlockInsideInlineVisitor(this.name);
1336
+ check(node, context) {
1337
+ const visitor = new BlockInsideInlineVisitor(this.name, context);
959
1338
  visitor.visit(node);
960
1339
  return visitor.offenses;
961
1340
  }
962
1341
  }
963
1342
 
964
- export { ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, HTMLAnchorRequireHrefRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeValuesRequireQuotesRule, HTMLBooleanAttributesNoValueRule, HTMLImgRequireAltRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLTagNameLowercaseRule, Linter };
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 };
965
1344
  //# sourceMappingURL=index.js.map