@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.
- package/README.md +60 -16
- package/dist/herb-lint.js +364 -181
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +321 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +270 -89
- package/dist/index.js.map +1 -1
- package/dist/package.json +11 -5
- package/dist/src/cli/argument-parser.js +11 -6
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +5 -6
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +3 -5
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
- package/dist/src/cli/index.js +1 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/output-manager.js +23 -5
- package/dist/src/cli/output-manager.js.map +1 -1
- package/dist/src/cli/summary-reporter.js +2 -11
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +88 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +8 -4
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-no-empty-attributes.js +56 -0
- package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
- package/dist/src/rules/html-no-positive-tab-index.js +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
- package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
- package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
- package/dist/src/rules/index.js +3 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/rule-utils.js +11 -7
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +2 -1
- package/dist/types/cli/file-processor.d.ts +6 -1
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/output-manager.d.ts +1 -0
- package/dist/types/cli.d.ts +20 -5
- package/dist/types/linter.d.ts +7 -7
- package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/rules/index.d.ts +3 -0
- package/dist/types/rules/rule-utils.d.ts +7 -5
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -1
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
- package/dist/types/src/cli/index.d.ts +1 -0
- package/dist/types/src/cli/output-manager.d.ts +1 -0
- package/dist/types/src/cli.d.ts +20 -5
- package/dist/types/src/linter.d.ts +7 -7
- package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
- package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
- package/dist/types/src/rules/index.d.ts +3 -0
- package/dist/types/src/rules/rule-utils.d.ts +7 -5
- package/docs/rules/README.md +2 -0
- package/docs/rules/html-img-require-alt.md +0 -2
- package/docs/rules/html-no-empty-attributes.md +77 -0
- package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
- package/package.json +11 -5
- package/src/cli/argument-parser.ts +15 -7
- package/src/cli/file-processor.ts +11 -7
- package/src/cli/formatters/detailed-formatter.ts +5 -7
- package/src/cli/formatters/github-actions-formatter.ts +64 -11
- package/src/cli/index.ts +2 -0
- package/src/cli/output-manager.ts +27 -5
- package/src/cli/summary-reporter.ts +3 -11
- package/src/cli.ts +125 -20
- package/src/default-rules.ts +8 -4
- package/src/linter.ts +6 -6
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
- package/src/rules/erb-prefer-image-tag-helper.ts +2 -2
- package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
- package/src/rules/html-attribute-double-quotes.ts +1 -1
- package/src/rules/html-boolean-attributes-no-value.ts +9 -11
- package/src/rules/html-no-empty-attributes.ts +75 -0
- package/src/rules/html-no-positive-tab-index.ts +1 -1
- package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
- package/src/rules/html-tag-name-lowercase.ts +1 -1
- package/src/rules/index.ts +3 -0
- package/src/rules/rule-utils.ts +15 -11
- package/src/rules/svg-tag-name-capitalization.ts +2 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Visitor,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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({
|
|
1790
|
-
|
|
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({
|
|
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 \`${
|
|
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
|
|
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
|
|
2592
|
+
if (node.type === "AST_HTML_OPEN_TAG_NODE")
|
|
2512
2593
|
type = "Opening";
|
|
2513
|
-
if (node.type
|
|
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
|
-
|
|
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
|