@herb-tools/linter 0.6.1 → 0.7.1

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 (93) hide show
  1. package/README.md +60 -16
  2. package/dist/herb-lint.js +364 -181
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +321 -100
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +270 -89
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +11 -5
  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 +1 -1
  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-empty-attributes.js +56 -0
  33. package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
  34. package/dist/src/rules/html-no-positive-tab-index.js +1 -1
  35. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
  36. package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
  37. package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
  38. package/dist/src/rules/index.js +3 -0
  39. package/dist/src/rules/index.js.map +1 -1
  40. package/dist/src/rules/rule-utils.js +11 -7
  41. package/dist/src/rules/rule-utils.js.map +1 -1
  42. package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
  43. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/dist/types/cli/argument-parser.d.ts +2 -1
  46. package/dist/types/cli/file-processor.d.ts +6 -1
  47. package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
  48. package/dist/types/cli/index.d.ts +1 -0
  49. package/dist/types/cli/output-manager.d.ts +1 -0
  50. package/dist/types/cli.d.ts +20 -5
  51. package/dist/types/linter.d.ts +7 -7
  52. package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
  53. package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  54. package/dist/types/rules/index.d.ts +3 -0
  55. package/dist/types/rules/rule-utils.d.ts +7 -5
  56. package/dist/types/src/cli/argument-parser.d.ts +2 -1
  57. package/dist/types/src/cli/file-processor.d.ts +6 -1
  58. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
  59. package/dist/types/src/cli/index.d.ts +1 -0
  60. package/dist/types/src/cli/output-manager.d.ts +1 -0
  61. package/dist/types/src/cli.d.ts +20 -5
  62. package/dist/types/src/linter.d.ts +7 -7
  63. package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
  64. package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  65. package/dist/types/src/rules/index.d.ts +3 -0
  66. package/dist/types/src/rules/rule-utils.d.ts +7 -5
  67. package/docs/rules/README.md +2 -0
  68. package/docs/rules/html-img-require-alt.md +0 -2
  69. package/docs/rules/html-no-empty-attributes.md +77 -0
  70. package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
  71. package/package.json +11 -5
  72. package/src/cli/argument-parser.ts +15 -7
  73. package/src/cli/file-processor.ts +11 -7
  74. package/src/cli/formatters/detailed-formatter.ts +5 -7
  75. package/src/cli/formatters/github-actions-formatter.ts +64 -11
  76. package/src/cli/index.ts +2 -0
  77. package/src/cli/output-manager.ts +27 -5
  78. package/src/cli/summary-reporter.ts +3 -11
  79. package/src/cli.ts +125 -20
  80. package/src/default-rules.ts +8 -4
  81. package/src/linter.ts +6 -6
  82. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
  83. package/src/rules/erb-prefer-image-tag-helper.ts +2 -2
  84. package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
  85. package/src/rules/html-attribute-double-quotes.ts +1 -1
  86. package/src/rules/html-boolean-attributes-no-value.ts +9 -11
  87. package/src/rules/html-no-empty-attributes.ts +75 -0
  88. package/src/rules/html-no-positive-tab-index.ts +1 -1
  89. package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
  90. package/src/rules/html-tag-name-lowercase.ts +1 -1
  91. package/src/rules/index.ts +3 -0
  92. package/src/rules/rule-utils.ts +15 -11
  93. package/src/rules/svg-tag-name-capitalization.ts +2 -2
package/dist/index.cjs CHANGED
@@ -18,11 +18,11 @@ class SourceRule {
18
18
  static type = "source";
19
19
  }
20
20
 
21
- var ControlFlowType;
21
+ exports.ControlFlowType = void 0;
22
22
  (function (ControlFlowType) {
23
23
  ControlFlowType[ControlFlowType["CONDITIONAL"] = 0] = "CONDITIONAL";
24
24
  ControlFlowType[ControlFlowType["LOOP"] = 1] = "LOOP";
25
- })(ControlFlowType || (ControlFlowType = {}));
25
+ })(exports.ControlFlowType || (exports.ControlFlowType = {}));
26
26
  /**
27
27
  * Base visitor class that provides common functionality for rule visitors
28
28
  */
@@ -89,28 +89,28 @@ class ControlFlowTrackingVisitor extends BaseRuleVisitor {
89
89
  this.onExitBranch(stateToRestore);
90
90
  }
91
91
  visitERBIfNode(node) {
92
- this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
92
+ this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
93
93
  }
94
94
  visitERBUnlessNode(node) {
95
- this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
95
+ this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
96
96
  }
97
97
  visitERBCaseNode(node) {
98
- this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
98
+ this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
99
99
  }
100
100
  visitERBCaseMatchNode(node) {
101
- this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
101
+ this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
102
102
  }
103
103
  visitERBWhileNode(node) {
104
- this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
104
+ this.handleControlFlowNode(node, exports.ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
105
105
  }
106
106
  visitERBForNode(node) {
107
- this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node));
107
+ this.handleControlFlowNode(node, exports.ControlFlowType.LOOP, () => super.visitERBForNode(node));
108
108
  }
109
109
  visitERBUntilNode(node) {
110
- this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
110
+ this.handleControlFlowNode(node, exports.ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
111
111
  }
112
112
  visitERBBlockNode(node) {
113
- this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
113
+ this.handleControlFlowNode(node, exports.ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
114
114
  }
115
115
  visitERBElseNode(node) {
116
116
  this.startNewBranch(() => super.visitERBElseNode(node));
@@ -135,10 +135,12 @@ function getTagName(node) {
135
135
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
136
136
  * Returns null if the attribute name contains dynamic content (ERB)
137
137
  */
138
- function getAttributeName(attributeNode) {
138
+ function getAttributeName(attributeNode, lowercase = true) {
139
139
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
140
140
  const nameNode = attributeNode.name;
141
141
  const staticName = core.getStaticAttributeName(nameNode);
142
+ if (!lowercase)
143
+ return staticName;
142
144
  return staticName ? staticName.toLowerCase() : null;
143
145
  }
144
146
  return null;
@@ -173,6 +175,15 @@ function hasStaticAttributeValue(attributeNode) {
173
175
  return false;
174
176
  return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
175
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
+ }
176
187
  /**
177
188
  * Gets the static string value of an attribute (returns null if it contains ERB)
178
189
  */
@@ -193,6 +204,21 @@ function getAttributeValueNodes(attributeNode) {
193
204
  const valueNode = attributeNode.value;
194
205
  return valueNode?.children || [];
195
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
+ }
196
222
  /**
197
223
  * Gets the attribute value content from an HTMLAttributeValueNode
198
224
  */
@@ -467,6 +493,7 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
467
493
  checkAttributesOnNode(node) {
468
494
  forEachAttribute(node, (attributeNode) => {
469
495
  const staticAttributeName = getAttributeName(attributeNode);
496
+ const originalAttributeName = getAttributeName(attributeNode, false) || "";
470
497
  const isDynamicName = hasDynamicAttributeName(attributeNode);
471
498
  const staticAttributeValue = getStaticAttributeValue(attributeNode);
472
499
  const valueNodes = getAttributeValueNodes(attributeNode);
@@ -477,16 +504,17 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
477
504
  attributeName: staticAttributeName,
478
505
  attributeValue: staticAttributeValue,
479
506
  attributeNode,
507
+ originalAttributeName,
480
508
  parentNode: node
481
509
  });
482
510
  }
483
511
  else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
484
512
  const validatableContent = core.getValidatableStaticContent(valueNodes) || "";
485
- this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
513
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, originalAttributeName, parentNode: node });
486
514
  }
487
515
  else if (staticAttributeName && hasOutputERB) {
488
516
  const combinedValue = getAttributeValue(attributeNode);
489
- this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
517
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, originalAttributeName, combinedValue });
490
518
  }
491
519
  else if (isDynamicName && staticAttributeValue !== null) {
492
520
  const nameNode = attributeNode.name;
@@ -506,28 +534,38 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
506
534
  /**
507
535
  * Static attribute name with static value: class="container"
508
536
  */
509
- checkStaticAttributeStaticValue(params) {
537
+ checkStaticAttributeStaticValue(_params) {
510
538
  // Default implementation does nothing
511
539
  }
512
540
  /**
513
541
  * Static attribute name with dynamic value: class="<%= css_class %>"
514
542
  */
515
- checkStaticAttributeDynamicValue(params) {
543
+ checkStaticAttributeDynamicValue(_params) {
516
544
  // Default implementation does nothing
517
545
  }
518
546
  /**
519
547
  * Dynamic attribute name with static value: data-<%= key %>="foo"
520
548
  */
521
- checkDynamicAttributeStaticValue(params) {
549
+ checkDynamicAttributeStaticValue(_params) {
522
550
  // Default implementation does nothing
523
551
  }
524
552
  /**
525
553
  * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
526
554
  */
527
- checkDynamicAttributeDynamicValue(params) {
555
+ checkDynamicAttributeDynamicValue(_params) {
528
556
  // Default implementation does nothing
529
557
  }
530
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
+ }
531
569
  /**
532
570
  * Iterates over all attributes of a tag node, calling the callback for each attribute
533
571
  */
@@ -539,6 +577,60 @@ function forEachAttribute(node, callback) {
539
577
  }
540
578
  }
541
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
+ }
542
634
  /**
543
635
  * Base source visitor class that provides common functionality for source-based rule visitors
544
636
  */
@@ -802,6 +894,8 @@ class Printer extends core.Visitor {
802
894
  * @throws {Error} When node has parse errors and ignoreErrors is false
803
895
  */
804
896
  print(input, options = DEFAULT_PRINT_OPTIONS) {
897
+ if (!input)
898
+ return "";
805
899
  if (core.isToken(input)) {
806
900
  return input.value;
807
901
  }
@@ -1404,7 +1498,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
1404
1498
  try {
1405
1499
  return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
1406
1500
  }
1407
- catch (error) {
1501
+ catch {
1408
1502
  return "expression";
1409
1503
  }
1410
1504
  }
@@ -1788,19 +1882,18 @@ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
1788
1882
  }
1789
1883
 
1790
1884
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
1791
- checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
1792
- if (!isBooleanAttribute(attributeName))
1793
- return;
1794
- if (!hasAttributeValue(attributeNode))
1795
- return;
1796
- 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);
1797
1887
  }
1798
- checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1888
+ checkStaticAttributeDynamicValue({ originalAttributeName, attributeNode }) {
1889
+ this.checkAttribute(originalAttributeName, attributeNode);
1890
+ }
1891
+ checkAttribute(attributeName, attributeNode) {
1799
1892
  if (!isBooleanAttribute(attributeName))
1800
1893
  return;
1801
1894
  if (!hasAttributeValue(attributeNode))
1802
1895
  return;
1803
- 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");
1804
1897
  }
1805
1898
  }
1806
1899
  class HTMLBooleanAttributesNoValueRule extends ParserRule {
@@ -1873,47 +1966,6 @@ class HTMLImgRequireAltRule extends ParserRule {
1873
1966
  }
1874
1967
  }
1875
1968
 
1876
- class NavigationHasLabelVisitor extends BaseRuleVisitor {
1877
- visitHTMLOpenTagNode(node) {
1878
- this.checkNavigationElement(node);
1879
- super.visitHTMLOpenTagNode(node);
1880
- }
1881
- checkNavigationElement(node) {
1882
- const tagName = getTagName(node);
1883
- const isNavElement = tagName === "nav";
1884
- const hasNavigationRole = this.hasRoleNavigation(node);
1885
- if (!isNavElement && !hasNavigationRole) {
1886
- return;
1887
- }
1888
- const hasAriaLabel = hasAttribute(node, "aria-label");
1889
- const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
1890
- if (!hasAriaLabel && !hasAriaLabelledby) {
1891
- 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.`;
1892
- if (hasNavigationRole && !isNavElement) {
1893
- message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
1894
- }
1895
- this.addOffense(message, node.tag_name.location, "error");
1896
- }
1897
- }
1898
- hasRoleNavigation(node) {
1899
- const attributes = getAttributes(node);
1900
- const roleAttribute = findAttributeByName(attributes, "role");
1901
- if (!roleAttribute) {
1902
- return false;
1903
- }
1904
- const roleValue = getAttributeValue(roleAttribute);
1905
- return roleValue === "navigation";
1906
- }
1907
- }
1908
- class HTMLNavigationHasLabelRule extends ParserRule {
1909
- name = "html-navigation-has-label";
1910
- check(result, context) {
1911
- const visitor = new NavigationHasLabelVisitor(this.name, context);
1912
- visitor.visit(result.value);
1913
- return visitor.offenses;
1914
- }
1915
- }
1916
-
1917
1969
  const INTERACTIVE_ELEMENTS = new Set([
1918
1970
  "button", "summary", "input", "select", "textarea", "a"
1919
1971
  ]);
@@ -2047,7 +2099,7 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
2047
2099
  return stateToRestore;
2048
2100
  }
2049
2101
  onExitControlFlow(controlFlowType, wasAlreadyInControlFlow, stateToRestore) {
2050
- if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
2102
+ if (controlFlowType === exports.ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
2051
2103
  this.controlFlowIds.forEach(id => this.documentIds.add(id));
2052
2104
  }
2053
2105
  this.currentBranchIds = stateToRestore.previousBranchIds;
@@ -2080,7 +2132,7 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
2080
2132
  }
2081
2133
  extractIdValue(attributeNode) {
2082
2134
  const valueNodes = attributeNode.value?.children || [];
2083
- if (core.hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
2135
+ if (core.hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === exports.ControlFlowType.LOOP) {
2084
2136
  return null;
2085
2137
  }
2086
2138
  const identifier = core.isEffectivelyStatic(valueNodes) ? core.getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
@@ -2103,7 +2155,7 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
2103
2155
  }
2104
2156
  }
2105
2157
  handleControlFlowId(identifier, attributeNode) {
2106
- if (this.currentControlFlowType === ControlFlowType.LOOP) {
2158
+ if (this.currentControlFlowType === exports.ControlFlowType.LOOP) {
2107
2159
  this.handleLoopId(identifier, attributeNode);
2108
2160
  }
2109
2161
  else {
@@ -2164,6 +2216,60 @@ class HTMLNoDuplicateIdsRule extends ParserRule {
2164
2216
  }
2165
2217
  }
2166
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
+
2167
2273
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
2168
2274
  visitHTMLElementNode(node) {
2169
2275
  this.checkHeadingElement(node);
@@ -2340,7 +2446,7 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
2340
2446
  return;
2341
2447
  const tabIndexValue = parseInt(attributeValue, 10);
2342
2448
  if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
2343
- 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");
2344
2450
  }
2345
2451
  }
2346
2452
  }
@@ -2379,31 +2485,6 @@ class HTMLNoSelfClosingRule extends ParserRule {
2379
2485
  }
2380
2486
  }
2381
2487
 
2382
- class NoTitleAttributeVisitor extends BaseRuleVisitor {
2383
- ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
2384
- visitHTMLOpenTagNode(node) {
2385
- this.checkTitleAttribute(node);
2386
- super.visitHTMLOpenTagNode(node);
2387
- }
2388
- checkTitleAttribute(node) {
2389
- const tagName = getTagName(node);
2390
- if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
2391
- return;
2392
- }
2393
- if (hasAttribute(node, "title")) {
2394
- 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");
2395
- }
2396
- }
2397
- }
2398
- class HTMLNoTitleAttributeRule extends ParserRule {
2399
- name = "html-no-title-attribute";
2400
- check(result, context) {
2401
- const visitor = new NoTitleAttributeVisitor(this.name, context);
2402
- visitor.visit(result.value);
2403
- return visitor.offenses;
2404
- }
2405
- }
2406
-
2407
2488
  class XMLDeclarationChecker extends BaseRuleVisitor {
2408
2489
  hasXMLDeclaration = false;
2409
2490
  visitXMLDeclarationNode(_node) {
@@ -2510,9 +2591,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
2510
2591
  const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
2511
2592
  if (correctCamelCase && tagName !== correctCamelCase) {
2512
2593
  let type = node.type;
2513
- if (node.type == "AST_HTML_OPEN_TAG_NODE")
2594
+ if (node.type === "AST_HTML_OPEN_TAG_NODE")
2514
2595
  type = "Opening";
2515
- if (node.type == "AST_HTML_CLOSE_TAG_NODE")
2596
+ if (node.type === "AST_HTML_CLOSE_TAG_NODE")
2516
2597
  type = "Closing";
2517
2598
  this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
2518
2599
  }
@@ -2527,6 +2608,38 @@ class SVGTagNameCapitalizationRule extends ParserRule {
2527
2608
  }
2528
2609
  }
2529
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
+
2530
2643
  const defaultRules = [
2531
2644
  ERBNoEmptyTagsRule,
2532
2645
  ERBNoOutputControlFlowRule,
@@ -2547,19 +2660,21 @@ const defaultRules = [
2547
2660
  HTMLBooleanAttributesNoValueRule,
2548
2661
  HTMLIframeHasTitleRule,
2549
2662
  HTMLImgRequireAltRule,
2550
- HTMLNavigationHasLabelRule,
2663
+ // HTMLNavigationHasLabelRule,
2551
2664
  HTMLNoAriaHiddenOnFocusableRule,
2552
2665
  // HTMLNoBlockInsideInlineRule,
2553
2666
  HTMLNoDuplicateAttributesRule,
2554
2667
  HTMLNoDuplicateIdsRule,
2668
+ HTMLNoEmptyAttributesRule,
2555
2669
  HTMLNoEmptyHeadingsRule,
2556
2670
  HTMLNoNestedLinksRule,
2557
2671
  HTMLNoPositiveTabIndexRule,
2558
2672
  HTMLNoSelfClosingRule,
2559
- HTMLNoTitleAttributeRule,
2673
+ // HTMLNoTitleAttributeRule,
2560
2674
  HTMLTagNameLowercaseRule,
2561
2675
  ParserNoErrorsRule,
2562
2676
  SVGTagNameCapitalizationRule,
2677
+ HTMLNoUnderscoresInAttributeNamesRule,
2563
2678
  ];
2564
2679
 
2565
2680
  class Linter {
@@ -2656,6 +2771,47 @@ class Linter {
2656
2771
  }
2657
2772
  }
2658
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
+
2659
2815
  class BlockInsideInlineVisitor extends BaseRuleVisitor {
2660
2816
  inlineStack = [];
2661
2817
  isValidHTMLOpenTag(node) {
@@ -2714,12 +2870,44 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
2714
2870
  }
2715
2871
  }
2716
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;
2717
2904
  exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
2718
2905
  exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
2719
2906
  exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
2720
2907
  exports.ERBNoSilentTagInAttributeNameRule = ERBNoSilentTagInAttributeNameRule;
2721
2908
  exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
2722
2909
  exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
2910
+ exports.HEADING_TAGS = HEADING_TAGS;
2723
2911
  exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
2724
2912
  exports.HTMLAriaLabelIsWellFormattedRule = HTMLAriaLabelIsWellFormattedRule;
2725
2913
  exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
@@ -2737,15 +2925,48 @@ exports.HTMLNoAriaHiddenOnFocusableRule = HTMLNoAriaHiddenOnFocusableRule;
2737
2925
  exports.HTMLNoBlockInsideInlineRule = HTMLNoBlockInsideInlineRule;
2738
2926
  exports.HTMLNoDuplicateAttributesRule = HTMLNoDuplicateAttributesRule;
2739
2927
  exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
2928
+ exports.HTMLNoEmptyAttributesRule = HTMLNoEmptyAttributesRule;
2740
2929
  exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
2741
2930
  exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
2742
2931
  exports.HTMLNoPositiveTabIndexRule = HTMLNoPositiveTabIndexRule;
2743
2932
  exports.HTMLNoSelfClosingRule = HTMLNoSelfClosingRule;
2744
2933
  exports.HTMLNoTitleAttributeRule = HTMLNoTitleAttributeRule;
2934
+ exports.HTMLNoUnderscoresInAttributeNamesRule = HTMLNoUnderscoresInAttributeNamesRule;
2745
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;
2746
2940
  exports.LexerRule = LexerRule;
2747
2941
  exports.Linter = Linter;
2748
2942
  exports.ParserRule = ParserRule;
2749
2943
  exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
2944
+ exports.SVG_CAMEL_CASE_ELEMENTS = SVG_CAMEL_CASE_ELEMENTS;
2945
+ exports.SVG_LOWERCASE_TO_CAMELCASE = SVG_LOWERCASE_TO_CAMELCASE;
2750
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;
2751
2972
  //# sourceMappingURL=index.cjs.map