@herb-tools/linter 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +60 -16
  2. package/dist/herb-lint.js +1684 -295
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +1226 -158
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +1188 -160
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +11 -4
  9. package/dist/src/cli/argument-parser.js +11 -6
  10. package/dist/src/cli/argument-parser.js.map +1 -1
  11. package/dist/src/cli/file-processor.js +5 -6
  12. package/dist/src/cli/file-processor.js.map +1 -1
  13. package/dist/src/cli/formatters/detailed-formatter.js +3 -5
  14. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
  15. package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
  16. package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
  17. package/dist/src/cli/index.js +1 -0
  18. package/dist/src/cli/index.js.map +1 -1
  19. package/dist/src/cli/output-manager.js +23 -5
  20. package/dist/src/cli/output-manager.js.map +1 -1
  21. package/dist/src/cli/summary-reporter.js +2 -11
  22. package/dist/src/cli/summary-reporter.js.map +1 -1
  23. package/dist/src/cli.js +88 -4
  24. package/dist/src/cli.js.map +1 -1
  25. package/dist/src/default-rules.js +8 -4
  26. package/dist/src/default-rules.js.map +1 -1
  27. package/dist/src/linter.js.map +1 -1
  28. package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
  29. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  30. package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
  31. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  32. package/dist/src/rules/html-no-duplicate-ids.js +134 -9
  33. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  34. package/dist/src/rules/html-no-empty-attributes.js +56 -0
  35. package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
  36. package/dist/src/rules/html-no-positive-tab-index.js +1 -1
  37. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
  38. package/dist/src/rules/html-no-self-closing.js +12 -5
  39. package/dist/src/rules/html-no-self-closing.js.map +1 -1
  40. package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
  41. package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
  42. package/dist/src/rules/index.js +3 -0
  43. package/dist/src/rules/index.js.map +1 -1
  44. package/dist/src/rules/rule-utils.js +80 -7
  45. package/dist/src/rules/rule-utils.js.map +1 -1
  46. package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
  47. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/dist/types/cli/argument-parser.d.ts +2 -1
  50. package/dist/types/cli/file-processor.d.ts +6 -1
  51. package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
  52. package/dist/types/cli/index.d.ts +1 -0
  53. package/dist/types/cli/output-manager.d.ts +1 -0
  54. package/dist/types/cli.d.ts +20 -5
  55. package/dist/types/linter.d.ts +7 -7
  56. package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
  57. package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  58. package/dist/types/rules/index.d.ts +3 -0
  59. package/dist/types/rules/rule-utils.d.ts +46 -5
  60. package/dist/types/src/cli/argument-parser.d.ts +2 -1
  61. package/dist/types/src/cli/file-processor.d.ts +6 -1
  62. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
  63. package/dist/types/src/cli/index.d.ts +1 -0
  64. package/dist/types/src/cli/output-manager.d.ts +1 -0
  65. package/dist/types/src/cli.d.ts +20 -5
  66. package/dist/types/src/linter.d.ts +7 -7
  67. package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
  68. package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  69. package/dist/types/src/rules/index.d.ts +3 -0
  70. package/dist/types/src/rules/rule-utils.d.ts +46 -5
  71. package/docs/rules/README.md +2 -0
  72. package/docs/rules/html-img-require-alt.md +0 -2
  73. package/docs/rules/html-no-empty-attributes.md +77 -0
  74. package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
  75. package/package.json +11 -4
  76. package/src/cli/argument-parser.ts +15 -7
  77. package/src/cli/file-processor.ts +11 -7
  78. package/src/cli/formatters/detailed-formatter.ts +5 -7
  79. package/src/cli/formatters/github-actions-formatter.ts +64 -11
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/output-manager.ts +27 -5
  82. package/src/cli/summary-reporter.ts +3 -11
  83. package/src/cli.ts +125 -20
  84. package/src/default-rules.ts +8 -4
  85. package/src/linter.ts +6 -6
  86. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
  87. package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
  88. package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
  89. package/src/rules/html-attribute-double-quotes.ts +1 -1
  90. package/src/rules/html-boolean-attributes-no-value.ts +9 -11
  91. package/src/rules/html-no-duplicate-ids.ts +188 -14
  92. package/src/rules/html-no-empty-attributes.ts +75 -0
  93. package/src/rules/html-no-positive-tab-index.ts +1 -1
  94. package/src/rules/html-no-self-closing.ts +13 -8
  95. package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
  96. package/src/rules/html-tag-name-lowercase.ts +1 -1
  97. package/src/rules/index.ts +3 -0
  98. package/src/rules/rule-utils.ts +110 -9
  99. package/src/rules/svg-tag-name-capitalization.ts +2 -2
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Visitor, Position, Location, getStaticAttributeName, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, filterERBContentNodes, isERBNode, filterLiteralNodes, isERBOutputNode, getTagName as getTagName$1, isNode, HTMLOpenTagNode } from '@herb-tools/core';
1
+ import { Visitor, getStaticAttributeName, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, hasStaticContent, getStaticContentFromNodes, Position, Location, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, filterERBContentNodes, filterNodes, ERBContentNode, isERBOutputNode, getNodesBeforePosition, getNodesAfterPosition, isToken, isParseResult, isNode, LiteralNode, isERBNode, filterLiteralNodes, getTagName as getTagName$1, HTMLOpenTagNode } from '@herb-tools/core';
2
2
 
3
3
  class ParserRule {
4
4
  static type = "parser";
@@ -16,6 +16,11 @@ class SourceRule {
16
16
  static type = "source";
17
17
  }
18
18
 
19
+ var ControlFlowType;
20
+ (function (ControlFlowType) {
21
+ ControlFlowType[ControlFlowType["CONDITIONAL"] = 0] = "CONDITIONAL";
22
+ ControlFlowType[ControlFlowType["LOOP"] = 1] = "LOOP";
23
+ })(ControlFlowType || (ControlFlowType = {}));
19
24
  /**
20
25
  * Base visitor class that provides common functionality for rule visitors
21
26
  */
@@ -48,6 +53,70 @@ class BaseRuleVisitor extends Visitor {
48
53
  this.offenses.push(this.createOffense(message, location, severity));
49
54
  }
50
55
  }
56
+ /**
57
+ * Mixin that adds control flow tracking capabilities to rule visitors
58
+ * This allows rules to track state across different control flow structures
59
+ * like if/else branches, loops, etc.
60
+ *
61
+ * @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
62
+ * @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
63
+ */
64
+ class ControlFlowTrackingVisitor extends BaseRuleVisitor {
65
+ isInControlFlow = false;
66
+ currentControlFlowType = null;
67
+ /**
68
+ * Handle visiting a control flow node with proper scope management
69
+ */
70
+ handleControlFlowNode(node, controlFlowType, visitChildren) {
71
+ const wasInControlFlow = this.isInControlFlow;
72
+ const previousControlFlowType = this.currentControlFlowType;
73
+ this.isInControlFlow = true;
74
+ this.currentControlFlowType = controlFlowType;
75
+ const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow);
76
+ visitChildren();
77
+ this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore);
78
+ this.isInControlFlow = wasInControlFlow;
79
+ this.currentControlFlowType = previousControlFlowType;
80
+ }
81
+ /**
82
+ * Handle visiting a branch node (like else, when) with proper scope management
83
+ */
84
+ startNewBranch(visitChildren) {
85
+ const stateToRestore = this.onEnterBranch();
86
+ visitChildren();
87
+ this.onExitBranch(stateToRestore);
88
+ }
89
+ visitERBIfNode(node) {
90
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
91
+ }
92
+ visitERBUnlessNode(node) {
93
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
94
+ }
95
+ visitERBCaseNode(node) {
96
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
97
+ }
98
+ visitERBCaseMatchNode(node) {
99
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
100
+ }
101
+ visitERBWhileNode(node) {
102
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
103
+ }
104
+ visitERBForNode(node) {
105
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node));
106
+ }
107
+ visitERBUntilNode(node) {
108
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
109
+ }
110
+ visitERBBlockNode(node) {
111
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
112
+ }
113
+ visitERBElseNode(node) {
114
+ this.startNewBranch(() => super.visitERBElseNode(node));
115
+ }
116
+ visitERBWhenNode(node) {
117
+ this.startNewBranch(() => super.visitERBWhenNode(node));
118
+ }
119
+ }
51
120
  /**
52
121
  * Gets attributes from an HTMLOpenTagNode
53
122
  */
@@ -64,10 +133,12 @@ function getTagName(node) {
64
133
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
65
134
  * Returns null if the attribute name contains dynamic content (ERB)
66
135
  */
67
- function getAttributeName(attributeNode) {
136
+ function getAttributeName(attributeNode, lowercase = true) {
68
137
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
69
138
  const nameNode = attributeNode.name;
70
139
  const staticName = getStaticAttributeName(nameNode);
140
+ if (!lowercase)
141
+ return staticName;
71
142
  return staticName ? staticName.toLowerCase() : null;
72
143
  }
73
144
  return null;
@@ -102,6 +173,15 @@ function hasStaticAttributeValue(attributeNode) {
102
173
  return false;
103
174
  return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
104
175
  }
176
+ /**
177
+ * Checks if an attribute value contains dynamic content (ERB)
178
+ */
179
+ function hasDynamicAttributeValue(attributeNode) {
180
+ const valueNode = attributeNode.value;
181
+ if (!valueNode?.children)
182
+ return false;
183
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
184
+ }
105
185
  /**
106
186
  * Gets the static string value of an attribute (returns null if it contains ERB)
107
187
  */
@@ -122,6 +202,21 @@ function getAttributeValueNodes(attributeNode) {
122
202
  const valueNode = attributeNode.value;
123
203
  return valueNode?.children || [];
124
204
  }
205
+ /**
206
+ * Checks if an attribute value contains any static content (for validation purposes)
207
+ */
208
+ function hasStaticAttributeValueContent(attributeNode) {
209
+ const valueNodes = getAttributeValueNodes(attributeNode);
210
+ return hasStaticContent(valueNodes);
211
+ }
212
+ /**
213
+ * Gets the static content of an attribute value (all literal parts combined)
214
+ * Returns the concatenated literal content, or null if no literal nodes exist
215
+ */
216
+ function getStaticAttributeValueContent(attributeNode) {
217
+ const valueNodes = getAttributeValueNodes(attributeNode);
218
+ return getStaticContentFromNodes(valueNodes);
219
+ }
125
220
  /**
126
221
  * Gets the attribute value content from an HTMLAttributeValueNode
127
222
  */
@@ -396,6 +491,7 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
396
491
  checkAttributesOnNode(node) {
397
492
  forEachAttribute(node, (attributeNode) => {
398
493
  const staticAttributeName = getAttributeName(attributeNode);
494
+ const originalAttributeName = getAttributeName(attributeNode, false) || "";
399
495
  const isDynamicName = hasDynamicAttributeName(attributeNode);
400
496
  const staticAttributeValue = getStaticAttributeValue(attributeNode);
401
497
  const valueNodes = getAttributeValueNodes(attributeNode);
@@ -406,16 +502,17 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
406
502
  attributeName: staticAttributeName,
407
503
  attributeValue: staticAttributeValue,
408
504
  attributeNode,
505
+ originalAttributeName,
409
506
  parentNode: node
410
507
  });
411
508
  }
412
509
  else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
413
510
  const validatableContent = getValidatableStaticContent(valueNodes) || "";
414
- this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
511
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, originalAttributeName, parentNode: node });
415
512
  }
416
513
  else if (staticAttributeName && hasOutputERB) {
417
514
  const combinedValue = getAttributeValue(attributeNode);
418
- this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
515
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, originalAttributeName, combinedValue });
419
516
  }
420
517
  else if (isDynamicName && staticAttributeValue !== null) {
421
518
  const nameNode = attributeNode.name;
@@ -435,28 +532,38 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
435
532
  /**
436
533
  * Static attribute name with static value: class="container"
437
534
  */
438
- checkStaticAttributeStaticValue(params) {
535
+ checkStaticAttributeStaticValue(_params) {
439
536
  // Default implementation does nothing
440
537
  }
441
538
  /**
442
539
  * Static attribute name with dynamic value: class="<%= css_class %>"
443
540
  */
444
- checkStaticAttributeDynamicValue(params) {
541
+ checkStaticAttributeDynamicValue(_params) {
445
542
  // Default implementation does nothing
446
543
  }
447
544
  /**
448
545
  * Dynamic attribute name with static value: data-<%= key %>="foo"
449
546
  */
450
- checkDynamicAttributeStaticValue(params) {
547
+ checkDynamicAttributeStaticValue(_params) {
451
548
  // Default implementation does nothing
452
549
  }
453
550
  /**
454
551
  * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
455
552
  */
456
- checkDynamicAttributeDynamicValue(params) {
553
+ checkDynamicAttributeDynamicValue(_params) {
457
554
  // Default implementation does nothing
458
555
  }
459
556
  }
557
+ /**
558
+ * Checks if an attribute value is quoted
559
+ */
560
+ function isAttributeValueQuoted(attributeNode) {
561
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
562
+ const valueNode = attributeNode.value;
563
+ return !!valueNode.quoted;
564
+ }
565
+ return false;
566
+ }
460
567
  /**
461
568
  * Iterates over all attributes of a tag node, calling the callback for each attribute
462
569
  */
@@ -468,6 +575,60 @@ function forEachAttribute(node, callback) {
468
575
  }
469
576
  }
470
577
  }
578
+ /**
579
+ * Base lexer visitor class that provides common functionality for lexer-based rule visitors
580
+ */
581
+ class BaseLexerRuleVisitor {
582
+ offenses = [];
583
+ ruleName;
584
+ context;
585
+ constructor(ruleName, context) {
586
+ this.ruleName = ruleName;
587
+ this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
588
+ }
589
+ /**
590
+ * Helper method to create a lint offense for lexer rules
591
+ */
592
+ createOffense(message, location, severity = "error") {
593
+ return {
594
+ rule: this.ruleName,
595
+ code: this.ruleName,
596
+ source: "Herb Linter",
597
+ message,
598
+ location,
599
+ severity,
600
+ };
601
+ }
602
+ /**
603
+ * Helper method to add an offense to the offenses array
604
+ */
605
+ addOffense(message, location, severity = "error") {
606
+ this.offenses.push(this.createOffense(message, location, severity));
607
+ }
608
+ /**
609
+ * Main entry point for lexer rule visitors
610
+ * @param lexResult - The lexer result containing tokens and source
611
+ */
612
+ visit(lexResult) {
613
+ this.visitTokens(lexResult.value.tokens);
614
+ }
615
+ /**
616
+ * Visit all tokens
617
+ * Override this method to implement token-level checks
618
+ */
619
+ visitTokens(tokens) {
620
+ for (const token of tokens) {
621
+ this.visitToken(token);
622
+ }
623
+ }
624
+ /**
625
+ * Visit individual tokens
626
+ * Override this method to implement per-token checks
627
+ */
628
+ visitToken(_token) {
629
+ // Default implementation does nothing
630
+ }
631
+ }
471
632
  /**
472
633
  * Base source visitor class that provides common functionality for source-based rule visitors
473
634
  */
@@ -610,82 +771,734 @@ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
610
771
  }
611
772
  }
612
773
 
774
+ class PrintContext {
775
+ output = "";
776
+ indentLevel = 0;
777
+ currentColumn = 0;
778
+ preserveStack = [];
779
+ /**
780
+ * Write text to the output
781
+ */
782
+ write(text) {
783
+ this.output += text;
784
+ this.currentColumn += text.length;
785
+ }
786
+ /**
787
+ * Write text and update column tracking for newlines
788
+ */
789
+ writeWithColumnTracking(text) {
790
+ this.output += text;
791
+ const lines = text.split('\n');
792
+ if (lines.length > 1) {
793
+ this.currentColumn = lines[lines.length - 1].length;
794
+ }
795
+ else {
796
+ this.currentColumn += text.length;
797
+ }
798
+ }
799
+ /**
800
+ * Increase indentation level
801
+ */
802
+ indent() {
803
+ this.indentLevel++;
804
+ }
805
+ /**
806
+ * Decrease indentation level
807
+ */
808
+ dedent() {
809
+ if (this.indentLevel > 0) {
810
+ this.indentLevel--;
811
+ }
812
+ }
813
+ /**
814
+ * Enter a tag that may preserve whitespace
815
+ */
816
+ enterTag(tagName) {
817
+ this.preserveStack.push(tagName.toLowerCase());
818
+ }
819
+ /**
820
+ * Exit the current tag
821
+ */
822
+ exitTag() {
823
+ this.preserveStack.pop();
824
+ }
825
+ /**
826
+ * Check if we're at the start of a line
827
+ */
828
+ isAtStartOfLine() {
829
+ return this.currentColumn === 0;
830
+ }
831
+ /**
832
+ * Get current indentation level
833
+ */
834
+ getCurrentIndentLevel() {
835
+ return this.indentLevel;
836
+ }
837
+ /**
838
+ * Get current column position
839
+ */
840
+ getCurrentColumn() {
841
+ return this.currentColumn;
842
+ }
843
+ /**
844
+ * Get the current tag stack (for debugging)
845
+ */
846
+ getTagStack() {
847
+ return [...this.preserveStack];
848
+ }
849
+ /**
850
+ * Get the complete output string
851
+ */
852
+ getOutput() {
853
+ return this.output;
854
+ }
855
+ /**
856
+ * Reset the context for reuse
857
+ */
858
+ reset() {
859
+ this.output = "";
860
+ this.indentLevel = 0;
861
+ this.currentColumn = 0;
862
+ this.preserveStack = [];
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Default print options used when none are provided
868
+ */
869
+ const DEFAULT_PRINT_OPTIONS = {
870
+ ignoreErrors: false
871
+ };
872
+ class Printer extends Visitor {
873
+ context = new PrintContext();
874
+ /**
875
+ * Static method to print a node without creating an instance
876
+ *
877
+ * @param input - The AST Node, Token, or ParseResult to print
878
+ * @param options - Print options to control behavior
879
+ * @returns The printed string representation of the input
880
+ * @throws {Error} When node has parse errors and ignoreErrors is false
881
+ */
882
+ static print(input, options = DEFAULT_PRINT_OPTIONS) {
883
+ const printer = new this();
884
+ return printer.print(input, options);
885
+ }
886
+ /**
887
+ * Print a node, token, or parse result to a string
888
+ *
889
+ * @param input - The AST Node, Token, or ParseResult to print
890
+ * @param options - Print options to control behavior
891
+ * @returns The printed string representation of the input
892
+ * @throws {Error} When node has parse errors and ignoreErrors is false
893
+ */
894
+ print(input, options = DEFAULT_PRINT_OPTIONS) {
895
+ if (!input)
896
+ return "";
897
+ if (isToken(input)) {
898
+ return input.value;
899
+ }
900
+ if (Array.isArray(input)) {
901
+ this.context.reset();
902
+ input.forEach(node => this.visit(node));
903
+ return this.context.getOutput();
904
+ }
905
+ const node = isParseResult(input) ? input.value : input;
906
+ if (options.ignoreErrors === false && node.recursiveErrors().length > 0) {
907
+ throw new Error(`Cannot print the node (${node.type}) since it or any of its children has parse errors. Either pass in a valid Node or call \`print()\` using \`print(node, { ignoreErrors: true })\``);
908
+ }
909
+ this.context.reset();
910
+ this.visit(node);
911
+ return this.context.getOutput();
912
+ }
913
+ write(content) {
914
+ this.context.write(content);
915
+ }
916
+ }
917
+
918
+ /**
919
+ * IdentityPrinter - Provides lossless reconstruction of the original source
920
+ *
921
+ * This printer aims to reconstruct the original input as faithfully as possible,
922
+ * preserving all whitespace, formatting, and structure. It's useful for:
923
+ * - Testing parser accuracy (input should equal output)
924
+ * - Baseline printing before applying transformations
925
+ * - Verifying AST round-trip fidelity
926
+ */
927
+ class IdentityPrinter extends Printer {
928
+ visitLiteralNode(node) {
929
+ this.write(node.content);
930
+ }
931
+ visitHTMLTextNode(node) {
932
+ this.write(node.content);
933
+ }
934
+ visitWhitespaceNode(node) {
935
+ if (node.value) {
936
+ this.write(node.value.value);
937
+ }
938
+ }
939
+ visitHTMLOpenTagNode(node) {
940
+ if (node.tag_opening) {
941
+ this.write(node.tag_opening.value);
942
+ }
943
+ if (node.tag_name) {
944
+ this.write(node.tag_name.value);
945
+ }
946
+ this.visitChildNodes(node);
947
+ if (node.tag_closing) {
948
+ this.write(node.tag_closing.value);
949
+ }
950
+ }
951
+ visitHTMLCloseTagNode(node) {
952
+ if (node.tag_opening) {
953
+ this.write(node.tag_opening.value);
954
+ }
955
+ if (node.tag_name) {
956
+ const before = getNodesBeforePosition(node.children, node.tag_name.location.start, true);
957
+ const after = getNodesAfterPosition(node.children, node.tag_name.location.end);
958
+ this.visitAll(before);
959
+ this.write(node.tag_name.value);
960
+ this.visitAll(after);
961
+ }
962
+ else {
963
+ this.visitAll(node.children);
964
+ }
965
+ if (node.tag_closing) {
966
+ this.write(node.tag_closing.value);
967
+ }
968
+ }
969
+ visitHTMLElementNode(node) {
970
+ const tagName = node.tag_name?.value;
971
+ if (tagName) {
972
+ this.context.enterTag(tagName);
973
+ }
974
+ if (node.open_tag) {
975
+ this.visit(node.open_tag);
976
+ }
977
+ if (node.body) {
978
+ node.body.forEach(child => this.visit(child));
979
+ }
980
+ if (node.close_tag) {
981
+ this.visit(node.close_tag);
982
+ }
983
+ if (tagName) {
984
+ this.context.exitTag();
985
+ }
986
+ }
987
+ visitHTMLAttributeNode(node) {
988
+ if (node.name) {
989
+ this.visit(node.name);
990
+ }
991
+ if (node.equals) {
992
+ this.write(node.equals.value);
993
+ }
994
+ if (node.equals && node.value) {
995
+ this.visit(node.value);
996
+ }
997
+ }
998
+ visitHTMLAttributeNameNode(node) {
999
+ this.visitChildNodes(node);
1000
+ }
1001
+ visitHTMLAttributeValueNode(node) {
1002
+ if (node.quoted && node.open_quote) {
1003
+ this.write(node.open_quote.value);
1004
+ }
1005
+ this.visitChildNodes(node);
1006
+ if (node.quoted && node.close_quote) {
1007
+ this.write(node.close_quote.value);
1008
+ }
1009
+ }
1010
+ visitHTMLCommentNode(node) {
1011
+ if (node.comment_start) {
1012
+ this.write(node.comment_start.value);
1013
+ }
1014
+ this.visitChildNodes(node);
1015
+ if (node.comment_end) {
1016
+ this.write(node.comment_end.value);
1017
+ }
1018
+ }
1019
+ visitHTMLDoctypeNode(node) {
1020
+ if (node.tag_opening) {
1021
+ this.write(node.tag_opening.value);
1022
+ }
1023
+ this.visitChildNodes(node);
1024
+ if (node.tag_closing) {
1025
+ this.write(node.tag_closing.value);
1026
+ }
1027
+ }
1028
+ visitXMLDeclarationNode(node) {
1029
+ if (node.tag_opening) {
1030
+ this.write(node.tag_opening.value);
1031
+ }
1032
+ this.visitChildNodes(node);
1033
+ if (node.tag_closing) {
1034
+ this.write(node.tag_closing.value);
1035
+ }
1036
+ }
1037
+ visitCDATANode(node) {
1038
+ if (node.tag_opening) {
1039
+ this.write(node.tag_opening.value);
1040
+ }
1041
+ this.visitChildNodes(node);
1042
+ if (node.tag_closing) {
1043
+ this.write(node.tag_closing.value);
1044
+ }
1045
+ }
1046
+ visitERBContentNode(node) {
1047
+ this.printERBNode(node);
1048
+ }
1049
+ visitERBIfNode(node) {
1050
+ this.printERBNode(node);
1051
+ if (node.statements) {
1052
+ node.statements.forEach(statement => this.visit(statement));
1053
+ }
1054
+ if (node.subsequent) {
1055
+ this.visit(node.subsequent);
1056
+ }
1057
+ if (node.end_node) {
1058
+ this.visit(node.end_node);
1059
+ }
1060
+ }
1061
+ visitERBElseNode(node) {
1062
+ this.printERBNode(node);
1063
+ if (node.statements) {
1064
+ node.statements.forEach(statement => this.visit(statement));
1065
+ }
1066
+ }
1067
+ visitERBEndNode(node) {
1068
+ this.printERBNode(node);
1069
+ }
1070
+ visitERBBlockNode(node) {
1071
+ this.printERBNode(node);
1072
+ if (node.body) {
1073
+ node.body.forEach(child => this.visit(child));
1074
+ }
1075
+ if (node.end_node) {
1076
+ this.visit(node.end_node);
1077
+ }
1078
+ }
1079
+ visitERBCaseNode(node) {
1080
+ this.printERBNode(node);
1081
+ if (node.children) {
1082
+ node.children.forEach(child => this.visit(child));
1083
+ }
1084
+ if (node.conditions) {
1085
+ node.conditions.forEach(condition => this.visit(condition));
1086
+ }
1087
+ if (node.else_clause) {
1088
+ this.visit(node.else_clause);
1089
+ }
1090
+ if (node.end_node) {
1091
+ this.visit(node.end_node);
1092
+ }
1093
+ }
1094
+ visitERBWhenNode(node) {
1095
+ this.printERBNode(node);
1096
+ if (node.statements) {
1097
+ node.statements.forEach(statement => this.visit(statement));
1098
+ }
1099
+ }
1100
+ visitERBWhileNode(node) {
1101
+ this.printERBNode(node);
1102
+ if (node.statements) {
1103
+ node.statements.forEach(statement => this.visit(statement));
1104
+ }
1105
+ if (node.end_node) {
1106
+ this.visit(node.end_node);
1107
+ }
1108
+ }
1109
+ visitERBUntilNode(node) {
1110
+ this.printERBNode(node);
1111
+ if (node.statements) {
1112
+ node.statements.forEach(statement => this.visit(statement));
1113
+ }
1114
+ if (node.end_node) {
1115
+ this.visit(node.end_node);
1116
+ }
1117
+ }
1118
+ visitERBForNode(node) {
1119
+ this.printERBNode(node);
1120
+ if (node.statements) {
1121
+ node.statements.forEach(statement => this.visit(statement));
1122
+ }
1123
+ if (node.end_node) {
1124
+ this.visit(node.end_node);
1125
+ }
1126
+ }
1127
+ visitERBBeginNode(node) {
1128
+ this.printERBNode(node);
1129
+ if (node.statements) {
1130
+ node.statements.forEach(statement => this.visit(statement));
1131
+ }
1132
+ if (node.rescue_clause) {
1133
+ this.visit(node.rescue_clause);
1134
+ }
1135
+ if (node.else_clause) {
1136
+ this.visit(node.else_clause);
1137
+ }
1138
+ if (node.ensure_clause) {
1139
+ this.visit(node.ensure_clause);
1140
+ }
1141
+ if (node.end_node) {
1142
+ this.visit(node.end_node);
1143
+ }
1144
+ }
1145
+ visitERBRescueNode(node) {
1146
+ this.printERBNode(node);
1147
+ if (node.statements) {
1148
+ node.statements.forEach(statement => this.visit(statement));
1149
+ }
1150
+ if (node.subsequent) {
1151
+ this.visit(node.subsequent);
1152
+ }
1153
+ }
1154
+ visitERBEnsureNode(node) {
1155
+ this.printERBNode(node);
1156
+ if (node.statements) {
1157
+ node.statements.forEach(statement => this.visit(statement));
1158
+ }
1159
+ }
1160
+ visitERBUnlessNode(node) {
1161
+ this.printERBNode(node);
1162
+ if (node.statements) {
1163
+ node.statements.forEach(statement => this.visit(statement));
1164
+ }
1165
+ if (node.else_clause) {
1166
+ this.visit(node.else_clause);
1167
+ }
1168
+ if (node.end_node) {
1169
+ this.visit(node.end_node);
1170
+ }
1171
+ }
1172
+ visitERBYieldNode(node) {
1173
+ this.printERBNode(node);
1174
+ }
1175
+ visitERBInNode(node) {
1176
+ this.printERBNode(node);
1177
+ if (node.statements) {
1178
+ node.statements.forEach(statement => this.visit(statement));
1179
+ }
1180
+ }
1181
+ visitERBCaseMatchNode(node) {
1182
+ this.printERBNode(node);
1183
+ if (node.children) {
1184
+ node.children.forEach(child => this.visit(child));
1185
+ }
1186
+ if (node.conditions) {
1187
+ node.conditions.forEach(condition => this.visit(condition));
1188
+ }
1189
+ if (node.else_clause) {
1190
+ this.visit(node.else_clause);
1191
+ }
1192
+ if (node.end_node) {
1193
+ this.visit(node.end_node);
1194
+ }
1195
+ }
1196
+ /**
1197
+ * Print ERB node tags and content
1198
+ */
1199
+ printERBNode(node) {
1200
+ if (node.tag_opening) {
1201
+ this.write(node.tag_opening.value);
1202
+ }
1203
+ if (node.content) {
1204
+ this.write(node.content.value);
1205
+ }
1206
+ if (node.tag_closing) {
1207
+ this.write(node.tag_closing.value);
1208
+ }
1209
+ }
1210
+ }
1211
+
1212
+ const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
1213
+ ...DEFAULT_PRINT_OPTIONS,
1214
+ forceQuotes: false
1215
+ };
1216
+ /**
1217
+ * ERBToRubyStringPrinter - Converts ERB snippets to Ruby strings with interpolation
1218
+ *
1219
+ * This printer transforms ERB templates into Ruby strings by:
1220
+ * - Converting literal text to string content
1221
+ * - Converting <%= %> tags to #{} interpolation
1222
+ * - Converting simple if/else blocks to ternary operators
1223
+ * - Ignoring <% %> tags (they don't produce output)
1224
+ *
1225
+ * Examples:
1226
+ * - `hello world <%= hello %>` => `"hello world #{hello}"`
1227
+ * - `hello world <% hello %>` => `"hello world "`
1228
+ * - `Welcome <%= user.name %>!` => `"Welcome #{user.name}!"`
1229
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>` => `"logged_in? ? "Welcome" : "Login"`
1230
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
1231
+ */
1232
+ class ERBToRubyStringPrinter extends IdentityPrinter {
1233
+ // TODO: cleanup `.type === "AST_*" checks`
1234
+ static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
1235
+ const erbNodes = filterNodes([node], ERBContentNode);
1236
+ if (erbNodes.length === 1 && isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
1237
+ return (erbNodes[0].content?.value || "").trim();
1238
+ }
1239
+ if ('children' in node && Array.isArray(node.children)) {
1240
+ const childErbNodes = filterNodes(node.children, ERBContentNode);
1241
+ const hasOnlyERBContent = node.children.length > 0 && node.children.length === childErbNodes.length;
1242
+ if (hasOnlyERBContent && childErbNodes.length === 1 && isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
1243
+ return (childErbNodes[0].content?.value || "").trim();
1244
+ }
1245
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
1246
+ const ifNode = node.children[0];
1247
+ const printer = new ERBToRubyStringPrinter();
1248
+ if (printer.canConvertToTernary(ifNode)) {
1249
+ printer.convertToTernaryWithoutWrapper(ifNode);
1250
+ return printer.context.getOutput();
1251
+ }
1252
+ }
1253
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
1254
+ const unlessNode = node.children[0];
1255
+ const printer = new ERBToRubyStringPrinter();
1256
+ if (printer.canConvertUnlessToTernary(unlessNode)) {
1257
+ printer.convertUnlessToTernaryWithoutWrapper(unlessNode);
1258
+ return printer.context.getOutput();
1259
+ }
1260
+ }
1261
+ }
1262
+ const printer = new ERBToRubyStringPrinter();
1263
+ printer.context.write('"');
1264
+ printer.visit(node);
1265
+ printer.context.write('"');
1266
+ return printer.context.getOutput();
1267
+ }
1268
+ visitHTMLTextNode(node) {
1269
+ if (node.content) {
1270
+ const escapedContent = node.content.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1271
+ this.context.write(escapedContent);
1272
+ }
1273
+ }
1274
+ visitERBContentNode(node) {
1275
+ if (isERBOutputNode(node)) {
1276
+ this.context.write("#{");
1277
+ if (node.content?.value) {
1278
+ this.context.write(node.content.value.trim());
1279
+ }
1280
+ this.context.write("}");
1281
+ }
1282
+ }
1283
+ visitERBIfNode(node) {
1284
+ if (this.canConvertToTernary(node)) {
1285
+ this.convertToTernary(node);
1286
+ }
1287
+ }
1288
+ visitERBUnlessNode(node) {
1289
+ if (this.canConvertUnlessToTernary(node)) {
1290
+ this.convertUnlessToTernary(node);
1291
+ }
1292
+ }
1293
+ visitHTMLAttributeValueNode(node) {
1294
+ this.visitChildNodes(node);
1295
+ }
1296
+ canConvertToTernary(node) {
1297
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
1298
+ return false;
1299
+ }
1300
+ const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
1301
+ if (!ifOnlyText)
1302
+ return false;
1303
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
1304
+ return node.subsequent.statements
1305
+ ? node.subsequent.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
1306
+ : true;
1307
+ }
1308
+ return true;
1309
+ }
1310
+ convertToTernary(node) {
1311
+ this.context.write("#{");
1312
+ if (node.content?.value) {
1313
+ const condition = node.content.value.trim();
1314
+ const cleanCondition = condition.replace(/^if\s+/, '');
1315
+ const needsParentheses = cleanCondition.includes(' ');
1316
+ if (needsParentheses) {
1317
+ this.context.write("(");
1318
+ }
1319
+ this.context.write(cleanCondition);
1320
+ if (needsParentheses) {
1321
+ this.context.write(")");
1322
+ }
1323
+ }
1324
+ this.context.write(" ? ");
1325
+ this.context.write('"');
1326
+ if (node.statements) {
1327
+ node.statements.forEach(statement => this.visit(statement));
1328
+ }
1329
+ this.context.write('"');
1330
+ this.context.write(" : ");
1331
+ this.context.write('"');
1332
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
1333
+ node.subsequent.statements.forEach(statement => this.visit(statement));
1334
+ }
1335
+ this.context.write('"');
1336
+ this.context.write("}");
1337
+ }
1338
+ convertToTernaryWithoutWrapper(node) {
1339
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
1340
+ return false;
1341
+ }
1342
+ if (node.content?.value) {
1343
+ const condition = node.content.value.trim();
1344
+ const cleanCondition = condition.replace(/^if\s+/, '');
1345
+ const needsParentheses = cleanCondition.includes(' ');
1346
+ if (needsParentheses) {
1347
+ this.context.write("(");
1348
+ }
1349
+ this.context.write(cleanCondition);
1350
+ if (needsParentheses) {
1351
+ this.context.write(")");
1352
+ }
1353
+ }
1354
+ this.context.write(" ? ");
1355
+ this.context.write('"');
1356
+ if (node.statements) {
1357
+ node.statements.forEach(statement => this.visit(statement));
1358
+ }
1359
+ this.context.write('"');
1360
+ this.context.write(" : ");
1361
+ this.context.write('"');
1362
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
1363
+ node.subsequent.statements.forEach(statement => this.visit(statement));
1364
+ }
1365
+ this.context.write('"');
1366
+ }
1367
+ canConvertUnlessToTernary(node) {
1368
+ const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
1369
+ if (!unlessOnlyText)
1370
+ return false;
1371
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1372
+ return node.else_clause.statements
1373
+ ? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
1374
+ : true;
1375
+ }
1376
+ return true;
1377
+ }
1378
+ convertUnlessToTernary(node) {
1379
+ this.context.write("#{");
1380
+ if (node.content?.value) {
1381
+ const condition = node.content.value.trim();
1382
+ const cleanCondition = condition.replace(/^unless\s+/, '');
1383
+ const needsParentheses = cleanCondition.includes(' ');
1384
+ this.context.write("!(");
1385
+ if (needsParentheses) {
1386
+ this.context.write("(");
1387
+ }
1388
+ this.context.write(cleanCondition);
1389
+ if (needsParentheses) {
1390
+ this.context.write(")");
1391
+ }
1392
+ this.context.write(")");
1393
+ }
1394
+ this.context.write(" ? ");
1395
+ this.context.write('"');
1396
+ if (node.statements) {
1397
+ node.statements.forEach(statement => this.visit(statement));
1398
+ }
1399
+ this.context.write('"');
1400
+ this.context.write(" : ");
1401
+ this.context.write('"');
1402
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1403
+ node.else_clause.statements.forEach(statement => this.visit(statement));
1404
+ }
1405
+ this.context.write('"');
1406
+ this.context.write("}");
1407
+ }
1408
+ convertUnlessToTernaryWithoutWrapper(node) {
1409
+ if (node.content?.value) {
1410
+ const condition = node.content.value.trim();
1411
+ const cleanCondition = condition.replace(/^unless\s+/, '');
1412
+ const needsParentheses = cleanCondition.includes(' ');
1413
+ this.context.write("!(");
1414
+ if (needsParentheses) {
1415
+ this.context.write("(");
1416
+ }
1417
+ this.context.write(cleanCondition);
1418
+ if (needsParentheses) {
1419
+ this.context.write(")");
1420
+ }
1421
+ this.context.write(")");
1422
+ }
1423
+ this.context.write(" ? ");
1424
+ this.context.write('"');
1425
+ if (node.statements) {
1426
+ node.statements.forEach(statement => this.visit(statement));
1427
+ }
1428
+ this.context.write('"');
1429
+ this.context.write(" : ");
1430
+ this.context.write('"');
1431
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1432
+ node.else_clause.statements.forEach(statement => this.visit(statement));
1433
+ }
1434
+ this.context.write('"');
1435
+ }
1436
+ }
1437
+
613
1438
  class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
614
1439
  visitHTMLOpenTagNode(node) {
615
1440
  this.checkImgTag(node);
616
1441
  super.visitHTMLOpenTagNode(node);
617
1442
  }
618
- checkImgTag(node) {
619
- const tagName = getTagName(node);
620
- if (tagName !== "img") {
1443
+ checkImgTag(openTag) {
1444
+ const tagName = getTagName(openTag);
1445
+ if (tagName !== "img")
621
1446
  return;
622
- }
623
- const attributes = getAttributes(node);
1447
+ const attributes = getAttributes(openTag);
624
1448
  const srcAttribute = findAttributeByName(attributes, "src");
625
- if (!srcAttribute) {
1449
+ if (!srcAttribute)
626
1450
  return;
627
- }
628
- if (!srcAttribute.value) {
1451
+ if (!srcAttribute.value)
629
1452
  return;
630
- }
631
- const valueNode = srcAttribute.value;
632
- const hasERBContent = this.containsERBContent(valueNode);
1453
+ const node = srcAttribute.value;
1454
+ const hasERBContent = this.containsERBContent(node);
633
1455
  if (hasERBContent) {
634
- const suggestedExpression = this.buildSuggestedExpression(valueNode);
635
- this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
1456
+ if (this.isDataUri(node))
1457
+ return;
1458
+ if (this.shouldFlagAsImageTagCandidate(node)) {
1459
+ const suggestedExpression = this.buildSuggestedExpression(node);
1460
+ this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
1461
+ }
1462
+ }
1463
+ }
1464
+ containsERBContent(node) {
1465
+ return filterNodes(node.children, ERBContentNode).length > 0;
1466
+ }
1467
+ isOnlyERBContent(node) {
1468
+ return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length;
1469
+ }
1470
+ getContentofFirstChild(node) {
1471
+ if (!node.children || node.children.length === 0)
1472
+ return "";
1473
+ const firstChild = node.children[0];
1474
+ if (isNode(firstChild, LiteralNode)) {
1475
+ return (firstChild.content || "").trim();
636
1476
  }
1477
+ return "";
637
1478
  }
638
- containsERBContent(valueNode) {
639
- if (!valueNode.children)
1479
+ isDataUri(node) {
1480
+ return this.getContentofFirstChild(node).startsWith("data:");
1481
+ }
1482
+ isFullUrl(node) {
1483
+ const content = this.getContentofFirstChild(node);
1484
+ return content.startsWith("http://") || content.startsWith("https://");
1485
+ }
1486
+ shouldFlagAsImageTagCandidate(node) {
1487
+ if (this.isOnlyERBContent(node))
1488
+ return true;
1489
+ if (this.isFullUrl(node))
640
1490
  return false;
641
- return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
1491
+ return true;
642
1492
  }
643
- buildSuggestedExpression(valueNode) {
644
- if (!valueNode.children)
1493
+ buildSuggestedExpression(node) {
1494
+ if (!node.children)
645
1495
  return "expression";
646
- let hasText = false;
647
- let hasERB = false;
648
- for (const child of valueNode.children) {
649
- if (child.type === "AST_ERB_CONTENT_NODE") {
650
- hasERB = true;
651
- }
652
- else if (child.type === "AST_LITERAL_NODE") {
653
- const literalNode = child;
654
- if (literalNode.content && literalNode.content.trim()) {
655
- hasText = true;
656
- }
657
- }
658
- }
659
- if (hasText && hasERB) {
660
- let result = '"';
661
- for (const child of valueNode.children) {
662
- if (child.type === "AST_ERB_CONTENT_NODE") {
663
- const erbNode = child;
664
- result += `#{${(erbNode.content?.value || "").trim()}}`;
665
- }
666
- else if (child.type === "AST_LITERAL_NODE") {
667
- const literalNode = child;
668
- result += literalNode.content || "";
669
- }
670
- }
671
- result += '"';
672
- return result;
1496
+ try {
1497
+ return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
673
1498
  }
674
- if (hasERB && !hasText) {
675
- const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE");
676
- if (erbNodes.length === 1) {
677
- return (erbNodes[0].content?.value || "").trim();
678
- }
679
- else if (erbNodes.length > 1) {
680
- let result = '"';
681
- for (const erbNode of erbNodes) {
682
- result += `#{${(erbNode.content?.value || "").trim()}}`;
683
- }
684
- result += '"';
685
- return result;
686
- }
1499
+ catch {
1500
+ return "expression";
687
1501
  }
688
- return "expression";
689
1502
  }
690
1503
  }
691
1504
  class ERBPreferImageTagHelperRule extends ParserRule {
@@ -1067,19 +1880,18 @@ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
1067
1880
  }
1068
1881
 
1069
1882
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
1070
- checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
1071
- if (!isBooleanAttribute(attributeName))
1072
- return;
1073
- if (!hasAttributeValue(attributeNode))
1074
- return;
1075
- this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
1883
+ checkStaticAttributeStaticValue({ originalAttributeName, attributeNode }) {
1884
+ this.checkAttribute(originalAttributeName, attributeNode);
1076
1885
  }
1077
- checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1886
+ checkStaticAttributeDynamicValue({ originalAttributeName, attributeNode }) {
1887
+ this.checkAttribute(originalAttributeName, attributeNode);
1888
+ }
1889
+ checkAttribute(attributeName, attributeNode) {
1078
1890
  if (!isBooleanAttribute(attributeName))
1079
1891
  return;
1080
1892
  if (!hasAttributeValue(attributeNode))
1081
1893
  return;
1082
- this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "error");
1894
+ this.addOffense(`Boolean attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not have a value. Use \`${attributeName.toLowerCase()}\` instead of \`${IdentityPrinter.print(attributeNode)}\`.`, attributeNode.value.location, "error");
1083
1895
  }
1084
1896
  }
1085
1897
  class HTMLBooleanAttributesNoValueRule extends ParserRule {
@@ -1152,47 +1964,6 @@ class HTMLImgRequireAltRule extends ParserRule {
1152
1964
  }
1153
1965
  }
1154
1966
 
1155
- class NavigationHasLabelVisitor extends BaseRuleVisitor {
1156
- visitHTMLOpenTagNode(node) {
1157
- this.checkNavigationElement(node);
1158
- super.visitHTMLOpenTagNode(node);
1159
- }
1160
- checkNavigationElement(node) {
1161
- const tagName = getTagName(node);
1162
- const isNavElement = tagName === "nav";
1163
- const hasNavigationRole = this.hasRoleNavigation(node);
1164
- if (!isNavElement && !hasNavigationRole) {
1165
- return;
1166
- }
1167
- const hasAriaLabel = hasAttribute(node, "aria-label");
1168
- const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
1169
- if (!hasAriaLabel && !hasAriaLabelledby) {
1170
- let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
1171
- if (hasNavigationRole && !isNavElement) {
1172
- message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
1173
- }
1174
- this.addOffense(message, node.tag_name.location, "error");
1175
- }
1176
- }
1177
- hasRoleNavigation(node) {
1178
- const attributes = getAttributes(node);
1179
- const roleAttribute = findAttributeByName(attributes, "role");
1180
- if (!roleAttribute) {
1181
- return false;
1182
- }
1183
- const roleValue = getAttributeValue(roleAttribute);
1184
- return roleValue === "navigation";
1185
- }
1186
- }
1187
- class HTMLNavigationHasLabelRule extends ParserRule {
1188
- name = "html-navigation-has-label";
1189
- check(result, context) {
1190
- const visitor = new NavigationHasLabelVisitor(this.name, context);
1191
- visitor.visit(result.value);
1192
- return visitor.offenses;
1193
- }
1194
- }
1195
-
1196
1967
  const INTERACTIVE_ELEMENTS = new Set([
1197
1968
  "button", "summary", "input", "select", "textarea", "a"
1198
1969
  ]);
@@ -1297,19 +2068,141 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
1297
2068
  }
1298
2069
  }
1299
2070
 
1300
- class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
2071
+ class OutputPrinter extends Printer {
2072
+ visitLiteralNode(node) {
2073
+ this.write(IdentityPrinter.print(node));
2074
+ }
2075
+ visitERBContentNode(node) {
2076
+ if (isERBOutputNode(node)) {
2077
+ this.write(IdentityPrinter.print(node));
2078
+ }
2079
+ }
2080
+ }
2081
+ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
1301
2082
  documentIds = new Set();
1302
- checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
1303
- if (attributeName.toLowerCase() !== "id")
2083
+ currentBranchIds = new Set();
2084
+ controlFlowIds = new Set();
2085
+ visitHTMLAttributeNode(node) {
2086
+ this.checkAttribute(node);
2087
+ }
2088
+ onEnterControlFlow(_controlFlowType, wasAlreadyInControlFlow) {
2089
+ const stateToRestore = {
2090
+ previousBranchIds: this.currentBranchIds,
2091
+ previousControlFlowIds: this.controlFlowIds
2092
+ };
2093
+ this.currentBranchIds = new Set();
2094
+ if (!wasAlreadyInControlFlow) {
2095
+ this.controlFlowIds = new Set();
2096
+ }
2097
+ return stateToRestore;
2098
+ }
2099
+ onExitControlFlow(controlFlowType, wasAlreadyInControlFlow, stateToRestore) {
2100
+ if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
2101
+ this.controlFlowIds.forEach(id => this.documentIds.add(id));
2102
+ }
2103
+ this.currentBranchIds = stateToRestore.previousBranchIds;
2104
+ this.controlFlowIds = stateToRestore.previousControlFlowIds;
2105
+ }
2106
+ onEnterBranch() {
2107
+ const stateToRestore = {
2108
+ previousBranchIds: this.currentBranchIds
2109
+ };
2110
+ if (this.isInControlFlow) {
2111
+ this.currentBranchIds = new Set();
2112
+ }
2113
+ return stateToRestore;
2114
+ }
2115
+ onExitBranch(_stateToRestore) { }
2116
+ checkAttribute(attributeNode) {
2117
+ if (!this.isIdAttribute(attributeNode))
1304
2118
  return;
1305
- if (!attributeValue)
2119
+ const idValue = this.extractIdValue(attributeNode);
2120
+ if (!idValue)
2121
+ return;
2122
+ if (this.isWhitespaceOnlyId(idValue.identifier))
2123
+ return;
2124
+ this.processIdDuplicate(idValue, attributeNode);
2125
+ }
2126
+ isIdAttribute(attributeNode) {
2127
+ if (!attributeNode.name?.children || !attributeNode.value)
2128
+ return false;
2129
+ return getStaticAttributeName(attributeNode.name) === "id";
2130
+ }
2131
+ extractIdValue(attributeNode) {
2132
+ const valueNodes = attributeNode.value?.children || [];
2133
+ if (hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
2134
+ return null;
2135
+ }
2136
+ const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
2137
+ if (!identifier)
2138
+ return null;
2139
+ return { identifier, shouldTrackDuplicates: true };
2140
+ }
2141
+ isWhitespaceOnlyId(identifier) {
2142
+ return identifier !== '' && identifier.trim() === '';
2143
+ }
2144
+ processIdDuplicate(idValue, attributeNode) {
2145
+ const { identifier, shouldTrackDuplicates } = idValue;
2146
+ if (!shouldTrackDuplicates)
1306
2147
  return;
1307
- const id = attributeValue.trim();
1308
- if (this.documentIds.has(id)) {
1309
- this.addOffense(`Duplicate ID \`${id}\` found. IDs must be unique within a document.`, attributeNode.location, "error");
2148
+ if (this.isInControlFlow) {
2149
+ this.handleControlFlowId(identifier, attributeNode);
2150
+ }
2151
+ else {
2152
+ this.handleGlobalId(identifier, attributeNode);
2153
+ }
2154
+ }
2155
+ handleControlFlowId(identifier, attributeNode) {
2156
+ if (this.currentControlFlowType === ControlFlowType.LOOP) {
2157
+ this.handleLoopId(identifier, attributeNode);
2158
+ }
2159
+ else {
2160
+ this.handleConditionalId(identifier, attributeNode);
2161
+ }
2162
+ this.currentBranchIds.add(identifier);
2163
+ }
2164
+ handleLoopId(identifier, attributeNode) {
2165
+ const isStaticId = this.isStaticId(attributeNode);
2166
+ if (isStaticId) {
2167
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2168
+ return;
2169
+ }
2170
+ if (this.currentBranchIds.has(identifier)) {
2171
+ this.addSameLoopIterationOffense(identifier, attributeNode.location);
2172
+ }
2173
+ }
2174
+ handleConditionalId(identifier, attributeNode) {
2175
+ if (this.currentBranchIds.has(identifier)) {
2176
+ this.addSameBranchOffense(identifier, attributeNode.location);
2177
+ return;
2178
+ }
2179
+ if (this.documentIds.has(identifier)) {
2180
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2181
+ return;
2182
+ }
2183
+ this.controlFlowIds.add(identifier);
2184
+ }
2185
+ handleGlobalId(identifier, attributeNode) {
2186
+ if (this.documentIds.has(identifier)) {
2187
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
1310
2188
  return;
1311
2189
  }
1312
- this.documentIds.add(id);
2190
+ this.documentIds.add(identifier);
2191
+ }
2192
+ isStaticId(attributeNode) {
2193
+ const valueNodes = attributeNode.value.children;
2194
+ const isCompletelyStatic = valueNodes.every(child => isNode(child, LiteralNode));
2195
+ const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes);
2196
+ return isCompletelyStatic || isEffectivelyStaticValue;
2197
+ }
2198
+ addDuplicateIdOffense(identifier, location) {
2199
+ this.addOffense(`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`, location, "error");
2200
+ }
2201
+ addSameLoopIterationOffense(identifier, location) {
2202
+ this.addOffense(`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`, location, "error");
2203
+ }
2204
+ addSameBranchOffense(identifier, location) {
2205
+ this.addOffense(`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`, location, "error");
1313
2206
  }
1314
2207
  }
1315
2208
  class HTMLNoDuplicateIdsRule extends ParserRule {
@@ -1321,6 +2214,60 @@ class HTMLNoDuplicateIdsRule extends ParserRule {
1321
2214
  }
1322
2215
  }
1323
2216
 
2217
+ // Attributes that must not have empty values
2218
+ const RESTRICTED_ATTRIBUTES = new Set([
2219
+ 'id',
2220
+ 'class',
2221
+ 'name',
2222
+ 'for',
2223
+ 'src',
2224
+ 'href',
2225
+ 'title',
2226
+ 'data',
2227
+ 'role'
2228
+ ]);
2229
+ // Check if attribute name matches any restricted patterns
2230
+ function isRestrictedAttribute(attributeName) {
2231
+ // Check direct matches
2232
+ if (RESTRICTED_ATTRIBUTES.has(attributeName)) {
2233
+ return true;
2234
+ }
2235
+ // Check for data-* attributes
2236
+ if (attributeName.startsWith('data-')) {
2237
+ return true;
2238
+ }
2239
+ // Check for aria-* attributes
2240
+ if (attributeName.startsWith('aria-')) {
2241
+ return true;
2242
+ }
2243
+ return false;
2244
+ }
2245
+ class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
2246
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
2247
+ if (!isRestrictedAttribute(attributeName))
2248
+ return;
2249
+ if (attributeValue.trim() !== "")
2250
+ return;
2251
+ this.addOffense(`Attribute \`${attributeName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`, attributeNode.name.location, "warning");
2252
+ }
2253
+ checkDynamicAttributeStaticValue({ combinedName, attributeValue, attributeNode }) {
2254
+ const name = (combinedName || "").toLowerCase();
2255
+ if (!isRestrictedAttribute(name))
2256
+ return;
2257
+ if (attributeValue.trim() !== "")
2258
+ return;
2259
+ this.addOffense(`Attribute \`${combinedName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`, attributeNode.name.location, "warning");
2260
+ }
2261
+ }
2262
+ class HTMLNoEmptyAttributesRule extends ParserRule {
2263
+ name = "html-no-empty-attributes";
2264
+ check(result, context) {
2265
+ const visitor = new NoEmptyAttributesVisitor(this.name, context);
2266
+ visitor.visit(result.value);
2267
+ return visitor.offenses;
2268
+ }
2269
+ }
2270
+
1324
2271
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
1325
2272
  visitHTMLElementNode(node) {
1326
2273
  this.checkHeadingElement(node);
@@ -1497,7 +2444,7 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
1497
2444
  return;
1498
2445
  const tabIndexValue = parseInt(attributeValue, 10);
1499
2446
  if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
1500
- this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex=\"-1\"\` to remove it from the tab sequence.`, attributeNode.location, "error");
2447
+ this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex="-1"\` to remove it from the tab sequence.`, attributeNode.location, "error");
1501
2448
  }
1502
2449
  }
1503
2450
  }
@@ -1511,14 +2458,20 @@ class HTMLNoPositiveTabIndexRule extends ParserRule {
1511
2458
  }
1512
2459
 
1513
2460
  class NoSelfClosingVisitor extends BaseRuleVisitor {
2461
+ visitHTMLElementNode(node) {
2462
+ if (getTagName$1(node) === "svg") {
2463
+ this.visit(node.open_tag);
2464
+ }
2465
+ else {
2466
+ this.visitChildNodes(node);
2467
+ }
2468
+ }
1514
2469
  visitHTMLOpenTagNode(node) {
1515
2470
  if (node.tag_closing?.value === "/>") {
1516
- const tagName = getTagName(node);
1517
- const shouldBeVoid = tagName ? isVoidElement(tagName) : false;
1518
- const instead = shouldBeVoid ? `Use \`<${tagName}>\` instead.` : `Use \`<${tagName}></${tagName}>\` instead.`;
1519
- this.addOffense(`Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`, node.location, "error");
2471
+ const tagName = getTagName$1(node);
2472
+ const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
2473
+ this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, "error");
1520
2474
  }
1521
- super.visitHTMLOpenTagNode(node);
1522
2475
  }
1523
2476
  }
1524
2477
  class HTMLNoSelfClosingRule extends ParserRule {
@@ -1530,31 +2483,6 @@ class HTMLNoSelfClosingRule extends ParserRule {
1530
2483
  }
1531
2484
  }
1532
2485
 
1533
- class NoTitleAttributeVisitor extends BaseRuleVisitor {
1534
- ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
1535
- visitHTMLOpenTagNode(node) {
1536
- this.checkTitleAttribute(node);
1537
- super.visitHTMLOpenTagNode(node);
1538
- }
1539
- checkTitleAttribute(node) {
1540
- const tagName = getTagName(node);
1541
- if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
1542
- return;
1543
- }
1544
- if (hasAttribute(node, "title")) {
1545
- this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
1546
- }
1547
- }
1548
- }
1549
- class HTMLNoTitleAttributeRule extends ParserRule {
1550
- name = "html-no-title-attribute";
1551
- check(result, context) {
1552
- const visitor = new NoTitleAttributeVisitor(this.name, context);
1553
- visitor.visit(result.value);
1554
- return visitor.offenses;
1555
- }
1556
- }
1557
-
1558
2486
  class XMLDeclarationChecker extends BaseRuleVisitor {
1559
2487
  hasXMLDeclaration = false;
1560
2488
  visitXMLDeclarationNode(_node) {
@@ -1661,9 +2589,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
1661
2589
  const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
1662
2590
  if (correctCamelCase && tagName !== correctCamelCase) {
1663
2591
  let type = node.type;
1664
- if (node.type == "AST_HTML_OPEN_TAG_NODE")
2592
+ if (node.type === "AST_HTML_OPEN_TAG_NODE")
1665
2593
  type = "Opening";
1666
- if (node.type == "AST_HTML_CLOSE_TAG_NODE")
2594
+ if (node.type === "AST_HTML_CLOSE_TAG_NODE")
1667
2595
  type = "Closing";
1668
2596
  this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
1669
2597
  }
@@ -1678,6 +2606,38 @@ class SVGTagNameCapitalizationRule extends ParserRule {
1678
2606
  }
1679
2607
  }
1680
2608
 
2609
+ class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
2610
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
2611
+ this.check(attributeName, attributeNode);
2612
+ }
2613
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
2614
+ this.check(attributeName, attributeNode);
2615
+ }
2616
+ checkDynamicAttributeStaticValue({ nameNodes, attributeNode }) {
2617
+ const attributeName = getStaticContentFromNodes(nameNodes);
2618
+ this.check(attributeName, attributeNode);
2619
+ }
2620
+ checkDynamicAttributeDynamicValue({ nameNodes, attributeNode }) {
2621
+ const attributeName = getStaticContentFromNodes(nameNodes);
2622
+ this.check(attributeName, attributeNode);
2623
+ }
2624
+ check(attributeName, attributeNode) {
2625
+ if (!attributeName)
2626
+ return;
2627
+ if (attributeName.includes("_")) {
2628
+ this.addOffense(`Attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not contain underscores. Use hyphens (-) instead.`, attributeNode.value.location, "warning");
2629
+ }
2630
+ }
2631
+ }
2632
+ class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
2633
+ name = "html-no-underscores-in-attribute-names";
2634
+ check(result, context) {
2635
+ const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.name, context);
2636
+ visitor.visit(result.value);
2637
+ return visitor.offenses;
2638
+ }
2639
+ }
2640
+
1681
2641
  const defaultRules = [
1682
2642
  ERBNoEmptyTagsRule,
1683
2643
  ERBNoOutputControlFlowRule,
@@ -1698,19 +2658,21 @@ const defaultRules = [
1698
2658
  HTMLBooleanAttributesNoValueRule,
1699
2659
  HTMLIframeHasTitleRule,
1700
2660
  HTMLImgRequireAltRule,
1701
- HTMLNavigationHasLabelRule,
2661
+ // HTMLNavigationHasLabelRule,
1702
2662
  HTMLNoAriaHiddenOnFocusableRule,
1703
2663
  // HTMLNoBlockInsideInlineRule,
1704
2664
  HTMLNoDuplicateAttributesRule,
1705
2665
  HTMLNoDuplicateIdsRule,
2666
+ HTMLNoEmptyAttributesRule,
1706
2667
  HTMLNoEmptyHeadingsRule,
1707
2668
  HTMLNoNestedLinksRule,
1708
2669
  HTMLNoPositiveTabIndexRule,
1709
2670
  HTMLNoSelfClosingRule,
1710
- HTMLNoTitleAttributeRule,
2671
+ // HTMLNoTitleAttributeRule,
1711
2672
  HTMLTagNameLowercaseRule,
1712
2673
  ParserNoErrorsRule,
1713
2674
  SVGTagNameCapitalizationRule,
2675
+ HTMLNoUnderscoresInAttributeNamesRule,
1714
2676
  ];
1715
2677
 
1716
2678
  class Linter {
@@ -1807,6 +2769,47 @@ class Linter {
1807
2769
  }
1808
2770
  }
1809
2771
 
2772
+ class NavigationHasLabelVisitor extends BaseRuleVisitor {
2773
+ visitHTMLOpenTagNode(node) {
2774
+ this.checkNavigationElement(node);
2775
+ super.visitHTMLOpenTagNode(node);
2776
+ }
2777
+ checkNavigationElement(node) {
2778
+ const tagName = getTagName(node);
2779
+ const isNavElement = tagName === "nav";
2780
+ const hasNavigationRole = this.hasRoleNavigation(node);
2781
+ if (!isNavElement && !hasNavigationRole) {
2782
+ return;
2783
+ }
2784
+ const hasAriaLabel = hasAttribute(node, "aria-label");
2785
+ const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
2786
+ if (!hasAriaLabel && !hasAriaLabelledby) {
2787
+ let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
2788
+ if (hasNavigationRole && !isNavElement) {
2789
+ message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
2790
+ }
2791
+ this.addOffense(message, node.tag_name.location, "error");
2792
+ }
2793
+ }
2794
+ hasRoleNavigation(node) {
2795
+ const attributes = getAttributes(node);
2796
+ const roleAttribute = findAttributeByName(attributes, "role");
2797
+ if (!roleAttribute) {
2798
+ return false;
2799
+ }
2800
+ const roleValue = getAttributeValue(roleAttribute);
2801
+ return roleValue === "navigation";
2802
+ }
2803
+ }
2804
+ class HTMLNavigationHasLabelRule extends ParserRule {
2805
+ name = "html-navigation-has-label";
2806
+ check(result, context) {
2807
+ const visitor = new NavigationHasLabelVisitor(this.name, context);
2808
+ visitor.visit(result.value);
2809
+ return visitor.offenses;
2810
+ }
2811
+ }
2812
+
1810
2813
  class BlockInsideInlineVisitor extends BaseRuleVisitor {
1811
2814
  inlineStack = [];
1812
2815
  isValidHTMLOpenTag(node) {
@@ -1865,5 +2868,30 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
1865
2868
  }
1866
2869
  }
1867
2870
 
1868
- export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBooleanAttributesNoValueRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoTitleAttributeRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
2871
+ class NoTitleAttributeVisitor extends BaseRuleVisitor {
2872
+ ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
2873
+ visitHTMLOpenTagNode(node) {
2874
+ this.checkTitleAttribute(node);
2875
+ super.visitHTMLOpenTagNode(node);
2876
+ }
2877
+ checkTitleAttribute(node) {
2878
+ const tagName = getTagName(node);
2879
+ if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
2880
+ return;
2881
+ }
2882
+ if (hasAttribute(node, "title")) {
2883
+ this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
2884
+ }
2885
+ }
2886
+ }
2887
+ class HTMLNoTitleAttributeRule extends ParserRule {
2888
+ name = "html-no-title-attribute";
2889
+ check(result, context) {
2890
+ const visitor = new NoTitleAttributeVisitor(this.name, context);
2891
+ visitor.visit(result.value);
2892
+ return visitor.offenses;
2893
+ }
2894
+ }
2895
+
2896
+ export { ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HEADING_TAGS, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBooleanAttributesNoValueRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_VOID_ELEMENTS, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findAttributeByName, forEachAttribute, getAttribute, getAttributeName, getAttributeValue, getAttributeValueNodes, getAttributeValueQuoteType, getAttributes, getCombinedAttributeNameString, getStaticAttributeValue, getStaticAttributeValueContent, getTagName, hasAttribute, hasAttributeValue, hasDynamicAttributeName, hasDynamicAttributeValue, hasStaticAttributeValue, hasStaticAttributeValueContent, isAttributeValueQuoted, isBlockElement, isBooleanAttribute, isInlineElement, isVoidElement };
1869
2897
  //# sourceMappingURL=index.js.map