@herb-tools/linter 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/bin/herb-lint +3 -0
- package/dist/herb-lint.js +16505 -0
- package/dist/herb-lint.js.map +1 -0
- package/dist/index.cjs +834 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +820 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +49 -0
- package/dist/src/cli/argument-parser.js +96 -0
- package/dist/src/cli/argument-parser.js.map +1 -0
- package/dist/src/cli/file-processor.js +58 -0
- package/dist/src/cli/file-processor.js.map +1 -0
- package/dist/src/cli/formatters/base-formatter.js +3 -0
- package/dist/src/cli/formatters/base-formatter.js.map +1 -0
- package/dist/src/cli/formatters/detailed-formatter.js +62 -0
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -0
- package/dist/src/cli/formatters/index.js +4 -0
- package/dist/src/cli/formatters/index.js.map +1 -0
- package/dist/src/cli/formatters/simple-formatter.js +31 -0
- package/dist/src/cli/formatters/simple-formatter.js.map +1 -0
- package/dist/src/cli/index.js +5 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/cli/summary-reporter.js +96 -0
- package/dist/src/cli/summary-reporter.js.map +1 -0
- package/dist/src/cli.js +50 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/default-rules.js +31 -0
- package/dist/src/default-rules.js.map +1 -0
- package/dist/src/herb-lint.js +5 -0
- package/dist/src/herb-lint.js.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/linter.js +39 -0
- package/dist/src/linter.js.map +1 -0
- package/dist/src/rules/erb-no-empty-tags.js +23 -0
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -0
- package/dist/src/rules/erb-no-output-control-flow.js +47 -0
- package/dist/src/rules/erb-no-output-control-flow.js.map +1 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +43 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -0
- package/dist/src/rules/html-anchor-require-href.js +25 -0
- package/dist/src/rules/html-anchor-require-href.js.map +1 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js +26 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -0
- package/dist/src/rules/html-attribute-double-quotes.js +21 -0
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js +22 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js +19 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -0
- package/dist/src/rules/html-img-require-alt.js +29 -0
- package/dist/src/rules/html-img-require-alt.js.map +1 -0
- package/dist/src/rules/html-no-block-inside-inline.js +59 -0
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -0
- package/dist/src/rules/html-no-duplicate-attributes.js +43 -0
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -0
- package/dist/src/rules/html-no-empty-headings.js +148 -0
- package/dist/src/rules/html-no-empty-headings.js.map +1 -0
- package/dist/src/rules/html-no-nested-links.js +45 -0
- package/dist/src/rules/html-no-nested-links.js.map +1 -0
- package/dist/src/rules/html-tag-name-lowercase.js +39 -0
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -0
- package/dist/src/rules/index.js +13 -0
- package/dist/src/rules/index.js.map +1 -0
- package/dist/src/rules/rule-utils.js +198 -0
- package/dist/src/rules/rule-utils.js.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/cli/argument-parser.d.ts +14 -0
- package/dist/types/cli/file-processor.d.ts +21 -0
- package/dist/types/cli/formatters/base-formatter.d.ts +6 -0
- package/dist/types/cli/formatters/detailed-formatter.d.ts +13 -0
- package/dist/types/cli/formatters/index.d.ts +3 -0
- package/dist/types/cli/formatters/simple-formatter.d.ts +7 -0
- package/dist/types/cli/summary-reporter.d.ts +22 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/default-rules.d.ts +2 -0
- package/dist/types/herb-lint.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/linter.d.ts +18 -0
- package/dist/types/rules/erb-no-empty-tags.d.ts +6 -0
- package/dist/types/rules/erb-no-output-control-flow.d.ts +6 -0
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
- package/dist/types/rules/html-anchor-require-href.d.ts +6 -0
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +6 -0
- package/dist/types/rules/html-attribute-double-quotes.d.ts +6 -0
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +6 -0
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +6 -0
- package/dist/types/rules/html-img-require-alt.d.ts +6 -0
- package/dist/types/rules/html-no-block-inside-inline.d.ts +6 -0
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +6 -0
- package/dist/types/rules/html-no-empty-headings.d.ts +6 -0
- package/dist/types/rules/html-no-nested-links.d.ts +6 -0
- package/dist/types/rules/html-tag-name-lowercase.d.ts +6 -0
- package/dist/types/rules/index.d.ts +12 -0
- package/dist/types/rules/rule-utils.d.ts +89 -0
- package/dist/types/src/cli/argument-parser.d.ts +14 -0
- package/dist/types/src/cli/file-processor.d.ts +21 -0
- package/dist/types/src/cli/formatters/base-formatter.d.ts +6 -0
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +13 -0
- package/dist/types/src/cli/formatters/index.d.ts +3 -0
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +7 -0
- package/dist/types/src/cli/index.d.ts +4 -0
- package/dist/types/src/cli/summary-reporter.d.ts +22 -0
- package/dist/types/src/cli.d.ts +6 -0
- package/dist/types/src/default-rules.d.ts +2 -0
- package/dist/types/src/herb-lint.d.ts +2 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/linter.d.ts +18 -0
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +6 -0
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +6 -0
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
- package/dist/types/src/rules/html-anchor-require-href.d.ts +6 -0
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +6 -0
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +6 -0
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +6 -0
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +6 -0
- package/dist/types/src/rules/html-img-require-alt.d.ts +6 -0
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +6 -0
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +6 -0
- package/dist/types/src/rules/html-no-empty-headings.d.ts +6 -0
- package/dist/types/src/rules/html-no-nested-links.d.ts +6 -0
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +6 -0
- package/dist/types/src/rules/index.d.ts +12 -0
- package/dist/types/src/rules/rule-utils.d.ts +89 -0
- package/dist/types/src/types.d.ts +26 -0
- package/dist/types/types.d.ts +26 -0
- package/docs/rules/README.md +39 -0
- package/docs/rules/erb-no-empty-tags.md +38 -0
- package/docs/rules/erb-no-output-control-flow.md +45 -0
- package/docs/rules/erb-require-whitespace-inside-tags.md +43 -0
- package/docs/rules/html-anchor-require-href.md +32 -0
- package/docs/rules/html-aria-role-heading-requires-level.md +34 -0
- package/docs/rules/html-attribute-double-quotes.md +43 -0
- package/docs/rules/html-attribute-values-require-quotes.md +43 -0
- package/docs/rules/html-boolean-attributes-no-value.md +39 -0
- package/docs/rules/html-img-require-alt.md +44 -0
- package/docs/rules/html-no-block-inside-inline.md +66 -0
- package/docs/rules/html-no-duplicate-attributes.md +35 -0
- package/docs/rules/html-no-empty-headings.md +78 -0
- package/docs/rules/html-no-nested-links.md +44 -0
- package/docs/rules/html-tag-name-lowercase.md +44 -0
- package/package.json +49 -0
- package/src/cli/argument-parser.ts +125 -0
- package/src/cli/file-processor.ts +86 -0
- package/src/cli/formatters/base-formatter.ts +11 -0
- package/src/cli/formatters/detailed-formatter.ts +74 -0
- package/src/cli/formatters/index.ts +3 -0
- package/src/cli/formatters/simple-formatter.ts +40 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/summary-reporter.ts +127 -0
- package/src/cli.ts +60 -0
- package/src/default-rules.ts +33 -0
- package/src/herb-lint.ts +6 -0
- package/src/index.ts +3 -0
- package/src/linter.ts +50 -0
- package/src/rules/erb-no-empty-tags.ts +34 -0
- package/src/rules/erb-no-output-control-flow.ts +61 -0
- package/src/rules/erb-require-whitespace-inside-tags.ts +61 -0
- package/src/rules/html-anchor-require-href.ts +39 -0
- package/src/rules/html-aria-role-heading-requires-level.ts +44 -0
- package/src/rules/html-attribute-double-quotes.ts +28 -0
- package/src/rules/html-attribute-values-require-quotes.ts +30 -0
- package/src/rules/html-boolean-attributes-no-value.ts +27 -0
- package/src/rules/html-img-require-alt.ts +42 -0
- package/src/rules/html-no-block-inside-inline.ts +84 -0
- package/src/rules/html-no-duplicate-attributes.ts +59 -0
- package/src/rules/html-no-empty-headings.ts +185 -0
- package/src/rules/html-no-nested-links.ts +65 -0
- package/src/rules/html-tag-name-lowercase.ts +50 -0
- package/src/rules/index.ts +12 -0
- package/src/rules/rule-utils.ts +257 -0
- package/src/types.ts +32 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@herb-tools/core');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base visitor class that provides common functionality for rule visitors
|
|
7
|
+
*/
|
|
8
|
+
class BaseRuleVisitor extends core.Visitor {
|
|
9
|
+
offenses = [];
|
|
10
|
+
ruleName;
|
|
11
|
+
constructor(ruleName) {
|
|
12
|
+
super();
|
|
13
|
+
this.ruleName = ruleName;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Helper method to create a lint offense
|
|
17
|
+
*/
|
|
18
|
+
createOffense(message, location, severity = "error") {
|
|
19
|
+
return {
|
|
20
|
+
rule: this.ruleName,
|
|
21
|
+
code: this.ruleName,
|
|
22
|
+
source: "Herb Linter",
|
|
23
|
+
message,
|
|
24
|
+
location,
|
|
25
|
+
severity,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Helper method to add an offense to the offenses array
|
|
30
|
+
*/
|
|
31
|
+
addOffense(message, location, severity = "error") {
|
|
32
|
+
this.offenses.push(this.createOffense(message, location, severity));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Gets attributes from either an HTMLOpenTagNode or HTMLSelfCloseTagNode
|
|
37
|
+
*/
|
|
38
|
+
function getAttributes(node) {
|
|
39
|
+
return node.type === "AST_HTML_SELF_CLOSE_TAG_NODE"
|
|
40
|
+
? node.attributes
|
|
41
|
+
: node.children;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Gets the tag name from an HTML tag node (lowercased)
|
|
45
|
+
*/
|
|
46
|
+
function getTagName(node) {
|
|
47
|
+
return node.tag_name?.value.toLowerCase() || null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
51
|
+
*/
|
|
52
|
+
function getAttributeName(attributeNode) {
|
|
53
|
+
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
54
|
+
const nameNode = attributeNode.name;
|
|
55
|
+
return nameNode.name?.value.toLowerCase() || null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
61
|
+
*/
|
|
62
|
+
function getAttributeValue(attributeNode) {
|
|
63
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
64
|
+
const valueNode = attributeNode.value;
|
|
65
|
+
if (valueNode.children && valueNode.children.length > 0) {
|
|
66
|
+
return valueNode.children
|
|
67
|
+
.filter(child => child.type === "AST_LITERAL_NODE")
|
|
68
|
+
.map(child => child.content)
|
|
69
|
+
.join("");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Checks if an attribute has a value
|
|
76
|
+
*/
|
|
77
|
+
function hasAttributeValue(attributeNode) {
|
|
78
|
+
return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Gets the quote type used for an attribute value
|
|
82
|
+
*/
|
|
83
|
+
function getAttributeValueQuoteType(attributeNode) {
|
|
84
|
+
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
85
|
+
const valueNode = attributeNode.value;
|
|
86
|
+
if (valueNode.quoted && valueNode.open_quote) {
|
|
87
|
+
return valueNode.open_quote.value === '"' ? "double" : "single";
|
|
88
|
+
}
|
|
89
|
+
return "none";
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Finds an attribute by name in a list of attributes
|
|
95
|
+
*/
|
|
96
|
+
function findAttributeByName(attributes, attributeName) {
|
|
97
|
+
for (const child of attributes) {
|
|
98
|
+
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
99
|
+
const attributeNode = child;
|
|
100
|
+
const name = getAttributeName(attributeNode);
|
|
101
|
+
if (name === attributeName.toLowerCase()) {
|
|
102
|
+
return attributeNode;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Checks if a tag has a specific attribute
|
|
110
|
+
*/
|
|
111
|
+
function hasAttribute(node, attributeName) {
|
|
112
|
+
const attributes = getAttributes(node);
|
|
113
|
+
return findAttributeByName(attributes, attributeName) !== null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Common HTML element categorization
|
|
117
|
+
*/
|
|
118
|
+
const HTML_INLINE_ELEMENTS = new Set([
|
|
119
|
+
"a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code",
|
|
120
|
+
"dfn", "em", "i", "img", "input", "kbd", "label", "map", "object", "output",
|
|
121
|
+
"q", "samp", "script", "select", "small", "span", "strong", "sub", "sup",
|
|
122
|
+
"textarea", "time", "tt", "var"
|
|
123
|
+
]);
|
|
124
|
+
const HTML_BLOCK_ELEMENTS = new Set([
|
|
125
|
+
"address", "article", "aside", "blockquote", "canvas", "dd", "div", "dl",
|
|
126
|
+
"dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2",
|
|
127
|
+
"h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript",
|
|
128
|
+
"ol", "p", "pre", "section", "table", "tfoot", "ul", "video"
|
|
129
|
+
]);
|
|
130
|
+
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
131
|
+
"autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
|
|
132
|
+
"loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
|
|
133
|
+
"open", "default", "formnovalidate", "novalidate", "itemscope", "scoped",
|
|
134
|
+
"seamless", "allowfullscreen", "async", "compact", "declare", "nohref",
|
|
135
|
+
"noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
|
|
136
|
+
]);
|
|
137
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
138
|
+
/**
|
|
139
|
+
* Checks if an element is inline
|
|
140
|
+
*/
|
|
141
|
+
function isInlineElement(tagName) {
|
|
142
|
+
return HTML_INLINE_ELEMENTS.has(tagName.toLowerCase());
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Checks if an element is block-level
|
|
146
|
+
*/
|
|
147
|
+
function isBlockElement(tagName) {
|
|
148
|
+
return HTML_BLOCK_ELEMENTS.has(tagName.toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Checks if an attribute is a boolean attribute
|
|
152
|
+
*/
|
|
153
|
+
function isBooleanAttribute(attributeName) {
|
|
154
|
+
return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase());
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Abstract base class for rules that need to check individual attributes on HTML tags
|
|
158
|
+
* Eliminates duplication of visitHTMLOpenTagNode/visitHTMLSelfCloseTagNode patterns
|
|
159
|
+
* and attribute iteration logic. Provides simplified interface with extracted attribute info.
|
|
160
|
+
*/
|
|
161
|
+
class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
162
|
+
visitHTMLOpenTagNode(node) {
|
|
163
|
+
this.checkAttributesOnNode(node);
|
|
164
|
+
super.visitHTMLOpenTagNode(node);
|
|
165
|
+
}
|
|
166
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
167
|
+
this.checkAttributesOnNode(node);
|
|
168
|
+
super.visitHTMLSelfCloseTagNode(node);
|
|
169
|
+
}
|
|
170
|
+
checkAttributesOnNode(node) {
|
|
171
|
+
forEachAttribute(node, (attributeNode) => {
|
|
172
|
+
const attributeName = getAttributeName(attributeNode);
|
|
173
|
+
const attributeValue = getAttributeValue(attributeNode);
|
|
174
|
+
if (attributeName) {
|
|
175
|
+
this.checkAttribute(attributeName, attributeValue, attributeNode, node);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
182
|
+
*/
|
|
183
|
+
function forEachAttribute(node, callback) {
|
|
184
|
+
const attributes = getAttributes(node);
|
|
185
|
+
for (const child of attributes) {
|
|
186
|
+
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
187
|
+
callback(child);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
193
|
+
visitERBContentNode(node) {
|
|
194
|
+
this.visitChildNodes(node);
|
|
195
|
+
const { content, tag_closing } = node;
|
|
196
|
+
if (!content)
|
|
197
|
+
return;
|
|
198
|
+
if (tag_closing?.value === "")
|
|
199
|
+
return;
|
|
200
|
+
if (content.value.trim().length > 0)
|
|
201
|
+
return;
|
|
202
|
+
this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location, "error");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
class ERBNoEmptyTagsRule {
|
|
206
|
+
name = "erb-no-empty-tags";
|
|
207
|
+
check(node) {
|
|
208
|
+
const visitor = new ERBNoEmptyTagsVisitor(this.name);
|
|
209
|
+
visitor.visit(node);
|
|
210
|
+
return visitor.offenses;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
215
|
+
visitERBIfNode(node) {
|
|
216
|
+
this.checkOutputControlFlow(node);
|
|
217
|
+
this.visitChildNodes(node);
|
|
218
|
+
}
|
|
219
|
+
visitERBUnlessNode(node) {
|
|
220
|
+
this.checkOutputControlFlow(node);
|
|
221
|
+
this.visitChildNodes(node);
|
|
222
|
+
}
|
|
223
|
+
visitERBElseNode(node) {
|
|
224
|
+
this.checkOutputControlFlow(node);
|
|
225
|
+
this.visitChildNodes(node);
|
|
226
|
+
}
|
|
227
|
+
visitERBEndNode(node) {
|
|
228
|
+
this.checkOutputControlFlow(node);
|
|
229
|
+
this.visitChildNodes(node);
|
|
230
|
+
}
|
|
231
|
+
checkOutputControlFlow(controlBlock) {
|
|
232
|
+
const openTag = controlBlock.tag_opening;
|
|
233
|
+
if (!openTag) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (openTag.value === "<%=") {
|
|
237
|
+
let controlBlockType = controlBlock.type;
|
|
238
|
+
if (controlBlock.type === "AST_ERB_IF_NODE")
|
|
239
|
+
controlBlockType = "if";
|
|
240
|
+
if (controlBlock.type === "AST_ERB_ELSE_NODE")
|
|
241
|
+
controlBlockType = "else";
|
|
242
|
+
if (controlBlock.type === "AST_ERB_END_NODE")
|
|
243
|
+
controlBlockType = "end";
|
|
244
|
+
if (controlBlock.type === "AST_ERB_UNLESS_NODE")
|
|
245
|
+
controlBlockType = "unless";
|
|
246
|
+
this.addOffense(`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`, openTag.location, "error");
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
class ERBNoOutputControlFlowRule {
|
|
252
|
+
name = "erb-no-output-control-flow";
|
|
253
|
+
check(node) {
|
|
254
|
+
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name);
|
|
255
|
+
visitor.visit(node);
|
|
256
|
+
return visitor.offenses;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
261
|
+
visitChildNodes(node) {
|
|
262
|
+
this.checkWhitespace(node);
|
|
263
|
+
super.visitChildNodes(node);
|
|
264
|
+
}
|
|
265
|
+
checkWhitespace(node) {
|
|
266
|
+
if (!core.isERBNode(node)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const openTag = node.tag_opening;
|
|
270
|
+
const closeTag = node.tag_closing;
|
|
271
|
+
const content = node.content;
|
|
272
|
+
if (!openTag || !closeTag || !content) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const value = content.value;
|
|
276
|
+
this.checkOpenTagWhitespace(openTag, value);
|
|
277
|
+
this.checkCloseTagWhitespace(closeTag, value);
|
|
278
|
+
}
|
|
279
|
+
checkOpenTagWhitespace(openTag, content) {
|
|
280
|
+
if (content.startsWith(" ") || content.startsWith("\n")) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, "error");
|
|
284
|
+
}
|
|
285
|
+
checkCloseTagWhitespace(closeTag, content) {
|
|
286
|
+
if (content.endsWith(" ") || content.endsWith("\n")) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, "error");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
class ERBRequireWhitespaceRule {
|
|
293
|
+
name = "erb-require-whitespace-inside-tags";
|
|
294
|
+
check(node) {
|
|
295
|
+
const visitor = new RequireWhitespaceInsideTags(this.name);
|
|
296
|
+
visitor.visit(node);
|
|
297
|
+
return visitor.offenses;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
class AnchorRechireHrefVisitor extends BaseRuleVisitor {
|
|
302
|
+
visitHTMLOpenTagNode(node) {
|
|
303
|
+
this.checkATag(node);
|
|
304
|
+
super.visitHTMLOpenTagNode(node);
|
|
305
|
+
}
|
|
306
|
+
checkATag(node) {
|
|
307
|
+
const tagName = getTagName(node);
|
|
308
|
+
if (tagName !== "a") {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (!hasAttribute(node, "href")) {
|
|
312
|
+
this.addOffense("Add an `href` attribute to `<a>` to ensure it is focusable and accessible.", node.tag_name.location, "error");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
class HTMLAnchorRequireHrefRule {
|
|
317
|
+
name = "html-anchor-require-href";
|
|
318
|
+
check(node) {
|
|
319
|
+
const visitor = new AnchorRechireHrefVisitor(this.name);
|
|
320
|
+
visitor.visit(node);
|
|
321
|
+
return visitor.offenses;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
|
|
326
|
+
// We want to check 2 attributes here:
|
|
327
|
+
// 1. role="heading"
|
|
328
|
+
// 2. aria-level (which must be present if role="heading")
|
|
329
|
+
checkAttribute(attributeName, attributeValue, attributeNode, parentNode) {
|
|
330
|
+
if (!(attributeName === "role" && attributeValue === "heading")) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const allAttributes = getAttributes(parentNode);
|
|
334
|
+
// If we have a role="heading", we must check for aria-level
|
|
335
|
+
const ariaLevelAttr = allAttributes.find(attr => getAttributeName(attr) === "aria-level");
|
|
336
|
+
if (!ariaLevelAttr) {
|
|
337
|
+
this.addOffense(`Element with \`role="heading"\` must have an \`aria-level\` attribute.`, attributeNode.location, "error");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
class HTMLAriaRoleHeadingRequiresLevelRule {
|
|
342
|
+
name = "html-aria-role-heading-requires-level";
|
|
343
|
+
check(node) {
|
|
344
|
+
const visitor = new AriaRoleHeadingRequiresLevel(this.name);
|
|
345
|
+
visitor.visit(node);
|
|
346
|
+
return visitor.offenses;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
351
|
+
checkAttribute(attributeName, attributeValue, attributeNode) {
|
|
352
|
+
if (!hasAttributeValue(attributeNode))
|
|
353
|
+
return;
|
|
354
|
+
if (getAttributeValueQuoteType(attributeNode) !== "single")
|
|
355
|
+
return;
|
|
356
|
+
if (attributeValue?.includes('"'))
|
|
357
|
+
return; // Single quotes acceptable when value contains double quotes
|
|
358
|
+
this.addOffense(`Attribute \`${attributeName}\` uses single quotes. Prefer double quotes for HTML attribute values: \`${attributeName}="value"\`.`, attributeNode.value.location, "warning");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
class HTMLAttributeDoubleQuotesRule {
|
|
362
|
+
name = "html-attribute-double-quotes";
|
|
363
|
+
check(node) {
|
|
364
|
+
const visitor = new AttributeDoubleQuotesVisitor(this.name);
|
|
365
|
+
visitor.visit(node);
|
|
366
|
+
return visitor.offenses;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
|
|
371
|
+
checkAttribute(attributeName, _attributeValue, attributeNode) {
|
|
372
|
+
if (attributeNode.value?.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE")
|
|
373
|
+
return;
|
|
374
|
+
const valueNode = attributeNode.value;
|
|
375
|
+
if (valueNode.quoted)
|
|
376
|
+
return;
|
|
377
|
+
this.addOffense(
|
|
378
|
+
// TODO: print actual attribute value in message
|
|
379
|
+
`Attribute value should be quoted: \`${attributeName}="value"\`. Always wrap attribute values in quotes.`, valueNode.location, "error");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
class HTMLAttributeValuesRequireQuotesRule {
|
|
383
|
+
name = "html-attribute-values-require-quotes";
|
|
384
|
+
check(node) {
|
|
385
|
+
const visitor = new AttributeValuesRequireQuotesVisitor(this.name);
|
|
386
|
+
visitor.visit(node);
|
|
387
|
+
return visitor.offenses;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
392
|
+
checkAttribute(attributeName, _attributeValue, attributeNode) {
|
|
393
|
+
if (!isBooleanAttribute(attributeName))
|
|
394
|
+
return;
|
|
395
|
+
if (!hasAttributeValue(attributeNode))
|
|
396
|
+
return;
|
|
397
|
+
this.addOffense(`Boolean attribute \`${attributeName}\` should not have a value. Use \`${attributeName}\` instead of \`${attributeName}="${attributeName}"\`.`, attributeNode.value.location, "error");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
class HTMLBooleanAttributesNoValueRule {
|
|
401
|
+
name = "html-boolean-attributes-no-value";
|
|
402
|
+
check(node) {
|
|
403
|
+
const visitor = new BooleanAttributesNoValueVisitor(this.name);
|
|
404
|
+
visitor.visit(node);
|
|
405
|
+
return visitor.offenses;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
410
|
+
visitHTMLOpenTagNode(node) {
|
|
411
|
+
this.checkImgTag(node);
|
|
412
|
+
super.visitHTMLOpenTagNode(node);
|
|
413
|
+
}
|
|
414
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
415
|
+
this.checkImgTag(node);
|
|
416
|
+
super.visitHTMLSelfCloseTagNode(node);
|
|
417
|
+
}
|
|
418
|
+
checkImgTag(node) {
|
|
419
|
+
const tagName = getTagName(node);
|
|
420
|
+
if (tagName !== "img") {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!hasAttribute(node, "alt")) {
|
|
424
|
+
this.addOffense('Missing required `alt` attribute on `<img>` tag. Add `alt=""` for decorative images or `alt="description"` for informative images.', node.tag_name.location, "error");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
class HTMLImgRequireAltRule {
|
|
429
|
+
name = "html-img-require-alt";
|
|
430
|
+
check(node) {
|
|
431
|
+
const visitor = new ImgRequireAltVisitor(this.name);
|
|
432
|
+
visitor.visit(node);
|
|
433
|
+
return visitor.offenses;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
438
|
+
inlineStack = [];
|
|
439
|
+
isValidHTMLOpenTag(node) {
|
|
440
|
+
return !!(node.open_tag && node.open_tag.type === "AST_HTML_OPEN_TAG_NODE");
|
|
441
|
+
}
|
|
442
|
+
getElementType(tagName) {
|
|
443
|
+
const isInline = isInlineElement(tagName);
|
|
444
|
+
const isBlock = isBlockElement(tagName);
|
|
445
|
+
const isUnknown = !isInline && !isBlock;
|
|
446
|
+
return { isInline, isBlock, isUnknown };
|
|
447
|
+
}
|
|
448
|
+
addViolationMessage(tagName, isBlock, openTag) {
|
|
449
|
+
const parentInline = this.inlineStack[this.inlineStack.length - 1];
|
|
450
|
+
const elementType = isBlock ? "Block-level" : "Unknown";
|
|
451
|
+
this.addOffense(`${elementType} element \`<${tagName}>\` cannot be placed inside inline element \`<${parentInline}>\`.`, openTag.tag_name.location, "error");
|
|
452
|
+
}
|
|
453
|
+
visitInlineElement(node, tagName) {
|
|
454
|
+
this.inlineStack.push(tagName);
|
|
455
|
+
super.visitHTMLElementNode(node);
|
|
456
|
+
this.inlineStack.pop();
|
|
457
|
+
}
|
|
458
|
+
visitBlockElement(node) {
|
|
459
|
+
const savedStack = [...this.inlineStack];
|
|
460
|
+
this.inlineStack = [];
|
|
461
|
+
super.visitHTMLElementNode(node);
|
|
462
|
+
this.inlineStack = savedStack;
|
|
463
|
+
}
|
|
464
|
+
visitHTMLElementNode(node) {
|
|
465
|
+
if (!this.isValidHTMLOpenTag(node)) {
|
|
466
|
+
super.visitHTMLElementNode(node);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const openTag = node.open_tag;
|
|
470
|
+
const tagName = openTag.tag_name?.value.toLowerCase();
|
|
471
|
+
if (!tagName) {
|
|
472
|
+
super.visitHTMLElementNode(node);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const { isInline, isBlock, isUnknown } = this.getElementType(tagName);
|
|
476
|
+
if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
|
|
477
|
+
this.addViolationMessage(tagName, isBlock, openTag);
|
|
478
|
+
}
|
|
479
|
+
if (isInline) {
|
|
480
|
+
this.visitInlineElement(node, tagName);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.visitBlockElement(node);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
class HTMLNoBlockInsideInlineRule {
|
|
487
|
+
name = "html-no-block-inside-inline";
|
|
488
|
+
check(node) {
|
|
489
|
+
const visitor = new BlockInsideInlineVisitor(this.name);
|
|
490
|
+
visitor.visit(node);
|
|
491
|
+
return visitor.offenses;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
class NoDuplicateAttributesVisitor extends BaseRuleVisitor {
|
|
496
|
+
visitHTMLOpenTagNode(node) {
|
|
497
|
+
this.checkDuplicateAttributes(node);
|
|
498
|
+
super.visitHTMLOpenTagNode(node);
|
|
499
|
+
}
|
|
500
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
501
|
+
this.checkDuplicateAttributes(node);
|
|
502
|
+
super.visitHTMLSelfCloseTagNode(node);
|
|
503
|
+
}
|
|
504
|
+
checkDuplicateAttributes(node) {
|
|
505
|
+
const attributeNames = new Map();
|
|
506
|
+
forEachAttribute(node, (attributeNode) => {
|
|
507
|
+
if (attributeNode.name?.type !== "AST_HTML_ATTRIBUTE_NAME_NODE")
|
|
508
|
+
return;
|
|
509
|
+
const nameNode = attributeNode.name;
|
|
510
|
+
if (!nameNode.name)
|
|
511
|
+
return;
|
|
512
|
+
const attributeName = nameNode.name.value.toLowerCase(); // HTML attributes are case-insensitive
|
|
513
|
+
if (!attributeNames.has(attributeName)) {
|
|
514
|
+
attributeNames.set(attributeName, []);
|
|
515
|
+
}
|
|
516
|
+
attributeNames.get(attributeName).push(nameNode);
|
|
517
|
+
});
|
|
518
|
+
for (const [attributeName, nameNodes] of attributeNames) {
|
|
519
|
+
if (nameNodes.length > 1) {
|
|
520
|
+
for (let i = 1; i < nameNodes.length; i++) {
|
|
521
|
+
const nameNode = nameNodes[i];
|
|
522
|
+
this.addOffense(`Duplicate attribute \`${attributeName}\` found on tag. Remove the duplicate occurrence.`, nameNode.location, "error");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
class HTMLNoDuplicateAttributesRule {
|
|
529
|
+
name = "html-no-duplicate-attributes";
|
|
530
|
+
check(node) {
|
|
531
|
+
const visitor = new NoDuplicateAttributesVisitor(this.name);
|
|
532
|
+
visitor.visit(node);
|
|
533
|
+
return visitor.offenses;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
538
|
+
visitHTMLElementNode(node) {
|
|
539
|
+
this.checkHeadingElement(node);
|
|
540
|
+
super.visitHTMLElementNode(node);
|
|
541
|
+
}
|
|
542
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
543
|
+
this.checkSelfClosingHeading(node);
|
|
544
|
+
super.visitHTMLSelfCloseTagNode(node);
|
|
545
|
+
}
|
|
546
|
+
checkHeadingElement(node) {
|
|
547
|
+
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const openTag = node.open_tag;
|
|
551
|
+
const tagName = getTagName(openTag);
|
|
552
|
+
if (!tagName) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const isStandardHeading = HEADING_TAGS.has(tagName);
|
|
556
|
+
const isAriaHeading = this.hasHeadingRole(openTag);
|
|
557
|
+
if (!isStandardHeading && !isAriaHeading) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (this.isEmptyHeading(node)) {
|
|
561
|
+
const elementDescription = isStandardHeading
|
|
562
|
+
? `\`<${tagName}>\``
|
|
563
|
+
: `\`<${tagName} role="heading">\``;
|
|
564
|
+
this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.location, "error");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
checkSelfClosingHeading(node) {
|
|
568
|
+
const tagName = getTagName(node);
|
|
569
|
+
if (!tagName) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// Check if it's a standard heading tag (h1-h6) or has role="heading"
|
|
573
|
+
const isStandardHeading = HEADING_TAGS.has(tagName);
|
|
574
|
+
const isAriaHeading = this.hasHeadingRole(node);
|
|
575
|
+
if (!isStandardHeading && !isAriaHeading) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Self-closing headings are always empty
|
|
579
|
+
const elementDescription = isStandardHeading
|
|
580
|
+
? `\`<${tagName}>\``
|
|
581
|
+
: `\`<${tagName} role="heading">\``;
|
|
582
|
+
this.addOffense(`Heading element ${elementDescription} must not be empty. Provide accessible text content for screen readers and SEO.`, node.tag_name.location, "error");
|
|
583
|
+
}
|
|
584
|
+
isEmptyHeading(node) {
|
|
585
|
+
if (!node.body || node.body.length === 0) {
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
// Check if all content is just whitespace or inaccessible
|
|
589
|
+
let hasAccessibleContent = false;
|
|
590
|
+
for (const child of node.body) {
|
|
591
|
+
if (child.type === "AST_LITERAL_NODE") {
|
|
592
|
+
const literalNode = child;
|
|
593
|
+
if (literalNode.content.trim().length > 0) {
|
|
594
|
+
hasAccessibleContent = true;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
599
|
+
const textNode = child;
|
|
600
|
+
if (textNode.content.trim().length > 0) {
|
|
601
|
+
hasAccessibleContent = true;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
606
|
+
const elementNode = child;
|
|
607
|
+
// Check if this element is accessible (not aria-hidden="true")
|
|
608
|
+
if (this.isElementAccessible(elementNode)) {
|
|
609
|
+
hasAccessibleContent = true;
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
// If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
|
|
615
|
+
hasAccessibleContent = true;
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return !hasAccessibleContent;
|
|
620
|
+
}
|
|
621
|
+
hasHeadingRole(node) {
|
|
622
|
+
const attributes = getAttributes(node);
|
|
623
|
+
const roleAttribute = findAttributeByName(attributes, "role");
|
|
624
|
+
if (!roleAttribute) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
const roleValue = getAttributeValue(roleAttribute);
|
|
628
|
+
return roleValue === "heading";
|
|
629
|
+
}
|
|
630
|
+
isElementAccessible(node) {
|
|
631
|
+
// Check if the element has aria-hidden="true"
|
|
632
|
+
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
const openTag = node.open_tag;
|
|
636
|
+
const attributes = getAttributes(openTag);
|
|
637
|
+
const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
|
|
638
|
+
if (ariaHiddenAttribute) {
|
|
639
|
+
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
640
|
+
if (ariaHiddenValue === "true") {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Recursively check if the element has any accessible content
|
|
645
|
+
if (!node.body || node.body.length === 0) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
for (const child of node.body) {
|
|
649
|
+
if (child.type === "AST_LITERAL_NODE") {
|
|
650
|
+
const literalNode = child;
|
|
651
|
+
if (literalNode.content.trim().length > 0) {
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
656
|
+
const textNode = child;
|
|
657
|
+
if (textNode.content.trim().length > 0) {
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
662
|
+
const elementNode = child;
|
|
663
|
+
if (this.isElementAccessible(elementNode)) {
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
// If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
class HTMLNoEmptyHeadingsRule {
|
|
676
|
+
name = "html-no-empty-headings";
|
|
677
|
+
check(node) {
|
|
678
|
+
const visitor = new NoEmptyHeadingsVisitor(this.name);
|
|
679
|
+
visitor.visit(node);
|
|
680
|
+
return visitor.offenses;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
class NestedLinkVisitor extends BaseRuleVisitor {
|
|
685
|
+
linkStack = [];
|
|
686
|
+
checkNestedLink(openTag) {
|
|
687
|
+
if (this.linkStack.length > 0) {
|
|
688
|
+
this.addOffense("Nested `<a>` elements are not allowed. Links cannot contain other links.", openTag.tag_name.location, "error");
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
visitHTMLElementNode(node) {
|
|
694
|
+
if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
|
|
695
|
+
super.visitHTMLElementNode(node);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const openTag = node.open_tag;
|
|
699
|
+
const tagName = getTagName(openTag);
|
|
700
|
+
if (tagName !== "a") {
|
|
701
|
+
super.visitHTMLElementNode(node);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// If we're already inside a link, this is a nested link
|
|
705
|
+
this.checkNestedLink(openTag);
|
|
706
|
+
this.linkStack.push(openTag);
|
|
707
|
+
super.visitHTMLElementNode(node);
|
|
708
|
+
this.linkStack.pop();
|
|
709
|
+
}
|
|
710
|
+
// Handle self-closing <a> tags (though they're not valid HTML, they might exist)
|
|
711
|
+
visitHTMLOpenTagNode(node) {
|
|
712
|
+
const tagName = getTagName(node);
|
|
713
|
+
if (tagName === "a" && node.is_void) {
|
|
714
|
+
this.checkNestedLink(node);
|
|
715
|
+
}
|
|
716
|
+
super.visitHTMLOpenTagNode(node);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
class HTMLNoNestedLinksRule {
|
|
720
|
+
name = "html-no-nested-links";
|
|
721
|
+
check(node) {
|
|
722
|
+
const visitor = new NestedLinkVisitor(this.name);
|
|
723
|
+
visitor.visit(node);
|
|
724
|
+
return visitor.offenses;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
729
|
+
visitHTMLOpenTagNode(node) {
|
|
730
|
+
this.checkTagName(node);
|
|
731
|
+
this.visitChildNodes(node);
|
|
732
|
+
}
|
|
733
|
+
visitHTMLCloseTagNode(node) {
|
|
734
|
+
this.checkTagName(node);
|
|
735
|
+
this.visitChildNodes(node);
|
|
736
|
+
}
|
|
737
|
+
visitHTMLSelfCloseTagNode(node) {
|
|
738
|
+
this.checkTagName(node);
|
|
739
|
+
this.visitChildNodes(node);
|
|
740
|
+
}
|
|
741
|
+
checkTagName(node) {
|
|
742
|
+
const tagName = node.tag_name?.value;
|
|
743
|
+
if (!tagName)
|
|
744
|
+
return;
|
|
745
|
+
if (tagName !== tagName.toLowerCase()) {
|
|
746
|
+
let type = node.type;
|
|
747
|
+
if (node.type == "AST_HTML_OPEN_TAG_NODE")
|
|
748
|
+
type = "Opening";
|
|
749
|
+
if (node.type == "AST_HTML_CLOSE_TAG_NODE")
|
|
750
|
+
type = "Closing";
|
|
751
|
+
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE")
|
|
752
|
+
type = "Self-closing";
|
|
753
|
+
this.addOffense(`${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`, node.tag_name.location, "error");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
class HTMLTagNameLowercaseRule {
|
|
758
|
+
name = "html-tag-name-lowercase";
|
|
759
|
+
check(node) {
|
|
760
|
+
const visitor = new TagNameLowercaseVisitor(this.name);
|
|
761
|
+
visitor.visit(node);
|
|
762
|
+
return visitor.offenses;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const defaultRules = [
|
|
767
|
+
ERBNoEmptyTagsRule,
|
|
768
|
+
ERBNoOutputControlFlowRule,
|
|
769
|
+
ERBRequireWhitespaceRule,
|
|
770
|
+
HTMLAnchorRequireHrefRule,
|
|
771
|
+
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
772
|
+
HTMLAttributeDoubleQuotesRule,
|
|
773
|
+
HTMLAttributeValuesRequireQuotesRule,
|
|
774
|
+
HTMLBooleanAttributesNoValueRule,
|
|
775
|
+
HTMLImgRequireAltRule,
|
|
776
|
+
HTMLNoBlockInsideInlineRule,
|
|
777
|
+
HTMLNoDuplicateAttributesRule,
|
|
778
|
+
HTMLNoEmptyHeadingsRule,
|
|
779
|
+
HTMLNoNestedLinksRule,
|
|
780
|
+
HTMLTagNameLowercaseRule,
|
|
781
|
+
];
|
|
782
|
+
|
|
783
|
+
class Linter {
|
|
784
|
+
rules;
|
|
785
|
+
offenses;
|
|
786
|
+
/**
|
|
787
|
+
* Creates a new Linter instance.
|
|
788
|
+
* @param rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
|
|
789
|
+
*/
|
|
790
|
+
constructor(rules) {
|
|
791
|
+
this.rules = rules !== undefined ? rules : this.getDefaultRules();
|
|
792
|
+
this.offenses = [];
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Returns the default set of rule classes used by the linter.
|
|
796
|
+
* @returns Array of rule classes
|
|
797
|
+
*/
|
|
798
|
+
getDefaultRules() {
|
|
799
|
+
return defaultRules;
|
|
800
|
+
}
|
|
801
|
+
getRuleCount() {
|
|
802
|
+
return this.rules.length;
|
|
803
|
+
}
|
|
804
|
+
lint(document) {
|
|
805
|
+
this.offenses = [];
|
|
806
|
+
for (const Rule of this.rules) {
|
|
807
|
+
const rule = new Rule();
|
|
808
|
+
const ruleOffenses = rule.check(document);
|
|
809
|
+
this.offenses.push(...ruleOffenses);
|
|
810
|
+
}
|
|
811
|
+
const errors = this.offenses.filter(offense => offense.severity === "error").length;
|
|
812
|
+
const warnings = this.offenses.filter(offense => offense.severity === "warning").length;
|
|
813
|
+
return {
|
|
814
|
+
offenses: this.offenses,
|
|
815
|
+
errors,
|
|
816
|
+
warnings
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
exports.ERBNoEmptyTagsRule = ERBNoEmptyTagsRule;
|
|
822
|
+
exports.ERBNoOutputControlFlowRule = ERBNoOutputControlFlowRule;
|
|
823
|
+
exports.HTMLAnchorRequireHrefRule = HTMLAnchorRequireHrefRule;
|
|
824
|
+
exports.HTMLAttributeDoubleQuotesRule = HTMLAttributeDoubleQuotesRule;
|
|
825
|
+
exports.HTMLAttributeValuesRequireQuotesRule = HTMLAttributeValuesRequireQuotesRule;
|
|
826
|
+
exports.HTMLBooleanAttributesNoValueRule = HTMLBooleanAttributesNoValueRule;
|
|
827
|
+
exports.HTMLImgRequireAltRule = HTMLImgRequireAltRule;
|
|
828
|
+
exports.HTMLNoBlockInsideInlineRule = HTMLNoBlockInsideInlineRule;
|
|
829
|
+
exports.HTMLNoDuplicateAttributesRule = HTMLNoDuplicateAttributesRule;
|
|
830
|
+
exports.HTMLNoEmptyHeadingsRule = HTMLNoEmptyHeadingsRule;
|
|
831
|
+
exports.HTMLNoNestedLinksRule = HTMLNoNestedLinksRule;
|
|
832
|
+
exports.HTMLTagNameLowercaseRule = HTMLTagNameLowercaseRule;
|
|
833
|
+
exports.Linter = Linter;
|
|
834
|
+
//# sourceMappingURL=index.cjs.map
|