WIP ESLint rule to require 'data-*' attribute based selectors

This commit is contained in:
Keavon Chambers 2024-04-26 12:13:07 -07:00
parent 44ffb635e9
commit 0fbff9f96f
5 changed files with 1423 additions and 0 deletions

View file

@ -0,0 +1 @@
node_modules/

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
{
"name": "eslint-require-data-selectors",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "node require-data-selectors.test.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"eslint": "^8.57.0"
}
}

View file

@ -0,0 +1,132 @@
// Custom rule docs <https://eslint.org/docs/latest/extend/custom-rule-tutorial>
//
// Helpful tool 1 <https://astexplorer.net/>:
//
// document.querySelector("[data-foo]");
// window.body.querySelector("[data-foo]", 456, true);
//
// Helpful tool 2 <https://estools.github.io/esquery/> (querying for `[id.name="bar"]`):
//
// document.querySelector("[data-foo]");
// window.body.querySelectorAll("[data-foo] [foo]").closest(null).matches("#bar", 123);
module.exports = {
meta: {
type: "problem",
docs: {
description:
"Enforce that CSS selector strings used with `querySelector`, `querySelectorAll`, `closest`, and `matches` only contains `data-*` attributes and disallow `getElementsBy*` APIs.",
},
schema: [],
},
create: (context) => ({
// After <https://github.com/estools/esquery/issues/132> was fixed, we were able to add the `> ` direct child combinator to the start of the `:has()` pseudo-selector (before `MemberExpression`) which was needed for better correctness. But this alteration hasn't been tested yet. This comment can be deleted after testing it.
"CallExpression:has(> MemberExpression > Identifier[name=/^querySelector(All)?|closest|matches$/]) > .arguments:first-child:last-child[raw=/^\".*\"$/]": (node) => {
const split = deepAssemble(buildParenthesesTree(node.value));
if (split === undefined) {
context.report({
node,
message: "Unbalanced parentheses in CSS selector string.",
});
} else {
// Split between selector combinators
const list = node.value.split(/ +| *> *| *~ *| *\+ *|(?<=.)(?=\[data-.+\])/).filter((s) => s !== "");
// console.log(list);
const valid = list.every((s) => /^\[data-[^\]]+\]$/.test(s));
if (!valid) {
context.report({
node,
message: "CSS selector strings should only contain `data-*` attributes and avoid tag type, class, ID, or non-data attribute selectors.",
});
}
}
},
// TODO: Disallow `getElements?By*` APIs.
}),
};
function* cartesian_product(head, ...tail) {
const remainder = tail.length > 0 ? cartesian_product(...tail) : [[]];
for (let r of remainder) for (let h of head) yield [h, ...r];
}
function deepAssemble(sequence) {
const assembled = assemble(sequence);
console.log(assembled);
console.log("============");
assembled.forEach((group) => {
// TODO: Flatten contiguous arrays
// group.forEach((item) => {
// if (Array.isArray(item)) {
// const result = assemble(item);
// result.forEach((x) => {
// console.log("SUB:", x);
// })
// } else {
// console.log("TOP:", item);
// }
// });
console.log(group);
});
}
function assemble(sequence) {
const output = [];
const flatSequence = sequence.flat();
for (let i = 0; i < flatSequence.length; i++) {
const item = flatSequence[i];
const previous = output[output.length - 1];
const previousLast = previous?.[previous.length - 1];
// Append to the last group if this item is an array, the previous group's last item was an array, or the previous group's last item ended with an open parenthesis
if (Array.isArray(item) || Array.isArray(previousLast) || previousLast?.endsWith("(")) {
output[output.length - 1].push(item);
} else {
output.push([item]);
}
}
return output;
}
function buildParenthesesTree(givenString) {
// Preprocess commas for multiple selectors, but be careful about commas within pseudo-selectors:
//
// a, div:not(.fun, .boring), img, foo > .bar ~ :has(> div, span:is([data-red], [data-blue] .bold:not(&.italic), .italic)), qux:not(.foo):not(.bar)
// ================================================================================================================================================
// a, div:not( ), img, foo > .bar ~ :has( ), qux:not( ):not( )
// .fun, .boring > div, span:is( ) .foo .bar
// [data-red], [data-blue] .bold:not( ), .italic
// &.italic
let balancedCount = 0;
let stringGroups = [""];
const balanced = Array.from(givenString).every((c) => {
if (c === ")") balancedCount -= 1;
if (balancedCount < 0) return false;
if (c === "(" && balancedCount === 0) {
stringGroups[stringGroups.length - 1] += "(";
stringGroups.push("");
} else if (c === ")" && balancedCount === 0) {
stringGroups.push(")");
} else {
stringGroups[stringGroups.length - 1] += c;
}
if (c === "(") balancedCount += 1;
return true;
});
if (!balanced || balancedCount !== 0) return undefined;
return stringGroups.map((text, i) => i % 2 === 1 ? buildParenthesesTree(text) : text.split(/ *, */));
}
debugger;

View file

@ -0,0 +1,36 @@
const { RuleTester } = require("eslint");
const myRule = require("./require-data-selectors");
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2015 } });
ruleTester.run(
"require-data-selectors",
myRule,
{
valid: [
{ code: `document.querySelector("[data-foo]")` },
{ code: `document.querySelector("[data-foo] [data-bar]")` },
{ code: `document.querySelector("[data-foo] > [data-bar]")` },
{ code: `document.querySelector("[data-foo]>[data-bar]")` },
{ code: `document.querySelector("[data-foo] ~ [data-bar]")` },
{ code: `document.querySelector("[data-foo]~[data-bar]")` },
{ code: `document.querySelector("[data-foo] + [data-bar]")` },
{ code: `document.querySelector("[data-foo]+[data-bar]")` },
{ code: `document.querySelector(" [data-foo][data-bar] ")` },
],
invalid: [
{ code: `document.querySelector(".foo")`, errors: 1 },
{ code: `document.querySelector("div")`, errors: 1 },
{ code: `document.querySelector("div.foo")`, errors: 1 },
{ code: `document.querySelector("#foo")`, errors: 1 },
{ code: `document.querySelector("div#foo")`, errors: 1 },
{ code: `document.querySelector("div#foo.bar")`, errors: 1 },
{ code: `document.querySelector("foo[data-bar]")`, errors: 1 },
{ code: `document.querySelector("[foo][data-bar]")`, errors: 1 },
{ code: `document.querySelector("[title]")`, errors: 1 },
{ code: `document.querySelector("foo[title]")`, errors: 1 },
],
}
);
console.log("All tests passed!");