diff --git a/README.md b/README.md index 9d9a1d9..d15c01e 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,13 @@ export default [ -| **Rule Name** | **Description** | **Recommended** | -| :--------------------------------------------------------------- | :------------------------------- | :-------------: | -| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules | yes | -| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks | yes | -| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | -| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | +| **Rule Name** | **Description** | **Recommended** | +| :--------------------------------------------------------------- | :----------------------------------------------- | :-------------: | +| [`max-specificity`](./docs/rules/max-specificity.md) | Enforce the maximum specificity of CSS selectors | no | +| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules | yes | +| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks | yes | +| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | +| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | diff --git a/src/index.js b/src/index.js index b601722..c0c7721 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import noEmptyBlocks from "./rules/no-empty-blocks.js"; import noDuplicateImports from "./rules/no-duplicate-imports.js"; import noInvalidProperties from "./rules/no-invalid-properties.js"; import noInvalidAtRules from "./rules/no-invalid-at-rules.js"; +import maxSpecificity from "./rules/max-specificity.js"; //----------------------------------------------------------------------------- // Plugin @@ -31,6 +32,7 @@ const plugin = { "no-duplicate-imports": noDuplicateImports, "no-invalid-at-rules": noInvalidAtRules, "no-invalid-properties": noInvalidProperties, + "max-specificity": maxSpecificity, }, configs: { recommended: { diff --git a/src/rules/max-specificity.js b/src/rules/max-specificity.js new file mode 100644 index 0000000..d8d7b79 --- /dev/null +++ b/src/rules/max-specificity.js @@ -0,0 +1,130 @@ +/** + * @fileoverview Rule to enforce the maximum specificity of CSS selectors. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("css-tree").SelectorPlain} SelectorPlain */ +/** + * @typedef {object} Specificity + * @property {number} a The number of ID selectors in the selector. + * @property {number} b The number of class selectors, attribute selectors, and pseudo-classes in the selector. + * @property {number} c The number of type selectors and pseudo-elements in the selector. + */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Calculates the specificity of a CSS selector. + * @param {SelectorPlain} selector The selector to calculate specificity for. + * @returns {Specificity} An object with the properties `a`, `b`, and `c` representing the specificity. + */ +function calculateSpecificity(selector) { + let a = 0; + let b = 0; + let c = 0; + + selector.children.forEach(node => { + // perf: Checking short strings is faster than checking long strings + + // IdSelector + if (node.type.startsWith("Id")) { + a += 1; + return; + } + + // AttributeSelector, ClassSelector, PseudoClassSelector + if ( + node.type.startsWith("Att") || + node.type.startsWith("Class") || + node.type.startsWith("PseudoClass") + ) { + b += 1; + return; + } + + // TypeSelector, PseudoElementSelector + if ( + node.type.startsWith("Type") || + node.type.startsWith("PseudoElement") + ) { + c += 1; + } + }); + + return { a, b, c }; +} + +/** + * Determines if one specificity is greater than another. + * @param {Specificity} a The first specificity to compare. + * @param {Specificity} b The second specificity to compare. + * @returns {number} -1 if `a` is less than `b`, 0 if they are equal, 1 if `a` is greater than `b`. + */ +function compareSpecificity(a, b) { + if (a.a !== b.a) { + return a.a - b.a; + } + if (a.b !== b.b) { + return a.b - b.b; + } + return a.c - b.c; +} + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: /** @type {const} */ ("problem"), + + docs: { + description: "Enforce the maximum specificity of CSS selectors", + }, + + messages: { + unexpectedSpecificity: + "Specificity of [{{actual}}] exceeds maximum specificity of [{{expected}}].", + }, + + schema: { + type: "array", + items: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" }, + c: { type: "number" }, + }, + additionalProperties: false, + }, + minItems: 1, + maxItems: 1, + }, + }, + + create(context) { + return { + Selector(node) { + const specificity = calculateSpecificity(node); + + if (compareSpecificity(specificity, context.options[0]) > 0) { + context.report({ + node, + messageId: "unexpectedSpecificity", + data: { + actual: `${specificity.a},${specificity.b},${specificity.c}`, + expected: `${context.options[0].a},${context.options[0].b},${context.options[0].c}`, + }, + }); + } + }, + }; + }, +}; diff --git a/tests/rules/max-specificity.test.js b/tests/rules/max-specificity.test.js new file mode 100644 index 0000000..19ee139 --- /dev/null +++ b/tests/rules/max-specificity.test.js @@ -0,0 +1,51 @@ +/** + * @fileoverview Tests for max-specificity rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/max-specificity.js"; +import css from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + css, + }, + language: "css/css", +}); + +ruleTester.run("max-specificity", rule, { + valid: [ + { + code: "a { color: red; }", + options: [{ a: 0, b: 1, c: 0 }], + }, + ], + invalid: [ + { + code: "a { color: bar }", + options: [{ a: 0, b: 0, c: 0 }], + errors: [ + { + messageId: "unexpectedSpecificity", + data: { + actual: "0,0,1", + expected: "0,0,0", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 2, + }, + ], + }, + ], +});