diff --git a/lib/rules/selector-max-specificity/index.cjs b/lib/rules/selector-max-specificity/index.cjs index ca0ac359f0..86c60b7a10 100644 --- a/lib/rules/selector-max-specificity/index.cjs +++ b/lib/rules/selector-max-specificity/index.cjs @@ -3,7 +3,6 @@ 'use strict'; const selectorSpecificity = require('@csstools/selector-specificity'); -const selectors = require('../../reference/selectors.cjs'); const validateTypes = require('../../utils/validateTypes.cjs'); const flattenNestedSelectorsForRule = require('../../utils/flattenNestedSelectorsForRule.cjs'); const isStandardSyntaxRule = require('../../utils/isStandardSyntaxRule.cjs'); @@ -31,24 +30,6 @@ const meta = { */ const zeroSpecificity = () => ({ a: 0, b: 0, c: 0 }); -/** - * Calculate the sum of given specificities. - * - * @param {Specificity[]} specificities - * @returns {Specificity} - */ -const specificitySum = (specificities) => { - const sum = zeroSpecificity(); - - for (const { a, b, c } of specificities) { - sum.a += a; - sum.b += b; - sum.c += c; - } - - return sum; -}; - /** @type {import('stylelint').Rule} */ const rule = (primary, secondaryOptions) => { return (root, result) => { @@ -79,148 +60,45 @@ const rule = (primary, secondaryOptions) => { const isSelectorIgnored = (selector) => optionsMatches(secondaryOptions, 'ignoreSelectors', selector); - /** - * Calculate the specificity of a simple selector (type, attribute, class, ID, or pseudos's own value). - * - * @param {import('postcss-selector-parser').Node} node - * @returns {Specificity} - */ - const simpleSpecificity = (node) => { - if (isSelectorIgnored(node.toString())) { - return zeroSpecificity(); - } - - return selectorSpecificity.selectorSpecificity(node); - }; - - /** - * Calculate the specificity of the most specific direct child. - * - * @param {import('postcss-selector-parser').Container} node - * @returns {Specificity} - */ - const maxChildSpecificity = (node) => - node.reduce((maxSpec, child) => { - const childSpecificity = nodeSpecificity(child); // eslint-disable-line no-use-before-define - - return selectorSpecificity.compare(childSpecificity, maxSpec) > 0 ? childSpecificity : maxSpec; - }, zeroSpecificity()); - - /** - * If a `of ` (`An+B of S`) is found in the specified pseudo node, - * returns a copy of the pseudo node, ignoring a `of` selector (`An+B of S`). - * Otherwise, returns the specified node as-is. - * - * @see https://drafts.csswg.org/selectors/#the-nth-child-pseudo - * @param {import('postcss-selector-parser').Pseudo} pseudo - * @returns {import('postcss-selector-parser').Pseudo} - */ - const ignoreOfSelectorIfAny = (pseudo) => { - /** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */ - const isOfSelector = (node) => node?.type === 'tag' && node.value === 'of'; - - /** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */ - const isSpace = (node) => node?.type === 'combinator' && node.value === ' '; - - const nodes = pseudo.nodes[0]?.nodes ?? []; - const ofSelectorIndex = nodes.findIndex((child, i, children) => { - // Find ' of ' nodes - return isSpace(child) && isOfSelector(children[i + 1]) && isSpace(children[i + 2]); - }); - - const ofSelector = nodes[ofSelectorIndex + 3]; - - if (!ofSelector || !ofSelector.value) return pseudo; - - if (!isSelectorIgnored(ofSelector.value)) return pseudo; - - const copy = pseudo.clone(); - const rootSelector = copy.nodes[0]; - - if (rootSelector) { - // Remove ' of ' nodes - rootSelector.nodes = rootSelector.nodes.slice(0, ofSelectorIndex); - } - - return copy; - }; - - /** - * Calculate the specificity of a pseudo selector including own value and children. - * - * @param {import('postcss-selector-parser').Pseudo} node - * @returns {Specificity} - */ - const pseudoSpecificity = (node) => { - // `node.toString()` includes children which should be processed separately, - // so use `node.value` instead - const ownValue = node.value.toLowerCase(); - - if (ownValue === ':where') { - return zeroSpecificity(); - } - - let ownSpecificity; - - if (isSelectorIgnored(ownValue)) { - ownSpecificity = zeroSpecificity(); - } else if (selectors.aNPlusBOfSNotationPseudoClasses.has(ownValue.replace(/^:/, ''))) { - return selectorSpecificity.selectorSpecificity(ignoreOfSelectorIfAny(node)); - } else { - ownSpecificity = selectorSpecificity.selectorSpecificity(node.clone({ nodes: [] })); - } - - return specificitySum([ownSpecificity, maxChildSpecificity(node)]); - }; - - /** - * @param {import('postcss-selector-parser').Node} node - * @returns {boolean} - */ - const shouldSkipPseudoClassArgument = (node) => { - // postcss-selector-parser includes the arguments to nth-child() functions - // as "tags", so we need to ignore them ourselves. - // The fake-tag's "parent" is actually a selector node, whose parent - // should be the :nth-child pseudo node. - const parentNode = node.parent && node.parent.parent; - - if (parentNode && parentNode.type === 'pseudo' && parentNode.value) { - const pseudoClass = parentNode.value.toLowerCase().replace(/^:/, ''); - - return ( - selectors.aNPlusBNotationPseudoClasses.has(pseudoClass) || selectors.linguisticPseudoClasses.has(pseudoClass) - ); - } - - return false; - }; - - /** - * Calculate the specificity of a node parsed by `postcss-selector-parser`. - * - * @param {import('postcss-selector-parser').Node} node - * @returns {Specificity} - */ - const nodeSpecificity = (node) => { - if (shouldSkipPseudoClassArgument(node)) { - return zeroSpecificity(); - } - - switch (node.type) { - case 'attribute': - case 'class': - case 'id': - case 'tag': - return simpleSpecificity(node); - case 'pseudo': - return pseudoSpecificity(node); - case 'selector': - // Calculate the sum of all the direct children - return specificitySum(node.map((n) => nodeSpecificity(n))); - default: - return zeroSpecificity(); - } - }; + /** @type {import('@csstools/selector-specificity').CustomSpecificityCallback | undefined} */ + const customSpecificity = secondaryOptions?.ignoreSelectors + ? (node) => { + switch (node.type) { + case 'attribute': + case 'class': + case 'id': + case 'tag': + if (!isSelectorIgnored(node.toString())) { + return; + } + + return zeroSpecificity(); + case 'pseudo': { + if (!isSelectorIgnored(node.value.toLowerCase())) { + return; + } + + if (!node.nodes?.length) { + return zeroSpecificity(); + } + + // We only ignore the current pseudo-class, not the specificity of the child nodes. + // Calculate the diff between specificity with and without child nodes. + const entireSpecificity = selectorSpecificity.selectorSpecificity(node); + + const emptySpecificity = selectorSpecificity.selectorSpecificity(node.clone({ nodes: [] })); + + return { + a: entireSpecificity.a - emptySpecificity.a, + b: entireSpecificity.b - emptySpecificity.b, + c: entireSpecificity.c - emptySpecificity.c, + }; + } + default: + // Other node types are not ignorable. + } + } + : undefined; const [a, b, c] = primary.split(',').map((s) => Number.parseFloat(s)); @@ -235,8 +113,10 @@ const rule = (primary, secondaryOptions) => { flattenNestedSelectorsForRule(ruleNode, result).forEach(({ selector, resolvedSelectors }) => { resolvedSelectors.forEach((resolvedSelector) => { + const specificity = selectorSpecificity.selectorSpecificity(resolvedSelector, { customSpecificity }); + // Check if the selector specificity exceeds the allowed maximum - if (selectorSpecificity.compare(nodeSpecificity(resolvedSelector), maxSpecificity) > 0) { + if (selectorSpecificity.compare(specificity, maxSpecificity) > 0) { const index = selector.first?.sourceIndex ?? 0; const selectorStr = selector.toString().trim(); diff --git a/lib/rules/selector-max-specificity/index.mjs b/lib/rules/selector-max-specificity/index.mjs index 611d2b40a1..4e3c0c7035 100644 --- a/lib/rules/selector-max-specificity/index.mjs +++ b/lib/rules/selector-max-specificity/index.mjs @@ -1,10 +1,5 @@ import { compare, selectorSpecificity } from '@csstools/selector-specificity'; -import { - aNPlusBNotationPseudoClasses, - aNPlusBOfSNotationPseudoClasses, - linguisticPseudoClasses, -} from '../../reference/selectors.mjs'; import { assertNumber, isRegExp, isString } from '../../utils/validateTypes.mjs'; import flattenNestedSelectorsForRule from '../../utils/flattenNestedSelectorsForRule.mjs'; import isStandardSyntaxRule from '../../utils/isStandardSyntaxRule.mjs'; @@ -32,24 +27,6 @@ const meta = { */ const zeroSpecificity = () => ({ a: 0, b: 0, c: 0 }); -/** - * Calculate the sum of given specificities. - * - * @param {Specificity[]} specificities - * @returns {Specificity} - */ -const specificitySum = (specificities) => { - const sum = zeroSpecificity(); - - for (const { a, b, c } of specificities) { - sum.a += a; - sum.b += b; - sum.c += c; - } - - return sum; -}; - /** @type {import('stylelint').Rule} */ const rule = (primary, secondaryOptions) => { return (root, result) => { @@ -80,148 +57,45 @@ const rule = (primary, secondaryOptions) => { const isSelectorIgnored = (selector) => optionsMatches(secondaryOptions, 'ignoreSelectors', selector); - /** - * Calculate the specificity of a simple selector (type, attribute, class, ID, or pseudos's own value). - * - * @param {import('postcss-selector-parser').Node} node - * @returns {Specificity} - */ - const simpleSpecificity = (node) => { - if (isSelectorIgnored(node.toString())) { - return zeroSpecificity(); - } - - return selectorSpecificity(node); - }; - - /** - * Calculate the specificity of the most specific direct child. - * - * @param {import('postcss-selector-parser').Container} node - * @returns {Specificity} - */ - const maxChildSpecificity = (node) => - node.reduce((maxSpec, child) => { - const childSpecificity = nodeSpecificity(child); // eslint-disable-line no-use-before-define - - return compare(childSpecificity, maxSpec) > 0 ? childSpecificity : maxSpec; - }, zeroSpecificity()); - - /** - * If a `of ` (`An+B of S`) is found in the specified pseudo node, - * returns a copy of the pseudo node, ignoring a `of` selector (`An+B of S`). - * Otherwise, returns the specified node as-is. - * - * @see https://drafts.csswg.org/selectors/#the-nth-child-pseudo - * @param {import('postcss-selector-parser').Pseudo} pseudo - * @returns {import('postcss-selector-parser').Pseudo} - */ - const ignoreOfSelectorIfAny = (pseudo) => { - /** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */ - const isOfSelector = (node) => node?.type === 'tag' && node.value === 'of'; - - /** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */ - const isSpace = (node) => node?.type === 'combinator' && node.value === ' '; - - const nodes = pseudo.nodes[0]?.nodes ?? []; - const ofSelectorIndex = nodes.findIndex((child, i, children) => { - // Find ' of ' nodes - return isSpace(child) && isOfSelector(children[i + 1]) && isSpace(children[i + 2]); - }); - - const ofSelector = nodes[ofSelectorIndex + 3]; - - if (!ofSelector || !ofSelector.value) return pseudo; - - if (!isSelectorIgnored(ofSelector.value)) return pseudo; - - const copy = pseudo.clone(); - const rootSelector = copy.nodes[0]; - - if (rootSelector) { - // Remove ' of ' nodes - rootSelector.nodes = rootSelector.nodes.slice(0, ofSelectorIndex); - } - - return copy; - }; - - /** - * Calculate the specificity of a pseudo selector including own value and children. - * - * @param {import('postcss-selector-parser').Pseudo} node - * @returns {Specificity} - */ - const pseudoSpecificity = (node) => { - // `node.toString()` includes children which should be processed separately, - // so use `node.value` instead - const ownValue = node.value.toLowerCase(); - - if (ownValue === ':where') { - return zeroSpecificity(); - } - - let ownSpecificity; - - if (isSelectorIgnored(ownValue)) { - ownSpecificity = zeroSpecificity(); - } else if (aNPlusBOfSNotationPseudoClasses.has(ownValue.replace(/^:/, ''))) { - return selectorSpecificity(ignoreOfSelectorIfAny(node)); - } else { - ownSpecificity = selectorSpecificity(node.clone({ nodes: [] })); - } - - return specificitySum([ownSpecificity, maxChildSpecificity(node)]); - }; - - /** - * @param {import('postcss-selector-parser').Node} node - * @returns {boolean} - */ - const shouldSkipPseudoClassArgument = (node) => { - // postcss-selector-parser includes the arguments to nth-child() functions - // as "tags", so we need to ignore them ourselves. - // The fake-tag's "parent" is actually a selector node, whose parent - // should be the :nth-child pseudo node. - const parentNode = node.parent && node.parent.parent; - - if (parentNode && parentNode.type === 'pseudo' && parentNode.value) { - const pseudoClass = parentNode.value.toLowerCase().replace(/^:/, ''); - - return ( - aNPlusBNotationPseudoClasses.has(pseudoClass) || linguisticPseudoClasses.has(pseudoClass) - ); - } - - return false; - }; - - /** - * Calculate the specificity of a node parsed by `postcss-selector-parser`. - * - * @param {import('postcss-selector-parser').Node} node - * @returns {Specificity} - */ - const nodeSpecificity = (node) => { - if (shouldSkipPseudoClassArgument(node)) { - return zeroSpecificity(); - } - - switch (node.type) { - case 'attribute': - case 'class': - case 'id': - case 'tag': - return simpleSpecificity(node); - case 'pseudo': - return pseudoSpecificity(node); - case 'selector': - // Calculate the sum of all the direct children - return specificitySum(node.map((n) => nodeSpecificity(n))); - default: - return zeroSpecificity(); - } - }; + /** @type {import('@csstools/selector-specificity').CustomSpecificityCallback | undefined} */ + const customSpecificity = secondaryOptions?.ignoreSelectors + ? (node) => { + switch (node.type) { + case 'attribute': + case 'class': + case 'id': + case 'tag': + if (!isSelectorIgnored(node.toString())) { + return; + } + + return zeroSpecificity(); + case 'pseudo': { + if (!isSelectorIgnored(node.value.toLowerCase())) { + return; + } + + if (!node.nodes?.length) { + return zeroSpecificity(); + } + + // We only ignore the current pseudo-class, not the specificity of the child nodes. + // Calculate the diff between specificity with and without child nodes. + const entireSpecificity = selectorSpecificity(node); + + const emptySpecificity = selectorSpecificity(node.clone({ nodes: [] })); + + return { + a: entireSpecificity.a - emptySpecificity.a, + b: entireSpecificity.b - emptySpecificity.b, + c: entireSpecificity.c - emptySpecificity.c, + }; + } + default: + // Other node types are not ignorable. + } + } + : undefined; const [a, b, c] = primary.split(',').map((s) => Number.parseFloat(s)); @@ -236,8 +110,10 @@ const rule = (primary, secondaryOptions) => { flattenNestedSelectorsForRule(ruleNode, result).forEach(({ selector, resolvedSelectors }) => { resolvedSelectors.forEach((resolvedSelector) => { + const specificity = selectorSpecificity(resolvedSelector, { customSpecificity }); + // Check if the selector specificity exceeds the allowed maximum - if (compare(nodeSpecificity(resolvedSelector), maxSpecificity) > 0) { + if (compare(specificity, maxSpecificity) > 0) { const index = selector.first?.sourceIndex ?? 0; const selectorStr = selector.toString().trim(); diff --git a/package-lock.json b/package-lock.json index 76b512ca38..f3b4a86f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@csstools/css-parser-algorithms": "^2.6.3", "@csstools/css-tokenizer": "^2.3.1", "@csstools/media-query-list-parser": "^2.1.11", - "@csstools/selector-specificity": "^3.0.3", + "@csstools/selector-specificity": "^3.1.0", "@dual-bundle/import-meta-resolve": "^4.1.0", "balanced-match": "^2.0.0", "colord": "^2.9.3", @@ -1186,9 +1186,9 @@ } }, "node_modules/@csstools/selector-specificity": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.0.3.tgz", - "integrity": "sha512-KEPNw4+WW5AVEIyzC80rTbWEUatTW2lXpN8+8ILC8PiPeWPjwUzrPZDIOZ2wwqDmeqOYTdSGyL3+vE5GC3FB3Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.0.tgz", + "integrity": "sha512-tGDFEHZ4XJeIt5NF7/nAfLGqPckmDZSnYne5gl67p4agQolE5s4rofdQ3e+VkeukfR91lVtSQ/Jt9DqM1ICiIQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 9b66c10ffd..f054c0cd21 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@csstools/css-parser-algorithms": "^2.6.3", "@csstools/css-tokenizer": "^2.3.1", "@csstools/media-query-list-parser": "^2.1.11", - "@csstools/selector-specificity": "^3.0.3", + "@csstools/selector-specificity": "^3.1.0", "@dual-bundle/import-meta-resolve": "^4.1.0", "balanced-match": "^2.0.0", "colord": "^2.9.3",