config/stylelint-selector-indexing.mjs
Rama main · réplica de lectura del repositorio original.
import { createHash } from "node:crypto";
import path from "node:path";
import selectorParser from "postcss-selector-parser";
import stylelint from "stylelint";
const ruleName = "openclaw/no-unindexed-selector-list";
const messages = stylelint.utils.ruleMessages(ruleName, {
/**
* @param {string} selector
* @param {string} id
*/
rejected: (selector, id) =>
`Split the unkeyed rightmost selector list in "${selector}" into separately indexed selectors (id=${id})`,
/**
* @param {string} id
* @param {number} observed
* @param {number} allowed
*/
stale: (id, observed, allowed) =>
`Prune selector allowance ${id}: found ${observed}, recorded ${allowed}`,
});
const canonical = (selector) => selectorParser().processSync(selector, { lossless: false });
function subjectNodes(selector) {
return selector.nodes.slice(
selector.nodes.findLastIndex((node) => node.type === "combinator") + 1,
);
}
function hasSubjectKey(nodes) {
return nodes.some((node) => {
if (["class", "id", "tag", "attribute", "nesting"].includes(node.type)) {
return true;
}
return (
node.type === "pseudo" &&
[":is", ":where"].includes(node.value.toLowerCase()) &&
node.nodes?.length === 1 &&
hasSubjectKey(subjectNodes(node.nodes[0]))
);
});
}
// Plain, unescaped identifiers exclude malformed forgiving-list members and
// namespace syntax. Escaped identifiers remain untouched rather than guessed.
const identifier = /^(?:--|-?[_a-zA-Z\u0080-\uFFFF])[_a-zA-Z0-9\u0080-\uFFFF-]*$/u;
function simpleArm(arm) {
// Keep this intervention on class/ID buckets; type and universal subjects
// need their own byte/performance qualification.
const nodes = arm.nodes.filter((node) => node.type !== "comment");
if (!nodes.length || nodes.some((node) => !["class", "id"].includes(node.type))) {
return false;
}
return nodes.every((node) => {
if (!identifier.test(node.value)) {
return false;
}
const prefix = node.type === "class" ? "." : "#";
return node.toString().trim() === `${prefix}${node.value}`;
});
}
function specificity(arm) {
const value = [0, 0];
for (const node of arm.nodes) {
if (node.type === "id") {
value[0]++;
}
if (node.type === "class") {
value[1]++;
}
}
return value.join(",");
}
function unindexedSelectorLists(text) {
return selectorParser()
.astSync(text)
.nodes.filter((selector) => {
const subject = subjectNodes(selector);
if (hasSubjectKey(subject)) {
return false;
}
return subject.some(
(node) =>
node.type === "pseudo" &&
[":is", ":where"].includes(node.value.toLowerCase()) &&
node.nodes?.length > 1 &&
node.nodes.every(simpleArm) &&
(node.value.toLowerCase() === ":where" ||
node.nodes.every((arm) => specificity(arm) === specificity(node.nodes[0]))),
);
})
.map((selector) => selector.toString());
}
const rule =
(enabled, options = {}) =>
(root, result) => {
if (!stylelint.utils.validateOptions(result, ruleName, { actual: enabled, possible: [true] })) {
return;
}
const filename = root.source?.input.file;
const file = filename ? path.relative(process.cwd(), filename).split(path.sep).join("/") : "";
const allowances = options.allowances?.[file] ?? {};
const observed = new Map();
root.walkRules((node) => {
for (const selector of unindexedSelectorLists(node.selector)) {
const context = [canonical(selector)];
for (let parent = node.parent; parent; parent = parent.parent) {
if (parent.type === "rule") {
context.unshift(canonical(parent.selector));
}
}
const id = createHash("sha256").update(JSON.stringify(context)).digest("hex");
const count = (observed.get(id) ?? 0) + 1;
observed.set(id, count);
if (count <= (allowances[id] ?? 0)) {
continue;
}
stylelint.utils.report({
ruleName,
result,
node,
message: messages.rejected(context.join(" / "), id),
word: selector,
});
}
});
// Deletions reduce the allowance in the same change; an old count cannot be
// reused by a later duplicate of the same selector. New selectors never borrow
// another selector's or another file's allowance.
for (const [id, count] of Object.entries(allowances)) {
if ((observed.get(id) ?? 0) >= count) {
continue;
}
stylelint.utils.report({
ruleName,
result,
node: root,
message: messages.stale(id, observed.get(id) ?? 0, count),
});
}
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = { url: "https://github.com/openclaw/openclaw/tree/main/config" };
export default stylelint.createPlugin(ruleName, rule);