Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor selector-max-specificity to leverage upstream helper #7689

204 changes: 42 additions & 162 deletions lib/rules/selector-max-specificity/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<string>} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
Expand Down Expand Up @@ -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<string | undefined>} 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 <selector>` (`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 <selector>' 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 <selector>' 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));

Expand All @@ -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();

Expand Down