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