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