@herb-tools/linter 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/herb-lint.js +6627 -1937
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +1574 -210
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1566 -212
- package/dist/index.js.map +1 -1
- package/dist/package.json +5 -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 +50 -64
- 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 +134 -9
- 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 +29 -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 +245 -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 +146 -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 +146 -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 +5 -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 +53 -76
- 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 +189 -14
- 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 +41 -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 +357 -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, filterNodes, ERBContentNode, isERBOutputNode, getNodesBeforePosition, getNodesAfterPosition, isToken, isParseResult, isNode, LiteralNode, isERBNode, filterLiteralNodes, getTagName as getTagName$1, HTMLOpenTagNode } from '@herb-tools/core';
|
|
2
2
|
|
|
3
3
|
class ParserRule {
|
|
4
4
|
static type = "parser";
|
|
@@ -16,6 +16,11 @@ class SourceRule {
|
|
|
16
16
|
static type = "source";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
var ControlFlowType;
|
|
20
|
+
(function (ControlFlowType) {
|
|
21
|
+
ControlFlowType[ControlFlowType["CONDITIONAL"] = 0] = "CONDITIONAL";
|
|
22
|
+
ControlFlowType[ControlFlowType["LOOP"] = 1] = "LOOP";
|
|
23
|
+
})(ControlFlowType || (ControlFlowType = {}));
|
|
19
24
|
/**
|
|
20
25
|
* Base visitor class that provides common functionality for rule visitors
|
|
21
26
|
*/
|
|
@@ -49,12 +54,74 @@ class BaseRuleVisitor extends Visitor {
|
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
/**
|
|
52
|
-
*
|
|
57
|
+
* Mixin that adds control flow tracking capabilities to rule visitors
|
|
58
|
+
* This allows rules to track state across different control flow structures
|
|
59
|
+
* like if/else branches, loops, etc.
|
|
60
|
+
*
|
|
61
|
+
* @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
|
|
62
|
+
* @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
|
|
63
|
+
*/
|
|
64
|
+
class ControlFlowTrackingVisitor extends BaseRuleVisitor {
|
|
65
|
+
isInControlFlow = false;
|
|
66
|
+
currentControlFlowType = null;
|
|
67
|
+
/**
|
|
68
|
+
* Handle visiting a control flow node with proper scope management
|
|
69
|
+
*/
|
|
70
|
+
handleControlFlowNode(node, controlFlowType, visitChildren) {
|
|
71
|
+
const wasInControlFlow = this.isInControlFlow;
|
|
72
|
+
const previousControlFlowType = this.currentControlFlowType;
|
|
73
|
+
this.isInControlFlow = true;
|
|
74
|
+
this.currentControlFlowType = controlFlowType;
|
|
75
|
+
const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow);
|
|
76
|
+
visitChildren();
|
|
77
|
+
this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore);
|
|
78
|
+
this.isInControlFlow = wasInControlFlow;
|
|
79
|
+
this.currentControlFlowType = previousControlFlowType;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Handle visiting a branch node (like else, when) with proper scope management
|
|
83
|
+
*/
|
|
84
|
+
startNewBranch(visitChildren) {
|
|
85
|
+
const stateToRestore = this.onEnterBranch();
|
|
86
|
+
visitChildren();
|
|
87
|
+
this.onExitBranch(stateToRestore);
|
|
88
|
+
}
|
|
89
|
+
visitERBIfNode(node) {
|
|
90
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
|
|
91
|
+
}
|
|
92
|
+
visitERBUnlessNode(node) {
|
|
93
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
|
|
94
|
+
}
|
|
95
|
+
visitERBCaseNode(node) {
|
|
96
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
|
|
97
|
+
}
|
|
98
|
+
visitERBCaseMatchNode(node) {
|
|
99
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
|
|
100
|
+
}
|
|
101
|
+
visitERBWhileNode(node) {
|
|
102
|
+
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
|
|
103
|
+
}
|
|
104
|
+
visitERBForNode(node) {
|
|
105
|
+
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node));
|
|
106
|
+
}
|
|
107
|
+
visitERBUntilNode(node) {
|
|
108
|
+
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
|
|
109
|
+
}
|
|
110
|
+
visitERBBlockNode(node) {
|
|
111
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
|
|
112
|
+
}
|
|
113
|
+
visitERBElseNode(node) {
|
|
114
|
+
this.startNewBranch(() => super.visitERBElseNode(node));
|
|
115
|
+
}
|
|
116
|
+
visitERBWhenNode(node) {
|
|
117
|
+
this.startNewBranch(() => super.visitERBWhenNode(node));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Gets attributes from an HTMLOpenTagNode
|
|
53
122
|
*/
|
|
54
123
|
function getAttributes(node) {
|
|
55
|
-
return node.type === "
|
|
56
|
-
? node.attributes
|
|
57
|
-
: node.children;
|
|
124
|
+
return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE");
|
|
58
125
|
}
|
|
59
126
|
/**
|
|
60
127
|
* Gets the tag name from an HTML tag node (lowercased)
|
|
@@ -64,14 +131,66 @@ function getTagName(node) {
|
|
|
64
131
|
}
|
|
65
132
|
/**
|
|
66
133
|
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
134
|
+
* Returns null if the attribute name contains dynamic content (ERB)
|
|
67
135
|
*/
|
|
68
136
|
function getAttributeName(attributeNode) {
|
|
69
137
|
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
70
138
|
const nameNode = attributeNode.name;
|
|
71
|
-
|
|
139
|
+
const staticName = getStaticAttributeName(nameNode);
|
|
140
|
+
return staticName ? staticName.toLowerCase() : null;
|
|
72
141
|
}
|
|
73
142
|
return null;
|
|
74
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Checks if an attribute has a dynamic (ERB-containing) name
|
|
146
|
+
*/
|
|
147
|
+
function hasDynamicAttributeName(attributeNode) {
|
|
148
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
149
|
+
const nameNode = attributeNode.name;
|
|
150
|
+
return hasDynamicAttributeName$1(nameNode);
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Gets the combined string representation of an attribute name (for debugging)
|
|
156
|
+
* This includes both static content and ERB syntax
|
|
157
|
+
*/
|
|
158
|
+
function getCombinedAttributeNameString(attributeNode) {
|
|
159
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
160
|
+
const nameNode = attributeNode.name;
|
|
161
|
+
return getCombinedAttributeName(nameNode);
|
|
162
|
+
}
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Checks if an attribute value contains only static content (no ERB)
|
|
167
|
+
*/
|
|
168
|
+
function hasStaticAttributeValue(attributeNode) {
|
|
169
|
+
const valueNode = attributeNode.value;
|
|
170
|
+
if (!valueNode?.children)
|
|
171
|
+
return false;
|
|
172
|
+
return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Gets the static string value of an attribute (returns null if it contains ERB)
|
|
176
|
+
*/
|
|
177
|
+
function getStaticAttributeValue(attributeNode) {
|
|
178
|
+
if (!hasStaticAttributeValue(attributeNode))
|
|
179
|
+
return null;
|
|
180
|
+
const valueNode = attributeNode.value;
|
|
181
|
+
const result = valueNode.children
|
|
182
|
+
?.filter(child => child.type === "AST_LITERAL_NODE")
|
|
183
|
+
.map(child => child.content)
|
|
184
|
+
.join("") || "";
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Gets the value nodes array for dynamic inspection
|
|
189
|
+
*/
|
|
190
|
+
function getAttributeValueNodes(attributeNode) {
|
|
191
|
+
const valueNode = attributeNode.value;
|
|
192
|
+
return valueNode?.children || [];
|
|
193
|
+
}
|
|
75
194
|
/**
|
|
76
195
|
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
77
196
|
*/
|
|
@@ -109,9 +228,18 @@ function hasAttributeValue(attributeNode) {
|
|
|
109
228
|
/**
|
|
110
229
|
* Gets the quote type used for an attribute value
|
|
111
230
|
*/
|
|
112
|
-
function getAttributeValueQuoteType(
|
|
113
|
-
|
|
114
|
-
|
|
231
|
+
function getAttributeValueQuoteType(nodeOrAttribute) {
|
|
232
|
+
let valueNode;
|
|
233
|
+
if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
234
|
+
const attributeNode = nodeOrAttribute;
|
|
235
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
236
|
+
valueNode = attributeNode.value;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
240
|
+
valueNode = nodeOrAttribute;
|
|
241
|
+
}
|
|
242
|
+
if (valueNode) {
|
|
115
243
|
if (valueNode.quoted && valueNode.open_quote) {
|
|
116
244
|
return valueNode.open_quote.value === '"' ? "double" : "single";
|
|
117
245
|
}
|
|
@@ -138,8 +266,14 @@ function findAttributeByName(attributes, attributeName) {
|
|
|
138
266
|
* Checks if a tag has a specific attribute
|
|
139
267
|
*/
|
|
140
268
|
function hasAttribute(node, attributeName) {
|
|
269
|
+
return getAttribute(node, attributeName) !== null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Checks if a tag has a specific attribute
|
|
273
|
+
*/
|
|
274
|
+
function getAttribute(node, attributeName) {
|
|
141
275
|
const attributes = getAttributes(node);
|
|
142
|
-
return findAttributeByName(attributes, attributeName)
|
|
276
|
+
return findAttributeByName(attributes, attributeName);
|
|
143
277
|
}
|
|
144
278
|
/**
|
|
145
279
|
* Common HTML element categorization
|
|
@@ -156,6 +290,10 @@ const HTML_BLOCK_ELEMENTS = new Set([
|
|
|
156
290
|
"h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
|
|
157
291
|
"ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
|
|
158
292
|
]);
|
|
293
|
+
const HTML_VOID_ELEMENTS = new Set([
|
|
294
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
|
|
295
|
+
"param", "source", "track", "wbr",
|
|
296
|
+
]);
|
|
159
297
|
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
160
298
|
"autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
|
|
161
299
|
"loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
|
|
@@ -294,6 +432,12 @@ function isInlineElement(tagName) {
|
|
|
294
432
|
function isBlockElement(tagName) {
|
|
295
433
|
return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase());
|
|
296
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Checks if an element is a void element
|
|
437
|
+
*/
|
|
438
|
+
function isVoidElement(tagName) {
|
|
439
|
+
return HTML_VOID_ELEMENTS.has(tagName.toLowerCase());
|
|
440
|
+
}
|
|
297
441
|
/**
|
|
298
442
|
* Checks if an attribute is a boolean attribute
|
|
299
443
|
*/
|
|
@@ -301,9 +445,14 @@ function isBooleanAttribute(attributeName) {
|
|
|
301
445
|
return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase());
|
|
302
446
|
}
|
|
303
447
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
448
|
+
* Attribute visitor that provides granular processing based on both
|
|
449
|
+
* attribute name type (static/dynamic) and value type (static/dynamic)
|
|
450
|
+
*
|
|
451
|
+
* This gives you 4 distinct methods to override:
|
|
452
|
+
* - checkStaticAttributeStaticValue() - name="class" value="foo"
|
|
453
|
+
* - checkStaticAttributeDynamicValue() - name="class" value="<%= css_class %>"
|
|
454
|
+
* - checkDynamicAttributeStaticValue() - name="data-<%= key %>" value="foo"
|
|
455
|
+
* - checkDynamicAttributeDynamicValue() - name="data-<%= key %>" value="<%= value %>"
|
|
307
456
|
*/
|
|
308
457
|
class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
309
458
|
constructor(ruleName, context) {
|
|
@@ -313,19 +462,69 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
313
462
|
this.checkAttributesOnNode(node);
|
|
314
463
|
super.visitHTMLOpenTagNode(node);
|
|
315
464
|
}
|
|
316
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
317
|
-
this.checkAttributesOnNode(node);
|
|
318
|
-
super.visitHTMLSelfCloseTagNode(node);
|
|
319
|
-
}
|
|
320
465
|
checkAttributesOnNode(node) {
|
|
321
466
|
forEachAttribute(node, (attributeNode) => {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
467
|
+
const staticAttributeName = getAttributeName(attributeNode);
|
|
468
|
+
const isDynamicName = hasDynamicAttributeName(attributeNode);
|
|
469
|
+
const staticAttributeValue = getStaticAttributeValue(attributeNode);
|
|
470
|
+
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
471
|
+
const hasOutputERB = hasERBOutput(valueNodes);
|
|
472
|
+
const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes);
|
|
473
|
+
if (staticAttributeName && staticAttributeValue !== null) {
|
|
474
|
+
this.checkStaticAttributeStaticValue({
|
|
475
|
+
attributeName: staticAttributeName,
|
|
476
|
+
attributeValue: staticAttributeValue,
|
|
477
|
+
attributeNode,
|
|
478
|
+
parentNode: node
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
else if (staticAttributeName && isEffectivelyStaticValue && !hasOutputERB) {
|
|
482
|
+
const validatableContent = getValidatableStaticContent(valueNodes) || "";
|
|
483
|
+
this.checkStaticAttributeStaticValue({ attributeName: staticAttributeName, attributeValue: validatableContent, attributeNode, parentNode: node });
|
|
484
|
+
}
|
|
485
|
+
else if (staticAttributeName && hasOutputERB) {
|
|
486
|
+
const combinedValue = getAttributeValue(attributeNode);
|
|
487
|
+
this.checkStaticAttributeDynamicValue({ attributeName: staticAttributeName, valueNodes, attributeNode, parentNode: node, combinedValue });
|
|
488
|
+
}
|
|
489
|
+
else if (isDynamicName && staticAttributeValue !== null) {
|
|
490
|
+
const nameNode = attributeNode.name;
|
|
491
|
+
const nameNodes = nameNode.children || [];
|
|
492
|
+
const combinedName = getCombinedAttributeNameString(attributeNode);
|
|
493
|
+
this.checkDynamicAttributeStaticValue({ nameNodes, attributeValue: staticAttributeValue, attributeNode, parentNode: node, combinedName });
|
|
494
|
+
}
|
|
495
|
+
else if (isDynamicName) {
|
|
496
|
+
const nameNode = attributeNode.name;
|
|
497
|
+
const nameNodes = nameNode.children || [];
|
|
498
|
+
const combinedName = getCombinedAttributeNameString(attributeNode);
|
|
499
|
+
const combinedValue = getAttributeValue(attributeNode);
|
|
500
|
+
this.checkDynamicAttributeDynamicValue({ nameNodes, valueNodes, attributeNode, parentNode: node, combinedName, combinedValue });
|
|
326
501
|
}
|
|
327
502
|
});
|
|
328
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Static attribute name with static value: class="container"
|
|
506
|
+
*/
|
|
507
|
+
checkStaticAttributeStaticValue(params) {
|
|
508
|
+
// Default implementation does nothing
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Static attribute name with dynamic value: class="<%= css_class %>"
|
|
512
|
+
*/
|
|
513
|
+
checkStaticAttributeDynamicValue(params) {
|
|
514
|
+
// Default implementation does nothing
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Dynamic attribute name with static value: data-<%= key %>="foo"
|
|
518
|
+
*/
|
|
519
|
+
checkDynamicAttributeStaticValue(params) {
|
|
520
|
+
// Default implementation does nothing
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
|
|
524
|
+
*/
|
|
525
|
+
checkDynamicAttributeDynamicValue(params) {
|
|
526
|
+
// Default implementation does nothing
|
|
527
|
+
}
|
|
329
528
|
}
|
|
330
529
|
/**
|
|
331
530
|
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
@@ -354,7 +553,7 @@ class BaseSourceRuleVisitor {
|
|
|
354
553
|
*/
|
|
355
554
|
createOffense(message, location, severity = "error") {
|
|
356
555
|
return {
|
|
357
|
-
rule: this.ruleName,
|
|
556
|
+
rule: this.ruleName,
|
|
358
557
|
code: this.ruleName,
|
|
359
558
|
source: "Herb Linter",
|
|
360
559
|
message,
|
|
@@ -457,86 +656,755 @@ class ERBNoOutputControlFlowRule extends ParserRule {
|
|
|
457
656
|
}
|
|
458
657
|
}
|
|
459
658
|
|
|
460
|
-
class
|
|
659
|
+
class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
|
|
660
|
+
visitHTMLAttributeNameNode(node) {
|
|
661
|
+
const erbNodes = filterERBContentNodes(node.children);
|
|
662
|
+
const silentNodes = erbNodes.filter(this.isSilentERBTag);
|
|
663
|
+
for (const node of silentNodes) {
|
|
664
|
+
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");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// TODO: might be worth to extract
|
|
668
|
+
isSilentERBTag(node) {
|
|
669
|
+
const silentTags = ["<%", "<%-", "<%#"];
|
|
670
|
+
return silentTags.includes(node.tag_opening?.value || "");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
class ERBNoSilentTagInAttributeNameRule extends ParserRule {
|
|
674
|
+
name = "erb-no-silent-tag-in-attribute-name";
|
|
675
|
+
check(result, context) {
|
|
676
|
+
const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.name, context);
|
|
677
|
+
visitor.visit(result.value);
|
|
678
|
+
return visitor.offenses;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
class PrintContext {
|
|
683
|
+
output = "";
|
|
684
|
+
indentLevel = 0;
|
|
685
|
+
currentColumn = 0;
|
|
686
|
+
preserveStack = [];
|
|
687
|
+
/**
|
|
688
|
+
* Write text to the output
|
|
689
|
+
*/
|
|
690
|
+
write(text) {
|
|
691
|
+
this.output += text;
|
|
692
|
+
this.currentColumn += text.length;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Write text and update column tracking for newlines
|
|
696
|
+
*/
|
|
697
|
+
writeWithColumnTracking(text) {
|
|
698
|
+
this.output += text;
|
|
699
|
+
const lines = text.split('\n');
|
|
700
|
+
if (lines.length > 1) {
|
|
701
|
+
this.currentColumn = lines[lines.length - 1].length;
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
this.currentColumn += text.length;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Increase indentation level
|
|
709
|
+
*/
|
|
710
|
+
indent() {
|
|
711
|
+
this.indentLevel++;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Decrease indentation level
|
|
715
|
+
*/
|
|
716
|
+
dedent() {
|
|
717
|
+
if (this.indentLevel > 0) {
|
|
718
|
+
this.indentLevel--;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Enter a tag that may preserve whitespace
|
|
723
|
+
*/
|
|
724
|
+
enterTag(tagName) {
|
|
725
|
+
this.preserveStack.push(tagName.toLowerCase());
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Exit the current tag
|
|
729
|
+
*/
|
|
730
|
+
exitTag() {
|
|
731
|
+
this.preserveStack.pop();
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Check if we're at the start of a line
|
|
735
|
+
*/
|
|
736
|
+
isAtStartOfLine() {
|
|
737
|
+
return this.currentColumn === 0;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Get current indentation level
|
|
741
|
+
*/
|
|
742
|
+
getCurrentIndentLevel() {
|
|
743
|
+
return this.indentLevel;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Get current column position
|
|
747
|
+
*/
|
|
748
|
+
getCurrentColumn() {
|
|
749
|
+
return this.currentColumn;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Get the current tag stack (for debugging)
|
|
753
|
+
*/
|
|
754
|
+
getTagStack() {
|
|
755
|
+
return [...this.preserveStack];
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Get the complete output string
|
|
759
|
+
*/
|
|
760
|
+
getOutput() {
|
|
761
|
+
return this.output;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Reset the context for reuse
|
|
765
|
+
*/
|
|
766
|
+
reset() {
|
|
767
|
+
this.output = "";
|
|
768
|
+
this.indentLevel = 0;
|
|
769
|
+
this.currentColumn = 0;
|
|
770
|
+
this.preserveStack = [];
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Default print options used when none are provided
|
|
776
|
+
*/
|
|
777
|
+
const DEFAULT_PRINT_OPTIONS = {
|
|
778
|
+
ignoreErrors: false
|
|
779
|
+
};
|
|
780
|
+
class Printer extends Visitor {
|
|
781
|
+
context = new PrintContext();
|
|
782
|
+
/**
|
|
783
|
+
* Static method to print a node without creating an instance
|
|
784
|
+
*
|
|
785
|
+
* @param input - The AST Node, Token, or ParseResult to print
|
|
786
|
+
* @param options - Print options to control behavior
|
|
787
|
+
* @returns The printed string representation of the input
|
|
788
|
+
* @throws {Error} When node has parse errors and ignoreErrors is false
|
|
789
|
+
*/
|
|
790
|
+
static print(input, options = DEFAULT_PRINT_OPTIONS) {
|
|
791
|
+
const printer = new this();
|
|
792
|
+
return printer.print(input, options);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Print a node, token, or parse result to a string
|
|
796
|
+
*
|
|
797
|
+
* @param input - The AST Node, Token, or ParseResult to print
|
|
798
|
+
* @param options - Print options to control behavior
|
|
799
|
+
* @returns The printed string representation of the input
|
|
800
|
+
* @throws {Error} When node has parse errors and ignoreErrors is false
|
|
801
|
+
*/
|
|
802
|
+
print(input, options = DEFAULT_PRINT_OPTIONS) {
|
|
803
|
+
if (isToken(input)) {
|
|
804
|
+
return input.value;
|
|
805
|
+
}
|
|
806
|
+
if (Array.isArray(input)) {
|
|
807
|
+
this.context.reset();
|
|
808
|
+
input.forEach(node => this.visit(node));
|
|
809
|
+
return this.context.getOutput();
|
|
810
|
+
}
|
|
811
|
+
const node = isParseResult(input) ? input.value : input;
|
|
812
|
+
if (options.ignoreErrors === false && node.recursiveErrors().length > 0) {
|
|
813
|
+
throw new Error(`Cannot print the node (${node.type}) since it or any of its children has parse errors. Either pass in a valid Node or call \`print()\` using \`print(node, { ignoreErrors: true })\``);
|
|
814
|
+
}
|
|
815
|
+
this.context.reset();
|
|
816
|
+
this.visit(node);
|
|
817
|
+
return this.context.getOutput();
|
|
818
|
+
}
|
|
819
|
+
write(content) {
|
|
820
|
+
this.context.write(content);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* IdentityPrinter - Provides lossless reconstruction of the original source
|
|
826
|
+
*
|
|
827
|
+
* This printer aims to reconstruct the original input as faithfully as possible,
|
|
828
|
+
* preserving all whitespace, formatting, and structure. It's useful for:
|
|
829
|
+
* - Testing parser accuracy (input should equal output)
|
|
830
|
+
* - Baseline printing before applying transformations
|
|
831
|
+
* - Verifying AST round-trip fidelity
|
|
832
|
+
*/
|
|
833
|
+
class IdentityPrinter extends Printer {
|
|
834
|
+
visitLiteralNode(node) {
|
|
835
|
+
this.write(node.content);
|
|
836
|
+
}
|
|
837
|
+
visitHTMLTextNode(node) {
|
|
838
|
+
this.write(node.content);
|
|
839
|
+
}
|
|
840
|
+
visitWhitespaceNode(node) {
|
|
841
|
+
if (node.value) {
|
|
842
|
+
this.write(node.value.value);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
461
845
|
visitHTMLOpenTagNode(node) {
|
|
462
|
-
|
|
463
|
-
|
|
846
|
+
if (node.tag_opening) {
|
|
847
|
+
this.write(node.tag_opening.value);
|
|
848
|
+
}
|
|
849
|
+
if (node.tag_name) {
|
|
850
|
+
this.write(node.tag_name.value);
|
|
851
|
+
}
|
|
852
|
+
this.visitChildNodes(node);
|
|
853
|
+
if (node.tag_closing) {
|
|
854
|
+
this.write(node.tag_closing.value);
|
|
855
|
+
}
|
|
464
856
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
857
|
+
visitHTMLCloseTagNode(node) {
|
|
858
|
+
if (node.tag_opening) {
|
|
859
|
+
this.write(node.tag_opening.value);
|
|
860
|
+
}
|
|
861
|
+
if (node.tag_name) {
|
|
862
|
+
const before = getNodesBeforePosition(node.children, node.tag_name.location.start, true);
|
|
863
|
+
const after = getNodesAfterPosition(node.children, node.tag_name.location.end);
|
|
864
|
+
this.visitAll(before);
|
|
865
|
+
this.write(node.tag_name.value);
|
|
866
|
+
this.visitAll(after);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
this.visitAll(node.children);
|
|
870
|
+
}
|
|
871
|
+
if (node.tag_closing) {
|
|
872
|
+
this.write(node.tag_closing.value);
|
|
873
|
+
}
|
|
468
874
|
}
|
|
469
|
-
|
|
470
|
-
const tagName =
|
|
471
|
-
if (tagName
|
|
472
|
-
|
|
875
|
+
visitHTMLElementNode(node) {
|
|
876
|
+
const tagName = node.tag_name?.value;
|
|
877
|
+
if (tagName) {
|
|
878
|
+
this.context.enterTag(tagName);
|
|
473
879
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (!srcAttribute) {
|
|
477
|
-
return;
|
|
880
|
+
if (node.open_tag) {
|
|
881
|
+
this.visit(node.open_tag);
|
|
478
882
|
}
|
|
479
|
-
if (
|
|
480
|
-
|
|
883
|
+
if (node.body) {
|
|
884
|
+
node.body.forEach(child => this.visit(child));
|
|
481
885
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
this.
|
|
886
|
+
if (node.close_tag) {
|
|
887
|
+
this.visit(node.close_tag);
|
|
888
|
+
}
|
|
889
|
+
if (tagName) {
|
|
890
|
+
this.context.exitTag();
|
|
487
891
|
}
|
|
488
892
|
}
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
|
|
492
|
-
|
|
893
|
+
visitHTMLAttributeNode(node) {
|
|
894
|
+
if (node.name) {
|
|
895
|
+
this.visit(node.name);
|
|
896
|
+
}
|
|
897
|
+
if (node.equals) {
|
|
898
|
+
this.write(node.equals.value);
|
|
899
|
+
}
|
|
900
|
+
if (node.equals && node.value) {
|
|
901
|
+
this.visit(node.value);
|
|
902
|
+
}
|
|
493
903
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
904
|
+
visitHTMLAttributeNameNode(node) {
|
|
905
|
+
this.visitChildNodes(node);
|
|
906
|
+
}
|
|
907
|
+
visitHTMLAttributeValueNode(node) {
|
|
908
|
+
if (node.quoted && node.open_quote) {
|
|
909
|
+
this.write(node.open_quote.value);
|
|
910
|
+
}
|
|
911
|
+
this.visitChildNodes(node);
|
|
912
|
+
if (node.quoted && node.close_quote) {
|
|
913
|
+
this.write(node.close_quote.value);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
visitHTMLCommentNode(node) {
|
|
917
|
+
if (node.comment_start) {
|
|
918
|
+
this.write(node.comment_start.value);
|
|
919
|
+
}
|
|
920
|
+
this.visitChildNodes(node);
|
|
921
|
+
if (node.comment_end) {
|
|
922
|
+
this.write(node.comment_end.value);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
visitHTMLDoctypeNode(node) {
|
|
926
|
+
if (node.tag_opening) {
|
|
927
|
+
this.write(node.tag_opening.value);
|
|
928
|
+
}
|
|
929
|
+
this.visitChildNodes(node);
|
|
930
|
+
if (node.tag_closing) {
|
|
931
|
+
this.write(node.tag_closing.value);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
visitXMLDeclarationNode(node) {
|
|
935
|
+
if (node.tag_opening) {
|
|
936
|
+
this.write(node.tag_opening.value);
|
|
937
|
+
}
|
|
938
|
+
this.visitChildNodes(node);
|
|
939
|
+
if (node.tag_closing) {
|
|
940
|
+
this.write(node.tag_closing.value);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
visitCDATANode(node) {
|
|
944
|
+
if (node.tag_opening) {
|
|
945
|
+
this.write(node.tag_opening.value);
|
|
946
|
+
}
|
|
947
|
+
this.visitChildNodes(node);
|
|
948
|
+
if (node.tag_closing) {
|
|
949
|
+
this.write(node.tag_closing.value);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
visitERBContentNode(node) {
|
|
953
|
+
this.printERBNode(node);
|
|
954
|
+
}
|
|
955
|
+
visitERBIfNode(node) {
|
|
956
|
+
this.printERBNode(node);
|
|
957
|
+
if (node.statements) {
|
|
958
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
959
|
+
}
|
|
960
|
+
if (node.subsequent) {
|
|
961
|
+
this.visit(node.subsequent);
|
|
962
|
+
}
|
|
963
|
+
if (node.end_node) {
|
|
964
|
+
this.visit(node.end_node);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
visitERBElseNode(node) {
|
|
968
|
+
this.printERBNode(node);
|
|
969
|
+
if (node.statements) {
|
|
970
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
visitERBEndNode(node) {
|
|
974
|
+
this.printERBNode(node);
|
|
975
|
+
}
|
|
976
|
+
visitERBBlockNode(node) {
|
|
977
|
+
this.printERBNode(node);
|
|
978
|
+
if (node.body) {
|
|
979
|
+
node.body.forEach(child => this.visit(child));
|
|
980
|
+
}
|
|
981
|
+
if (node.end_node) {
|
|
982
|
+
this.visit(node.end_node);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
visitERBCaseNode(node) {
|
|
986
|
+
this.printERBNode(node);
|
|
987
|
+
if (node.children) {
|
|
988
|
+
node.children.forEach(child => this.visit(child));
|
|
989
|
+
}
|
|
990
|
+
if (node.conditions) {
|
|
991
|
+
node.conditions.forEach(condition => this.visit(condition));
|
|
992
|
+
}
|
|
993
|
+
if (node.else_clause) {
|
|
994
|
+
this.visit(node.else_clause);
|
|
995
|
+
}
|
|
996
|
+
if (node.end_node) {
|
|
997
|
+
this.visit(node.end_node);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
visitERBWhenNode(node) {
|
|
1001
|
+
this.printERBNode(node);
|
|
1002
|
+
if (node.statements) {
|
|
1003
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
visitERBWhileNode(node) {
|
|
1007
|
+
this.printERBNode(node);
|
|
1008
|
+
if (node.statements) {
|
|
1009
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1010
|
+
}
|
|
1011
|
+
if (node.end_node) {
|
|
1012
|
+
this.visit(node.end_node);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
visitERBUntilNode(node) {
|
|
1016
|
+
this.printERBNode(node);
|
|
1017
|
+
if (node.statements) {
|
|
1018
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1019
|
+
}
|
|
1020
|
+
if (node.end_node) {
|
|
1021
|
+
this.visit(node.end_node);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
visitERBForNode(node) {
|
|
1025
|
+
this.printERBNode(node);
|
|
1026
|
+
if (node.statements) {
|
|
1027
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1028
|
+
}
|
|
1029
|
+
if (node.end_node) {
|
|
1030
|
+
this.visit(node.end_node);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
visitERBBeginNode(node) {
|
|
1034
|
+
this.printERBNode(node);
|
|
1035
|
+
if (node.statements) {
|
|
1036
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1037
|
+
}
|
|
1038
|
+
if (node.rescue_clause) {
|
|
1039
|
+
this.visit(node.rescue_clause);
|
|
1040
|
+
}
|
|
1041
|
+
if (node.else_clause) {
|
|
1042
|
+
this.visit(node.else_clause);
|
|
1043
|
+
}
|
|
1044
|
+
if (node.ensure_clause) {
|
|
1045
|
+
this.visit(node.ensure_clause);
|
|
1046
|
+
}
|
|
1047
|
+
if (node.end_node) {
|
|
1048
|
+
this.visit(node.end_node);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
visitERBRescueNode(node) {
|
|
1052
|
+
this.printERBNode(node);
|
|
1053
|
+
if (node.statements) {
|
|
1054
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1055
|
+
}
|
|
1056
|
+
if (node.subsequent) {
|
|
1057
|
+
this.visit(node.subsequent);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
visitERBEnsureNode(node) {
|
|
1061
|
+
this.printERBNode(node);
|
|
1062
|
+
if (node.statements) {
|
|
1063
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
visitERBUnlessNode(node) {
|
|
1067
|
+
this.printERBNode(node);
|
|
1068
|
+
if (node.statements) {
|
|
1069
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1070
|
+
}
|
|
1071
|
+
if (node.else_clause) {
|
|
1072
|
+
this.visit(node.else_clause);
|
|
1073
|
+
}
|
|
1074
|
+
if (node.end_node) {
|
|
1075
|
+
this.visit(node.end_node);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
visitERBYieldNode(node) {
|
|
1079
|
+
this.printERBNode(node);
|
|
1080
|
+
}
|
|
1081
|
+
visitERBInNode(node) {
|
|
1082
|
+
this.printERBNode(node);
|
|
1083
|
+
if (node.statements) {
|
|
1084
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
visitERBCaseMatchNode(node) {
|
|
1088
|
+
this.printERBNode(node);
|
|
1089
|
+
if (node.children) {
|
|
1090
|
+
node.children.forEach(child => this.visit(child));
|
|
1091
|
+
}
|
|
1092
|
+
if (node.conditions) {
|
|
1093
|
+
node.conditions.forEach(condition => this.visit(condition));
|
|
1094
|
+
}
|
|
1095
|
+
if (node.else_clause) {
|
|
1096
|
+
this.visit(node.else_clause);
|
|
1097
|
+
}
|
|
1098
|
+
if (node.end_node) {
|
|
1099
|
+
this.visit(node.end_node);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Print ERB node tags and content
|
|
1104
|
+
*/
|
|
1105
|
+
printERBNode(node) {
|
|
1106
|
+
if (node.tag_opening) {
|
|
1107
|
+
this.write(node.tag_opening.value);
|
|
1108
|
+
}
|
|
1109
|
+
if (node.content) {
|
|
1110
|
+
this.write(node.content.value);
|
|
1111
|
+
}
|
|
1112
|
+
if (node.tag_closing) {
|
|
1113
|
+
this.write(node.tag_closing.value);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
|
|
1119
|
+
...DEFAULT_PRINT_OPTIONS,
|
|
1120
|
+
forceQuotes: false
|
|
1121
|
+
};
|
|
1122
|
+
/**
|
|
1123
|
+
* ERBToRubyStringPrinter - Converts ERB snippets to Ruby strings with interpolation
|
|
1124
|
+
*
|
|
1125
|
+
* This printer transforms ERB templates into Ruby strings by:
|
|
1126
|
+
* - Converting literal text to string content
|
|
1127
|
+
* - Converting <%= %> tags to #{} interpolation
|
|
1128
|
+
* - Converting simple if/else blocks to ternary operators
|
|
1129
|
+
* - Ignoring <% %> tags (they don't produce output)
|
|
1130
|
+
*
|
|
1131
|
+
* Examples:
|
|
1132
|
+
* - `hello world <%= hello %>` => `"hello world #{hello}"`
|
|
1133
|
+
* - `hello world <% hello %>` => `"hello world "`
|
|
1134
|
+
* - `Welcome <%= user.name %>!` => `"Welcome #{user.name}!"`
|
|
1135
|
+
* - `<% if logged_in? %>Welcome<% else %>Login<% end %>` => `"logged_in? ? "Welcome" : "Login"`
|
|
1136
|
+
* - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
|
|
1137
|
+
*/
|
|
1138
|
+
class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
1139
|
+
// TODO: cleanup `.type === "AST_*" checks`
|
|
1140
|
+
static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
|
|
1141
|
+
const erbNodes = filterNodes([node], ERBContentNode);
|
|
1142
|
+
if (erbNodes.length === 1 && isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
|
|
1143
|
+
return (erbNodes[0].content?.value || "").trim();
|
|
1144
|
+
}
|
|
1145
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
1146
|
+
const childErbNodes = filterNodes(node.children, ERBContentNode);
|
|
1147
|
+
const hasOnlyERBContent = node.children.length > 0 && node.children.length === childErbNodes.length;
|
|
1148
|
+
if (hasOnlyERBContent && childErbNodes.length === 1 && isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
|
|
1149
|
+
return (childErbNodes[0].content?.value || "").trim();
|
|
502
1150
|
}
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
1151
|
+
if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
|
|
1152
|
+
const ifNode = node.children[0];
|
|
1153
|
+
const printer = new ERBToRubyStringPrinter();
|
|
1154
|
+
if (printer.canConvertToTernary(ifNode)) {
|
|
1155
|
+
printer.convertToTernaryWithoutWrapper(ifNode);
|
|
1156
|
+
return printer.context.getOutput();
|
|
507
1157
|
}
|
|
508
1158
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
result += `#{${(erbNode.content?.value || "").trim()}}`;
|
|
516
|
-
}
|
|
517
|
-
else if (child.type === "AST_LITERAL_NODE") {
|
|
518
|
-
const literalNode = child;
|
|
519
|
-
result += literalNode.content || "";
|
|
1159
|
+
if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
|
|
1160
|
+
const unlessNode = node.children[0];
|
|
1161
|
+
const printer = new ERBToRubyStringPrinter();
|
|
1162
|
+
if (printer.canConvertUnlessToTernary(unlessNode)) {
|
|
1163
|
+
printer.convertUnlessToTernaryWithoutWrapper(unlessNode);
|
|
1164
|
+
return printer.context.getOutput();
|
|
520
1165
|
}
|
|
521
1166
|
}
|
|
522
|
-
result += '"';
|
|
523
|
-
return result;
|
|
524
1167
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1168
|
+
const printer = new ERBToRubyStringPrinter();
|
|
1169
|
+
printer.context.write('"');
|
|
1170
|
+
printer.visit(node);
|
|
1171
|
+
printer.context.write('"');
|
|
1172
|
+
return printer.context.getOutput();
|
|
1173
|
+
}
|
|
1174
|
+
visitHTMLTextNode(node) {
|
|
1175
|
+
if (node.content) {
|
|
1176
|
+
const escapedContent = node.content.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1177
|
+
this.context.write(escapedContent);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
visitERBContentNode(node) {
|
|
1181
|
+
if (isERBOutputNode(node)) {
|
|
1182
|
+
this.context.write("#{");
|
|
1183
|
+
if (node.content?.value) {
|
|
1184
|
+
this.context.write(node.content.value.trim());
|
|
529
1185
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
1186
|
+
this.context.write("}");
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
visitERBIfNode(node) {
|
|
1190
|
+
if (this.canConvertToTernary(node)) {
|
|
1191
|
+
this.convertToTernary(node);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
visitERBUnlessNode(node) {
|
|
1195
|
+
if (this.canConvertUnlessToTernary(node)) {
|
|
1196
|
+
this.convertUnlessToTernary(node);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
visitHTMLAttributeValueNode(node) {
|
|
1200
|
+
this.visitChildNodes(node);
|
|
1201
|
+
}
|
|
1202
|
+
canConvertToTernary(node) {
|
|
1203
|
+
if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
|
|
1207
|
+
if (!ifOnlyText)
|
|
1208
|
+
return false;
|
|
1209
|
+
if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
|
|
1210
|
+
return node.subsequent.statements
|
|
1211
|
+
? node.subsequent.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
|
|
1212
|
+
: true;
|
|
1213
|
+
}
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
convertToTernary(node) {
|
|
1217
|
+
this.context.write("#{");
|
|
1218
|
+
if (node.content?.value) {
|
|
1219
|
+
const condition = node.content.value.trim();
|
|
1220
|
+
const cleanCondition = condition.replace(/^if\s+/, '');
|
|
1221
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1222
|
+
if (needsParentheses) {
|
|
1223
|
+
this.context.write("(");
|
|
1224
|
+
}
|
|
1225
|
+
this.context.write(cleanCondition);
|
|
1226
|
+
if (needsParentheses) {
|
|
1227
|
+
this.context.write(")");
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
this.context.write(" ? ");
|
|
1231
|
+
this.context.write('"');
|
|
1232
|
+
if (node.statements) {
|
|
1233
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1234
|
+
}
|
|
1235
|
+
this.context.write('"');
|
|
1236
|
+
this.context.write(" : ");
|
|
1237
|
+
this.context.write('"');
|
|
1238
|
+
if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
|
|
1239
|
+
node.subsequent.statements.forEach(statement => this.visit(statement));
|
|
1240
|
+
}
|
|
1241
|
+
this.context.write('"');
|
|
1242
|
+
this.context.write("}");
|
|
1243
|
+
}
|
|
1244
|
+
convertToTernaryWithoutWrapper(node) {
|
|
1245
|
+
if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
|
|
1246
|
+
return false;
|
|
1247
|
+
}
|
|
1248
|
+
if (node.content?.value) {
|
|
1249
|
+
const condition = node.content.value.trim();
|
|
1250
|
+
const cleanCondition = condition.replace(/^if\s+/, '');
|
|
1251
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1252
|
+
if (needsParentheses) {
|
|
1253
|
+
this.context.write("(");
|
|
1254
|
+
}
|
|
1255
|
+
this.context.write(cleanCondition);
|
|
1256
|
+
if (needsParentheses) {
|
|
1257
|
+
this.context.write(")");
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
this.context.write(" ? ");
|
|
1261
|
+
this.context.write('"');
|
|
1262
|
+
if (node.statements) {
|
|
1263
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1264
|
+
}
|
|
1265
|
+
this.context.write('"');
|
|
1266
|
+
this.context.write(" : ");
|
|
1267
|
+
this.context.write('"');
|
|
1268
|
+
if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
|
|
1269
|
+
node.subsequent.statements.forEach(statement => this.visit(statement));
|
|
1270
|
+
}
|
|
1271
|
+
this.context.write('"');
|
|
1272
|
+
}
|
|
1273
|
+
canConvertUnlessToTernary(node) {
|
|
1274
|
+
const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
|
|
1275
|
+
if (!unlessOnlyText)
|
|
1276
|
+
return false;
|
|
1277
|
+
if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
|
|
1278
|
+
return node.else_clause.statements
|
|
1279
|
+
? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
|
|
1280
|
+
: true;
|
|
1281
|
+
}
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
convertUnlessToTernary(node) {
|
|
1285
|
+
this.context.write("#{");
|
|
1286
|
+
if (node.content?.value) {
|
|
1287
|
+
const condition = node.content.value.trim();
|
|
1288
|
+
const cleanCondition = condition.replace(/^unless\s+/, '');
|
|
1289
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1290
|
+
this.context.write("!(");
|
|
1291
|
+
if (needsParentheses) {
|
|
1292
|
+
this.context.write("(");
|
|
1293
|
+
}
|
|
1294
|
+
this.context.write(cleanCondition);
|
|
1295
|
+
if (needsParentheses) {
|
|
1296
|
+
this.context.write(")");
|
|
1297
|
+
}
|
|
1298
|
+
this.context.write(")");
|
|
1299
|
+
}
|
|
1300
|
+
this.context.write(" ? ");
|
|
1301
|
+
this.context.write('"');
|
|
1302
|
+
if (node.statements) {
|
|
1303
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1304
|
+
}
|
|
1305
|
+
this.context.write('"');
|
|
1306
|
+
this.context.write(" : ");
|
|
1307
|
+
this.context.write('"');
|
|
1308
|
+
if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
|
|
1309
|
+
node.else_clause.statements.forEach(statement => this.visit(statement));
|
|
1310
|
+
}
|
|
1311
|
+
this.context.write('"');
|
|
1312
|
+
this.context.write("}");
|
|
1313
|
+
}
|
|
1314
|
+
convertUnlessToTernaryWithoutWrapper(node) {
|
|
1315
|
+
if (node.content?.value) {
|
|
1316
|
+
const condition = node.content.value.trim();
|
|
1317
|
+
const cleanCondition = condition.replace(/^unless\s+/, '');
|
|
1318
|
+
const needsParentheses = cleanCondition.includes(' ');
|
|
1319
|
+
this.context.write("!(");
|
|
1320
|
+
if (needsParentheses) {
|
|
1321
|
+
this.context.write("(");
|
|
1322
|
+
}
|
|
1323
|
+
this.context.write(cleanCondition);
|
|
1324
|
+
if (needsParentheses) {
|
|
1325
|
+
this.context.write(")");
|
|
1326
|
+
}
|
|
1327
|
+
this.context.write(")");
|
|
1328
|
+
}
|
|
1329
|
+
this.context.write(" ? ");
|
|
1330
|
+
this.context.write('"');
|
|
1331
|
+
if (node.statements) {
|
|
1332
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
1333
|
+
}
|
|
1334
|
+
this.context.write('"');
|
|
1335
|
+
this.context.write(" : ");
|
|
1336
|
+
this.context.write('"');
|
|
1337
|
+
if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
|
|
1338
|
+
node.else_clause.statements.forEach(statement => this.visit(statement));
|
|
1339
|
+
}
|
|
1340
|
+
this.context.write('"');
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
1345
|
+
visitHTMLOpenTagNode(node) {
|
|
1346
|
+
this.checkImgTag(node);
|
|
1347
|
+
super.visitHTMLOpenTagNode(node);
|
|
1348
|
+
}
|
|
1349
|
+
checkImgTag(openTag) {
|
|
1350
|
+
const tagName = getTagName(openTag);
|
|
1351
|
+
if (tagName !== "img")
|
|
1352
|
+
return;
|
|
1353
|
+
const attributes = getAttributes(openTag);
|
|
1354
|
+
const srcAttribute = findAttributeByName(attributes, "src");
|
|
1355
|
+
if (!srcAttribute)
|
|
1356
|
+
return;
|
|
1357
|
+
if (!srcAttribute.value)
|
|
1358
|
+
return;
|
|
1359
|
+
const node = srcAttribute.value;
|
|
1360
|
+
const hasERBContent = this.containsERBContent(node);
|
|
1361
|
+
if (hasERBContent) {
|
|
1362
|
+
if (this.isDataUri(node))
|
|
1363
|
+
return;
|
|
1364
|
+
if (this.shouldFlagAsImageTagCandidate(node)) {
|
|
1365
|
+
const suggestedExpression = this.buildSuggestedExpression(node);
|
|
1366
|
+
this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
|
|
537
1367
|
}
|
|
538
1368
|
}
|
|
539
|
-
|
|
1369
|
+
}
|
|
1370
|
+
containsERBContent(node) {
|
|
1371
|
+
return filterNodes(node.children, ERBContentNode).length > 0;
|
|
1372
|
+
}
|
|
1373
|
+
isOnlyERBContent(node) {
|
|
1374
|
+
return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length;
|
|
1375
|
+
}
|
|
1376
|
+
getContentofFirstChild(node) {
|
|
1377
|
+
if (!node.children || node.children.length === 0)
|
|
1378
|
+
return "";
|
|
1379
|
+
const firstChild = node.children[0];
|
|
1380
|
+
if (isNode(firstChild, LiteralNode)) {
|
|
1381
|
+
return (firstChild.content || "").trim();
|
|
1382
|
+
}
|
|
1383
|
+
return "";
|
|
1384
|
+
}
|
|
1385
|
+
isDataUri(node) {
|
|
1386
|
+
return this.getContentofFirstChild(node).startsWith("data:");
|
|
1387
|
+
}
|
|
1388
|
+
isFullUrl(node) {
|
|
1389
|
+
const content = this.getContentofFirstChild(node);
|
|
1390
|
+
return content.startsWith("http://") || content.startsWith("https://");
|
|
1391
|
+
}
|
|
1392
|
+
shouldFlagAsImageTagCandidate(node) {
|
|
1393
|
+
if (this.isOnlyERBContent(node))
|
|
1394
|
+
return true;
|
|
1395
|
+
if (this.isFullUrl(node))
|
|
1396
|
+
return false;
|
|
1397
|
+
return true;
|
|
1398
|
+
}
|
|
1399
|
+
buildSuggestedExpression(node) {
|
|
1400
|
+
if (!node.children)
|
|
1401
|
+
return "expression";
|
|
1402
|
+
try {
|
|
1403
|
+
return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
return "expression";
|
|
1407
|
+
}
|
|
540
1408
|
}
|
|
541
1409
|
}
|
|
542
1410
|
class ERBPreferImageTagHelperRule extends ParserRule {
|
|
@@ -650,17 +1518,18 @@ class HTMLAnchorRequireHrefRule extends ParserRule {
|
|
|
650
1518
|
}
|
|
651
1519
|
|
|
652
1520
|
class AriaAttributeMustBeValid extends AttributeVisitorMixin {
|
|
653
|
-
|
|
1521
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
1522
|
+
this.check(attributeName, attributeNode);
|
|
1523
|
+
}
|
|
1524
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
|
|
1525
|
+
this.check(attributeName, attributeNode);
|
|
1526
|
+
}
|
|
1527
|
+
check(attributeName, attributeNode) {
|
|
654
1528
|
if (!attributeName.startsWith("aria-"))
|
|
655
1529
|
return;
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
severity: "error",
|
|
660
|
-
location: attributeNode.location,
|
|
661
|
-
rule: this.ruleName,
|
|
662
|
-
});
|
|
663
|
-
}
|
|
1530
|
+
if (ARIA_ATTRIBUTES.has(attributeName))
|
|
1531
|
+
return;
|
|
1532
|
+
this.addOffense(`The attribute \`${attributeName}\` is not a valid ARIA attribute. ARIA attributes must match the WAI-ARIA specification.`, attributeNode.location, "error");
|
|
664
1533
|
}
|
|
665
1534
|
}
|
|
666
1535
|
class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
@@ -672,13 +1541,65 @@ class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
|
672
1541
|
}
|
|
673
1542
|
}
|
|
674
1543
|
|
|
1544
|
+
class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
|
|
1545
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
1546
|
+
if (attributeName !== "aria-label")
|
|
1547
|
+
return;
|
|
1548
|
+
if (attributeValue.match(/[\r\n]+/) || attributeValue.match(/ | |
|
/i)) {
|
|
1549
|
+
this.addOffense("The `aria-label` attribute value text should not contain line breaks. Use concise, single-line descriptions.", attributeNode.location, "error");
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (this.looksLikeId(attributeValue)) {
|
|
1553
|
+
this.addOffense("The `aria-label` attribute value should not be formatted like an ID. Use natural, sentence-case text instead.", attributeNode.location, "error");
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (attributeValue.match(/^[a-z]/)) {
|
|
1557
|
+
this.addOffense("The `aria-label` attribute value text should be formatted like visual text. Use sentence case (capitalize the first letter).", attributeNode.location, "error");
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
looksLikeId(text) {
|
|
1561
|
+
return (text.includes('_') ||
|
|
1562
|
+
text.includes('-') ||
|
|
1563
|
+
/^[a-z]+([A-Z][a-z]*)*$/.test(text)) && !text.includes(' ');
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
|
|
1567
|
+
name = "html-aria-label-is-well-formatted";
|
|
1568
|
+
check(result, context) {
|
|
1569
|
+
const visitor = new AriaLabelIsWellFormattedVisitor(this.name, context);
|
|
1570
|
+
visitor.visit(result.value);
|
|
1571
|
+
return visitor.offenses;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
675
1575
|
class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
676
|
-
|
|
1576
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
677
1577
|
if (attributeName !== "aria-level")
|
|
678
1578
|
return;
|
|
679
|
-
|
|
1579
|
+
this.validateAriaLevel(attributeValue, attributeNode);
|
|
1580
|
+
}
|
|
1581
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
|
|
1582
|
+
if (attributeName !== "aria-level")
|
|
1583
|
+
return;
|
|
1584
|
+
const validatableContent = getValidatableStaticContent(valueNodes);
|
|
1585
|
+
if (validatableContent !== null) {
|
|
1586
|
+
this.validateAriaLevel(validatableContent, attributeNode);
|
|
680
1587
|
return;
|
|
681
|
-
|
|
1588
|
+
}
|
|
1589
|
+
if (!hasERBOutput(valueNodes))
|
|
1590
|
+
return;
|
|
1591
|
+
const literalNodes = filterLiteralNodes(valueNodes);
|
|
1592
|
+
const erbOutputNodes = filterERBContentNodes(valueNodes).filter(isERBOutputNode);
|
|
1593
|
+
if (literalNodes.length > 0 && erbOutputNodes.length > 0) {
|
|
1594
|
+
const staticPart = literalNodes.map(node => node.content).join("");
|
|
1595
|
+
// TODO: this can be cleaned up using @herb-tools/printer
|
|
1596
|
+
const erbPart = erbOutputNodes[0];
|
|
1597
|
+
const erbText = `${erbPart.tag_opening?.value || ""}${erbPart.content?.value || ""}${erbPart.tag_closing?.value || ""}`;
|
|
1598
|
+
this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got \`${staticPart}\` and the ERB expression \`${erbText}\`.`, attributeNode.location);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
validateAriaLevel(attributeValue, attributeNode) {
|
|
1602
|
+
if (!attributeValue || attributeValue === "") {
|
|
682
1603
|
this.addOffense(`The \`aria-level\` attribute must be an integer between 1 and 6, got an empty value.`, attributeNode.location);
|
|
683
1604
|
return;
|
|
684
1605
|
}
|
|
@@ -698,19 +1619,13 @@ class HTMLAriaLevelMustBeValidRule extends ParserRule {
|
|
|
698
1619
|
}
|
|
699
1620
|
|
|
700
1621
|
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")) {
|
|
1622
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode, parentNode }) {
|
|
1623
|
+
if (!(attributeName === "role" && attributeValue === "heading"))
|
|
706
1624
|
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
|
-
}
|
|
1625
|
+
const ariaLevelAttributes = getAttributes(parentNode).find(attribute => getAttributeName(attribute) === "aria-level");
|
|
1626
|
+
if (ariaLevelAttributes)
|
|
1627
|
+
return;
|
|
1628
|
+
this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
|
|
714
1629
|
}
|
|
715
1630
|
}
|
|
716
1631
|
class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
@@ -723,10 +1638,10 @@ class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
|
723
1638
|
}
|
|
724
1639
|
|
|
725
1640
|
class AriaRoleMustBeValid extends AttributeVisitorMixin {
|
|
726
|
-
|
|
1641
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
727
1642
|
if (attributeName !== "role")
|
|
728
1643
|
return;
|
|
729
|
-
if (attributeValue
|
|
1644
|
+
if (!attributeValue)
|
|
730
1645
|
return;
|
|
731
1646
|
if (VALID_ARIA_ROLES.has(attributeValue))
|
|
732
1647
|
return;
|
|
@@ -743,35 +1658,77 @@ class HTMLAriaRoleMustBeValidRule extends ParserRule {
|
|
|
743
1658
|
}
|
|
744
1659
|
|
|
745
1660
|
class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
746
|
-
|
|
1661
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
747
1662
|
if (!hasAttributeValue(attributeNode))
|
|
748
1663
|
return;
|
|
749
1664
|
if (getAttributeValueQuoteType(attributeNode) !== "single")
|
|
750
1665
|
return;
|
|
751
1666
|
if (attributeValue?.includes('"'))
|
|
752
|
-
return;
|
|
753
|
-
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="
|
|
1667
|
+
return;
|
|
1668
|
+
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${attributeValue}"\`.`, attributeNode.value.location, "warning");
|
|
1669
|
+
}
|
|
1670
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode, combinedValue }) {
|
|
1671
|
+
if (!hasAttributeValue(attributeNode))
|
|
1672
|
+
return;
|
|
1673
|
+
if (getAttributeValueQuoteType(attributeNode) !== "single")
|
|
1674
|
+
return;
|
|
1675
|
+
if (filterLiteralNodes(valueNodes).some(node => node.content?.includes('"')))
|
|
1676
|
+
return;
|
|
1677
|
+
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "warning");
|
|
754
1678
|
}
|
|
755
1679
|
}
|
|
756
1680
|
class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
757
1681
|
name = "html-attribute-double-quotes";
|
|
758
1682
|
check(result, context) {
|
|
759
|
-
const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
|
|
1683
|
+
const visitor = new AttributeDoubleQuotesVisitor(this.name, context);
|
|
1684
|
+
visitor.visit(result.value);
|
|
1685
|
+
return visitor.offenses;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
|
|
1690
|
+
visitHTMLAttributeNode(attribute) {
|
|
1691
|
+
if (!attribute.equals || !attribute.name || !attribute.value) {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (attribute.equals.value.startsWith(" ")) {
|
|
1695
|
+
this.addOffense("Remove whitespace before `=` in HTML attribute", attribute.equals.location, "error");
|
|
1696
|
+
}
|
|
1697
|
+
if (attribute.equals.value.endsWith(" ")) {
|
|
1698
|
+
this.addOffense("Remove whitespace after `=` in HTML attribute", attribute.equals.location, "error");
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
class HTMLAttributeEqualsSpacingRule extends ParserRule {
|
|
1703
|
+
name = "html-attribute-equals-spacing";
|
|
1704
|
+
check(result, context) {
|
|
1705
|
+
const visitor = new HTMLAttributeEqualsSpacingVisitor(this.name, context);
|
|
760
1706
|
visitor.visit(result.value);
|
|
761
1707
|
return visitor.offenses;
|
|
762
1708
|
}
|
|
763
1709
|
}
|
|
764
1710
|
|
|
765
1711
|
class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
|
|
766
|
-
|
|
767
|
-
if (attributeNode
|
|
1712
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
1713
|
+
if (this.hasAttributeValue(attributeNode))
|
|
768
1714
|
return;
|
|
769
|
-
|
|
770
|
-
|
|
1715
|
+
if (this.isQuoted(attributeNode))
|
|
1716
|
+
return;
|
|
1717
|
+
this.addOffense(`Attribute value should be quoted: \`${attributeName}="${attributeValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
|
|
1718
|
+
}
|
|
1719
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
|
|
1720
|
+
if (this.hasAttributeValue(attributeNode))
|
|
771
1721
|
return;
|
|
772
|
-
this.
|
|
773
|
-
|
|
774
|
-
`Attribute value should be quoted: \`${attributeName}="
|
|
1722
|
+
if (this.isQuoted(attributeNode))
|
|
1723
|
+
return;
|
|
1724
|
+
this.addOffense(`Attribute value should be quoted: \`${attributeName}="${combinedValue}"\`. Always wrap attribute values in quotes.`, attributeNode.value.location, "error");
|
|
1725
|
+
}
|
|
1726
|
+
hasAttributeValue(attributeNode) {
|
|
1727
|
+
return attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE";
|
|
1728
|
+
}
|
|
1729
|
+
isQuoted(attributeNode) {
|
|
1730
|
+
const valueNode = attributeNode.value;
|
|
1731
|
+
return valueNode.quoted;
|
|
775
1732
|
}
|
|
776
1733
|
}
|
|
777
1734
|
class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
@@ -783,14 +1740,66 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
|
783
1740
|
}
|
|
784
1741
|
}
|
|
785
1742
|
|
|
1743
|
+
const ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT = new Set([
|
|
1744
|
+
"button", "fieldset", "input", "optgroup", "option", "select", "textarea"
|
|
1745
|
+
]);
|
|
1746
|
+
class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
|
|
1747
|
+
visitHTMLOpenTagNode(node) {
|
|
1748
|
+
this.checkElement(node);
|
|
1749
|
+
super.visitHTMLOpenTagNode(node);
|
|
1750
|
+
}
|
|
1751
|
+
checkElement(node) {
|
|
1752
|
+
const tagName = getTagName(node);
|
|
1753
|
+
if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const hasDisabled = hasAttribute(node, "disabled");
|
|
1757
|
+
const hasAriaDisabled = hasAttribute(node, "aria-disabled");
|
|
1758
|
+
if ((hasDisabled && this.hasERBContent(node, "disabled")) || (hasAriaDisabled && this.hasERBContent(node, "aria-disabled"))) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
if (hasDisabled && hasAriaDisabled) {
|
|
1762
|
+
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");
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
hasERBContent(node, attributeName) {
|
|
1766
|
+
const attributes = getAttributes(node);
|
|
1767
|
+
const attribute = findAttributeByName(attributes, attributeName);
|
|
1768
|
+
if (!attribute)
|
|
1769
|
+
return false;
|
|
1770
|
+
const valueNode = attribute.value;
|
|
1771
|
+
if (!valueNode || valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
|
|
1772
|
+
return false;
|
|
1773
|
+
const htmlValueNode = valueNode;
|
|
1774
|
+
if (!htmlValueNode.children)
|
|
1775
|
+
return false;
|
|
1776
|
+
return htmlValueNode.children.some((child) => child.type === "AST_ERB_CONTENT_NODE");
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
|
|
1780
|
+
name = "html-avoid-both-disabled-and-aria-disabled";
|
|
1781
|
+
check(result, context) {
|
|
1782
|
+
const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.name, context);
|
|
1783
|
+
visitor.visit(result.value);
|
|
1784
|
+
return visitor.offenses;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
786
1788
|
class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
787
|
-
|
|
1789
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
788
1790
|
if (!isBooleanAttribute(attributeName))
|
|
789
1791
|
return;
|
|
790
1792
|
if (!hasAttributeValue(attributeNode))
|
|
791
1793
|
return;
|
|
792
1794
|
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
|
|
793
1795
|
}
|
|
1796
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode, combinedValue }) {
|
|
1797
|
+
if (!isBooleanAttribute(attributeName))
|
|
1798
|
+
return;
|
|
1799
|
+
if (!hasAttributeValue(attributeNode))
|
|
1800
|
+
return;
|
|
1801
|
+
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${combinedValue}"\`.`, attributeNode.value.location, "error");
|
|
1802
|
+
}
|
|
794
1803
|
}
|
|
795
1804
|
class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
796
1805
|
name = "html-boolean-attributes-no-value";
|
|
@@ -801,14 +1810,47 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
|
801
1810
|
}
|
|
802
1811
|
}
|
|
803
1812
|
|
|
804
|
-
class
|
|
1813
|
+
class IframeHasTitleVisitor extends BaseRuleVisitor {
|
|
805
1814
|
visitHTMLOpenTagNode(node) {
|
|
806
|
-
this.
|
|
1815
|
+
this.checkIframeElement(node);
|
|
807
1816
|
super.visitHTMLOpenTagNode(node);
|
|
808
1817
|
}
|
|
809
|
-
|
|
1818
|
+
checkIframeElement(node) {
|
|
1819
|
+
const tagName = getTagName(node);
|
|
1820
|
+
if (tagName !== "iframe") {
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const ariaHiddenAttribute = getAttribute(node, "aria-hidden");
|
|
1824
|
+
if (ariaHiddenAttribute) {
|
|
1825
|
+
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
1826
|
+
if (ariaHiddenValue === "true") {
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
const attribute = getAttribute(node, "title");
|
|
1831
|
+
if (!attribute) {
|
|
1832
|
+
this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const value = getAttributeValue(attribute);
|
|
1836
|
+
if (!value || value.trim() === "") {
|
|
1837
|
+
this.addOffense("`<iframe>` elements must have a `title` attribute that describes the content of the frame for screen reader users.", node.location, "error");
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
class HTMLIframeHasTitleRule extends ParserRule {
|
|
1842
|
+
name = "html-iframe-has-title";
|
|
1843
|
+
check(result, context) {
|
|
1844
|
+
const visitor = new IframeHasTitleVisitor(this.name, context);
|
|
1845
|
+
visitor.visit(result.value);
|
|
1846
|
+
return visitor.offenses;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
1851
|
+
visitHTMLOpenTagNode(node) {
|
|
810
1852
|
this.checkImgTag(node);
|
|
811
|
-
super.
|
|
1853
|
+
super.visitHTMLOpenTagNode(node);
|
|
812
1854
|
}
|
|
813
1855
|
checkImgTag(node) {
|
|
814
1856
|
const tagName = getTagName(node);
|
|
@@ -829,34 +1871,137 @@ class HTMLImgRequireAltRule extends ParserRule {
|
|
|
829
1871
|
}
|
|
830
1872
|
}
|
|
831
1873
|
|
|
832
|
-
class
|
|
1874
|
+
class NavigationHasLabelVisitor extends BaseRuleVisitor {
|
|
1875
|
+
visitHTMLOpenTagNode(node) {
|
|
1876
|
+
this.checkNavigationElement(node);
|
|
1877
|
+
super.visitHTMLOpenTagNode(node);
|
|
1878
|
+
}
|
|
1879
|
+
checkNavigationElement(node) {
|
|
1880
|
+
const tagName = getTagName(node);
|
|
1881
|
+
const isNavElement = tagName === "nav";
|
|
1882
|
+
const hasNavigationRole = this.hasRoleNavigation(node);
|
|
1883
|
+
if (!isNavElement && !hasNavigationRole) {
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const hasAriaLabel = hasAttribute(node, "aria-label");
|
|
1887
|
+
const hasAriaLabelledby = hasAttribute(node, "aria-labelledby");
|
|
1888
|
+
if (!hasAriaLabel && !hasAriaLabelledby) {
|
|
1889
|
+
let message = `The navigation landmark should have a unique accessible name via \`aria-label\` or \`aria-labelledby\`. Remember that the name does not need to include "navigation" or "nav" since it will already be announced.`;
|
|
1890
|
+
if (hasNavigationRole && !isNavElement) {
|
|
1891
|
+
message += ` Additionally, you can safely drop the \`role="navigation"\` and replace it with the native HTML \`<nav>\` element.`;
|
|
1892
|
+
}
|
|
1893
|
+
this.addOffense(message, node.tag_name.location, "error");
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
hasRoleNavigation(node) {
|
|
1897
|
+
const attributes = getAttributes(node);
|
|
1898
|
+
const roleAttribute = findAttributeByName(attributes, "role");
|
|
1899
|
+
if (!roleAttribute) {
|
|
1900
|
+
return false;
|
|
1901
|
+
}
|
|
1902
|
+
const roleValue = getAttributeValue(roleAttribute);
|
|
1903
|
+
return roleValue === "navigation";
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
class HTMLNavigationHasLabelRule extends ParserRule {
|
|
1907
|
+
name = "html-navigation-has-label";
|
|
1908
|
+
check(result, context) {
|
|
1909
|
+
const visitor = new NavigationHasLabelVisitor(this.name, context);
|
|
1910
|
+
visitor.visit(result.value);
|
|
1911
|
+
return visitor.offenses;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const INTERACTIVE_ELEMENTS = new Set([
|
|
1916
|
+
"button", "summary", "input", "select", "textarea", "a"
|
|
1917
|
+
]);
|
|
1918
|
+
class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
|
|
833
1919
|
visitHTMLOpenTagNode(node) {
|
|
834
|
-
this.
|
|
1920
|
+
this.checkAriaHiddenOnFocusable(node);
|
|
835
1921
|
super.visitHTMLOpenTagNode(node);
|
|
836
1922
|
}
|
|
837
|
-
|
|
838
|
-
this.
|
|
839
|
-
|
|
1923
|
+
checkAriaHiddenOnFocusable(node) {
|
|
1924
|
+
if (!this.hasAriaHiddenTrue(node))
|
|
1925
|
+
return;
|
|
1926
|
+
if (this.isFocusable(node)) {
|
|
1927
|
+
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");
|
|
1928
|
+
}
|
|
840
1929
|
}
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1930
|
+
hasAriaHiddenTrue(node) {
|
|
1931
|
+
const attributes = getAttributes(node);
|
|
1932
|
+
const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden");
|
|
1933
|
+
if (!ariaHiddenAttr)
|
|
1934
|
+
return false;
|
|
1935
|
+
const value = getAttributeValue(ariaHiddenAttr);
|
|
1936
|
+
return value === "true";
|
|
1937
|
+
}
|
|
1938
|
+
isFocusable(node) {
|
|
1939
|
+
const tagName = getTagName(node);
|
|
1940
|
+
if (!tagName)
|
|
1941
|
+
return false;
|
|
1942
|
+
const tabIndexValue = this.getTabIndexValue(node);
|
|
1943
|
+
if (tagName === "a") {
|
|
1944
|
+
const hasHref = hasAttribute(node, "href");
|
|
1945
|
+
if (!hasHref) {
|
|
1946
|
+
return tabIndexValue !== null && tabIndexValue >= 0;
|
|
852
1947
|
}
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1948
|
+
return tabIndexValue === null || tabIndexValue >= 0;
|
|
1949
|
+
}
|
|
1950
|
+
if (INTERACTIVE_ELEMENTS.has(tagName)) {
|
|
1951
|
+
// Interactive elements are focusable unless tabindex is negative
|
|
1952
|
+
return tabIndexValue === null || tabIndexValue >= 0;
|
|
1953
|
+
}
|
|
1954
|
+
else {
|
|
1955
|
+
// Non-interactive elements are focusable only if tabindex >= 0
|
|
1956
|
+
return tabIndexValue !== null && tabIndexValue >= 0;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
getTabIndexValue(node) {
|
|
1960
|
+
const attributes = getAttributes(node);
|
|
1961
|
+
const tabIndexAttribute = findAttributeByName(attributes, "tabindex");
|
|
1962
|
+
if (!tabIndexAttribute)
|
|
1963
|
+
return null;
|
|
1964
|
+
const value = getAttributeValue(tabIndexAttribute);
|
|
1965
|
+
if (!value)
|
|
1966
|
+
return null;
|
|
1967
|
+
const parsed = parseInt(value, 10);
|
|
1968
|
+
return isNaN(parsed) ? null : parsed;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
|
|
1972
|
+
name = "html-no-aria-hidden-on-focusable";
|
|
1973
|
+
check(result, context) {
|
|
1974
|
+
const visitor = new NoAriaHiddenOnFocusableVisitor(this.name, context);
|
|
1975
|
+
visitor.visit(result.value);
|
|
1976
|
+
return visitor.offenses;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
class NoDuplicateAttributesVisitor extends AttributeVisitorMixin {
|
|
1981
|
+
attributeNames = new Map();
|
|
1982
|
+
visitHTMLOpenTagNode(node) {
|
|
1983
|
+
this.attributeNames.clear();
|
|
1984
|
+
super.visitHTMLOpenTagNode(node);
|
|
1985
|
+
this.reportDuplicates();
|
|
1986
|
+
}
|
|
1987
|
+
checkStaticAttributeStaticValue({ attributeName, attributeNode }) {
|
|
1988
|
+
this.trackAttributeName(attributeName, attributeNode);
|
|
1989
|
+
}
|
|
1990
|
+
checkStaticAttributeDynamicValue({ attributeName, attributeNode }) {
|
|
1991
|
+
this.trackAttributeName(attributeName, attributeNode);
|
|
1992
|
+
}
|
|
1993
|
+
trackAttributeName(attributeName, attributeNode) {
|
|
1994
|
+
if (!this.attributeNames.has(attributeName)) {
|
|
1995
|
+
this.attributeNames.set(attributeName, []);
|
|
1996
|
+
}
|
|
1997
|
+
this.attributeNames.get(attributeName).push(attributeNode);
|
|
1998
|
+
}
|
|
1999
|
+
reportDuplicates() {
|
|
2000
|
+
for (const [attributeName, attributeNodes] of this.attributeNames) {
|
|
2001
|
+
if (attributeNodes.length > 1) {
|
|
2002
|
+
for (let i = 1; i < attributeNodes.length; i++) {
|
|
2003
|
+
const attributeNode = attributeNodes[i];
|
|
2004
|
+
this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, attributeNode.name.location, "error");
|
|
860
2005
|
}
|
|
861
2006
|
}
|
|
862
2007
|
}
|
|
@@ -871,19 +2016,141 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
|
871
2016
|
}
|
|
872
2017
|
}
|
|
873
2018
|
|
|
874
|
-
class
|
|
2019
|
+
class OutputPrinter extends Printer {
|
|
2020
|
+
visitLiteralNode(node) {
|
|
2021
|
+
this.write(IdentityPrinter.print(node));
|
|
2022
|
+
}
|
|
2023
|
+
visitERBContentNode(node) {
|
|
2024
|
+
if (isERBOutputNode(node)) {
|
|
2025
|
+
this.write(IdentityPrinter.print(node));
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
|
|
875
2030
|
documentIds = new Set();
|
|
876
|
-
|
|
877
|
-
|
|
2031
|
+
currentBranchIds = new Set();
|
|
2032
|
+
controlFlowIds = new Set();
|
|
2033
|
+
visitHTMLAttributeNode(node) {
|
|
2034
|
+
this.checkAttribute(node);
|
|
2035
|
+
}
|
|
2036
|
+
onEnterControlFlow(_controlFlowType, wasAlreadyInControlFlow) {
|
|
2037
|
+
const stateToRestore = {
|
|
2038
|
+
previousBranchIds: this.currentBranchIds,
|
|
2039
|
+
previousControlFlowIds: this.controlFlowIds
|
|
2040
|
+
};
|
|
2041
|
+
this.currentBranchIds = new Set();
|
|
2042
|
+
if (!wasAlreadyInControlFlow) {
|
|
2043
|
+
this.controlFlowIds = new Set();
|
|
2044
|
+
}
|
|
2045
|
+
return stateToRestore;
|
|
2046
|
+
}
|
|
2047
|
+
onExitControlFlow(controlFlowType, wasAlreadyInControlFlow, stateToRestore) {
|
|
2048
|
+
if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
|
|
2049
|
+
this.controlFlowIds.forEach(id => this.documentIds.add(id));
|
|
2050
|
+
}
|
|
2051
|
+
this.currentBranchIds = stateToRestore.previousBranchIds;
|
|
2052
|
+
this.controlFlowIds = stateToRestore.previousControlFlowIds;
|
|
2053
|
+
}
|
|
2054
|
+
onEnterBranch() {
|
|
2055
|
+
const stateToRestore = {
|
|
2056
|
+
previousBranchIds: this.currentBranchIds
|
|
2057
|
+
};
|
|
2058
|
+
if (this.isInControlFlow) {
|
|
2059
|
+
this.currentBranchIds = new Set();
|
|
2060
|
+
}
|
|
2061
|
+
return stateToRestore;
|
|
2062
|
+
}
|
|
2063
|
+
onExitBranch(_stateToRestore) { }
|
|
2064
|
+
checkAttribute(attributeNode) {
|
|
2065
|
+
if (!this.isIdAttribute(attributeNode))
|
|
878
2066
|
return;
|
|
879
|
-
|
|
2067
|
+
const idValue = this.extractIdValue(attributeNode);
|
|
2068
|
+
if (!idValue)
|
|
2069
|
+
return;
|
|
2070
|
+
if (this.isWhitespaceOnlyId(idValue.identifier))
|
|
2071
|
+
return;
|
|
2072
|
+
this.processIdDuplicate(idValue, attributeNode);
|
|
2073
|
+
}
|
|
2074
|
+
isIdAttribute(attributeNode) {
|
|
2075
|
+
if (!attributeNode.name?.children || !attributeNode.value)
|
|
2076
|
+
return false;
|
|
2077
|
+
return getStaticAttributeName(attributeNode.name) === "id";
|
|
2078
|
+
}
|
|
2079
|
+
extractIdValue(attributeNode) {
|
|
2080
|
+
const valueNodes = attributeNode.value?.children || [];
|
|
2081
|
+
if (hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
2082
|
+
return null;
|
|
2083
|
+
}
|
|
2084
|
+
const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
|
|
2085
|
+
if (!identifier)
|
|
2086
|
+
return null;
|
|
2087
|
+
return { identifier, shouldTrackDuplicates: true };
|
|
2088
|
+
}
|
|
2089
|
+
isWhitespaceOnlyId(identifier) {
|
|
2090
|
+
return identifier !== '' && identifier.trim() === '';
|
|
2091
|
+
}
|
|
2092
|
+
processIdDuplicate(idValue, attributeNode) {
|
|
2093
|
+
const { identifier, shouldTrackDuplicates } = idValue;
|
|
2094
|
+
if (!shouldTrackDuplicates)
|
|
2095
|
+
return;
|
|
2096
|
+
if (this.isInControlFlow) {
|
|
2097
|
+
this.handleControlFlowId(identifier, attributeNode);
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
this.handleGlobalId(identifier, attributeNode);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
handleControlFlowId(identifier, attributeNode) {
|
|
2104
|
+
if (this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
2105
|
+
this.handleLoopId(identifier, attributeNode);
|
|
2106
|
+
}
|
|
2107
|
+
else {
|
|
2108
|
+
this.handleConditionalId(identifier, attributeNode);
|
|
2109
|
+
}
|
|
2110
|
+
this.currentBranchIds.add(identifier);
|
|
2111
|
+
}
|
|
2112
|
+
handleLoopId(identifier, attributeNode) {
|
|
2113
|
+
const isStaticId = this.isStaticId(attributeNode);
|
|
2114
|
+
if (isStaticId) {
|
|
2115
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
if (this.currentBranchIds.has(identifier)) {
|
|
2119
|
+
this.addSameLoopIterationOffense(identifier, attributeNode.location);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
handleConditionalId(identifier, attributeNode) {
|
|
2123
|
+
if (this.currentBranchIds.has(identifier)) {
|
|
2124
|
+
this.addSameBranchOffense(identifier, attributeNode.location);
|
|
880
2125
|
return;
|
|
881
|
-
|
|
882
|
-
if (this.documentIds.has(
|
|
883
|
-
this.
|
|
2126
|
+
}
|
|
2127
|
+
if (this.documentIds.has(identifier)) {
|
|
2128
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
this.controlFlowIds.add(identifier);
|
|
2132
|
+
}
|
|
2133
|
+
handleGlobalId(identifier, attributeNode) {
|
|
2134
|
+
if (this.documentIds.has(identifier)) {
|
|
2135
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
884
2136
|
return;
|
|
885
2137
|
}
|
|
886
|
-
this.documentIds.add(
|
|
2138
|
+
this.documentIds.add(identifier);
|
|
2139
|
+
}
|
|
2140
|
+
isStaticId(attributeNode) {
|
|
2141
|
+
const valueNodes = attributeNode.value.children;
|
|
2142
|
+
const isCompletelyStatic = valueNodes.every(child => isNode(child, LiteralNode));
|
|
2143
|
+
const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes);
|
|
2144
|
+
return isCompletelyStatic || isEffectivelyStaticValue;
|
|
2145
|
+
}
|
|
2146
|
+
addDuplicateIdOffense(identifier, location) {
|
|
2147
|
+
this.addOffense(`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`, location, "error");
|
|
2148
|
+
}
|
|
2149
|
+
addSameLoopIterationOffense(identifier, location) {
|
|
2150
|
+
this.addOffense(`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`, location, "error");
|
|
2151
|
+
}
|
|
2152
|
+
addSameBranchOffense(identifier, location) {
|
|
2153
|
+
this.addOffense(`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`, location, "error");
|
|
887
2154
|
}
|
|
888
2155
|
}
|
|
889
2156
|
class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
@@ -900,10 +2167,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
900
2167
|
this.checkHeadingElement(node);
|
|
901
2168
|
super.visitHTMLElementNode(node);
|
|
902
2169
|
}
|
|
903
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
904
|
-
this.checkSelfClosingHeading(node);
|
|
905
|
-
super.visitHTMLSelfCloseTagNode(node);
|
|
906
|
-
}
|
|
907
2170
|
checkHeadingElement(node) {
|
|
908
2171
|
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
909
2172
|
return;
|
|
@@ -925,23 +2188,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
925
2188
|
this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.location, "error");
|
|
926
2189
|
}
|
|
927
2190
|
}
|
|
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
2191
|
isEmptyHeading(node) {
|
|
946
2192
|
if (!node.body || node.body.length === 0) {
|
|
947
2193
|
return true;
|
|
@@ -1086,46 +2332,127 @@ class HTMLNoNestedLinksRule extends ParserRule {
|
|
|
1086
2332
|
}
|
|
1087
2333
|
}
|
|
1088
2334
|
|
|
1089
|
-
class
|
|
2335
|
+
class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
|
|
2336
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
2337
|
+
if (attributeName !== "tabindex")
|
|
2338
|
+
return;
|
|
2339
|
+
const tabIndexValue = parseInt(attributeValue, 10);
|
|
2340
|
+
if (!isNaN(tabIndexValue) && tabIndexValue > 0) {
|
|
2341
|
+
this.addOffense(`Do not use positive \`tabindex\` values as they are error prone and can severely disrupt navigation experience for keyboard users. Use \`tabindex="0"\` to make an element focusable or \`tabindex=\"-1\"\` to remove it from the tab sequence.`, attributeNode.location, "error");
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
class HTMLNoPositiveTabIndexRule extends ParserRule {
|
|
2346
|
+
name = "html-no-positive-tab-index";
|
|
2347
|
+
check(result, context) {
|
|
2348
|
+
const visitor = new NoPositiveTabIndexVisitor(this.name, context);
|
|
2349
|
+
visitor.visit(result.value);
|
|
2350
|
+
return visitor.offenses;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
1090
2355
|
visitHTMLElementNode(node) {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
this.checkTagName(node.open_tag);
|
|
2356
|
+
if (getTagName$1(node) === "svg") {
|
|
2357
|
+
this.visit(node.open_tag);
|
|
1094
2358
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
2359
|
+
else {
|
|
2360
|
+
this.visitChildNodes(node);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
visitHTMLOpenTagNode(node) {
|
|
2364
|
+
if (node.tag_closing?.value === "/>") {
|
|
2365
|
+
const tagName = getTagName$1(node);
|
|
2366
|
+
const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
|
|
2367
|
+
this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, "error");
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
class HTMLNoSelfClosingRule extends ParserRule {
|
|
2372
|
+
name = "html-no-self-closing";
|
|
2373
|
+
check(result, context) {
|
|
2374
|
+
const visitor = new NoSelfClosingVisitor(this.name, context);
|
|
2375
|
+
visitor.visit(result.value);
|
|
2376
|
+
return visitor.offenses;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
class NoTitleAttributeVisitor extends BaseRuleVisitor {
|
|
2381
|
+
ALLOWED_ELEMENTS_WITH_TITLE = new Set(["iframe", "link"]);
|
|
2382
|
+
visitHTMLOpenTagNode(node) {
|
|
2383
|
+
this.checkTitleAttribute(node);
|
|
2384
|
+
super.visitHTMLOpenTagNode(node);
|
|
2385
|
+
}
|
|
2386
|
+
checkTitleAttribute(node) {
|
|
2387
|
+
const tagName = getTagName(node);
|
|
2388
|
+
if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
|
|
1099
2389
|
return;
|
|
1100
2390
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
2391
|
+
if (hasAttribute(node, "title")) {
|
|
2392
|
+
this.addOffense("The `title` attribute should never be used as it is inaccessible for several groups of users. Use `aria-label` or `aria-describedby` instead. Exceptions are provided for `<iframe>` and `<link>` elements.", node.tag_name.location, "error");
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
class HTMLNoTitleAttributeRule extends ParserRule {
|
|
2397
|
+
name = "html-no-title-attribute";
|
|
2398
|
+
check(result, context) {
|
|
2399
|
+
const visitor = new NoTitleAttributeVisitor(this.name, context);
|
|
2400
|
+
visitor.visit(result.value);
|
|
2401
|
+
return visitor.offenses;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
class XMLDeclarationChecker extends BaseRuleVisitor {
|
|
2406
|
+
hasXMLDeclaration = false;
|
|
2407
|
+
visitXMLDeclarationNode(_node) {
|
|
2408
|
+
this.hasXMLDeclaration = true;
|
|
2409
|
+
}
|
|
2410
|
+
visitChildNodes(node) {
|
|
2411
|
+
if (this.hasXMLDeclaration)
|
|
2412
|
+
return;
|
|
2413
|
+
super.visitChildNodes(node);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
2417
|
+
visitHTMLElementNode(node) {
|
|
2418
|
+
if (getTagName$1(node).toLowerCase() === "svg") {
|
|
2419
|
+
this.checkTagName(node.open_tag);
|
|
1103
2420
|
this.checkTagName(node.close_tag);
|
|
1104
2421
|
}
|
|
2422
|
+
else {
|
|
2423
|
+
super.visitHTMLElementNode(node);
|
|
2424
|
+
}
|
|
1105
2425
|
}
|
|
1106
|
-
|
|
2426
|
+
visitHTMLOpenTagNode(node) {
|
|
2427
|
+
this.checkTagName(node);
|
|
2428
|
+
}
|
|
2429
|
+
visitHTMLCloseTagNode(node) {
|
|
1107
2430
|
this.checkTagName(node);
|
|
1108
|
-
this.visitChildNodes(node);
|
|
1109
2431
|
}
|
|
1110
2432
|
checkTagName(node) {
|
|
1111
|
-
|
|
2433
|
+
if (!node)
|
|
2434
|
+
return;
|
|
2435
|
+
const tagName = getTagName$1(node);
|
|
1112
2436
|
if (!tagName)
|
|
1113
2437
|
return;
|
|
1114
2438
|
const lowercaseTagName = tagName.toLowerCase();
|
|
2439
|
+
const type = isNode(node, HTMLOpenTagNode) ? "Opening" : "Closing";
|
|
2440
|
+
const open = isNode(node, HTMLOpenTagNode) ? "<" : "</";
|
|
1115
2441
|
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");
|
|
2442
|
+
this.addOffense(`${type} tag name \`${open}${tagName}>\` should be lowercase. Use \`${open}${lowercaseTagName}>\` instead.`, node.tag_name.location, "error");
|
|
1124
2443
|
}
|
|
1125
2444
|
}
|
|
1126
2445
|
}
|
|
1127
2446
|
class HTMLTagNameLowercaseRule extends ParserRule {
|
|
1128
2447
|
name = "html-tag-name-lowercase";
|
|
2448
|
+
isEnabled(result, context) {
|
|
2449
|
+
if (context?.fileName?.endsWith(".xml") || context?.fileName?.endsWith(".xml.erb")) {
|
|
2450
|
+
return false;
|
|
2451
|
+
}
|
|
2452
|
+
const checker = new XMLDeclarationChecker(this.name);
|
|
2453
|
+
checker.visit(result.value);
|
|
2454
|
+
return !checker.hasXMLDeclaration;
|
|
2455
|
+
}
|
|
1129
2456
|
check(result, context) {
|
|
1130
2457
|
const visitor = new TagNameLowercaseVisitor(this.name, context);
|
|
1131
2458
|
visitor.visit(result.value);
|
|
@@ -1171,12 +2498,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1171
2498
|
}
|
|
1172
2499
|
this.visitChildNodes(node);
|
|
1173
2500
|
}
|
|
1174
|
-
visitHTMLSelfCloseTagNode(node) {
|
|
1175
|
-
if (this.insideSVG) {
|
|
1176
|
-
this.checkTagName(node);
|
|
1177
|
-
}
|
|
1178
|
-
this.visitChildNodes(node);
|
|
1179
|
-
}
|
|
1180
2501
|
checkTagName(node) {
|
|
1181
2502
|
const tagName = node.tag_name?.value;
|
|
1182
2503
|
if (!tagName)
|
|
@@ -1191,8 +2512,6 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
1191
2512
|
type = "Opening";
|
|
1192
2513
|
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
1193
2514
|
type = "Closing";
|
|
1194
|
-
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
1195
|
-
type = "Self-closing";
|
|
1196
2515
|
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, "error");
|
|
1197
2516
|
}
|
|
1198
2517
|
}
|
|
@@ -1209,23 +2528,33 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
1209
2528
|
const defaultRules = [
|
|
1210
2529
|
ERBNoEmptyTagsRule,
|
|
1211
2530
|
ERBNoOutputControlFlowRule,
|
|
2531
|
+
ERBNoSilentTagInAttributeNameRule,
|
|
1212
2532
|
ERBPreferImageTagHelperRule,
|
|
1213
2533
|
ERBRequiresTrailingNewlineRule,
|
|
1214
2534
|
ERBRequireWhitespaceRule,
|
|
1215
2535
|
HTMLAnchorRequireHrefRule,
|
|
1216
2536
|
HTMLAriaAttributeMustBeValid,
|
|
2537
|
+
HTMLAriaLabelIsWellFormattedRule,
|
|
1217
2538
|
HTMLAriaLevelMustBeValidRule,
|
|
1218
2539
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
1219
2540
|
HTMLAriaRoleMustBeValidRule,
|
|
1220
2541
|
HTMLAttributeDoubleQuotesRule,
|
|
2542
|
+
HTMLAttributeEqualsSpacingRule,
|
|
1221
2543
|
HTMLAttributeValuesRequireQuotesRule,
|
|
2544
|
+
HTMLAvoidBothDisabledAndAriaDisabledRule,
|
|
1222
2545
|
HTMLBooleanAttributesNoValueRule,
|
|
2546
|
+
HTMLIframeHasTitleRule,
|
|
1223
2547
|
HTMLImgRequireAltRule,
|
|
2548
|
+
HTMLNavigationHasLabelRule,
|
|
2549
|
+
HTMLNoAriaHiddenOnFocusableRule,
|
|
1224
2550
|
// HTMLNoBlockInsideInlineRule,
|
|
1225
2551
|
HTMLNoDuplicateAttributesRule,
|
|
1226
2552
|
HTMLNoDuplicateIdsRule,
|
|
1227
2553
|
HTMLNoEmptyHeadingsRule,
|
|
1228
2554
|
HTMLNoNestedLinksRule,
|
|
2555
|
+
HTMLNoPositiveTabIndexRule,
|
|
2556
|
+
HTMLNoSelfClosingRule,
|
|
2557
|
+
HTMLNoTitleAttributeRule,
|
|
1229
2558
|
HTMLTagNameLowercaseRule,
|
|
1230
2559
|
ParserNoErrorsRule,
|
|
1231
2560
|
SVGTagNameCapitalizationRule,
|
|
@@ -1274,19 +2603,44 @@ class Linter {
|
|
|
1274
2603
|
*/
|
|
1275
2604
|
lint(source, context) {
|
|
1276
2605
|
this.offenses = [];
|
|
1277
|
-
const parseResult = this.herb.parse(source);
|
|
2606
|
+
const parseResult = this.herb.parse(source, { track_whitespace: true });
|
|
1278
2607
|
const lexResult = this.herb.lex(source);
|
|
1279
2608
|
for (const RuleClass of this.rules) {
|
|
1280
2609
|
const rule = new RuleClass();
|
|
2610
|
+
let isEnabled = true;
|
|
1281
2611
|
let ruleOffenses;
|
|
1282
2612
|
if (this.isLexerRule(rule)) {
|
|
1283
|
-
|
|
2613
|
+
if (rule.isEnabled) {
|
|
2614
|
+
isEnabled = rule.isEnabled(lexResult, context);
|
|
2615
|
+
}
|
|
2616
|
+
if (isEnabled) {
|
|
2617
|
+
ruleOffenses = rule.check(lexResult, context);
|
|
2618
|
+
}
|
|
2619
|
+
else {
|
|
2620
|
+
ruleOffenses = [];
|
|
2621
|
+
}
|
|
1284
2622
|
}
|
|
1285
2623
|
else if (this.isSourceRule(rule)) {
|
|
1286
|
-
|
|
2624
|
+
if (rule.isEnabled) {
|
|
2625
|
+
isEnabled = rule.isEnabled(source, context);
|
|
2626
|
+
}
|
|
2627
|
+
if (isEnabled) {
|
|
2628
|
+
ruleOffenses = rule.check(source, context);
|
|
2629
|
+
}
|
|
2630
|
+
else {
|
|
2631
|
+
ruleOffenses = [];
|
|
2632
|
+
}
|
|
1287
2633
|
}
|
|
1288
2634
|
else {
|
|
1289
|
-
|
|
2635
|
+
if (rule.isEnabled) {
|
|
2636
|
+
isEnabled = rule.isEnabled(parseResult, context);
|
|
2637
|
+
}
|
|
2638
|
+
if (isEnabled) {
|
|
2639
|
+
ruleOffenses = rule.check(parseResult, context);
|
|
2640
|
+
}
|
|
2641
|
+
else {
|
|
2642
|
+
ruleOffenses = [];
|
|
2643
|
+
}
|
|
1290
2644
|
}
|
|
1291
2645
|
this.offenses.push(...ruleOffenses);
|
|
1292
2646
|
}
|
|
@@ -1358,5 +2712,5 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
|
1358
2712
|
}
|
|
1359
2713
|
}
|
|
1360
2714
|
|
|
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 };
|
|
2715
|
+
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
2716
|
//# sourceMappingURL=index.js.map
|