From 0caf2e6f8b3035657006a22660e64c51bfff285c Mon Sep 17 00:00:00 2001 From: Wahid Rizka Fathurrohman Date: Mon, 12 Oct 2026 23:30:15 +0700 Subject: [PATCH] Keep all parent selectors inside :not() With a selector list as the parent, & inside :not() was replaced with one parent selector per rule, so `.a, .b { .x:not(&) {} }` gave `.x:not(.a), .x:not(.b)`, which also matches `.x.a`. Sass and native CSS nesting put the whole list inside: `.x:not(.a, .b)`. --- index.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++- index.test.js | 13 ++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 22c2e11..81a6f6d 100644 --- a/index.js +++ b/index.js @@ -53,6 +53,53 @@ function interpolateAmpInSelector(nodes, parent) { return replaced } +/** + * Pseudo-classes which take a selector list + */ +const SELECTOR_LISTS = new Set([':has', ':is', ':not', ':where']) + +/** + * Replaces "&" inside :not() with the whole parent selector list, like Sass. + * A copy for each parent selector would turn "neither .a nor .b" + * into "not .a or not .b". + * + * Mutates the nodes list + */ +function replaceAmpsInNot(nodes, parents, negated = false) { + let replaced = false + nodes.each(node => { + if (node.type !== 'pseudo') { + return + } + let name = node.value.toLowerCase() + let inside = negated || name === ':not' + node.each(selector => { + if (replaceAmpsInNot(selector, parents, inside)) { + replaced = true + } + let amps = selector.nodes.filter(i => i.type === 'nesting') + if (!inside || !SELECTOR_LISTS.has(name) || amps.length === 0) { + return + } + let combos = [[]] + for (let i = 0; i < amps.length; i++) { + combos = combos.flatMap(combo => parents.map(p => [...combo, p])) + } + let list = combos.map(combo => { + let copy = selector.clone() + copy.nodes + .filter(i => i.type === 'nesting') + .forEach((amp, i) => replaceAmp(amp, combo[i])) + return copy.toString().trim() + }) + let space = node.index(selector) > 0 ? ' ' : '' + selector.replaceWith(...parser().astSync(space + list.join(', ')).nodes) + replaced = true + }) + }) + return replaced +} + /** * Combines parent and child selectors, in a SCSS-like way */ @@ -65,6 +112,15 @@ function mergeSelectors(parent, child) { continue } let node = parse(selector, child) + let replacedInNot = replaceAmpsInNot(node, parents) + let ampsLeft = false + node.walkNesting(() => { + ampsLeft = true + }) + // Without "&" outside :not(), every parent selector gives the same result + if (replacedInNot && !ampsLeft && parentNode !== parents[0]) { + continue + } let amps = node.nodes.filter(i => i.type === 'nesting') if (amps.length > 1) { // Like in Sass, every "&" after the first one takes each parent selector @@ -82,7 +138,7 @@ function mergeSelectors(parent, child) { } continue } - let replaced = interpolateAmpInSelector(node, parentNode) + let replaced = interpolateAmpInSelector(node, parentNode) || replacedInNot if (!replaced) { node.prepend(parser.combinator({ value: ' ' })) node.prepend(parentNode.clone({})) diff --git a/index.test.js b/index.test.js index ae3719b..0d219f3 100644 --- a/index.test.js +++ b/index.test.js @@ -729,6 +729,19 @@ test('replaces ampersands in not selector', () => { run('.a { &:not(&.no) {} }', '.a:not(.a.no) {}') }) +test('keeps all parent selectors inside not selector', () => { + run('.a, .b { .x:not(&) {} }', '.x:not(.a, .b) {}') + run( + '.a, .b { &:not(&.on) {} }', + '.a:not(.a.on, .b.on), .b:not(.a.on, .b.on) {}' + ) + run( + '.a, .b { .x:not(.z, &), .y:not(:is(&)), .w {} }', + '.x:not(.z, .a, .b), .y:not(:is(.a, .b)), .a .w, .b .w {}' + ) + run('.a, .b { .x:is(&) {} }', '.x:is(.a), .x:is(.b) {}') +}) + test('correctly replaces tail ampersands', () => { run('.a { .b & {} }', '.b .a {}') })