Skip to content

Commit da63b10

Browse files
yeonjuanrviscomi
andauthored
feat: implement use-baseline (#311)
* feat: implement use-baseline * feat: implement * Update .cspell.json * Update use-baseline.js * fix * add message * add tests * add tests * add tempalte visitors * add docs * Update use-baseline.md * feat: check script & style * Update docs/rules/use-baseline.md Co-authored-by: Rick Viscomi <rviscomi@users.noreply.github.com> * Update docs/rules/use-baseline.md Co-authored-by: Rick Viscomi <rviscomi@users.noreply.github.com> * apply reviews --------- Co-authored-by: Rick Viscomi <rviscomi@users.noreply.github.com>
1 parent c609ee2 commit da63b10

File tree

7 files changed

+555
-1
lines changed

7 files changed

+555
-1
lines changed

.cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"**/node_modules/**",
1212
"packages/website/**",
1313
"packages/eslint-plugin/types/**",
14-
"packages/eslint-plugin/lib/rules/utils/baseline.js"
14+
"packages/eslint-plugin/lib/rules/utils/baseline.js",
15+
"packages/eslint-plugin/tests/rules/use-baseline.test.js"
1516
],
1617
"words": [
1718
"tseslint",

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
| [require-explicit-size](rules/require-explicit-size) | Enforces that some elements (img, iframe) have explicitly defined width and height attributes. | |
2828
| [require-li-container](rules/require-li-container) | Enforce `<li>` to be in `<ul>`, `<ol>` or `<menu>`. ||
2929
| [require-meta-charset](rules/require-meta-charset) | Enforce to use `<meta charset="...">` in `<head>` | |
30+
| [use-baseline](rules/use-baseline) | Enforce the use of baseline features. ||
3031

3132
## SEO
3233

docs/rules/use-baseline.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# use-baseline
2+
3+
This rule enforces the use of baseline features.
4+
5+
## What is Baseline?
6+
7+
[Baseline](https://web-platform-dx.github.io/web-features/) is an effort by the [W3C WebDX Community Group](https://www.w3.org/community/webdx/) that provides clear information about which web platform features work across [core browser set](https://web-platform-dx.github.io/web-features/#how-do-features-become-part-of-baseline%3F) today.
8+
9+
Baseline features are available across popular browsers. Baseline has two stages:
10+
11+
- **Newly available**: The feature works across the latest devices and browser versions. The feature might not work in older devices or browsers.
12+
- **Widely available**: The feature is well established and works across many devices and browser versions. It’s been available across browsers for at least 2½ years (30 months).
13+
14+
Prior to being newly available, a feature has **Limited availability** when it's not yet available across all browsers.
15+
16+
## How to use
17+
18+
```js,.eslintrc.js
19+
module.exports = {
20+
rules: {
21+
"@html-eslint/use-baseline": "error",
22+
},
23+
};
24+
```
25+
26+
## Rule Details
27+
28+
This rule warns when it finds any of the following:
29+
30+
- An element that isn't widely available.
31+
- An attribute that isn't widely available.
32+
- An attribute value that isn't widely available.
33+
34+
The data is provided via the [web-features](https://www.npmjs.com/package/web-features) package.
35+
36+
### Options
37+
38+
This rule has an object option:
39+
40+
```ts
41+
"@html-eslint/use-baseline": ["error", {
42+
"available": "newly" | "widely" | number; // default: "widely"
43+
}]
44+
```
45+
46+
#### available: `"widely"`
47+
48+
If `"widely"` is used as an option, this rule allows features that are at the Baseline widely available stage: features that have been available across browsers for at least 30 months.
49+
50+
#### available: `"newly"`
51+
52+
If `"newly"` is used as an option, this rule allows features that are at the Baseline newly available stage: features that have been supported on all core browsers for less than 30 months.
53+
54+
### available: `number`
55+
56+
If an integer `number` is used as an option, this rule allows features that became Baseline newly available that year, or earlier. (minimum: 2000)
57+
58+
## Further Reading
59+
60+
- [W3C WebDX Community Group - Baseline](https://web-platform-dx.github.io/web-features/)
61+
- [web.dev - Baseline](https://web.dev/baseline)

packages/eslint-plugin/lib/configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ module.exports = {
2020
"@html-eslint/no-obsolete-tags": "error",
2121
"@html-eslint/require-closing-tags": "error",
2222
"@html-eslint/no-duplicate-attrs": "error",
23+
"@html-eslint/use-baseline": "error",
2324
},
2425
};

packages/eslint-plugin/lib/rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const noInvalidRole = require("./no-invalid-role");
4545
const noNestedInteractive = require("./no-nested-interactive");
4646
const maxElementDepth = require("./max-element-depth");
4747
const requireExplicitSize = require("./require-explicit-size");
48+
const useBaseLine = require("./use-baseline");
4849
// import new rule here ↑
4950
// DO NOT REMOVE THIS COMMENT
5051

@@ -96,6 +97,7 @@ module.exports = {
9697
"require-input-label": requireInputLabel,
9798
"max-element-depth": maxElementDepth,
9899
"require-explicit-size": requireExplicitSize,
100+
"use-baseline": useBaseLine,
99101
// export new rule here ↑
100102
// DO NOT REMOVE THIS COMMENT
101103
};
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
};

0 commit comments

Comments
 (0)