@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.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,
|
|
@@ -459,15 +589,34 @@ class ERBNoOutputControlFlowRule extends ParserRule {
|
|
|
459
589
|
}
|
|
460
590
|
}
|
|
461
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);
|
|
611
|
+
return visitor.offenses;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
462
615
|
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
463
616
|
visitHTMLOpenTagNode(node) {
|
|
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") {
|
|
@@ -652,17 +801,18 @@ class HTMLAnchorRequireHrefRule extends ParserRule {
|
|
|
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 {
|
|
@@ -674,13 +824,65 @@ class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
|
674
824
|
}
|
|
675
825
|
}
|
|
676
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);
|
|
854
|
+
return visitor.offenses;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
677
858
|
class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
678
|
-
|
|
859
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
679
860
|
if (attributeName !== "aria-level")
|
|
680
861
|
return;
|
|
681
|
-
|
|
862
|
+
this.validateAriaLevel(attributeValue, attributeNode);
|
|
863
|
+
}
|
|
864
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
|
|
865
|
+
if (attributeName !== "aria-level")
|
|
682
866
|
return;
|
|
683
|
-
|
|
867
|
+
const validatableContent = core.getValidatableStaticContent(valueNodes);
|
|
868
|
+
if (validatableContent !== null) {
|
|
869
|
+
this.validateAriaLevel(validatableContent, attributeNode);
|
|
870
|
+
return;
|
|
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
|
}
|
|
@@ -700,19 +902,13 @@ class HTMLAriaLevelMustBeValidRule extends ParserRule {
|
|
|
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 {
|
|
@@ -725,10 +921,10 @@ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
|
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;
|
|
@@ -745,14 +941,23 @@ class HTMLAriaRoleMustBeValidRule extends ParserRule {
|
|
|
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 {
|
|
@@ -764,16 +969,49 @@ class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
|
764
969
|
}
|
|
765
970
|
}
|
|
766
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);
|
|
990
|
+
return visitor.offenses;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
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 {
|
|
@@ -785,14 +1023,66 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
|
785
1023
|
}
|
|
786
1024
|
}
|
|
787
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);
|
|
1067
|
+
return visitor.offenses;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
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";
|
|
@@ -803,14 +1093,47 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
|
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);
|
|
@@ -831,34 +1154,137 @@ class HTMLImgRequireAltRule extends ParserRule {
|
|
|
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
|
}
|
|
@@ -875,7 +1301,7 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
|
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)
|
|
@@ -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;
|
|
@@ -1088,46 +1493,121 @@ class HTMLNoNestedLinksRule extends ParserRule {
|
|
|
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";
|
|
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
|
+
}
|
|
1131
1611
|
check(result, context) {
|
|
1132
1612
|
const visitor = new TagNameLowercaseVisitor(this.name, context);
|
|
1133
1613
|
visitor.visit(result.value);
|
|
@@ -1173,12 +1653,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1173
1653
|
}
|
|
1174
1654
|
this.visitChildNodes(node);
|
|
1175
1655
|
}
|
|
1176
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
1177
|
-
if (this.insideSVG) {
|
|
1178
|
-
this.checkTagName(node);
|
|
1179
|
-
}
|
|
1180
|
-
this.visitChildNodes(node);
|
|
1181
|
-
}
|
|
1182
1656
|
checkTagName(node) {
|
|
1183
1657
|
const tagName = node.tag_name?.value;
|
|
1184
1658
|
if (!tagName)
|
|
@@ -1193,8 +1667,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1193
1667
|
type = "Opening";
|
|
1194
1668
|
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
1195
1669
|
type = "Closing";
|
|
1196
|
-
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
1197
|
-
type = "Self-closing";
|
|
1198
1670
|
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
|
|
1199
1671
|
}
|
|
1200
1672
|
}
|
|
@@ -1211,23 +1683,33 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
1211
1683
|
const defaultRules = [
|
|
1212
1684
|
ERBNoEmptyTagsRule,
|
|
1213
1685
|
ERBNoOutputControlFlowRule,
|
|
1686
|
+
ERBNoSilentTagInAttributeNameRule,
|
|
1214
1687
|
ERBPreferImageTagHelperRule,
|
|
1215
1688
|
ERBRequiresTrailingNewlineRule,
|
|
1216
1689
|
ERBRequireWhitespaceRule,
|
|
1217
1690
|
HTMLAnchorRequireHrefRule,
|
|
1218
1691
|
HTMLAriaAttributeMustBeValid,
|
|
1692
|
+
HTMLAriaLabelIsWellFormattedRule,
|
|
1219
1693
|
HTMLAriaLevelMustBeValidRule,
|
|
1220
1694
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
1221
1695
|
HTMLAriaRoleMustBeValidRule,
|
|
1222
1696
|
HTMLAttributeDoubleQuotesRule,
|
|
1697
|
+
HTMLAttributeEqualsSpacingRule,
|
|
1223
1698
|
HTMLAttributeValuesRequireQuotesRule,
|
|
1699
|
+
HTMLAvoidBothDisabledAndAriaDisabledRule,
|
|
1224
1700
|
HTMLBooleanAttributesNoValueRule,
|
|
1701
|
+
HTMLIframeHasTitleRule,
|
|
1225
1702
|
HTMLImgRequireAltRule,
|
|
1703
|
+
HTMLNavigationHasLabelRule,
|
|
1704
|
+
HTMLNoAriaHiddenOnFocusableRule,
|
|
1226
1705
|
// HTMLNoBlockInsideInlineRule,
|
|
1227
1706
|
HTMLNoDuplicateAttributesRule,
|
|
1228
1707
|
HTMLNoDuplicateIdsRule,
|
|
1229
1708
|
HTMLNoEmptyHeadingsRule,
|
|
1230
1709
|
HTMLNoNestedLinksRule,
|
|
1710
|
+
HTMLNoPositiveTabIndexRule,
|
|
1711
|
+
HTMLNoSelfClosingRule,
|
|
1712
|
+
HTMLNoTitleAttributeRule,
|
|
1231
1713
|
HTMLTagNameLowercaseRule,
|
|
1232
1714
|
ParserNoErrorsRule,
|
|
1233
1715
|
SVGTagNameCapitalizationRule,
|
|
@@ -1276,19 +1758,44 @@ class Linter {
|
|
|
1276
1758
|
*/
|
|
1277
1759
|
lint(source, context) {
|
|
1278
1760
|
this.offenses = [];
|
|
1279
|
-
const parseResult = this.herb.parse(source);
|
|
1761
|
+
const parseResult = this.herb.parse(source, { track_whitespace: true });
|
|
1280
1762
|
const lexResult = this.herb.lex(source);
|
|
1281
1763
|
for (const RuleClass of this.rules) {
|
|
1282
1764
|
const rule = new RuleClass();
|
|
1765
|
+
let isEnabled = true;
|
|
1283
1766
|
let ruleOffenses;
|
|
1284
1767
|
if (this.isLexerRule(rule)) {
|
|
1285
|
-
|
|
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
|
+
}
|
|
1286
1777
|
}
|
|
1287
1778
|
else if (this.isSourceRule(rule)) {
|
|
1288
|
-
|
|
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
|
+
}
|
|
1289
1788
|
}
|
|
1290
1789
|
else {
|
|
1291
|
-
|
|
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
|
+
}
|
|
1292
1799
|
}
|
|
1293
1800
|
this.offenses.push(...ruleOffenses);
|
|
1294
1801
|
}
|
|
@@ -1363,21 +1870,31 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
|
1363
1870
|
exports.DEFAULT_LINT_CONTEXT = DEFAULT_LINT_CONTEXT;
|
|
1364
1871
|
exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
|
|
1365
1872
|
exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
|
|
1873
|
+
exports.ERBNoSilentTagInAttributeNameRule = ERBNoSilentTagInAttributeNameRule;
|
|
1366
1874
|
exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
|
|
1367
1875
|
exports.ERBRequiresTrailingNewlineRule = ERBRequiresTrailingNewlineRule;
|
|
1368
1876
|
exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
|
|
1877
|
+
exports.HTMLAriaLabelIsWellFormattedRule = HTMLAriaLabelIsWellFormattedRule;
|
|
1369
1878
|
exports.HTMLAriaLevelMustBeValidRule = HTMLAriaLevelMustBeValidRule;
|
|
1370
1879
|
exports.HTMLAriaRoleHeadingRequiresLevelRule = HTMLAriaRoleHeadingRequiresLevelRule;
|
|
1371
1880
|
exports.HTMLAriaRoleMustBeValidRule = HTMLAriaRoleMustBeValidRule;
|
|
1372
1881
|
exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
|
|
1882
|
+
exports.HTMLAttributeEqualsSpacingRule = HTMLAttributeEqualsSpacingRule;
|
|
1373
1883
|
exports.HTMLAttributeValuesRequireQuotesRule = HTMLAttributeValuesRequireQuotesRule;
|
|
1884
|
+
exports.HTMLAvoidBothDisabledAndAriaDisabledRule = HTMLAvoidBothDisabledAndAriaDisabledRule;
|
|
1374
1885
|
exports.HTMLBooleanAttributesNoValueRule = HTMLBooleanAttributesNoValueRule;
|
|
1886
|
+
exports.HTMLIframeHasTitleRule = HTMLIframeHasTitleRule;
|
|
1375
1887
|
exports.HTMLImgRequireAltRule = HTMLImgRequireAltRule;
|
|
1888
|
+
exports.HTMLNavigationHasLabelRule = HTMLNavigationHasLabelRule;
|
|
1889
|
+
exports.HTMLNoAriaHiddenOnFocusableRule = HTMLNoAriaHiddenOnFocusableRule;
|
|
1376
1890
|
exports.HTMLNoBlockInsideInlineRule = HTMLNoBlockInsideInlineRule;
|
|
1377
1891
|
exports.HTMLNoDuplicateAttributesRule = HTMLNoDuplicateAttributesRule;
|
|
1378
1892
|
exports.HTMLNoDuplicateIdsRule = HTMLNoDuplicateIdsRule;
|
|
1379
1893
|
exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
|
|
1380
1894
|
exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
|
|
1895
|
+
exports.HTMLNoPositiveTabIndexRule = HTMLNoPositiveTabIndexRule;
|
|
1896
|
+
exports.HTMLNoSelfClosingRule = HTMLNoSelfClosingRule;
|
|
1897
|
+
exports.HTMLNoTitleAttributeRule = HTMLNoTitleAttributeRule;
|
|
1381
1898
|
exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
|
|
1382
1899
|
exports.LexerRule = LexerRule;
|
|
1383
1900
|
exports.Linter = Linter;
|