@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Visitor, Position, Location, getStaticAttributeName, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, filterERBContentNodes, filterNodes, ERBContentNode, isERBOutputNode, getNodesBeforePosition, getNodesAfterPosition, isToken, isParseResult, isNode, LiteralNode, isERBNode, filterLiteralNodes, getTagName as getTagName$1, HTMLOpenTagNode } from '@herb-tools/core';
1
+ import { Visitor, getStaticAttributeName, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, hasStaticContent, getStaticContentFromNodes, Position, Location, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, filterERBContentNodes, filterNodes, ERBContentNode, isERBOutputNode, getNodesBeforePosition, getNodesAfterPosition, isToken, isParseResult, isNode, LiteralNode, isERBNode, filterLiteralNodes, getTagName as getTagName$1, HTMLOpenTagNode } from '@herb-tools/core';
2
2
 
3
3
  class ParserRule {
4
4
  static type = "parser";
@@ -133,10 +133,12 @@ function getTagName(node) {
133
133
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
134
134
  * Returns null if the attribute name contains dynamic content (ERB)
135
135
  */
136
- function getAttributeName(attributeNode) {
136
+ function getAttributeName(attributeNode, lowercase = true) {
137
137
  if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
138
138
  const nameNode = attributeNode.name;
139
139
  const staticName = getStaticAttributeName(nameNode);
140
+ if (!lowercase)
141
+ return staticName;
140
142
  return staticName ? staticName.toLowerCase() : null;
141
143
  }
142
144
  return null;
@@ -171,6 +173,15 @@ function hasStaticAttributeValue(attributeNode) {
171
173
  return false;
172
174
  return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
173
175
  }
176
+ /**
177
+ * Checks if an attribute value contains dynamic content (ERB)
178
+ */
179
+ function hasDynamicAttributeValue(attributeNode) {
180
+ const valueNode = attributeNode.value;
181
+ if (!valueNode?.children)
182
+ return false;
183
+ return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
184
+ }
174
185
  /**
175
186
  * Gets the static string value of an attribute (returns null if it contains ERB)
176
187
  */
@@ -191,6 +202,21 @@ function getAttributeValueNodes(attributeNode) {
191
202
  const valueNode = attributeNode.value;
192
203
  return valueNode?.children || [];
193
204
  }
205
+ /**
206
+ * Checks if an attribute value contains any static content (for validation purposes)
207
+ */
208
+ function hasStaticAttributeValueContent(attributeNode) {
209
+ const valueNodes = getAttributeValueNodes(attributeNode);
210
+ return hasStaticContent(valueNodes);
211
+ }
212
+ /**
213
+ * Gets the static content of an attribute value (all literal parts combined)
214
+ * Returns the concatenated literal content, or null if no literal nodes exist
215
+ */
216
+ function getStaticAttributeValueContent(attributeNode) {
217
+ const valueNodes = getAttributeValueNodes(attributeNode);
218
+ return getStaticContentFromNodes(valueNodes);
219
+ }
194
220
  /**
195
221
  * Gets the attribute value content from an HTMLAttributeValueNode
196
222
  */
@@ -465,6 +491,7 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
465
491
  checkAttributesOnNode(node) {
466
492
  forEachAttribute(node, (attributeNode) => {
467
493
  const staticAttributeName = getAttributeName(attributeNode);
494
+ const originalAttributeName = getAttributeName(attributeNode, false) || "";
468
495
  const isDynamicName = hasDynamicAttributeName(attributeNode);
469
496
  const staticAttributeValue = getStaticAttributeValue(attributeNode);
470
497
  const valueNodes = getAttributeValueNodes(attributeNode);
@@ -475,16 +502,17 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
475
502
  attributeName: staticAttributeName,
476
503
  attributeValue: staticAttributeValue,
477
504
  attributeNode,
505
+ originalAttributeName,
478
506
  parentNode: node
479
507
  });
480
508
  }
481
509
  else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
482
510
  const validatableContent = getValidatableStaticContent(valueNodes) || "";
483
- this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
511
+ this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, originalAttributeName, parentNode: node });
484
512
  }
485
513
  else if (staticAttributeName && hasOutputERB) {
486
514
  const combinedValue = getAttributeValue(attributeNode);
487
- this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
515
+ this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, originalAttributeName, combinedValue });
488
516
  }
489
517
  else if (isDynamicName && staticAttributeValue !== null) {
490
518
  const nameNode = attributeNode.name;
@@ -504,28 +532,38 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
504
532
  /**
505
533
  * Static attribute name with static value: class="container"
506
534
  */
507
- checkStaticAttributeStaticValue(params) {
535
+ checkStaticAttributeStaticValue(_params) {
508
536
  // Default implementation does nothing
509
537
  }
510
538
  /**
511
539
  * Static attribute name with dynamic value: class="<%= css_class %>"
512
540
  */
513
- checkStaticAttributeDynamicValue(params) {
541
+ checkStaticAttributeDynamicValue(_params) {
514
542
  // Default implementation does nothing
515
543
  }
516
544
  /**
517
545
  * Dynamic attribute name with static value: data-<%= key %>="foo"
518
546
  */
519
- checkDynamicAttributeStaticValue(params) {
547
+ checkDynamicAttributeStaticValue(_params) {
520
548
  // Default implementation does nothing
521
549
  }
522
550
  /**
523
551
  * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
524
552
  */
525
- checkDynamicAttributeDynamicValue(params) {
553
+ checkDynamicAttributeDynamicValue(_params) {
526
554
  // Default implementation does nothing
527
555
  }
528
556
  }
557
+ /**
558
+ * Checks if an attribute value is quoted
559
+ */
560
+ function isAttributeValueQuoted(attributeNode) {
561
+ if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
562
+ const valueNode = attributeNode.value;
563
+ return !!valueNode.quoted;
564
+ }
565
+ return false;
566
+ }
529
567
  /**
530
568
  * Iterates over all attributes of a tag node, calling the callback for each attribute
531
569
  */
@@ -537,6 +575,60 @@ function forEachAttribute(node, callback) {
537
575
  }
538
576
  }
539
577
  }
578
+ /**
579
+ * Base lexer visitor class that provides common functionality for lexer-based rule visitors
580
+ */
581
+ class BaseLexerRuleVisitor {
582
+ offenses = [];
583
+ ruleName;
584
+ context;
585
+ constructor(ruleName, context) {
586
+ this.ruleName = ruleName;
587
+ this.context = { ...DEFAULT_LINT_CONTEXT, ...context };
588
+ }
589
+ /**
590
+ * Helper method to create a lint offense for lexer rules
591
+ */
592
+ createOffense(message, location, severity = "error") {
593
+ return {
594
+ rule: this.ruleName,
595
+ code: this.ruleName,
596
+ source: "Herb Linter",
597
+ message,
598
+ location,
599
+ severity,
600
+ };
601
+ }
602
+ /**
603
+ * Helper method to add an offense to the offenses array
604
+ */
605
+ addOffense(message, location, severity = "error") {
606
+ this.offenses.push(this.createOffense(message, location, severity));
607
+ }
608
+ /**
609
+ * Main entry point for lexer rule visitors
610
+ * @param lexResult - The lexer result containing tokens and source
611
+ */
612
+ visit(lexResult) {
613
+ this.visitTokens(lexResult.value.tokens);
614
+ }
615
+ /**
616
+ * Visit all tokens
617
+ * Override this method to implement token-level checks
618
+ */
619
+ visitTokens(tokens) {
620
+ for (const token of tokens) {
621
+ this.visitToken(token);
622
+ }
623
+ }
624
+ /**
625
+ * Visit individual tokens
626
+ * Override this method to implement per-token checks
627
+ */
628
+ visitToken(_token) {
629
+ // Default implementation does nothing
630
+ }
631
+ }
540
632
  /**
541
633
  * Base source visitor class that provides common functionality for source-based rule visitors
542
634
  */
@@ -800,6 +892,8 @@ class Printer extends Visitor {
800
892
  * @throws {Error} When node has parse errors and ignoreErrors is false
801
893
  */
802
894
  print(input, options = DEFAULT_PRINT_OPTIONS) {
895
+ if (!input)
896
+ return "";
803
897
  if (isToken(input)) {
804
898
  return input.value;
805
899
  }
@@ -1402,7 +1496,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
1402
1496
  try {
1403
1497
  return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
1404
1498
  }
1405
- catch (error) {
1499
+ catch {
1406
1500
  return "expression";
1407
1501
  }
1408
1502
  }
@@ -1786,19 +1880,18 @@ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
1786
1880
  }
1787
1881
 
1788
1882
  class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
1789
- checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
1790
- if (!isBooleanAttribute(attributeName))
1791
- return;
1792
- if (!hasAttributeValue(attributeNode))
1793
- return;
1794
- this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
1883
+ checkStaticAttributeStaticValue({ originalAttributeName, attributeNode }) {
1884
+ this.checkAttribute(originalAttributeName, attributeNode);
1795
1885
  }
1796
- checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
1886
+ checkStaticAttributeDynamicValue({ originalAttributeName, attributeNode }) {
1887
+ this.checkAttribute(originalAttributeName, attributeNode);
1888
+ }
1889
+ checkAttribute(attributeName, attributeNode) {
1797
1890
  if (!isBooleanAttribute(attributeName))
1798
1891
  return;
1799
1892
  if (!hasAttributeValue(attributeNode))
1800
1893
  return;
1801
- this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "error");
1894
+ this.addOffense(`Boolean attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not have a value. Use \`${attributeName.toLowerCase()}\` instead of \`${IdentityPrinter.print(attributeNode)}\`.`, attributeNode.value.location, "error");
1802
1895
  }
1803
1896
  }
1804
1897
  class HTMLBooleanAttributesNoValueRule extends ParserRule {
@@ -1871,47 +1964,6 @@ class HTMLImgRequireAltRule extends ParserRule {
1871
1964
  }
1872
1965
  }
1873
1966
 
1874
- class NavigationHasLabelVisitor extends BaseRuleVisitor {
1875
- visitHTMLOpenTagNode(node) {
1876
- this.checkNavigationElement(node);
1877
- super.visitHTMLOpenTagNode(node);
1878
- }
1879
- checkNavigationElement(node) {
1880
- const tagName = getTagName(node);
1881
- const isNavElement = tagName === "nav";
1882
- const hasNavigationRole = this.hasRoleNavigation(node);
1883
- if (!isNavElement && !hasNavigationRole) {
1884
- return;
1885
- }
1886
- const hasAriaLabel = hasAttribute(node, "aria-label");
1887
- const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
1888
- if (!hasAriaLabel && !hasAriaLabelledby) {
1889
- 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.`;
1890
- if (hasNavigationRole && !isNavElement) {
1891
- message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
1892
- }
1893
- this.addOffense(message, node.tag_name.location, "error");
1894
- }
1895
- }
1896
- hasRoleNavigation(node) {
1897
- const attributes = getAttributes(node);
1898
- const roleAttribute = findAttributeByName(attributes, "role");
1899
- if (!roleAttribute) {
1900
- return false;
1901
- }
1902
- const roleValue = getAttributeValue(roleAttribute);
1903
- return roleValue === "navigation";
1904
- }
1905
- }
1906
- class HTMLNavigationHasLabelRule extends ParserRule {
1907
- name = "html-navigation-has-label";
1908
- check(result, context) {
1909
- const visitor = new NavigationHasLabelVisitor(this.name, context);
1910
- visitor.visit(result.value);
1911
- return visitor.offenses;
1912
- }
1913
- }
1914
-
1915
1967
  const INTERACTIVE_ELEMENTS = new Set([
1916
1968
  "button", "summary", "input", "select", "textarea", "a"
1917
1969
  ]);
@@ -2162,6 +2214,60 @@ class HTMLNoDuplicateIdsRule extends ParserRule {
2162
2214
  }
2163
2215
  }
2164
2216
 
2217
+ // Attributes that must not have empty values
2218
+ const RESTRICTED_ATTRIBUTES = new Set([
2219
+ 'id',
2220
+ 'class',
2221
+ 'name',
2222
+ 'for',
2223
+ 'src',
2224
+ 'href',
2225
+ 'title',
2226
+ 'data',
2227
+ 'role'
2228
+ ]);
2229
+ // Check if attribute name matches any restricted patterns
2230
+ function isRestrictedAttribute(attributeName) {
2231
+ // Check direct matches
2232
+ if (RESTRICTED_ATTRIBUTES.has(attributeName)) {
2233
+ return true;
2234
+ }
2235
+ // Check for data-* attributes
2236
+ if (attributeName.startsWith('data-')) {
2237
+ return true;
2238
+ }
2239
+ // Check for aria-* attributes
2240
+ if (attributeName.startsWith('aria-')) {
2241
+ return true;
2242
+ }
2243
+ return false;
2244
+ }
2245
+ class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
2246
+ checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
2247
+ if (!isRestrictedAttribute(attributeName))
2248
+ return;
2249
+ if (attributeValue.trim() !== "")
2250
+ return;
2251
+ this.addOffense(`Attribute \`${attributeName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`, attributeNode.name.location, "warning");
2252
+ }
2253
+ checkDynamicAttributeStaticValue({ combinedName, attributeValue, attributeNode }) {
2254
+ const name = (combinedName || "").toLowerCase();
2255
+ if (!isRestrictedAttribute(name))
2256
+ return;
2257
+ if (attributeValue.trim() !== "")
2258
+ return;
2259
+ this.addOffense(`Attribute \`${combinedName}\` must not be empty. Either provide a meaningful value or remove the attribute entirely.`, attributeNode.name.location, "warning");
2260
+ }
2261
+ }
2262
+ class HTMLNoEmptyAttributesRule extends ParserRule {
2263
+ name = "html-no-empty-attributes";
2264
+ check(result, context) {
2265
+ const visitor = new NoEmptyAttributesVisitor(this.name, context);
2266
+ visitor.visit(result.value);
2267
+ return visitor.offenses;
2268
+ }
2269
+ }
2270
+
2165
2271
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
2166
2272
  visitHTMLElementNode(node) {
2167
2273
  this.checkHeadingElement(node);
@@ -2338,7 +2444,7 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
2338
2444
  return;
2339
2445
  const tabIndexValue = parseInt(attributeValue, 10);
2340
2446
  if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
2341
- this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex=\"-1\"\` to remove it from the tab sequence.`, attributeNode.location, "error");
2447
+ this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex="-1"\` to remove it from the tab sequence.`, attributeNode.location, "error");
2342
2448
  }
2343
2449
  }
2344
2450
  }
@@ -2377,31 +2483,6 @@ class HTMLNoSelfClosingRule extends ParserRule {
2377
2483
  }
2378
2484
  }
2379
2485
 
2380
- class NoTitleAttributeVisitor extends BaseRuleVisitor {
2381
- ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
2382
- visitHTMLOpenTagNode(node) {
2383
- this.checkTitleAttribute(node);
2384
- super.visitHTMLOpenTagNode(node);
2385
- }
2386
- checkTitleAttribute(node) {
2387
- const tagName = getTagName(node);
2388
- if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
2389
- return;
2390
- }
2391
- if (hasAttribute(node, "title")) {
2392
- 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");
2393
- }
2394
- }
2395
- }
2396
- class HTMLNoTitleAttributeRule extends ParserRule {
2397
- name = "html-no-title-attribute";
2398
- check(result, context) {
2399
- const visitor = new NoTitleAttributeVisitor(this.name, context);
2400
- visitor.visit(result.value);
2401
- return visitor.offenses;
2402
- }
2403
- }
2404
-
2405
2486
  class XMLDeclarationChecker extends BaseRuleVisitor {
2406
2487
  hasXMLDeclaration = false;
2407
2488
  visitXMLDeclarationNode(_node) {
@@ -2508,9 +2589,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
2508
2589
  const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
2509
2590
  if (correctCamelCase && tagName !== correctCamelCase) {
2510
2591
  let type = node.type;
2511
- if (node.type == "AST_HTML_OPEN_TAG_NODE")
2592
+ if (node.type === "AST_HTML_OPEN_TAG_NODE")
2512
2593
  type = "Opening";
2513
- if (node.type == "AST_HTML_CLOSE_TAG_NODE")
2594
+ if (node.type === "AST_HTML_CLOSE_TAG_NODE")
2514
2595
  type = "Closing";
2515
2596
  this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
2516
2597
  }
@@ -2525,6 +2606,38 @@ class SVGTagNameCapitalizationRule extends ParserRule {
2525
2606
  }
2526
2607
  }
2527
2608
 
2609
+ class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
2610
+ checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
2611
+ this.check(attributeName, attributeNode);
2612
+ }
2613
+ checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
2614
+ this.check(attributeName, attributeNode);
2615
+ }
2616
+ checkDynamicAttributeStaticValue({ nameNodes, attributeNode }) {
2617
+ const attributeName = getStaticContentFromNodes(nameNodes);
2618
+ this.check(attributeName, attributeNode);
2619
+ }
2620
+ checkDynamicAttributeDynamicValue({ nameNodes, attributeNode }) {
2621
+ const attributeName = getStaticContentFromNodes(nameNodes);
2622
+ this.check(attributeName, attributeNode);
2623
+ }
2624
+ check(attributeName, attributeNode) {
2625
+ if (!attributeName)
2626
+ return;
2627
+ if (attributeName.includes("_")) {
2628
+ this.addOffense(`Attribute \`${IdentityPrinter.print(attributeNode.name)}\` should not contain underscores. Use hyphens (-) instead.`, attributeNode.value.location, "warning");
2629
+ }
2630
+ }
2631
+ }
2632
+ class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
2633
+ name = "html-no-underscores-in-attribute-names";
2634
+ check(result, context) {
2635
+ const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.name, context);
2636
+ visitor.visit(result.value);
2637
+ return visitor.offenses;
2638
+ }
2639
+ }
2640
+
2528
2641
  const defaultRules = [
2529
2642
  ERBNoEmptyTagsRule,
2530
2643
  ERBNoOutputControlFlowRule,
@@ -2545,19 +2658,21 @@ const defaultRules = [
2545
2658
  HTMLBooleanAttributesNoValueRule,
2546
2659
  HTMLIframeHasTitleRule,
2547
2660
  HTMLImgRequireAltRule,
2548
- HTMLNavigationHasLabelRule,
2661
+ // HTMLNavigationHasLabelRule,
2549
2662
  HTMLNoAriaHiddenOnFocusableRule,
2550
2663
  // HTMLNoBlockInsideInlineRule,
2551
2664
  HTMLNoDuplicateAttributesRule,
2552
2665
  HTMLNoDuplicateIdsRule,
2666
+ HTMLNoEmptyAttributesRule,
2553
2667
  HTMLNoEmptyHeadingsRule,
2554
2668
  HTMLNoNestedLinksRule,
2555
2669
  HTMLNoPositiveTabIndexRule,
2556
2670
  HTMLNoSelfClosingRule,
2557
- HTMLNoTitleAttributeRule,
2671
+ // HTMLNoTitleAttributeRule,
2558
2672
  HTMLTagNameLowercaseRule,
2559
2673
  ParserNoErrorsRule,
2560
2674
  SVGTagNameCapitalizationRule,
2675
+ HTMLNoUnderscoresInAttributeNamesRule,
2561
2676
  ];
2562
2677
 
2563
2678
  class Linter {
@@ -2654,6 +2769,47 @@ class Linter {
2654
2769
  }
2655
2770
  }
2656
2771
 
2772
+ class NavigationHasLabelVisitor extends BaseRuleVisitor {
2773
+ visitHTMLOpenTagNode(node) {
2774
+ this.checkNavigationElement(node);
2775
+ super.visitHTMLOpenTagNode(node);
2776
+ }
2777
+ checkNavigationElement(node) {
2778
+ const tagName = getTagName(node);
2779
+ const isNavElement = tagName === "nav";
2780
+ const hasNavigationRole = this.hasRoleNavigation(node);
2781
+ if (!isNavElement && !hasNavigationRole) {
2782
+ return;
2783
+ }
2784
+ const hasAriaLabel = hasAttribute(node, "aria-label");
2785
+ const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
2786
+ if (!hasAriaLabel && !hasAriaLabelledby) {
2787
+ let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
2788
+ if (hasNavigationRole && !isNavElement) {
2789
+ message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
2790
+ }
2791
+ this.addOffense(message, node.tag_name.location, "error");
2792
+ }
2793
+ }
2794
+ hasRoleNavigation(node) {
2795
+ const attributes = getAttributes(node);
2796
+ const roleAttribute = findAttributeByName(attributes, "role");
2797
+ if (!roleAttribute) {
2798
+ return false;
2799
+ }
2800
+ const roleValue = getAttributeValue(roleAttribute);
2801
+ return roleValue === "navigation";
2802
+ }
2803
+ }
2804
+ class HTMLNavigationHasLabelRule extends ParserRule {
2805
+ name = "html-navigation-has-label";
2806
+ check(result, context) {
2807
+ const visitor = new NavigationHasLabelVisitor(this.name, context);
2808
+ visitor.visit(result.value);
2809
+ return visitor.offenses;
2810
+ }
2811
+ }
2812
+
2657
2813
  class BlockInsideInlineVisitor extends BaseRuleVisitor {
2658
2814
  inlineStack = [];
2659
2815
  isValidHTMLOpenTag(node) {
@@ -2712,5 +2868,30 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
2712
2868
  }
2713
2869
  }
2714
2870
 
2715
- export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBooleanAttributesNoValueRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoTitleAttributeRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
2871
+ class NoTitleAttributeVisitor extends BaseRuleVisitor {
2872
+ ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
2873
+ visitHTMLOpenTagNode(node) {
2874
+ this.checkTitleAttribute(node);
2875
+ super.visitHTMLOpenTagNode(node);
2876
+ }
2877
+ checkTitleAttribute(node) {
2878
+ const tagName = getTagName(node);
2879
+ if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
2880
+ return;
2881
+ }
2882
+ if (hasAttribute(node, "title")) {
2883
+ this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
2884
+ }
2885
+ }
2886
+ }
2887
+ class HTMLNoTitleAttributeRule extends ParserRule {
2888
+ name = "html-no-title-attribute";
2889
+ check(result, context) {
2890
+ const visitor = new NoTitleAttributeVisitor(this.name, context);
2891
+ visitor.visit(result.value);
2892
+ return visitor.offenses;
2893
+ }
2894
+ }
2895
+
2896
+ export { ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HEADING_TAGS, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBooleanAttributesNoValueRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_VOID_ELEMENTS, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findAttributeByName, forEachAttribute, getAttribute, getAttributeName, getAttributeValue, getAttributeValueNodes, getAttributeValueQuoteType, getAttributes, getCombinedAttributeNameString, getStaticAttributeValue, getStaticAttributeValueContent, getTagName, hasAttribute, hasAttributeValue, hasDynamicAttributeName, hasDynamicAttributeValue, hasStaticAttributeValue, hasStaticAttributeValueContent, isAttributeValueQuoted, isBlockElement, isBooleanAttribute, isInlineElement, isVoidElement };
2716
2897
  //# sourceMappingURL=index.js.map