@herb-tools/linter 0.4.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -19
- package/dist/herb-lint.js +5559 -1860
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +722 -187
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +714 -189
- package/dist/index.js.map +1 -1
- package/dist/package.json +4 -4
- package/dist/src/cli/argument-parser.js +28 -22
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +19 -13
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +9 -9
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +50 -0
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -0
- package/dist/src/cli/formatters/index.js +2 -0
- package/dist/src/cli/formatters/index.js.map +1 -1
- package/dist/src/cli/formatters/json-formatter.js +58 -0
- package/dist/src/cli/formatters/json-formatter.js.map +1 -0
- package/dist/src/cli/formatters/simple-formatter.js +15 -15
- package/dist/src/cli/formatters/simple-formatter.js.map +1 -1
- package/dist/src/cli/output-manager.js +120 -0
- package/dist/src/cli/output-manager.js.map +1 -0
- package/dist/src/cli/summary-reporter.js +22 -22
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +41 -26
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +22 -0
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js +29 -4
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-no-empty-tags.js +2 -2
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
- package/dist/src/rules/erb-no-output-control-flow.js +2 -2
- package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +26 -0
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
- package/dist/src/rules/erb-prefer-image-tag-helper.js +2 -6
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +2 -2
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
- package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -1
- package/dist/src/rules/html-anchor-require-href.js +2 -2
- package/dist/src/rules/html-anchor-require-href.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +13 -12
- package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-label-is-well-formatted.js +33 -0
- package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -0
- package/dist/src/rules/html-aria-level-must-be-valid.js +28 -6
- package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-role-heading-requires-level.js +9 -15
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
- package/dist/src/rules/html-aria-role-must-be-valid.js +5 -5
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +16 -6
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
- package/dist/src/rules/html-attribute-equals-spacing.js +24 -0
- package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js +21 -10
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +47 -0
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js +11 -4
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-iframe-has-title.js +39 -0
- package/dist/src/rules/html-iframe-has-title.js.map +1 -0
- package/dist/src/rules/html-img-require-alt.js +2 -6
- package/dist/src/rules/html-img-require-alt.js.map +1 -1
- package/dist/src/rules/html-navigation-has-label.js +43 -0
- package/dist/src/rules/html-navigation-has-label.js.map +1 -0
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js +67 -0
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
- package/dist/src/rules/html-no-block-inside-inline.js +4 -4
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-attributes.js +24 -27
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +4 -4
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +2 -23
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/html-no-nested-links.js +2 -2
- package/dist/src/rules/html-no-nested-links.js.map +1 -1
- package/dist/src/rules/html-no-positive-tab-index.js +21 -0
- package/dist/src/rules/html-no-positive-tab-index.js.map +1 -0
- package/dist/src/rules/html-no-self-closing.js +22 -0
- package/dist/src/rules/html-no-self-closing.js.map +1 -0
- package/dist/src/rules/html-no-title-attribute.js +27 -0
- package/dist/src/rules/html-no-title-attribute.js.map +1 -0
- package/dist/src/rules/html-tag-name-lowercase.js +37 -25
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
- package/dist/src/rules/index.js +10 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/parser-no-errors.js +18 -0
- package/dist/src/rules/parser-no-errors.js.map +1 -0
- package/dist/src/rules/rule-utils.js +176 -22
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +2 -10
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/src/types.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 -5
- package/dist/types/cli/formatters/base-formatter.d.ts +2 -2
- package/dist/types/cli/formatters/detailed-formatter.d.ts +2 -2
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +12 -0
- package/dist/types/cli/formatters/index.d.ts +2 -0
- package/dist/types/cli/formatters/json-formatter.d.ts +42 -0
- package/dist/types/cli/formatters/simple-formatter.d.ts +2 -2
- package/dist/types/cli/index.d.ts +4 -0
- package/dist/types/cli/output-manager.d.ts +31 -0
- package/dist/types/cli/summary-reporter.d.ts +3 -3
- package/dist/types/cli.d.ts +3 -1
- package/dist/types/rules/erb-no-empty-tags.d.ts +2 -2
- package/dist/types/rules/erb-no-output-control-flow.d.ts +2 -2
- package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +2 -2
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +2 -2
- package/dist/types/rules/html-anchor-require-href.d.ts +2 -2
- package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +2 -2
- package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/rules/html-aria-level-must-be-valid.d.ts +2 -2
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +2 -2
- package/dist/types/rules/html-aria-role-must-be-valid.d.ts +2 -2
- package/dist/types/rules/html-attribute-double-quotes.d.ts +2 -2
- package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +2 -2
- package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +2 -2
- package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/rules/html-img-require-alt.d.ts +2 -2
- package/dist/types/rules/html-navigation-has-label.d.ts +7 -0
- package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
- package/dist/types/rules/html-no-block-inside-inline.d.ts +2 -2
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +2 -2
- package/dist/types/rules/html-no-duplicate-ids.d.ts +2 -2
- package/dist/types/rules/html-no-empty-headings.d.ts +2 -2
- package/dist/types/rules/html-no-nested-links.d.ts +2 -2
- package/dist/types/rules/html-no-positive-tab-index.d.ts +7 -0
- package/dist/types/rules/html-no-self-closing.d.ts +7 -0
- package/dist/types/rules/html-no-title-attribute.d.ts +7 -0
- package/dist/types/rules/html-tag-name-lowercase.d.ts +3 -2
- package/dist/types/rules/index.d.ts +10 -0
- package/dist/types/rules/parser-no-errors.d.ts +8 -0
- package/dist/types/rules/rule-utils.d.ts +107 -13
- package/dist/types/rules/svg-tag-name-capitalization.d.ts +2 -2
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -5
- package/dist/types/src/cli/formatters/base-formatter.d.ts +2 -2
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +2 -2
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +12 -0
- package/dist/types/src/cli/formatters/index.d.ts +2 -0
- package/dist/types/src/cli/formatters/json-formatter.d.ts +42 -0
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +2 -2
- package/dist/types/src/cli/output-manager.d.ts +31 -0
- package/dist/types/src/cli/summary-reporter.d.ts +3 -3
- package/dist/types/src/cli.d.ts +3 -1
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +2 -2
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +2 -2
- package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +2 -2
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +2 -2
- package/dist/types/src/rules/html-anchor-require-href.d.ts +2 -2
- package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +2 -2
- package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +2 -2
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +2 -2
- package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +2 -2
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +2 -2
- package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +2 -2
- package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +2 -2
- package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
- package/dist/types/src/rules/html-img-require-alt.d.ts +2 -2
- package/dist/types/src/rules/html-navigation-has-label.d.ts +7 -0
- package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +7 -0
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +2 -2
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +2 -2
- package/dist/types/src/rules/html-no-duplicate-ids.d.ts +2 -2
- package/dist/types/src/rules/html-no-empty-headings.d.ts +2 -2
- package/dist/types/src/rules/html-no-nested-links.d.ts +2 -2
- package/dist/types/src/rules/html-no-positive-tab-index.d.ts +7 -0
- package/dist/types/src/rules/html-no-self-closing.d.ts +7 -0
- package/dist/types/src/rules/html-no-title-attribute.d.ts +7 -0
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +3 -2
- package/dist/types/src/rules/index.d.ts +10 -0
- package/dist/types/src/rules/parser-no-errors.d.ts +8 -0
- package/dist/types/src/rules/rule-utils.d.ts +107 -13
- package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +2 -2
- package/dist/types/src/types.d.ts +27 -3
- package/dist/types/types.d.ts +27 -3
- package/docs/rules/README.md +13 -2
- package/docs/rules/erb-no-silent-tag-in-attribute-name.md +34 -0
- package/docs/rules/html-aria-label-is-well-formatted.md +49 -0
- package/docs/rules/html-attribute-equals-spacing.md +35 -0
- package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +48 -0
- package/docs/rules/html-iframe-has-title.md +43 -0
- package/docs/rules/html-navigation-has-label.md +61 -0
- package/docs/rules/html-no-aria-hidden-on-focusable.md +54 -0
- package/docs/rules/html-no-positive-tab-index.md +55 -0
- package/docs/rules/html-no-self-closing.md +65 -0
- package/docs/rules/html-no-title-attribute.md +69 -0
- package/docs/rules/html-tag-name-lowercase.md +16 -3
- package/docs/rules/parser-no-errors.md +84 -0
- package/package.json +4 -4
- package/src/cli/argument-parser.ts +33 -24
- package/src/cli/file-processor.ts +25 -17
- package/src/cli/formatters/base-formatter.ts +2 -2
- package/src/cli/formatters/detailed-formatter.ts +9 -9
- package/src/cli/formatters/github-actions-formatter.ts +70 -0
- package/src/cli/formatters/index.ts +2 -0
- package/src/cli/formatters/json-formatter.ts +107 -0
- package/src/cli/formatters/simple-formatter.ts +15 -15
- package/src/cli/output-manager.ts +143 -0
- package/src/cli/summary-reporter.ts +24 -24
- package/src/cli.ts +48 -31
- package/src/default-rules.ts +22 -0
- package/src/linter.ts +30 -4
- package/src/rules/erb-no-empty-tags.ts +3 -3
- package/src/rules/erb-no-output-control-flow.ts +3 -3
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
- package/src/rules/erb-prefer-image-tag-helper.ts +4 -9
- package/src/rules/erb-require-whitespace-inside-tags.ts +3 -3
- package/src/rules/erb-requires-trailing-newline.ts +2 -0
- package/src/rules/html-anchor-require-href.ts +3 -3
- package/src/rules/html-aria-attribute-must-be-valid.ts +29 -33
- package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
- package/src/rules/html-aria-level-must-be-valid.ts +40 -7
- package/src/rules/html-aria-role-heading-requires-level.ts +18 -30
- package/src/rules/html-aria-role-must-be-valid.ts +7 -7
- package/src/rules/html-attribute-double-quotes.ts +23 -8
- package/src/rules/html-attribute-equals-spacing.ts +41 -0
- package/src/rules/html-attribute-values-require-quotes.ts +32 -12
- package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
- package/src/rules/html-boolean-attributes-no-value.ts +19 -6
- package/src/rules/html-iframe-has-title.ts +62 -0
- package/src/rules/html-img-require-alt.ts +4 -9
- package/src/rules/html-navigation-has-label.ts +64 -0
- package/src/rules/html-no-aria-hidden-on-focusable.ts +90 -0
- package/src/rules/html-no-block-inside-inline.ts +5 -5
- package/src/rules/html-no-duplicate-attributes.ts +30 -30
- package/src/rules/html-no-duplicate-ids.ts +6 -5
- package/src/rules/html-no-empty-headings.ts +4 -33
- package/src/rules/html-no-nested-links.ts +3 -3
- package/src/rules/html-no-positive-tab-index.ts +33 -0
- package/src/rules/html-no-self-closing.ts +36 -0
- package/src/rules/html-no-title-attribute.ts +42 -0
- package/src/rules/html-tag-name-lowercase.ts +44 -31
- package/src/rules/index.ts +10 -0
- package/src/rules/parser-no-errors.ts +25 -0
- package/src/rules/rule-utils.ts +260 -39
- package/src/rules/svg-tag-name-capitalization.ts +4 -11
- package/src/types.ts +30 -3
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Visitor, Position, Location, isERBNode } from '@herb-tools/core';
|
|
1
|
+
import { Visitor, Position, Location, getStaticAttributeName, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, filterERBContentNodes, isERBNode, filterLiteralNodes, isERBOutputNode, getTagName as getTagName$1, isNode, HTMLOpenTagNode } from '@herb-tools/core';
|
|
2
2
|
|
|
3
3
|
class ParserRule {
|
|
4
4
|
static type = "parser";
|
|
@@ -49,12 +49,10 @@ class BaseRuleVisitor extends Visitor {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Gets attributes from
|
|
52
|
+
* Gets attributes from an HTMLOpenTagNode
|
|
53
53
|
*/
|
|
54
54
|
function getAttributes(node) {
|
|
55
|
-
return node.type === "
|
|
56
|
-
? node.attributes
|
|
57
|
-
: node.children;
|
|
55
|
+
return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE");
|
|
58
56
|
}
|
|
59
57
|
/**
|
|
60
58
|
* Gets the tag name from an HTML tag node (lowercased)
|
|
@@ -64,14 +62,66 @@ function getTagName(node) {
|
|
|
64
62
|
}
|
|
65
63
|
/**
|
|
66
64
|
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
65
|
+
* Returns null if the attribute name contains dynamic content (ERB)
|
|
67
66
|
*/
|
|
68
67
|
function getAttributeName(attributeNode) {
|
|
69
68
|
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
70
69
|
const nameNode = attributeNode.name;
|
|
71
|
-
|
|
70
|
+
const staticName = getStaticAttributeName(nameNode);
|
|
71
|
+
return staticName ? staticName.toLowerCase() : null;
|
|
72
72
|
}
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Checks if an attribute has a dynamic (ERB-containing) name
|
|
77
|
+
*/
|
|
78
|
+
function hasDynamicAttributeName(attributeNode) {
|
|
79
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
80
|
+
const nameNode = attributeNode.name;
|
|
81
|
+
return hasDynamicAttributeName$1(nameNode);
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Gets the combined string representation of an attribute name (for debugging)
|
|
87
|
+
* This includes both static content and ERB syntax
|
|
88
|
+
*/
|
|
89
|
+
function getCombinedAttributeNameString(attributeNode) {
|
|
90
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
91
|
+
const nameNode = attributeNode.name;
|
|
92
|
+
return getCombinedAttributeName(nameNode);
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Checks if an attribute value contains only static content (no ERB)
|
|
98
|
+
*/
|
|
99
|
+
function hasStaticAttributeValue(attributeNode) {
|
|
100
|
+
const valueNode = attributeNode.value;
|
|
101
|
+
if (!valueNode?.children)
|
|
102
|
+
return false;
|
|
103
|
+
return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Gets the static string value of an attribute (returns null if it contains ERB)
|
|
107
|
+
*/
|
|
108
|
+
function getStaticAttributeValue(attributeNode) {
|
|
109
|
+
if (!hasStaticAttributeValue(attributeNode))
|
|
110
|
+
return null;
|
|
111
|
+
const valueNode = attributeNode.value;
|
|
112
|
+
const result = valueNode.children
|
|
113
|
+
?.filter(child => child.type === "AST_LITERAL_NODE")
|
|
114
|
+
.map(child => child.content)
|
|
115
|
+
.join("") || "";
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Gets the value nodes array for dynamic inspection
|
|
120
|
+
*/
|
|
121
|
+
function getAttributeValueNodes(attributeNode) {
|
|
122
|
+
const valueNode = attributeNode.value;
|
|
123
|
+
return valueNode?.children || [];
|
|
124
|
+
}
|
|
75
125
|
/**
|
|
76
126
|
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
77
127
|
*/
|
|
@@ -109,9 +159,18 @@ function hasAttributeValue(attributeNode) {
|
|
|
109
159
|
/**
|
|
110
160
|
* Gets the quote type used for an attribute value
|
|
111
161
|
*/
|
|
112
|
-
function getAttributeValueQuoteType(
|
|
113
|
-
|
|
114
|
-
|
|
162
|
+
function getAttributeValueQuoteType(nodeOrAttribute) {
|
|
163
|
+
let valueNode;
|
|
164
|
+
if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
165
|
+
const attributeNode = nodeOrAttribute;
|
|
166
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
167
|
+
valueNode = attributeNode.value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
171
|
+
valueNode = nodeOrAttribute;
|
|
172
|
+
}
|
|
173
|
+
if (valueNode) {
|
|
115
174
|
if (valueNode.quoted && valueNode.open_quote) {
|
|
116
175
|
return valueNode.open_quote.value === '"' ? "double" : "single";
|
|
117
176
|
}
|
|
@@ -138,8 +197,14 @@ function findAttributeByName(attributes, attributeName) {
|
|
|
138
197
|
* Checks if a tag has a specific attribute
|
|
139
198
|
*/
|
|
140
199
|
function hasAttribute(node, attributeName) {
|
|
200
|
+
return getAttribute(node, attributeName) !== null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Checks if a tag has a specific attribute
|
|
204
|
+
*/
|
|
205
|
+
function getAttribute(node, attributeName) {
|
|
141
206
|
const attributes = getAttributes(node);
|
|
142
|
-
return findAttributeByName(attributes, attributeName)
|
|
207
|
+
return findAttributeByName(attributes, attributeName);
|
|
143
208
|
}
|
|
144
209
|
/**
|
|
145
210
|
* Common HTML element categorization
|
|
@@ -156,6 +221,10 @@ const HTML_BLOCK_ELEMENTS = new Set([
|
|
|
156
221
|
"h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
|
|
157
222
|
"ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
|
|
158
223
|
]);
|
|
224
|
+
const HTML_VOID_ELEMENTS = new Set([
|
|
225
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
|
|
226
|
+
"param", "source", "track", "wbr",
|
|
227
|
+
]);
|
|
159
228
|
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
160
229
|
"autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
|
|
161
230
|
"loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
|
|
@@ -294,6 +363,12 @@ function isInlineElement(tagName) {
|
|
|
294
363
|
function isBlockElement(tagName) {
|
|
295
364
|
return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase());
|
|
296
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Checks if an element is a void element
|
|
368
|
+
*/
|
|
369
|
+
function isVoidElement(tagName) {
|
|
370
|
+
return HTML_VOID_ELEMENTS.has(tagName.toLowerCase());
|
|
371
|
+
}
|
|
297
372
|
/**
|
|
298
373
|
* Checks if an attribute is a boolean attribute
|
|
299
374
|
*/
|
|
@@ -301,9 +376,14 @@ function isBooleanAttribute(attributeName) {
|
|
|
301
376
|
return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase());
|
|
302
377
|
}
|
|
303
378
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
379
|
+
* Attribute visitor that provides granular processing based on both
|
|
380
|
+
* attribute name type (static/dynamic) and value type (static/dynamic)
|
|
381
|
+
*
|
|
382
|
+
* This gives you 4 distinct methods to override:
|
|
383
|
+
* - checkStaticAttributeStaticValue() - name="class" value="foo"
|
|
384
|
+
* - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
|
|
385
|
+
* - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
|
|
386
|
+
* - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
|
|
307
387
|
*/
|
|
308
388
|
class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
309
389
|
constructor(ruleName, context) {
|
|
@@ -313,19 +393,69 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
313
393
|
this.checkAttributesOnNode(node);
|
|
314
394
|
super.visitHTMLOpenTagNode(node);
|
|
315
395
|
}
|
|
316
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
317
|
-
this.checkAttributesOnNode(node);
|
|
318
|
-
super.visitHTMLSelfCloseTagNode(node);
|
|
319
|
-
}
|
|
320
396
|
checkAttributesOnNode(node) {
|
|
321
397
|
forEachAttribute(node, (attributeNode) => {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
398
|
+
const staticAttributeName = getAttributeName(attributeNode);
|
|
399
|
+
const isDynamicName = hasDynamicAttributeName(attributeNode);
|
|
400
|
+
const staticAttributeValue = getStaticAttributeValue(attributeNode);
|
|
401
|
+
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
402
|
+
const hasOutputERB = hasERBOutput(valueNodes);
|
|
403
|
+
const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes);
|
|
404
|
+
if (staticAttributeName && staticAttributeValue !== null) {
|
|
405
|
+
this.checkStaticAttributeStaticValue({
|
|
406
|
+
attributeName: staticAttributeName,
|
|
407
|
+
attributeValue: staticAttributeValue,
|
|
408
|
+
attributeNode,
|
|
409
|
+
parentNode: node
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
|
|
413
|
+
const validatableContent = getValidatableStaticContent(valueNodes) || "";
|
|
414
|
+
this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
|
|
415
|
+
}
|
|
416
|
+
else if (staticAttributeName && hasOutputERB) {
|
|
417
|
+
const combinedValue = getAttributeValue(attributeNode);
|
|
418
|
+
this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
|
|
419
|
+
}
|
|
420
|
+
else if (isDynamicName && staticAttributeValue !== null) {
|
|
421
|
+
const nameNode = attributeNode.name;
|
|
422
|
+
const nameNodes = nameNode.children || [];
|
|
423
|
+
const combinedName = getCombinedAttributeNameString(attributeNode);
|
|
424
|
+
this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName });
|
|
425
|
+
}
|
|
426
|
+
else if (isDynamicName) {
|
|
427
|
+
const nameNode = attributeNode.name;
|
|
428
|
+
const nameNodes = nameNode.children || [];
|
|
429
|
+
const combinedName = getCombinedAttributeNameString(attributeNode);
|
|
430
|
+
const combinedValue = getAttributeValue(attributeNode);
|
|
431
|
+
this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue });
|
|
326
432
|
}
|
|
327
433
|
});
|
|
328
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Static attribute name with static value: class="container"
|
|
437
|
+
*/
|
|
438
|
+
checkStaticAttributeStaticValue(params) {
|
|
439
|
+
// Default implementation does nothing
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Static attribute name with dynamic value: class="<%= css_class %>"
|
|
443
|
+
*/
|
|
444
|
+
checkStaticAttributeDynamicValue(params) {
|
|
445
|
+
// Default implementation does nothing
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Dynamic attribute name with static value: data-<%= key %>="foo"
|
|
449
|
+
*/
|
|
450
|
+
checkDynamicAttributeStaticValue(params) {
|
|
451
|
+
// Default implementation does nothing
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
|
|
455
|
+
*/
|
|
456
|
+
checkDynamicAttributeDynamicValue(params) {
|
|
457
|
+
// Default implementation does nothing
|
|
458
|
+
}
|
|
329
459
|
}
|
|
330
460
|
/**
|
|
331
461
|
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
@@ -354,7 +484,7 @@ class BaseSourceRuleVisitor {
|
|
|
354
484
|
*/
|
|
355
485
|
createOffense(message, location, severity = "error") {
|
|
356
486
|
return {
|
|
357
|
-
rule: this.ruleName,
|
|
487
|
+
rule: this.ruleName,
|
|
358
488
|
code: this.ruleName,
|
|
359
489
|
source: "Herb Linter",
|
|
360
490
|
message,
|
|
@@ -404,9 +534,9 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
|
404
534
|
}
|
|
405
535
|
class ERBNoEmptyTagsRule extends ParserRule {
|
|
406
536
|
name = "erb-no-empty-tags";
|
|
407
|
-
check(
|
|
537
|
+
check(result, context) {
|
|
408
538
|
const visitor = new ERBNoEmptyTagsVisitor(this.name, context);
|
|
409
|
-
visitor.visit(
|
|
539
|
+
visitor.visit(result.value);
|
|
410
540
|
return visitor.offenses;
|
|
411
541
|
}
|
|
412
542
|
}
|
|
@@ -450,9 +580,32 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
|
450
580
|
}
|
|
451
581
|
class ERBNoOutputControlFlowRule extends ParserRule {
|
|
452
582
|
name = "erb-no-output-control-flow";
|
|
453
|
-
check(
|
|
583
|
+
check(result, context) {
|
|
454
584
|
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context);
|
|
455
|
-
visitor.visit(
|
|
585
|
+
visitor.visit(result.value);
|
|
586
|
+
return visitor.offenses;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
|
|
591
|
+
visitHTMLAttributeNameNode(node) {
|
|
592
|
+
const erbNodes = filterERBContentNodes(node.children);
|
|
593
|
+
const silentNodes = erbNodes.filter(this.isSilentERBTag);
|
|
594
|
+
for (const node of silentNodes) {
|
|
595
|
+
this.addOffense(`Remove silent ERB tag from HTML attribute name. Silent ERB tags (\`${node.tag_opening?.value}\`) do not output content and should not be used in attribute names.`, node.location, "error");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// TODO: might be worth to extract
|
|
599
|
+
isSilentERBTag(node) {
|
|
600
|
+
const silentTags = ["<%", "<%-", "<%#"];
|
|
601
|
+
return silentTags.includes(node.tag_opening?.value || "");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
class ERBNoSilentTagInAttributeNameRule extends ParserRule {
|
|
605
|
+
name = "erb-no-silent-tag-in-attribute-name";
|
|
606
|
+
check(result, context) {
|
|
607
|
+
const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context);
|
|
608
|
+
visitor.visit(result.value);
|
|
456
609
|
return visitor.offenses;
|
|
457
610
|
}
|
|
458
611
|
}
|
|
@@ -462,10 +615,6 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
462
615
|
this.checkImgTag(node);
|
|
463
616
|
super.visitHTMLOpenTagNode(node);
|
|
464
617
|
}
|
|
465
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
466
|
-
this.checkImgTag(node);
|
|
467
|
-
super.visitHTMLSelfCloseTagNode(node);
|
|
468
|
-
}
|
|
469
618
|
checkImgTag(node) {
|
|
470
619
|
const tagName = getTagName(node);
|
|
471
620
|
if (tagName !== "img") {
|
|
@@ -541,9 +690,9 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
541
690
|
}
|
|
542
691
|
class ERBPreferImageTagHelperRule extends ParserRule {
|
|
543
692
|
name = "erb-prefer-image-tag-helper";
|
|
544
|
-
check(
|
|
693
|
+
check(result, context) {
|
|
545
694
|
const visitor = new ERBPreferImageTagHelperVisitor(this.name, context);
|
|
546
|
-
visitor.visit(
|
|
695
|
+
visitor.visit(result.value);
|
|
547
696
|
return visitor.offenses;
|
|
548
697
|
}
|
|
549
698
|
}
|
|
@@ -618,9 +767,9 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
618
767
|
}
|
|
619
768
|
class ERBRequireWhitespaceRule extends ParserRule {
|
|
620
769
|
name = "erb-require-whitespace-inside-tags";
|
|
621
|
-
check(
|
|
770
|
+
check(result, context) {
|
|
622
771
|
const visitor = new RequireWhitespaceInsideTags(this.name, context);
|
|
623
|
-
visitor.visit(
|
|
772
|
+
visitor.visit(result.value);
|
|
624
773
|
return visitor.offenses;
|
|
625
774
|
}
|
|
626
775
|
}
|
|
@@ -642,43 +791,96 @@ class AnchorRechireHrefVisitor extends BaseRuleVisitor {
|
|
|
642
791
|
}
|
|
643
792
|
class HTMLAnchorRequireHrefRule extends ParserRule {
|
|
644
793
|
name = "html-anchor-require-href";
|
|
645
|
-
check(
|
|
794
|
+
check(result, context) {
|
|
646
795
|
const visitor = new AnchorRechireHrefVisitor(this.name, context);
|
|
647
|
-
visitor.visit(
|
|
796
|
+
visitor.visit(result.value);
|
|
648
797
|
return visitor.offenses;
|
|
649
798
|
}
|
|
650
799
|
}
|
|
651
800
|
|
|
652
801
|
class AriaAttributeMustBeValid extends AttributeVisitorMixin {
|
|
653
|
-
|
|
802
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
803
|
+
this.check(attributeName, attributeNode);
|
|
804
|
+
}
|
|
805
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
|
|
806
|
+
this.check(attributeName, attributeNode);
|
|
807
|
+
}
|
|
808
|
+
check(attributeName, attributeNode) {
|
|
654
809
|
if (!attributeName.startsWith("aria-"))
|
|
655
810
|
return;
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
severity: "error",
|
|
660
|
-
location: attributeNode.location,
|
|
661
|
-
rule: this.ruleName,
|
|
662
|
-
});
|
|
663
|
-
}
|
|
811
|
+
if (ARIA_ATTRIBUTES.has(attributeName))
|
|
812
|
+
return;
|
|
813
|
+
this.addOffense(`The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`, attributeNode.location, "error");
|
|
664
814
|
}
|
|
665
815
|
}
|
|
666
816
|
class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
667
817
|
name = "html-aria-attribute-must-be-valid";
|
|
668
|
-
check(
|
|
818
|
+
check(result, context) {
|
|
669
819
|
const visitor = new AriaAttributeMustBeValid(this.name, context);
|
|
670
|
-
visitor.visit(
|
|
820
|
+
visitor.visit(result.value);
|
|
821
|
+
return visitor.offenses;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
|
|
826
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
827
|
+
if (attributeName !== "aria-label")
|
|
828
|
+
return;
|
|
829
|
+
if (attributeValue.match(/[\r\n]+/) || attributeValue.match(/ | |
|
/i)) {
|
|
830
|
+
this.addOffense("The `aria-label` attribute value text should not contain line breaks. Use concise, single-line descriptions.", attributeNode.location, "error");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (this.looksLikeId(attributeValue)) {
|
|
834
|
+
this.addOffense("The `aria-label` attribute value should not be formatted like an ID. Use natural, sentence-case text instead.", attributeNode.location, "error");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (attributeValue.match(/^[a-z]/)) {
|
|
838
|
+
this.addOffense("The `aria-label` attribute value text should be formatted like visual text. Use sentence case (capitalize the first letter).", attributeNode.location, "error");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
looksLikeId(text) {
|
|
842
|
+
return (text.includes('_') ||
|
|
843
|
+
text.includes('-') ||
|
|
844
|
+
/^[a-z]+([A-Z][a-z]*)*$/.test(text)) && !text.includes(' ');
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
|
|
848
|
+
name = "html-aria-label-is-well-formatted";
|
|
849
|
+
check(result, context) {
|
|
850
|
+
const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context);
|
|
851
|
+
visitor.visit(result.value);
|
|
671
852
|
return visitor.offenses;
|
|
672
853
|
}
|
|
673
854
|
}
|
|
674
855
|
|
|
675
856
|
class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
676
|
-
|
|
857
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
858
|
+
if (attributeName !== "aria-level")
|
|
859
|
+
return;
|
|
860
|
+
this.validateAriaLevel(attributeValue, attributeNode);
|
|
861
|
+
}
|
|
862
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
|
|
677
863
|
if (attributeName !== "aria-level")
|
|
678
864
|
return;
|
|
679
|
-
|
|
865
|
+
const validatableContent = getValidatableStaticContent(valueNodes);
|
|
866
|
+
if (validatableContent !== null) {
|
|
867
|
+
this.validateAriaLevel(validatableContent, attributeNode);
|
|
680
868
|
return;
|
|
681
|
-
|
|
869
|
+
}
|
|
870
|
+
if (!hasERBOutput(valueNodes))
|
|
871
|
+
return;
|
|
872
|
+
const literalNodes = filterLiteralNodes(valueNodes);
|
|
873
|
+
const erbOutputNodes = filterERBContentNodes(valueNodes).filter(isERBOutputNode);
|
|
874
|
+
if (literalNodes.length > 0 && erbOutputNodes.length > 0) {
|
|
875
|
+
const staticPart = literalNodes.map(node => node.content).join("");
|
|
876
|
+
// TODO: this can be cleaned up using @herb-tools/printer
|
|
877
|
+
const erbPart = erbOutputNodes[0];
|
|
878
|
+
const erbText = `${erbPart.tag_opening?.value || ""}${erbPart.content?.value || ""}${erbPart.tag_closing?.value || ""}`;
|
|
879
|
+
this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${staticPart}\` and the ERB expression \`${erbText}\`.`, attributeNode.location);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
validateAriaLevel(attributeValue, attributeNode) {
|
|
883
|
+
if (!attributeValue || attributeValue === "") {
|
|
682
884
|
this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
|
|
683
885
|
return;
|
|
684
886
|
}
|
|
@@ -690,43 +892,37 @@ class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
|
690
892
|
}
|
|
691
893
|
class HTMLAriaLevelMustBeValidRule extends ParserRule {
|
|
692
894
|
name = "html-aria-level-must-be-valid";
|
|
693
|
-
check(
|
|
895
|
+
check(result, context) {
|
|
694
896
|
const visitor = new HTMLAriaLevelMustBeValidVisitor(this.name, context);
|
|
695
|
-
visitor.visit(
|
|
897
|
+
visitor.visit(result.value);
|
|
696
898
|
return visitor.offenses;
|
|
697
899
|
}
|
|
698
900
|
}
|
|
699
901
|
|
|
700
902
|
class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
// 2. aria-level (which must be present if role="heading")
|
|
704
|
-
checkAttribute(attributeName, attributeValue, attributeNode, parentNode) {
|
|
705
|
-
if (!(attributeName === "role" && attributeValue === "heading")) {
|
|
903
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode, parentNode }) {
|
|
904
|
+
if (!(attributeName === "role" && attributeValue === "heading"))
|
|
706
905
|
return;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (!ariaLevelAttr) {
|
|
712
|
-
this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
|
|
713
|
-
}
|
|
906
|
+
const ariaLevelAttributes = getAttributes(parentNode).find(attribute => getAttributeName(attribute) === "aria-level");
|
|
907
|
+
if (ariaLevelAttributes)
|
|
908
|
+
return;
|
|
909
|
+
this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
|
|
714
910
|
}
|
|
715
911
|
}
|
|
716
912
|
class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
717
913
|
name = "html-aria-role-heading-requires-level";
|
|
718
|
-
check(
|
|
914
|
+
check(result, context) {
|
|
719
915
|
const visitor = new AriaRoleHeadingRequiresLevel(this.name, context);
|
|
720
|
-
visitor.visit(
|
|
916
|
+
visitor.visit(result.value);
|
|
721
917
|
return visitor.offenses;
|
|
722
918
|
}
|
|
723
919
|
}
|
|
724
920
|
|
|
725
921
|
class AriaRoleMustBeValid extends AttributeVisitorMixin {
|
|
726
|
-
|
|
922
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
727
923
|
if (attributeName !== "role")
|
|
728
924
|
return;
|
|
729
|
-
if (attributeValue
|
|
925
|
+
if (!attributeValue)
|
|
730
926
|
return;
|
|
731
927
|
if (VALID_ARIA_ROLES.has(attributeValue))
|
|
732
928
|
return;
|
|
@@ -735,80 +931,207 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
|
|
|
735
931
|
}
|
|
736
932
|
class HTMLAriaRoleMustBeValidRule extends ParserRule {
|
|
737
933
|
name = "html-aria-role-must-be-valid";
|
|
738
|
-
check(
|
|
934
|
+
check(result, context) {
|
|
739
935
|
const visitor = new AriaRoleMustBeValid(this.name, context);
|
|
740
|
-
visitor.visit(
|
|
936
|
+
visitor.visit(result.value);
|
|
741
937
|
return visitor.offenses;
|
|
742
938
|
}
|
|
743
939
|
}
|
|
744
940
|
|
|
745
941
|
class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
746
|
-
|
|
942
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
747
943
|
if (!hasAttributeValue(attributeNode))
|
|
748
944
|
return;
|
|
749
945
|
if (getAttributeValueQuoteType(attributeNode) !== "single")
|
|
750
946
|
return;
|
|
751
947
|
if (attributeValue?.includes('"'))
|
|
752
|
-
return;
|
|
753
|
-
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="
|
|
948
|
+
return;
|
|
949
|
+
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${attributeValue}"\`.`, attributeNode.value.location, "warning");
|
|
950
|
+
}
|
|
951
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode, combinedValue }) {
|
|
952
|
+
if (!hasAttributeValue(attributeNode))
|
|
953
|
+
return;
|
|
954
|
+
if (getAttributeValueQuoteType(attributeNode) !== "single")
|
|
955
|
+
return;
|
|
956
|
+
if (filterLiteralNodes(valueNodes).some(node => node.content?.includes('"')))
|
|
957
|
+
return;
|
|
958
|
+
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "warning");
|
|
754
959
|
}
|
|
755
960
|
}
|
|
756
961
|
class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
757
962
|
name = "html-attribute-double-quotes";
|
|
758
|
-
check(
|
|
963
|
+
check(result, context) {
|
|
759
964
|
const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
|
|
760
|
-
visitor.visit(
|
|
965
|
+
visitor.visit(result.value);
|
|
966
|
+
return visitor.offenses;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
|
|
971
|
+
visitHTMLAttributeNode(attribute) {
|
|
972
|
+
if (!attribute.equals || !attribute.name || !attribute.value) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (attribute.equals.value.startsWith(" ")) {
|
|
976
|
+
this.addOffense("Remove whitespace before `=` in HTML attribute", attribute.equals.location, "error");
|
|
977
|
+
}
|
|
978
|
+
if (attribute.equals.value.endsWith(" ")) {
|
|
979
|
+
this.addOffense("Remove whitespace after `=` in HTML attribute", attribute.equals.location, "error");
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
class HTMLAttributeEqualsSpacingRule extends ParserRule {
|
|
984
|
+
name = "html-attribute-equals-spacing";
|
|
985
|
+
check(result, context) {
|
|
986
|
+
const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context);
|
|
987
|
+
visitor.visit(result.value);
|
|
761
988
|
return visitor.offenses;
|
|
762
989
|
}
|
|
763
990
|
}
|
|
764
991
|
|
|
765
992
|
class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
|
|
766
|
-
|
|
767
|
-
if (attributeNode
|
|
993
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
994
|
+
if (this.hasAttributeValue(attributeNode))
|
|
768
995
|
return;
|
|
769
|
-
|
|
770
|
-
|
|
996
|
+
if (this.isQuoted(attributeNode))
|
|
997
|
+
return;
|
|
998
|
+
this.addOffense(`Attribute value should be quoted: \`${attributeName}="${attributeValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
|
|
999
|
+
}
|
|
1000
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
|
|
1001
|
+
if (this.hasAttributeValue(attributeNode))
|
|
1002
|
+
return;
|
|
1003
|
+
if (this.isQuoted(attributeNode))
|
|
771
1004
|
return;
|
|
772
|
-
this.addOffense(
|
|
773
|
-
|
|
774
|
-
|
|
1005
|
+
this.addOffense(`Attribute value should be quoted: \`${attributeName}="${combinedValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
|
|
1006
|
+
}
|
|
1007
|
+
hasAttributeValue(attributeNode) {
|
|
1008
|
+
return attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE";
|
|
1009
|
+
}
|
|
1010
|
+
isQuoted(attributeNode) {
|
|
1011
|
+
const valueNode = attributeNode.value;
|
|
1012
|
+
return valueNode.quoted;
|
|
775
1013
|
}
|
|
776
1014
|
}
|
|
777
1015
|
class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
778
1016
|
name = "html-attribute-values-require-quotes";
|
|
779
|
-
check(
|
|
1017
|
+
check(result, context) {
|
|
780
1018
|
const visitor = new AttributeValuesRequireQuotesVisitor(this.name, context);
|
|
781
|
-
visitor.visit(
|
|
1019
|
+
visitor.visit(result.value);
|
|
1020
|
+
return visitor.offenses;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT = new Set([
|
|
1025
|
+
"button", "fieldset", "input", "optgroup", "option", "select", "textarea"
|
|
1026
|
+
]);
|
|
1027
|
+
class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
|
|
1028
|
+
visitHTMLOpenTagNode(node) {
|
|
1029
|
+
this.checkElement(node);
|
|
1030
|
+
super.visitHTMLOpenTagNode(node);
|
|
1031
|
+
}
|
|
1032
|
+
checkElement(node) {
|
|
1033
|
+
const tagName = getTagName(node);
|
|
1034
|
+
if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const hasDisabled = hasAttribute(node, "disabled");
|
|
1038
|
+
const hasAriaDisabled = hasAttribute(node, "aria-disabled");
|
|
1039
|
+
if ((hasDisabled && this.hasERBContent(node, "disabled")) || (hasAriaDisabled && this.hasERBContent(node, "aria-disabled"))) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (hasDisabled && hasAriaDisabled) {
|
|
1043
|
+
this.addOffense("aria-disabled may be used in place of native HTML disabled to allow tab-focus on an otherwise ignored element. Setting both attributes is contradictory and confusing. Choose either disabled or aria-disabled, not both.", node.tag_name.location, "error");
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
hasERBContent(node, attributeName) {
|
|
1047
|
+
const attributes = getAttributes(node);
|
|
1048
|
+
const attribute = findAttributeByName(attributes, attributeName);
|
|
1049
|
+
if (!attribute)
|
|
1050
|
+
return false;
|
|
1051
|
+
const valueNode = attribute.value;
|
|
1052
|
+
if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
|
|
1053
|
+
return false;
|
|
1054
|
+
const htmlValueNode = valueNode;
|
|
1055
|
+
if (!htmlValueNode.children)
|
|
1056
|
+
return false;
|
|
1057
|
+
return htmlValueNode.children.some((child) => child.type === "AST_ERB_CONTENT_NODE");
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
|
|
1061
|
+
name = "html-avoid-both-disabled-and-aria-disabled";
|
|
1062
|
+
check(result, context) {
|
|
1063
|
+
const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context);
|
|
1064
|
+
visitor.visit(result.value);
|
|
782
1065
|
return visitor.offenses;
|
|
783
1066
|
}
|
|
784
1067
|
}
|
|
785
1068
|
|
|
786
1069
|
class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
787
|
-
|
|
1070
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
788
1071
|
if (!isBooleanAttribute(attributeName))
|
|
789
1072
|
return;
|
|
790
1073
|
if (!hasAttributeValue(attributeNode))
|
|
791
1074
|
return;
|
|
792
1075
|
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
|
|
793
1076
|
}
|
|
1077
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
|
|
1078
|
+
if (!isBooleanAttribute(attributeName))
|
|
1079
|
+
return;
|
|
1080
|
+
if (!hasAttributeValue(attributeNode))
|
|
1081
|
+
return;
|
|
1082
|
+
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "error");
|
|
1083
|
+
}
|
|
794
1084
|
}
|
|
795
1085
|
class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
796
1086
|
name = "html-boolean-attributes-no-value";
|
|
797
|
-
check(
|
|
1087
|
+
check(result, context) {
|
|
798
1088
|
const visitor = new BooleanAttributesNoValueVisitor(this.name, context);
|
|
799
|
-
visitor.visit(
|
|
1089
|
+
visitor.visit(result.value);
|
|
800
1090
|
return visitor.offenses;
|
|
801
1091
|
}
|
|
802
1092
|
}
|
|
803
1093
|
|
|
804
|
-
class
|
|
1094
|
+
class IframeHasTitleVisitor extends BaseRuleVisitor {
|
|
805
1095
|
visitHTMLOpenTagNode(node) {
|
|
806
|
-
this.
|
|
1096
|
+
this.checkIframeElement(node);
|
|
807
1097
|
super.visitHTMLOpenTagNode(node);
|
|
808
1098
|
}
|
|
809
|
-
|
|
1099
|
+
checkIframeElement(node) {
|
|
1100
|
+
const tagName = getTagName(node);
|
|
1101
|
+
if (tagName !== "iframe") {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const ariaHiddenAttribute = getAttribute(node, "aria-hidden");
|
|
1105
|
+
if (ariaHiddenAttribute) {
|
|
1106
|
+
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
1107
|
+
if (ariaHiddenValue === "true") {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const attribute = getAttribute(node, "title");
|
|
1112
|
+
if (!attribute) {
|
|
1113
|
+
this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const value = getAttributeValue(attribute);
|
|
1117
|
+
if (!value || value.trim() === "") {
|
|
1118
|
+
this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
class HTMLIframeHasTitleRule extends ParserRule {
|
|
1123
|
+
name = "html-iframe-has-title";
|
|
1124
|
+
check(result, context) {
|
|
1125
|
+
const visitor = new IframeHasTitleVisitor(this.name, context);
|
|
1126
|
+
visitor.visit(result.value);
|
|
1127
|
+
return visitor.offenses;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
1132
|
+
visitHTMLOpenTagNode(node) {
|
|
810
1133
|
this.checkImgTag(node);
|
|
811
|
-
super.
|
|
1134
|
+
super.visitHTMLOpenTagNode(node);
|
|
812
1135
|
}
|
|
813
1136
|
checkImgTag(node) {
|
|
814
1137
|
const tagName = getTagName(node);
|
|
@@ -822,41 +1145,144 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
|
822
1145
|
}
|
|
823
1146
|
class HTMLImgRequireAltRule extends ParserRule {
|
|
824
1147
|
name = "html-img-require-alt";
|
|
825
|
-
check(
|
|
1148
|
+
check(result, context) {
|
|
826
1149
|
const visitor = new ImgRequireAltVisitor(this.name, context);
|
|
827
|
-
visitor.visit(
|
|
1150
|
+
visitor.visit(result.value);
|
|
828
1151
|
return visitor.offenses;
|
|
829
1152
|
}
|
|
830
1153
|
}
|
|
831
1154
|
|
|
832
|
-
class
|
|
1155
|
+
class NavigationHasLabelVisitor extends BaseRuleVisitor {
|
|
833
1156
|
visitHTMLOpenTagNode(node) {
|
|
834
|
-
this.
|
|
1157
|
+
this.checkNavigationElement(node);
|
|
835
1158
|
super.visitHTMLOpenTagNode(node);
|
|
836
1159
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1160
|
+
checkNavigationElement(node) {
|
|
1161
|
+
const tagName = getTagName(node);
|
|
1162
|
+
const isNavElement = tagName === "nav";
|
|
1163
|
+
const hasNavigationRole = this.hasRoleNavigation(node);
|
|
1164
|
+
if (!isNavElement && !hasNavigationRole) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const hasAriaLabel = hasAttribute(node, "aria-label");
|
|
1168
|
+
const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
|
|
1169
|
+
if (!hasAriaLabel && !hasAriaLabelledby) {
|
|
1170
|
+
let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
|
|
1171
|
+
if (hasNavigationRole && !isNavElement) {
|
|
1172
|
+
message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
|
|
1173
|
+
}
|
|
1174
|
+
this.addOffense(message, node.tag_name.location, "error");
|
|
1175
|
+
}
|
|
840
1176
|
}
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1177
|
+
hasRoleNavigation(node) {
|
|
1178
|
+
const attributes = getAttributes(node);
|
|
1179
|
+
const roleAttribute = findAttributeByName(attributes, "role");
|
|
1180
|
+
if (!roleAttribute) {
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
const roleValue = getAttributeValue(roleAttribute);
|
|
1184
|
+
return roleValue === "navigation";
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
class HTMLNavigationHasLabelRule extends ParserRule {
|
|
1188
|
+
name = "html-navigation-has-label";
|
|
1189
|
+
check(result, context) {
|
|
1190
|
+
const visitor = new NavigationHasLabelVisitor(this.name, context);
|
|
1191
|
+
visitor.visit(result.value);
|
|
1192
|
+
return visitor.offenses;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const INTERACTIVE_ELEMENTS = new Set([
|
|
1197
|
+
"button", "summary", "input", "select", "textarea", "a"
|
|
1198
|
+
]);
|
|
1199
|
+
class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
|
|
1200
|
+
visitHTMLOpenTagNode(node) {
|
|
1201
|
+
this.checkAriaHiddenOnFocusable(node);
|
|
1202
|
+
super.visitHTMLOpenTagNode(node);
|
|
1203
|
+
}
|
|
1204
|
+
checkAriaHiddenOnFocusable(node) {
|
|
1205
|
+
if (!this.hasAriaHiddenTrue(node))
|
|
1206
|
+
return;
|
|
1207
|
+
if (this.isFocusable(node)) {
|
|
1208
|
+
this.addOffense(`Elements that are focusable should not have \`aria-hidden="true"\` because it will cause confusion for assistive technology users.`, node.tag_name.location, "error");
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
hasAriaHiddenTrue(node) {
|
|
1212
|
+
const attributes = getAttributes(node);
|
|
1213
|
+
const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden");
|
|
1214
|
+
if (!ariaHiddenAttr)
|
|
1215
|
+
return false;
|
|
1216
|
+
const value = getAttributeValue(ariaHiddenAttr);
|
|
1217
|
+
return value === "true";
|
|
1218
|
+
}
|
|
1219
|
+
isFocusable(node) {
|
|
1220
|
+
const tagName = getTagName(node);
|
|
1221
|
+
if (!tagName)
|
|
1222
|
+
return false;
|
|
1223
|
+
const tabIndexValue = this.getTabIndexValue(node);
|
|
1224
|
+
if (tagName === "a") {
|
|
1225
|
+
const hasHref = hasAttribute(node, "href");
|
|
1226
|
+
if (!hasHref) {
|
|
1227
|
+
return tabIndexValue !== null && tabIndexValue >= 0;
|
|
852
1228
|
}
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1229
|
+
return tabIndexValue === null || tabIndexValue >= 0;
|
|
1230
|
+
}
|
|
1231
|
+
if (INTERACTIVE_ELEMENTS.has(tagName)) {
|
|
1232
|
+
// Interactive elements are focusable unless tabindex is negative
|
|
1233
|
+
return tabIndexValue === null || tabIndexValue >= 0;
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
// Non-interactive elements are focusable only if tabindex >= 0
|
|
1237
|
+
return tabIndexValue !== null && tabIndexValue >= 0;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
getTabIndexValue(node) {
|
|
1241
|
+
const attributes = getAttributes(node);
|
|
1242
|
+
const tabIndexAttribute = findAttributeByName(attributes, "tabindex");
|
|
1243
|
+
if (!tabIndexAttribute)
|
|
1244
|
+
return null;
|
|
1245
|
+
const value = getAttributeValue(tabIndexAttribute);
|
|
1246
|
+
if (!value)
|
|
1247
|
+
return null;
|
|
1248
|
+
const parsed = parseInt(value, 10);
|
|
1249
|
+
return isNaN(parsed) ? null : parsed;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
|
|
1253
|
+
name = "html-no-aria-hidden-on-focusable";
|
|
1254
|
+
check(result, context) {
|
|
1255
|
+
const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context);
|
|
1256
|
+
visitor.visit(result.value);
|
|
1257
|
+
return visitor.offenses;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
class NoDuplicateAttributesVisitor extends AttributeVisitorMixin {
|
|
1262
|
+
attributeNames = new Map();
|
|
1263
|
+
visitHTMLOpenTagNode(node) {
|
|
1264
|
+
this.attributeNames.clear();
|
|
1265
|
+
super.visitHTMLOpenTagNode(node);
|
|
1266
|
+
this.reportDuplicates();
|
|
1267
|
+
}
|
|
1268
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
1269
|
+
this.trackAttributeName(attributeName, attributeNode);
|
|
1270
|
+
}
|
|
1271
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
|
|
1272
|
+
this.trackAttributeName(attributeName, attributeNode);
|
|
1273
|
+
}
|
|
1274
|
+
trackAttributeName(attributeName, attributeNode) {
|
|
1275
|
+
if (!this.attributeNames.has(attributeName)) {
|
|
1276
|
+
this.attributeNames.set(attributeName, []);
|
|
1277
|
+
}
|
|
1278
|
+
this.attributeNames.get(attributeName).push(attributeNode);
|
|
1279
|
+
}
|
|
1280
|
+
reportDuplicates() {
|
|
1281
|
+
for (const [attributeName, attributeNodes] of this.attributeNames) {
|
|
1282
|
+
if (attributeNodes.length > 1) {
|
|
1283
|
+
for (let i = 1; i < attributeNodes.length; i++) {
|
|
1284
|
+
const attributeNode = attributeNodes[i];
|
|
1285
|
+
this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, attributeNode.name.location, "error");
|
|
860
1286
|
}
|
|
861
1287
|
}
|
|
862
1288
|
}
|
|
@@ -864,16 +1290,16 @@ class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
|
|
|
864
1290
|
}
|
|
865
1291
|
class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
866
1292
|
name = "html-no-duplicate-attributes";
|
|
867
|
-
check(
|
|
1293
|
+
check(result, context) {
|
|
868
1294
|
const visitor = new NoDuplicateAttributesVisitor(this.name, context);
|
|
869
|
-
visitor.visit(
|
|
1295
|
+
visitor.visit(result.value);
|
|
870
1296
|
return visitor.offenses;
|
|
871
1297
|
}
|
|
872
1298
|
}
|
|
873
1299
|
|
|
874
1300
|
class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
|
|
875
1301
|
documentIds = new Set();
|
|
876
|
-
|
|
1302
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
877
1303
|
if (attributeName.toLowerCase() !== "id")
|
|
878
1304
|
return;
|
|
879
1305
|
if (!attributeValue)
|
|
@@ -888,9 +1314,9 @@ class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
|
|
|
888
1314
|
}
|
|
889
1315
|
class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
890
1316
|
name = "html-no-duplicate-ids";
|
|
891
|
-
check(
|
|
1317
|
+
check(result, context) {
|
|
892
1318
|
const visitor = new NoDuplicateIdsVisitor(this.name, context);
|
|
893
|
-
visitor.visit(
|
|
1319
|
+
visitor.visit(result.value);
|
|
894
1320
|
return visitor.offenses;
|
|
895
1321
|
}
|
|
896
1322
|
}
|
|
@@ -900,10 +1326,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
900
1326
|
this.checkHeadingElement(node);
|
|
901
1327
|
super.visitHTMLElementNode(node);
|
|
902
1328
|
}
|
|
903
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
904
|
-
this.checkSelfClosingHeading(node);
|
|
905
|
-
super.visitHTMLSelfCloseTagNode(node);
|
|
906
|
-
}
|
|
907
1329
|
checkHeadingElement(node) {
|
|
908
1330
|
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
909
1331
|
return;
|
|
@@ -925,23 +1347,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
925
1347
|
this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.location, "error");
|
|
926
1348
|
}
|
|
927
1349
|
}
|
|
928
|
-
checkSelfClosingHeading(node) {
|
|
929
|
-
const tagName = getTagName(node);
|
|
930
|
-
if (!tagName) {
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
// Check if it's a standard heading tag (h1-h6) or has role="heading"
|
|
934
|
-
const isStandardHeading = HEADING_TAGS.has(tagName);
|
|
935
|
-
const isAriaHeading = this.hasHeadingRole(node);
|
|
936
|
-
if (!isStandardHeading && !isAriaHeading) {
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
// Self-closing headings are always empty
|
|
940
|
-
const elementDescription = isStandardHeading
|
|
941
|
-
? `\`<${tagName}>\``
|
|
942
|
-
: `\`<${tagName} role="heading">\``;
|
|
943
|
-
this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.tag_name.location, "error");
|
|
944
|
-
}
|
|
945
1350
|
isEmptyHeading(node) {
|
|
946
1351
|
if (!node.body || node.body.length === 0) {
|
|
947
1352
|
return true;
|
|
@@ -1035,9 +1440,9 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
1035
1440
|
}
|
|
1036
1441
|
class HTMLNoEmptyHeadingsRule extends ParserRule {
|
|
1037
1442
|
name = "html-no-empty-headings";
|
|
1038
|
-
check(
|
|
1443
|
+
check(result, context) {
|
|
1039
1444
|
const visitor = new NoEmptyHeadingsVisitor(this.name, context);
|
|
1040
|
-
visitor.visit(
|
|
1445
|
+
visitor.visit(result.value);
|
|
1041
1446
|
return visitor.offenses;
|
|
1042
1447
|
}
|
|
1043
1448
|
}
|
|
@@ -1079,60 +1484,152 @@ class NestedLinkVisitor extends BaseRuleVisitor {
|
|
|
1079
1484
|
}
|
|
1080
1485
|
class HTMLNoNestedLinksRule extends ParserRule {
|
|
1081
1486
|
name = "html-no-nested-links";
|
|
1082
|
-
check(
|
|
1487
|
+
check(result, context) {
|
|
1083
1488
|
const visitor = new NestedLinkVisitor(this.name, context);
|
|
1084
|
-
visitor.visit(
|
|
1489
|
+
visitor.visit(result.value);
|
|
1085
1490
|
return visitor.offenses;
|
|
1086
1491
|
}
|
|
1087
1492
|
}
|
|
1088
1493
|
|
|
1089
|
-
class
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1494
|
+
class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
|
|
1495
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
1496
|
+
if (attributeName !== "tabindex")
|
|
1497
|
+
return;
|
|
1498
|
+
const tabIndexValue = parseInt(attributeValue, 10);
|
|
1499
|
+
if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
|
|
1500
|
+
this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex=\"-1\"\` to remove it from the tab sequence.`, attributeNode.location, "error");
|
|
1094
1501
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
class HTMLNoPositiveTabIndexRule extends ParserRule {
|
|
1505
|
+
name = "html-no-positive-tab-index";
|
|
1506
|
+
check(result, context) {
|
|
1507
|
+
const visitor = new NoPositiveTabIndexVisitor(this.name, context);
|
|
1508
|
+
visitor.visit(result.value);
|
|
1509
|
+
return visitor.offenses;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
1514
|
+
visitHTMLOpenTagNode(node) {
|
|
1515
|
+
if (node.tag_closing?.value === "/>") {
|
|
1516
|
+
const tagName = getTagName(node);
|
|
1517
|
+
const shouldBeVoid = tagName ? isVoidElement(tagName) : false;
|
|
1518
|
+
const instead = shouldBeVoid ? `Use \`<${tagName}>\` instead.` : `Use \`<${tagName}></${tagName}>\` instead.`;
|
|
1519
|
+
this.addOffense(`Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`, node.location, "error");
|
|
1520
|
+
}
|
|
1521
|
+
super.visitHTMLOpenTagNode(node);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
class HTMLNoSelfClosingRule extends ParserRule {
|
|
1525
|
+
name = "html-no-self-closing";
|
|
1526
|
+
check(result, context) {
|
|
1527
|
+
const visitor = new NoSelfClosingVisitor(this.name, context);
|
|
1528
|
+
visitor.visit(result.value);
|
|
1529
|
+
return visitor.offenses;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
class NoTitleAttributeVisitor extends BaseRuleVisitor {
|
|
1534
|
+
ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
|
|
1535
|
+
visitHTMLOpenTagNode(node) {
|
|
1536
|
+
this.checkTitleAttribute(node);
|
|
1537
|
+
super.visitHTMLOpenTagNode(node);
|
|
1538
|
+
}
|
|
1539
|
+
checkTitleAttribute(node) {
|
|
1540
|
+
const tagName = getTagName(node);
|
|
1541
|
+
if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
|
|
1099
1542
|
return;
|
|
1100
1543
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1544
|
+
if (hasAttribute(node, "title")) {
|
|
1545
|
+
this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
class HTMLNoTitleAttributeRule extends ParserRule {
|
|
1550
|
+
name = "html-no-title-attribute";
|
|
1551
|
+
check(result, context) {
|
|
1552
|
+
const visitor = new NoTitleAttributeVisitor(this.name, context);
|
|
1553
|
+
visitor.visit(result.value);
|
|
1554
|
+
return visitor.offenses;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
class XMLDeclarationChecker extends BaseRuleVisitor {
|
|
1559
|
+
hasXMLDeclaration = false;
|
|
1560
|
+
visitXMLDeclarationNode(_node) {
|
|
1561
|
+
this.hasXMLDeclaration = true;
|
|
1562
|
+
}
|
|
1563
|
+
visitChildNodes(node) {
|
|
1564
|
+
if (this.hasXMLDeclaration)
|
|
1565
|
+
return;
|
|
1566
|
+
super.visitChildNodes(node);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
1570
|
+
visitHTMLElementNode(node) {
|
|
1571
|
+
if (getTagName$1(node).toLowerCase() === "svg") {
|
|
1572
|
+
this.checkTagName(node.open_tag);
|
|
1103
1573
|
this.checkTagName(node.close_tag);
|
|
1104
1574
|
}
|
|
1575
|
+
else {
|
|
1576
|
+
super.visitHTMLElementNode(node);
|
|
1577
|
+
}
|
|
1105
1578
|
}
|
|
1106
|
-
|
|
1579
|
+
visitHTMLOpenTagNode(node) {
|
|
1580
|
+
this.checkTagName(node);
|
|
1581
|
+
}
|
|
1582
|
+
visitHTMLCloseTagNode(node) {
|
|
1107
1583
|
this.checkTagName(node);
|
|
1108
|
-
this.visitChildNodes(node);
|
|
1109
1584
|
}
|
|
1110
1585
|
checkTagName(node) {
|
|
1111
|
-
|
|
1586
|
+
if (!node)
|
|
1587
|
+
return;
|
|
1588
|
+
const tagName = getTagName$1(node);
|
|
1112
1589
|
if (!tagName)
|
|
1113
1590
|
return;
|
|
1114
1591
|
const lowercaseTagName = tagName.toLowerCase();
|
|
1592
|
+
const type = isNode(node, HTMLOpenTagNode) ? "Opening" : "Closing";
|
|
1593
|
+
const open = isNode(node, HTMLOpenTagNode) ? "<" : "</";
|
|
1115
1594
|
if (tagName !== lowercaseTagName) {
|
|
1116
|
-
|
|
1117
|
-
if (node.type == "AST_HTML_OPEN_TAG_NODE")
|
|
1118
|
-
type = "Opening";
|
|
1119
|
-
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
1120
|
-
type = "Closing";
|
|
1121
|
-
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
1122
|
-
type = "Self-closing";
|
|
1123
|
-
this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`, node.tag_name.location, "error");
|
|
1595
|
+
this.addOffense(`${type} tag name \`${open}${tagName}>\` should be lowercase. Use \`${open}${lowercaseTagName}>\` instead.`, node.tag_name.location, "error");
|
|
1124
1596
|
}
|
|
1125
1597
|
}
|
|
1126
1598
|
}
|
|
1127
1599
|
class HTMLTagNameLowercaseRule extends ParserRule {
|
|
1128
1600
|
name = "html-tag-name-lowercase";
|
|
1129
|
-
|
|
1601
|
+
isEnabled(result, context) {
|
|
1602
|
+
if (context?.fileName?.endsWith(".xml") || context?.fileName?.endsWith(".xml.erb")) {
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
const checker = new XMLDeclarationChecker(this.name);
|
|
1606
|
+
checker.visit(result.value);
|
|
1607
|
+
return !checker.hasXMLDeclaration;
|
|
1608
|
+
}
|
|
1609
|
+
check(result, context) {
|
|
1130
1610
|
const visitor = new TagNameLowercaseVisitor(this.name, context);
|
|
1131
|
-
visitor.visit(
|
|
1611
|
+
visitor.visit(result.value);
|
|
1132
1612
|
return visitor.offenses;
|
|
1133
1613
|
}
|
|
1134
1614
|
}
|
|
1135
1615
|
|
|
1616
|
+
class ParserNoErrorsRule extends ParserRule {
|
|
1617
|
+
name = "parser-no-errors";
|
|
1618
|
+
check(result) {
|
|
1619
|
+
return result.recursiveErrors().map(error => this.herbErrorToLintOffense(error));
|
|
1620
|
+
}
|
|
1621
|
+
herbErrorToLintOffense(error) {
|
|
1622
|
+
return {
|
|
1623
|
+
message: `${error.message} (\`${error.type}\`)`,
|
|
1624
|
+
location: error.location,
|
|
1625
|
+
severity: error.severity,
|
|
1626
|
+
rule: this.name,
|
|
1627
|
+
code: this.name,
|
|
1628
|
+
source: "linter"
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1136
1633
|
class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
1137
1634
|
insideSVG = false;
|
|
1138
1635
|
visitHTMLElementNode(node) {
|
|
@@ -1154,12 +1651,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1154
1651
|
}
|
|
1155
1652
|
this.visitChildNodes(node);
|
|
1156
1653
|
}
|
|
1157
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
1158
|
-
if (this.insideSVG) {
|
|
1159
|
-
this.checkTagName(node);
|
|
1160
|
-
}
|
|
1161
|
-
this.visitChildNodes(node);
|
|
1162
|
-
}
|
|
1163
1654
|
checkTagName(node) {
|
|
1164
1655
|
const tagName = node.tag_name?.value;
|
|
1165
1656
|
if (!tagName)
|
|
@@ -1174,17 +1665,15 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1174
1665
|
type = "Opening";
|
|
1175
1666
|
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
1176
1667
|
type = "Closing";
|
|
1177
|
-
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
1178
|
-
type = "Self-closing";
|
|
1179
1668
|
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
|
|
1180
1669
|
}
|
|
1181
1670
|
}
|
|
1182
1671
|
}
|
|
1183
1672
|
class SVGTagNameCapitalizationRule extends ParserRule {
|
|
1184
1673
|
name = "svg-tag-name-capitalization";
|
|
1185
|
-
check(
|
|
1674
|
+
check(result, context) {
|
|
1186
1675
|
const visitor = new SVGTagNameCapitalizationVisitor(this.name, context);
|
|
1187
|
-
visitor.visit(
|
|
1676
|
+
visitor.visit(result.value);
|
|
1188
1677
|
return visitor.offenses;
|
|
1189
1678
|
}
|
|
1190
1679
|
}
|
|
@@ -1192,24 +1681,35 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
1192
1681
|
const defaultRules = [
|
|
1193
1682
|
ERBNoEmptyTagsRule,
|
|
1194
1683
|
ERBNoOutputControlFlowRule,
|
|
1684
|
+
ERBNoSilentTagInAttributeNameRule,
|
|
1195
1685
|
ERBPreferImageTagHelperRule,
|
|
1196
1686
|
ERBRequiresTrailingNewlineRule,
|
|
1197
1687
|
ERBRequireWhitespaceRule,
|
|
1198
1688
|
HTMLAnchorRequireHrefRule,
|
|
1199
1689
|
HTMLAriaAttributeMustBeValid,
|
|
1690
|
+
HTMLAriaLabelIsWellFormattedRule,
|
|
1200
1691
|
HTMLAriaLevelMustBeValidRule,
|
|
1201
1692
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
1202
1693
|
HTMLAriaRoleMustBeValidRule,
|
|
1203
1694
|
HTMLAttributeDoubleQuotesRule,
|
|
1695
|
+
HTMLAttributeEqualsSpacingRule,
|
|
1204
1696
|
HTMLAttributeValuesRequireQuotesRule,
|
|
1697
|
+
HTMLAvoidBothDisabledAndAriaDisabledRule,
|
|
1205
1698
|
HTMLBooleanAttributesNoValueRule,
|
|
1699
|
+
HTMLIframeHasTitleRule,
|
|
1206
1700
|
HTMLImgRequireAltRule,
|
|
1701
|
+
HTMLNavigationHasLabelRule,
|
|
1702
|
+
HTMLNoAriaHiddenOnFocusableRule,
|
|
1207
1703
|
// HTMLNoBlockInsideInlineRule,
|
|
1208
1704
|
HTMLNoDuplicateAttributesRule,
|
|
1209
1705
|
HTMLNoDuplicateIdsRule,
|
|
1210
1706
|
HTMLNoEmptyHeadingsRule,
|
|
1211
1707
|
HTMLNoNestedLinksRule,
|
|
1708
|
+
HTMLNoPositiveTabIndexRule,
|
|
1709
|
+
HTMLNoSelfClosingRule,
|
|
1710
|
+
HTMLNoTitleAttributeRule,
|
|
1212
1711
|
HTMLTagNameLowercaseRule,
|
|
1712
|
+
ParserNoErrorsRule,
|
|
1213
1713
|
SVGTagNameCapitalizationRule,
|
|
1214
1714
|
];
|
|
1215
1715
|
|
|
@@ -1256,19 +1756,44 @@ class Linter {
|
|
|
1256
1756
|
*/
|
|
1257
1757
|
lint(source, context) {
|
|
1258
1758
|
this.offenses = [];
|
|
1259
|
-
const parseResult = this.herb.parse(source);
|
|
1759
|
+
const parseResult = this.herb.parse(source, { track_whitespace: true });
|
|
1260
1760
|
const lexResult = this.herb.lex(source);
|
|
1261
1761
|
for (const RuleClass of this.rules) {
|
|
1262
1762
|
const rule = new RuleClass();
|
|
1763
|
+
let isEnabled = true;
|
|
1263
1764
|
let ruleOffenses;
|
|
1264
1765
|
if (this.isLexerRule(rule)) {
|
|
1265
|
-
|
|
1766
|
+
if (rule.isEnabled) {
|
|
1767
|
+
isEnabled = rule.isEnabled(lexResult, context);
|
|
1768
|
+
}
|
|
1769
|
+
if (isEnabled) {
|
|
1770
|
+
ruleOffenses = rule.check(lexResult, context);
|
|
1771
|
+
}
|
|
1772
|
+
else {
|
|
1773
|
+
ruleOffenses = [];
|
|
1774
|
+
}
|
|
1266
1775
|
}
|
|
1267
1776
|
else if (this.isSourceRule(rule)) {
|
|
1268
|
-
|
|
1777
|
+
if (rule.isEnabled) {
|
|
1778
|
+
isEnabled = rule.isEnabled(source, context);
|
|
1779
|
+
}
|
|
1780
|
+
if (isEnabled) {
|
|
1781
|
+
ruleOffenses = rule.check(source, context);
|
|
1782
|
+
}
|
|
1783
|
+
else {
|
|
1784
|
+
ruleOffenses = [];
|
|
1785
|
+
}
|
|
1269
1786
|
}
|
|
1270
1787
|
else {
|
|
1271
|
-
|
|
1788
|
+
if (rule.isEnabled) {
|
|
1789
|
+
isEnabled = rule.isEnabled(parseResult, context);
|
|
1790
|
+
}
|
|
1791
|
+
if (isEnabled) {
|
|
1792
|
+
ruleOffenses = rule.check(parseResult, context);
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
ruleOffenses = [];
|
|
1796
|
+
}
|
|
1272
1797
|
}
|
|
1273
1798
|
this.offenses.push(...ruleOffenses);
|
|
1274
1799
|
}
|
|
@@ -1293,7 +1818,7 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
|
1293
1818
|
const isUnknown = !isInline && !isBlock;
|
|
1294
1819
|
return { isInline, isBlock, isUnknown };
|
|
1295
1820
|
}
|
|
1296
|
-
|
|
1821
|
+
addOffenseMessage(tagName, isBlock, openTag) {
|
|
1297
1822
|
const parentInline = this.inlineStack[this.inlineStack.length - 1];
|
|
1298
1823
|
const elementType = isBlock ? "Block-level" : "Unknown";
|
|
1299
1824
|
this.addOffense(`${elementType} element \`<${tagName}>\` cannot be placed inside inline element \`<${parentInline}>\`.`, openTag.tag_name.location, "error");
|
|
@@ -1322,7 +1847,7 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
|
1322
1847
|
}
|
|
1323
1848
|
const { isInline, isBlock, isUnknown } = this.getElementType(tagName);
|
|
1324
1849
|
if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
|
|
1325
|
-
this.
|
|
1850
|
+
this.addOffenseMessage(tagName, isBlock, openTag);
|
|
1326
1851
|
}
|
|
1327
1852
|
if (isInline) {
|
|
1328
1853
|
this.visitInlineElement(node, tagName);
|
|
@@ -1333,12 +1858,12 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
|
1333
1858
|
}
|
|
1334
1859
|
class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
1335
1860
|
name = "html-no-block-inside-inline";
|
|
1336
|
-
check(
|
|
1861
|
+
check(result, context) {
|
|
1337
1862
|
const visitor = new BlockInsideInlineVisitor(this.name, context);
|
|
1338
|
-
visitor.visit(
|
|
1863
|
+
visitor.visit(result.value);
|
|
1339
1864
|
return visitor.offenses;
|
|
1340
1865
|
}
|
|
1341
1866
|
}
|
|
1342
1867
|
|
|
1343
|
-
export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeValuesRequireQuotesRule, HTMLBooleanAttributesNoValueRule, HTMLImgRequireAltRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
|
|
1868
|
+
export { DEFAULT_LINT_CONTEXT, ERBNoEmptyTagsRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequiresTrailingNewlineRule, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBooleanAttributesNoValueRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoTitleAttributeRule, HTMLTagNameLowercaseRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SourceRule };
|
|
1344
1869
|
//# sourceMappingURL=index.js.map
|