@herb-tools/linter 0.5.0 → 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/dist/herb-lint.js +5131 -1647
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +662 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +654 -147
- package/dist/index.js.map +1 -1
- package/dist/package.json +4 -4
- package/dist/src/cli/argument-parser.js +0 -4
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/default-rules.js +20 -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-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 +0 -4
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +11 -10
- 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 +26 -4
- 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 +7 -13
- 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 +3 -3
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +14 -4
- 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 +19 -8
- 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 +9 -2
- 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 +0 -4
- 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-duplicate-attributes.js +22 -25
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +2 -2
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +0 -21
- package/dist/src/rules/html-no-empty-headings.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 +35 -23
- 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/rule-utils.js +176 -22
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +0 -8
- 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/index.d.ts +4 -0
- package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/rules/html-iframe-has-title.d.ts +7 -0
- 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-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 +2 -1
- package/dist/types/rules/index.d.ts +10 -0
- package/dist/types/rules/rule-utils.d.ts +107 -13
- package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +7 -0
- package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +7 -0
- package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +7 -0
- package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +7 -0
- package/dist/types/src/rules/html-iframe-has-title.d.ts +7 -0
- 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-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 +2 -1
- package/dist/types/src/rules/index.d.ts +10 -0
- package/dist/types/src/rules/rule-utils.d.ts +107 -13
- package/dist/types/src/types.d.ts +24 -0
- package/dist/types/types.d.ts +24 -0
- package/docs/rules/README.md +12 -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/package.json +4 -4
- package/src/cli/argument-parser.ts +0 -5
- package/src/default-rules.ts +20 -0
- package/src/linter.ts +30 -4
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +40 -0
- package/src/rules/erb-prefer-image-tag-helper.ts +2 -7
- package/src/rules/html-aria-attribute-must-be-valid.ts +28 -32
- package/src/rules/html-aria-label-is-well-formatted.ts +59 -0
- package/src/rules/html-aria-level-must-be-valid.ts +38 -5
- package/src/rules/html-aria-role-heading-requires-level.ts +16 -28
- package/src/rules/html-aria-role-must-be-valid.ts +5 -5
- package/src/rules/html-attribute-double-quotes.ts +21 -6
- package/src/rules/html-attribute-equals-spacing.ts +41 -0
- package/src/rules/html-attribute-values-require-quotes.ts +29 -9
- package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +66 -0
- package/src/rules/html-boolean-attributes-no-value.ts +17 -4
- package/src/rules/html-iframe-has-title.ts +62 -0
- package/src/rules/html-img-require-alt.ts +2 -7
- 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-duplicate-attributes.ts +28 -28
- package/src/rules/html-no-duplicate-ids.ts +4 -3
- package/src/rules/html-no-empty-headings.ts +2 -31
- 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 +42 -29
- package/src/rules/index.ts +10 -0
- package/src/rules/rule-utils.ts +260 -39
- package/src/rules/svg-tag-name-capitalization.ts +2 -9
- package/src/types.ts +27 -0
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,
|
|
@@ -457,15 +587,34 @@ class ERBNoOutputControlFlowRule extends ParserRule {
|
|
|
457
587
|
}
|
|
458
588
|
}
|
|
459
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);
|
|
609
|
+
return visitor.offenses;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
460
613
|
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
461
614
|
visitHTMLOpenTagNode(node) {
|
|
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") {
|
|
@@ -650,17 +799,18 @@ class HTMLAnchorRequireHrefRule extends ParserRule {
|
|
|
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 {
|
|
@@ -672,13 +822,65 @@ class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
|
672
822
|
}
|
|
673
823
|
}
|
|
674
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);
|
|
852
|
+
return visitor.offenses;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
675
856
|
class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
676
|
-
|
|
857
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
677
858
|
if (attributeName !== "aria-level")
|
|
678
859
|
return;
|
|
679
|
-
|
|
860
|
+
this.validateAriaLevel(attributeValue, attributeNode);
|
|
861
|
+
}
|
|
862
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
|
|
863
|
+
if (attributeName !== "aria-level")
|
|
680
864
|
return;
|
|
681
|
-
|
|
865
|
+
const validatableContent = getValidatableStaticContent(valueNodes);
|
|
866
|
+
if (validatableContent !== null) {
|
|
867
|
+
this.validateAriaLevel(validatableContent, attributeNode);
|
|
868
|
+
return;
|
|
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
|
}
|
|
@@ -698,19 +900,13 @@ class HTMLAriaLevelMustBeValidRule extends ParserRule {
|
|
|
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 {
|
|
@@ -723,10 +919,10 @@ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
|
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;
|
|
@@ -743,14 +939,23 @@ class HTMLAriaRoleMustBeValidRule extends ParserRule {
|
|
|
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 {
|
|
@@ -762,16 +967,49 @@ class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
|
762
967
|
}
|
|
763
968
|
}
|
|
764
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);
|
|
988
|
+
return visitor.offenses;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
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 {
|
|
@@ -783,14 +1021,66 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
|
783
1021
|
}
|
|
784
1022
|
}
|
|
785
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);
|
|
1065
|
+
return visitor.offenses;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
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";
|
|
@@ -801,14 +1091,47 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
|
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);
|
|
@@ -829,34 +1152,137 @@ class HTMLImgRequireAltRule extends ParserRule {
|
|
|
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
|
}
|
|
@@ -873,7 +1299,7 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
|
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)
|
|
@@ -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;
|
|
@@ -1086,46 +1491,121 @@ class HTMLNoNestedLinksRule extends ParserRule {
|
|
|
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";
|
|
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
|
+
}
|
|
1129
1609
|
check(result, context) {
|
|
1130
1610
|
const visitor = new TagNameLowercaseVisitor(this.name, context);
|
|
1131
1611
|
visitor.visit(result.value);
|
|
@@ -1171,12 +1651,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1171
1651
|
}
|
|
1172
1652
|
this.visitChildNodes(node);
|
|
1173
1653
|
}
|
|
1174
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
1175
|
-
if (this.insideSVG) {
|
|
1176
|
-
this.checkTagName(node);
|
|
1177
|
-
}
|
|
1178
|
-
this.visitChildNodes(node);
|
|
1179
|
-
}
|
|
1180
1654
|
checkTagName(node) {
|
|
1181
1655
|
const tagName = node.tag_name?.value;
|
|
1182
1656
|
if (!tagName)
|
|
@@ -1191,8 +1665,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1191
1665
|
type = "Opening";
|
|
1192
1666
|
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
1193
1667
|
type = "Closing";
|
|
1194
|
-
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
1195
|
-
type = "Self-closing";
|
|
1196
1668
|
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
|
|
1197
1669
|
}
|
|
1198
1670
|
}
|
|
@@ -1209,23 +1681,33 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
1209
1681
|
const defaultRules = [
|
|
1210
1682
|
ERBNoEmptyTagsRule,
|
|
1211
1683
|
ERBNoOutputControlFlowRule,
|
|
1684
|
+
ERBNoSilentTagInAttributeNameRule,
|
|
1212
1685
|
ERBPreferImageTagHelperRule,
|
|
1213
1686
|
ERBRequiresTrailingNewlineRule,
|
|
1214
1687
|
ERBRequireWhitespaceRule,
|
|
1215
1688
|
HTMLAnchorRequireHrefRule,
|
|
1216
1689
|
HTMLAriaAttributeMustBeValid,
|
|
1690
|
+
HTMLAriaLabelIsWellFormattedRule,
|
|
1217
1691
|
HTMLAriaLevelMustBeValidRule,
|
|
1218
1692
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
1219
1693
|
HTMLAriaRoleMustBeValidRule,
|
|
1220
1694
|
HTMLAttributeDoubleQuotesRule,
|
|
1695
|
+
HTMLAttributeEqualsSpacingRule,
|
|
1221
1696
|
HTMLAttributeValuesRequireQuotesRule,
|
|
1697
|
+
HTMLAvoidBothDisabledAndAriaDisabledRule,
|
|
1222
1698
|
HTMLBooleanAttributesNoValueRule,
|
|
1699
|
+
HTMLIframeHasTitleRule,
|
|
1223
1700
|
HTMLImgRequireAltRule,
|
|
1701
|
+
HTMLNavigationHasLabelRule,
|
|
1702
|
+
HTMLNoAriaHiddenOnFocusableRule,
|
|
1224
1703
|
// HTMLNoBlockInsideInlineRule,
|
|
1225
1704
|
HTMLNoDuplicateAttributesRule,
|
|
1226
1705
|
HTMLNoDuplicateIdsRule,
|
|
1227
1706
|
HTMLNoEmptyHeadingsRule,
|
|
1228
1707
|
HTMLNoNestedLinksRule,
|
|
1708
|
+
HTMLNoPositiveTabIndexRule,
|
|
1709
|
+
HTMLNoSelfClosingRule,
|
|
1710
|
+
HTMLNoTitleAttributeRule,
|
|
1229
1711
|
HTMLTagNameLowercaseRule,
|
|
1230
1712
|
ParserNoErrorsRule,
|
|
1231
1713
|
SVGTagNameCapitalizationRule,
|
|
@@ -1274,19 +1756,44 @@ class Linter {
|
|
|
1274
1756
|
*/
|
|
1275
1757
|
lint(source, context) {
|
|
1276
1758
|
this.offenses = [];
|
|
1277
|
-
const parseResult = this.herb.parse(source);
|
|
1759
|
+
const parseResult = this.herb.parse(source, { track_whitespace: true });
|
|
1278
1760
|
const lexResult = this.herb.lex(source);
|
|
1279
1761
|
for (const RuleClass of this.rules) {
|
|
1280
1762
|
const rule = new RuleClass();
|
|
1763
|
+
let isEnabled = true;
|
|
1281
1764
|
let ruleOffenses;
|
|
1282
1765
|
if (this.isLexerRule(rule)) {
|
|
1283
|
-
|
|
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
|
+
}
|
|
1284
1775
|
}
|
|
1285
1776
|
else if (this.isSourceRule(rule)) {
|
|
1286
|
-
|
|
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
|
+
}
|
|
1287
1786
|
}
|
|
1288
1787
|
else {
|
|
1289
|
-
|
|
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
|
+
}
|
|
1290
1797
|
}
|
|
1291
1798
|
this.offenses.push(...ruleOffenses);
|
|
1292
1799
|
}
|
|
@@ -1358,5 +1865,5 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
|
1358
1865
|
}
|
|
1359
1866
|
}
|
|
1360
1867
|
|
|
1361
|
-
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 };
|
|
1362
1869
|
//# sourceMappingURL=index.js.map
|