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