Skip to content

Commit 34cdc97

Browse files
authored
Fix selector-max-specificity false positives with ignoreSelectors option for of <selector> syntax (#7475)
```json {"ignoreSelectors": [".foo"]} ``` ```css :nth-child(even of .foo) {} /* ↓ Calculates specificity ignoring 'of .foo' in `ignoreSelectors` */ :nth-child(even) {} ```
1 parent 6a4397c commit 34cdc97

File tree

4 files changed

+97
-7
lines changed

4 files changed

+97
-7
lines changed

.changeset/gold-donkeys-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"stylelint": patch
3+
---
4+
5+
Fixed: `selector-max-specificity` false positives with `ignoreSelectors` option for `of <selector>` syntax

lib/rules/selector-max-specificity/__tests__/index.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,6 @@ testRule({
461461
},
462462
{
463463
code: ':nth-child(even of my-tag) {}',
464-
skip: true, // see #6615
465464
},
466465
],
467466

lib/rules/selector-max-specificity/index.cjs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,18 @@ const rule = (primary, secondaryOptions) => {
7777
return;
7878
}
7979

80+
/** @type {(selector: string) => boolean} */
81+
const isSelectorIgnored = (selector) =>
82+
optionsMatches(secondaryOptions, 'ignoreSelectors', selector);
83+
8084
/**
8185
* Calculate the specificity of a simple selector (type, attribute, class, ID, or pseudos's own value).
8286
*
8387
* @param {import('postcss-selector-parser').Node} node
8488
* @returns {Specificity}
8589
*/
8690
const simpleSpecificity = (node) => {
87-
if (optionsMatches(secondaryOptions, 'ignoreSelectors', node.toString())) {
91+
if (isSelectorIgnored(node.toString())) {
8892
return zeroSpecificity();
8993
}
9094

@@ -104,6 +108,45 @@ const rule = (primary, secondaryOptions) => {
104108
return selectorSpecificity.compare(childSpecificity, maxSpec) > 0 ? childSpecificity : maxSpec;
105109
}, zeroSpecificity());
106110

111+
/**
112+
* If a `of <selector>` (`An+B of S`) is found in the specified pseudo node,
113+
* returns a copy of the pseudo node, ignoring a `of` selector (`An+B of S`).
114+
* Otherwise, returns the specified node as-is.
115+
*
116+
* @see https://drafts.csswg.org/selectors/#the-nth-child-pseudo
117+
* @param {import('postcss-selector-parser').Pseudo} pseudo
118+
* @returns {import('postcss-selector-parser').Pseudo}
119+
*/
120+
const ignoreOfSelectorIfAny = (pseudo) => {
121+
/** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */
122+
const isOfSelector = (node) => node?.type === 'tag' && node.value === 'of';
123+
124+
/** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */
125+
const isSpace = (node) => node?.type === 'combinator' && node.value === ' ';
126+
127+
const nodes = pseudo.nodes[0]?.nodes ?? [];
128+
const ofSelectorIndex = nodes.findIndex((child, i, children) => {
129+
// Find ' of <selector>' nodes
130+
return isSpace(child) && isOfSelector(children[i + 1]) && isSpace(children[i + 2]);
131+
});
132+
133+
const ofSelector = nodes[ofSelectorIndex + 3];
134+
135+
if (!ofSelector || !ofSelector.value) return pseudo;
136+
137+
if (!isSelectorIgnored(ofSelector.value)) return pseudo;
138+
139+
const copy = pseudo.clone();
140+
const rootSelector = copy.nodes[0];
141+
142+
if (rootSelector) {
143+
// Remove ' of <selector>' nodes
144+
rootSelector.nodes = rootSelector.nodes.slice(0, ofSelectorIndex);
145+
}
146+
147+
return copy;
148+
};
149+
107150
/**
108151
* Calculate the specificity of a pseudo selector including own value and children.
109152
*
@@ -121,10 +164,10 @@ const rule = (primary, secondaryOptions) => {
121164

122165
let ownSpecificity;
123166

124-
if (optionsMatches(secondaryOptions, 'ignoreSelectors', ownValue)) {
167+
if (isSelectorIgnored(ownValue)) {
125168
ownSpecificity = zeroSpecificity();
126169
} else if (selectors.aNPlusBOfSNotationPseudoClasses.has(ownValue.replace(/^:/, ''))) {
127-
return selectorSpecificity.selectorSpecificity(node);
170+
return selectorSpecificity.selectorSpecificity(ignoreOfSelectorIfAny(node));
128171
} else {
129172
ownSpecificity = selectorSpecificity.selectorSpecificity(node.clone({ nodes: [] }));
130173
}

lib/rules/selector-max-specificity/index.mjs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,18 @@ const rule = (primary, secondaryOptions) => {
7878
return;
7979
}
8080

81+
/** @type {(selector: string) => boolean} */
82+
const isSelectorIgnored = (selector) =>
83+
optionsMatches(secondaryOptions, 'ignoreSelectors', selector);
84+
8185
/**
8286
* Calculate the specificity of a simple selector (type, attribute, class, ID, or pseudos's own value).
8387
*
8488
* @param {import('postcss-selector-parser').Node} node
8589
* @returns {Specificity}
8690
*/
8791
const simpleSpecificity = (node) => {
88-
if (optionsMatches(secondaryOptions, 'ignoreSelectors', node.toString())) {
92+
if (isSelectorIgnored(node.toString())) {
8993
return zeroSpecificity();
9094
}
9195

@@ -105,6 +109,45 @@ const rule = (primary, secondaryOptions) => {
105109
return compare(childSpecificity, maxSpec) > 0 ? childSpecificity : maxSpec;
106110
}, zeroSpecificity());
107111

112+
/**
113+
* If a `of <selector>` (`An+B of S`) is found in the specified pseudo node,
114+
* returns a copy of the pseudo node, ignoring a `of` selector (`An+B of S`).
115+
* Otherwise, returns the specified node as-is.
116+
*
117+
* @see https://drafts.csswg.org/selectors/#the-nth-child-pseudo
118+
* @param {import('postcss-selector-parser').Pseudo} pseudo
119+
* @returns {import('postcss-selector-parser').Pseudo}
120+
*/
121+
const ignoreOfSelectorIfAny = (pseudo) => {
122+
/** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */
123+
const isOfSelector = (node) => node?.type === 'tag' && node.value === 'of';
124+
125+
/** @type {(node: import('postcss-selector-parser').Node | undefined) => boolean} */
126+
const isSpace = (node) => node?.type === 'combinator' && node.value === ' ';
127+
128+
const nodes = pseudo.nodes[0]?.nodes ?? [];
129+
const ofSelectorIndex = nodes.findIndex((child, i, children) => {
130+
// Find ' of <selector>' nodes
131+
return isSpace(child) && isOfSelector(children[i + 1]) && isSpace(children[i + 2]);
132+
});
133+
134+
const ofSelector = nodes[ofSelectorIndex + 3];
135+
136+
if (!ofSelector || !ofSelector.value) return pseudo;
137+
138+
if (!isSelectorIgnored(ofSelector.value)) return pseudo;
139+
140+
const copy = pseudo.clone();
141+
const rootSelector = copy.nodes[0];
142+
143+
if (rootSelector) {
144+
// Remove ' of <selector>' nodes
145+
rootSelector.nodes = rootSelector.nodes.slice(0, ofSelectorIndex);
146+
}
147+
148+
return copy;
149+
};
150+
108151
/**
109152
* Calculate the specificity of a pseudo selector including own value and children.
110153
*
@@ -122,10 +165,10 @@ const rule = (primary, secondaryOptions) => {
122165

123166
let ownSpecificity;
124167

125-
if (optionsMatches(secondaryOptions, 'ignoreSelectors', ownValue)) {
168+
if (isSelectorIgnored(ownValue)) {
126169
ownSpecificity = zeroSpecificity();
127170
} else if (aNPlusBOfSNotationPseudoClasses.has(ownValue.replace(/^:/, ''))) {
128-
return selectorSpecificity(node);
171+
return selectorSpecificity(ignoreOfSelectorIfAny(node));
129172
} else {
130173
ownSpecificity = selectorSpecificity(node.clone({ nodes: [] }));
131174
}

0 commit comments

Comments
 (0)