@html-eslint/eslint-plugin 0.36.0 → 0.38.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.
@@ -20,5 +20,6 @@ module.exports = {
20
20
  "@html-eslint/no-obsolete-tags": "error",
21
21
  "@html-eslint/require-closing-tags": "error",
22
22
  "@html-eslint/no-duplicate-attrs": "error",
23
+ "@html-eslint/use-baseline": "error",
23
24
  },
24
25
  };
@@ -4,7 +4,6 @@
4
4
  * @typedef {Object} MessageId
5
5
  * @property {"closeStyleWrong"} CLOSE_STYLE_WRONG
6
6
  * @property {"newlineMissing"} NEWLINE_MISSING
7
- * @property {"newlineUnexpected"} NEWLINE_UNEXPECTED
8
7
  *
9
8
  * @typedef {Object} Option
10
9
  * @property {"sameline" | "newline"} [option.closeStyle]
@@ -22,7 +21,6 @@ const { createVisitors } = require("./utils/visitors");
22
21
  const MESSAGE_ID = {
23
22
  CLOSE_STYLE_WRONG: "closeStyleWrong",
24
23
  NEWLINE_MISSING: "newlineMissing",
25
- NEWLINE_UNEXPECTED: "newlineUnexpected",
26
24
  };
27
25
 
28
26
  /**
@@ -56,8 +54,6 @@ module.exports = {
56
54
  [MESSAGE_ID.CLOSE_STYLE_WRONG]:
57
55
  "Closing bracket was on {{actual}}; expected {{expected}}",
58
56
  [MESSAGE_ID.NEWLINE_MISSING]: "Newline expected before {{attrName}}",
59
- [MESSAGE_ID.NEWLINE_UNEXPECTED]:
60
- "Newlines not expected between attributes, since this tag has fewer than {{attrMin}} attributes",
61
57
  },
62
58
  },
63
59
 
@@ -70,21 +66,22 @@ module.exports = {
70
66
  return createVisitors(context, {
71
67
  Tag(node) {
72
68
  const shouldBeMultiline = node.attributes.length > attrMin;
69
+ if (!shouldBeMultiline) return;
73
70
 
74
71
  /**
75
72
  * This doesn't do any indentation, so the result will look silly. Indentation should be covered by the `indent` rule
76
73
  * @param {RuleFixer} fixer
77
74
  */
78
75
  function fix(fixer) {
79
- const spacer = shouldBeMultiline ? "\n" : " ";
80
76
  let expected = node.openStart.value;
81
77
  for (const attr of node.attributes) {
82
- expected += `${spacer}${attr.key.value}`;
78
+ expected += `\n${attr.key.value}`;
83
79
  if (attr.startWrapper && attr.value && attr.endWrapper) {
84
80
  expected += `=${attr.startWrapper.value}${attr.value.value}${attr.endWrapper.value}`;
85
81
  }
86
82
  }
87
- if (shouldBeMultiline && closeStyle === "newline") {
83
+
84
+ if (closeStyle === "newline") {
88
85
  expected += "\n";
89
86
  } else if (node.selfClosing) {
90
87
  expected += " ";
@@ -97,66 +94,38 @@ module.exports = {
97
94
  );
98
95
  }
99
96
 
100
- if (shouldBeMultiline) {
101
- let index = 0;
102
- for (const attr of node.attributes) {
103
- const attrPrevious = node.attributes[index - 1];
104
- const relativeToNode = attrPrevious || node.openStart;
105
- if (attr.loc.start.line === relativeToNode.loc.end.line) {
106
- return context.report({
107
- node,
108
- data: {
109
- attrName: attr.key.value,
110
- },
111
- fix,
112
- messageId: MESSAGE_ID.NEWLINE_MISSING,
113
- });
114
- }
115
- index += 1;
116
- }
117
-
118
- const attrLast = node.attributes[node.attributes.length - 1];
119
- const closeStyleActual =
120
- node.openEnd.loc.start.line === attrLast.loc.end.line
121
- ? "sameline"
122
- : "newline";
123
- if (closeStyle !== closeStyleActual) {
97
+ let index = 0;
98
+ for (const attr of node.attributes) {
99
+ const attrPrevious = node.attributes[index - 1];
100
+ const relativeToNode = attrPrevious || node.openStart;
101
+ if (attr.loc.start.line === relativeToNode.loc.end.line) {
124
102
  return context.report({
125
103
  node,
126
104
  data: {
127
- actual: closeStyleActual,
128
- expected: closeStyle,
105
+ attrName: attr.key.value,
129
106
  },
130
107
  fix,
131
- messageId: MESSAGE_ID.CLOSE_STYLE_WRONG,
108
+ messageId: MESSAGE_ID.NEWLINE_MISSING,
132
109
  });
133
110
  }
134
- } else {
135
- let expectedLastLineNum = node.openStart.loc.start.line;
136
- for (const attr of node.attributes) {
137
- if (shouldBeMultiline) {
138
- expectedLastLineNum += 1;
139
- }
140
- if (attr.value) {
141
- const valueLineSpan =
142
- attr.value.loc.end.line - attr.value.loc.start.line;
143
- expectedLastLineNum += valueLineSpan;
144
- }
145
- }
146
- if (shouldBeMultiline && closeStyle === "newline") {
147
- expectedLastLineNum += 1;
148
- }
111
+ index += 1;
112
+ }
149
113
 
150
- if (node.openEnd.loc.end.line !== expectedLastLineNum) {
151
- return context.report({
152
- node,
153
- data: {
154
- attrMin: `${attrMin}`,
155
- },
156
- fix,
157
- messageId: MESSAGE_ID.NEWLINE_UNEXPECTED,
158
- });
159
- }
114
+ const attrLast = node.attributes[node.attributes.length - 1];
115
+ const closeStyleActual =
116
+ node.openEnd.loc.start.line === attrLast.loc.end.line
117
+ ? "sameline"
118
+ : "newline";
119
+ if (closeStyle !== closeStyleActual) {
120
+ return context.report({
121
+ node,
122
+ data: {
123
+ actual: closeStyleActual,
124
+ expected: closeStyle,
125
+ },
126
+ fix,
127
+ messageId: MESSAGE_ID.CLOSE_STYLE_WRONG,
128
+ });
160
129
  }
161
130
  },
162
131
  });
@@ -12,6 +12,8 @@
12
12
  * @typedef { import("@html-eslint/types").TemplateLiteral } TemplateLiteral
13
13
  * @typedef { import("@html-eslint/types").OpenTemplate } OpenTemplate
14
14
  * @typedef { import("@html-eslint/types").CloseTemplate } CloseTemplate
15
+ * @typedef { import("@html-eslint/types").ScriptTag } ScriptTag
16
+ * @typedef { import("@html-eslint/types").StyleTag } StyleTag
15
17
  *
16
18
  * @typedef {AnyNode | Line} AnyNodeOrLine
17
19
  * @typedef {Object} IndentType
@@ -40,6 +42,8 @@ const {
40
42
  isLine,
41
43
  isTag,
42
44
  hasTemplate,
45
+ isScript,
46
+ isStyle,
43
47
  } = require("../utils/node");
44
48
  const {
45
49
  shouldCheckTaggedTemplateExpression,
@@ -121,7 +125,7 @@ module.exports = {
121
125
  const { indentType, indentSize, indentChar } = getIndentOptionInfo(context);
122
126
 
123
127
  /**
124
- * @param {Tag} node
128
+ * @param {Tag | ScriptTag | StyleTag} node
125
129
  * @return {number}
126
130
  */
127
131
  function getTagIncreasingLevel(node) {
@@ -150,7 +154,7 @@ module.exports = {
150
154
  if (isLine(node)) {
151
155
  return 1;
152
156
  }
153
- if (isTag(node)) {
157
+ if (isTag(node) || isScript(node) || isStyle(node)) {
154
158
  return getTagIncreasingLevel(node);
155
159
  }
156
160
  const type = node.type;
@@ -45,6 +45,7 @@ const noInvalidRole = require("./no-invalid-role");
45
45
  const noNestedInteractive = require("./no-nested-interactive");
46
46
  const maxElementDepth = require("./max-element-depth");
47
47
  const requireExplicitSize = require("./require-explicit-size");
48
+ const useBaseLine = require("./use-baseline");
48
49
  // import new rule here ↑
49
50
  // DO NOT REMOVE THIS COMMENT
50
51
 
@@ -96,6 +97,7 @@ module.exports = {
96
97
  "require-input-label": requireInputLabel,
97
98
  "max-element-depth": maxElementDepth,
98
99
  "require-explicit-size": requireExplicitSize,
100
+ "use-baseline": useBaseLine,
99
101
  // export new rule here ↑
100
102
  // DO NOT REMOVE THIS COMMENT
101
103
  };
@@ -3,6 +3,8 @@
3
3
  * @typedef { import("@html-eslint/types").StyleTag } StyleTag
4
4
  * @typedef { import("@html-eslint/types").ScriptTag } ScriptTag
5
5
  * @typedef { import("../types").RuleModule<[]> } RuleModule
6
+ * @typedef { import("@html-eslint/types").Attribute } Attribute
7
+ * @typedef { import("../types").SuggestionReportDescriptor } SuggestionReportDescriptor
6
8
  */
7
9
 
8
10
  const { RULE_CATEGORY } = require("../constants");
@@ -10,6 +12,7 @@ const { createVisitors } = require("./utils/visitors");
10
12
 
11
13
  const MESSAGE_IDS = {
12
14
  DUPLICATE_ATTRS: "duplicateAttrs",
15
+ REMOVE_ATTR: "removeAttr",
13
16
  };
14
17
 
15
18
  /**
@@ -26,14 +29,33 @@ module.exports = {
26
29
  },
27
30
 
28
31
  fixable: null,
32
+ hasSuggestions: true,
29
33
  schema: [],
30
34
  messages: {
31
35
  [MESSAGE_IDS.DUPLICATE_ATTRS]:
32
36
  "The attribute '{{attrName}}' is duplicated.",
37
+ [MESSAGE_IDS.REMOVE_ATTR]:
38
+ "Remove this duplicate '{{attrName}}' attribute.",
33
39
  },
34
40
  },
35
41
 
36
42
  create(context) {
43
+ /**
44
+ * @param {Attribute} node
45
+ * @returns {SuggestionReportDescriptor[]}
46
+ */
47
+ function getSuggestions(node) {
48
+ return [
49
+ {
50
+ messageId: MESSAGE_IDS.REMOVE_ATTR,
51
+ fix: (fixer) => fixer.removeRange(node.range),
52
+ data: {
53
+ attrName: node.key.value,
54
+ },
55
+ },
56
+ ];
57
+ }
58
+
37
59
  /**
38
60
  * @param {Tag | StyleTag | ScriptTag} node
39
61
  */
@@ -48,6 +70,7 @@ module.exports = {
48
70
  attrName: attr.key.value,
49
71
  },
50
72
  messageId: MESSAGE_IDS.DUPLICATE_ATTRS,
73
+ suggest: getSuggestions(attr),
51
74
  });
52
75
  } else {
53
76
  attrsSet.add(attr.key.value.toLowerCase());
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * @typedef { import("../types").RuleModule<[]> } RuleModule
3
+ * @typedef { import("@html-eslint/types").AttributeValue } AttributeValue
4
+ * @typedef { import("../types").SuggestionReportDescriptor } SuggestionReportDescriptor
3
5
  */
4
6
 
5
7
  const { RULE_CATEGORY } = require("../constants");
@@ -9,6 +11,9 @@ const { createVisitors } = require("./utils/visitors");
9
11
  const MESSAGE_IDS = {
10
12
  MISSING: "missing",
11
13
  INVALID: "invalid",
14
+ REPLACE_TO_SUBMIT: "replaceToSubmit",
15
+ REPLACE_TO_BUTTON: "replaceToButton",
16
+ REPLACE_TO_RESET: "replaceToReset",
12
17
  };
13
18
 
14
19
  const VALID_BUTTON_TYPES_SET = new Set(["submit", "button", "reset"]);
@@ -26,16 +31,41 @@ module.exports = {
26
31
  recommended: false,
27
32
  },
28
33
 
29
- fixable: null,
34
+ fixable: true,
35
+ hasSuggestions: true,
30
36
  schema: [],
31
37
  messages: {
32
38
  [MESSAGE_IDS.MISSING]: "Missing a type attribute for button",
33
39
  [MESSAGE_IDS.INVALID]:
34
40
  '"{{type}}" is an invalid value for button type attribute.',
41
+ [MESSAGE_IDS.REPLACE_TO_BUTTON]: "Replace the type with 'button'",
42
+ [MESSAGE_IDS.REPLACE_TO_SUBMIT]: "Replace the type with 'submit'",
43
+ [MESSAGE_IDS.REPLACE_TO_RESET]: "Replace the type with 'reset'",
35
44
  },
36
45
  },
37
46
 
38
47
  create(context) {
48
+ /**
49
+ * @param {AttributeValue} node
50
+ * @returns {SuggestionReportDescriptor[]}
51
+ */
52
+ function getSuggestions(node) {
53
+ return [
54
+ {
55
+ messageId: MESSAGE_IDS.REPLACE_TO_SUBMIT,
56
+ fix: (fixer) => fixer.replaceTextRange(node.range, "submit"),
57
+ },
58
+ {
59
+ messageId: MESSAGE_IDS.REPLACE_TO_BUTTON,
60
+ fix: (fixer) => fixer.replaceTextRange(node.range, "button"),
61
+ },
62
+ {
63
+ messageId: MESSAGE_IDS.REPLACE_TO_RESET,
64
+ fix: (fixer) => fixer.replaceTextRange(node.range, "reset"),
65
+ },
66
+ ];
67
+ }
68
+
39
69
  return createVisitors(context, {
40
70
  Tag(node) {
41
71
  if (node.name !== "button") {
@@ -46,6 +76,9 @@ module.exports = {
46
76
  context.report({
47
77
  node: node.openStart,
48
78
  messageId: MESSAGE_IDS.MISSING,
79
+ fix(fixer) {
80
+ return fixer.insertTextAfter(node.openStart, ' type="submit"');
81
+ },
49
82
  });
50
83
  } else if (
51
84
  !VALID_BUTTON_TYPES_SET.has(typeAttr.value.value) &&
@@ -57,6 +90,7 @@ module.exports = {
57
90
  data: {
58
91
  type: typeAttr.value.value,
59
92
  },
93
+ suggest: getSuggestions(typeAttr.value),
60
94
  });
61
95
  }
62
96
  },
@@ -0,0 +1,269 @@
1
+ /**
2
+ * @typedef {Object} Option
3
+ * @property {"widely" | "newly" | number} Option.available
4
+ * @typedef { import("../types").RuleModule<[Option]> } RuleModule
5
+ * @typedef {import("@html-eslint/types").Attribute} Attribute
6
+ * @typedef {import("@html-eslint/types").Tag} Tag
7
+ * @typedef {import("@html-eslint/types").ScriptTag} ScriptTag
8
+ * @typedef {import("@html-eslint/types").StyleTag} StyleTag
9
+ */
10
+
11
+ const { RULE_CATEGORY } = require("../constants");
12
+ const {
13
+ elements,
14
+ globalAttributes,
15
+ BASELINE_HIGH,
16
+ BASELINE_LOW,
17
+ } = require("./utils/baseline");
18
+ const { createVisitors } = require("./utils/visitors");
19
+
20
+ const MESSAGE_IDS = {
21
+ NOT_BASELINE_ELEMENT: "notBaselineElement",
22
+ NOT_BASELINE_ELEMENT_ATTRIBUTE: "notBaselineElementAttribute",
23
+ NOT_BASELINE_GLOBAL_ATTRIBUTE: "notBaselineGlobalAttribute",
24
+ };
25
+
26
+ /**
27
+ * @type {RuleModule}
28
+ */
29
+ module.exports = {
30
+ meta: {
31
+ type: "code",
32
+ docs: {
33
+ description: "Enforce the use of baseline features.",
34
+ recommended: true,
35
+ category: RULE_CATEGORY.BEST_PRACTICE,
36
+ },
37
+ fixable: null,
38
+ schema: [
39
+ {
40
+ type: "object",
41
+ properties: {
42
+ available: {
43
+ anyOf: [
44
+ {
45
+ enum: ["widely", "newly"],
46
+ },
47
+ {
48
+ // baseline year
49
+ type: "integer",
50
+ minimum: 2000,
51
+ maximum: new Date().getFullYear(),
52
+ },
53
+ ],
54
+ },
55
+ },
56
+ additionalProperties: false,
57
+ },
58
+ ],
59
+
60
+ messages: {
61
+ [MESSAGE_IDS.NOT_BASELINE_ELEMENT]:
62
+ "Element '{{element}}' is not a {{availability}} available baseline feature.",
63
+ [MESSAGE_IDS.NOT_BASELINE_ELEMENT_ATTRIBUTE]:
64
+ "Attribute '{{attr}}' on '{{element}}' is not a {{availability}} available baseline feature.",
65
+ [MESSAGE_IDS.NOT_BASELINE_GLOBAL_ATTRIBUTE]:
66
+ "Attribute '{{attr}}' is not a {{availability}} available baseline feature.",
67
+ },
68
+ },
69
+
70
+ create(context) {
71
+ const options = context.options[0] || { available: "widely" };
72
+ const available = options.available;
73
+
74
+ const baseYear = typeof available === "number" ? available : null;
75
+ const baseStatus = available === "widely" ? BASELINE_HIGH : BASELINE_LOW;
76
+ const availability = String(available);
77
+
78
+ /**
79
+ * @param {string} element
80
+ * @returns {boolean}
81
+ */
82
+ function isCustomElement(element) {
83
+ return element.includes("-");
84
+ }
85
+
86
+ /**
87
+ * @param {string} encoded
88
+ * @returns {[number, number]}
89
+ */
90
+ function decodeStatus(encoded) {
91
+ const [status, year = NaN] = encoded
92
+ .split(":")
93
+ .map((part) => Number(part));
94
+ return [status, year];
95
+ }
96
+
97
+ /**
98
+ * @param {string} encoded
99
+ * @returns {boolean}
100
+ */
101
+ function isSupported(encoded) {
102
+ const [status, year = NaN] = decodeStatus(encoded);
103
+ if (baseYear) {
104
+ return year <= baseYear;
105
+ }
106
+ return status >= baseStatus;
107
+ }
108
+
109
+ /**
110
+ * @param {string} element
111
+ * @returns {boolean}
112
+ */
113
+ function isSupportedElement(element) {
114
+ const elementEncoded = elements.get(element);
115
+ if (!elementEncoded) {
116
+ return true;
117
+ }
118
+ return isSupported(elementEncoded);
119
+ }
120
+
121
+ /**
122
+ * @param {string[]} parts
123
+ * @returns {string}
124
+ */
125
+ function toStatusKey(...parts) {
126
+ return parts.map((part) => part.toLowerCase().trim()).join(".");
127
+ }
128
+
129
+ /**
130
+ * @param {string} element
131
+ * @param {string} key
132
+ * @returns {boolean}
133
+ */
134
+ function isSupportedElementAttributeKey(element, key) {
135
+ const elementStatus = elements.get(toStatusKey(element, key));
136
+ if (!elementStatus) {
137
+ return true;
138
+ }
139
+ return isSupported(elementStatus);
140
+ }
141
+
142
+ /**
143
+ * @param {string} key
144
+ * @returns {boolean}
145
+ */
146
+ function isSupportedGlobalAttributeKey(key) {
147
+ const globalAttrStatus = globalAttributes.get(toStatusKey(key));
148
+ if (!globalAttrStatus) {
149
+ return true;
150
+ }
151
+ return isSupported(globalAttrStatus);
152
+ }
153
+
154
+ /**
155
+ * @param {string} element
156
+ * @param {string} key
157
+ * @param {string} value
158
+ * @returns {boolean}
159
+ */
160
+ function isSupportedElementAttributeKeyValue(element, key, value) {
161
+ const elementStatus = elements.get(toStatusKey(element, key, value));
162
+ if (!elementStatus) {
163
+ return true;
164
+ }
165
+ return isSupported(elementStatus);
166
+ }
167
+
168
+ /**
169
+ * @param {string} key
170
+ * @param {string} value
171
+ * @returns {boolean}
172
+ */
173
+ function isSupportedGlobalAttributeKeyValue(key, value) {
174
+ const globalAttrStatus = globalAttributes.get(toStatusKey(key, value));
175
+ if (!globalAttrStatus) {
176
+ return true;
177
+ }
178
+ return isSupported(globalAttrStatus);
179
+ }
180
+
181
+ /**
182
+ * @param {Tag | ScriptTag | StyleTag} node
183
+ * @param {string} elementName
184
+ * @param {Attribute[]} attributes
185
+ */
186
+ function check(node, elementName, attributes) {
187
+ if (isCustomElement(elementName)) {
188
+ return;
189
+ }
190
+
191
+ if (!isSupportedElement(elementName)) {
192
+ context.report({
193
+ node: node.openStart,
194
+ messageId: MESSAGE_IDS.NOT_BASELINE_ELEMENT,
195
+ data: {
196
+ element: `<${elementName}>`,
197
+ availability,
198
+ },
199
+ });
200
+ }
201
+ attributes.forEach((attribute) => {
202
+ if (!isSupportedElementAttributeKey(elementName, attribute.key.value)) {
203
+ context.report({
204
+ node: attribute.key,
205
+ messageId: MESSAGE_IDS.NOT_BASELINE_ELEMENT_ATTRIBUTE,
206
+ data: {
207
+ element: `<${elementName}>`,
208
+ attr: attribute.key.value,
209
+ availability,
210
+ },
211
+ });
212
+ } else if (!isSupportedGlobalAttributeKey(attribute.key.value)) {
213
+ context.report({
214
+ node: attribute.key,
215
+ messageId: MESSAGE_IDS.NOT_BASELINE_GLOBAL_ATTRIBUTE,
216
+ data: {
217
+ attr: attribute.key.value,
218
+ availability,
219
+ },
220
+ });
221
+ } else if (attribute.value) {
222
+ if (
223
+ !isSupportedElementAttributeKeyValue(
224
+ elementName,
225
+ attribute.key.value,
226
+ attribute.value.value
227
+ )
228
+ ) {
229
+ context.report({
230
+ node: attribute.key,
231
+ messageId: MESSAGE_IDS.NOT_BASELINE_ELEMENT_ATTRIBUTE,
232
+ data: {
233
+ element: `<${elementName}>`,
234
+ attr: `${attribute.key.value}="${attribute.value.value}"`,
235
+ availability,
236
+ },
237
+ });
238
+ } else if (
239
+ !isSupportedGlobalAttributeKeyValue(
240
+ attribute.key.value,
241
+ attribute.value.value
242
+ )
243
+ ) {
244
+ context.report({
245
+ node: attribute,
246
+ messageId: MESSAGE_IDS.NOT_BASELINE_GLOBAL_ATTRIBUTE,
247
+ data: {
248
+ attr: `${attribute.key.value}="${attribute.value.value}"`,
249
+ availability,
250
+ },
251
+ });
252
+ }
253
+ }
254
+ });
255
+ }
256
+
257
+ return createVisitors(context, {
258
+ ScriptTag(node) {
259
+ check(node, "script", node.attributes);
260
+ },
261
+ StyleTag(node) {
262
+ check(node, "style", node.attributes);
263
+ },
264
+ Tag(node) {
265
+ check(node, node.name, node.attributes);
266
+ },
267
+ });
268
+ },
269
+ };