From 8c26e52ab246d03c3af93dff202315360fdafc74 Mon Sep 17 00:00:00 2001 From: Wahid Rizka Fathurrohman Date: Mon, 12 Oct 2026 09:53:05 +0700 Subject: [PATCH] Combine every & with each parent selector A nested selector with more than one & got the same parent selector for every &, so `.a, .b { & + & {} }` gave `.a + .a, .b + .b` and dropped `.a + .b` and `.b + .a`. Sass and native CSS nesting match all four. --- index.js | 43 ++++++++++++++++++++++++++++++++----------- index.test.js | 5 +++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index e2c9b30..22c2e11 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,18 @@ function parse(rawSelector, rule) { return nodes.at(0) } +/** + * Replaces one "&" token with the parent selector + */ +function replaceAmp(node, parent) { + let clonedParent = parent.clone({}) + if (node.value !== '&') { + node.replaceWith(parse(node.value.replace('&', clonedParent.toString()))) + } else { + node.replaceWith(clonedParent) + } +} + /** * Replaces the "&" token in a node's selector with the parent selector * similar to what SCSS does. @@ -30,14 +42,7 @@ function interpolateAmpInSelector(nodes, parent) { let replaced = false nodes.each(node => { if (node.type === 'nesting') { - let clonedParent = parent.clone({}) - if (node.value !== '&') { - node.replaceWith( - parse(node.value.replace('&', clonedParent.toString())) - ) - } else { - node.replaceWith(clonedParent) - } + replaceAmp(node, parent) replaced = true } else if ('nodes' in node && node.nodes) { if (interpolateAmpInSelector(node, parent)) { @@ -53,14 +58,30 @@ function interpolateAmpInSelector(nodes, parent) { */ function mergeSelectors(parent, child) { let merged = [] - for (let sel of parent.selectors) { - let parentNode = parse(sel, parent) - + let parents = parent.selectors.map(sel => parse(sel, parent)) + for (let parentNode of parents) { for (let selector of child.selectors) { if (!selector) { continue } let node = parse(selector, child) + 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 + let combos = [[parentNode]] + for (let i = 1; i < amps.length; i++) { + combos = combos.flatMap(combo => parents.map(p => [...combo, p])) + } + for (let combo of combos) { + let copy = node.clone() + copy.nodes + .filter(i => i.type === 'nesting') + .forEach((amp, i) => replaceAmp(amp, combo[i])) + interpolateAmpInSelector(copy, parentNode) + merged.push(copy.toString()) + } + continue + } let replaced = interpolateAmpInSelector(node, parentNode) if (!replaced) { node.prepend(parser.combinator({ value: ' ' })) diff --git a/index.test.js b/index.test.js index b1ad386..a567f51 100644 --- a/index.test.js +++ b/index.test.js @@ -716,6 +716,11 @@ test('replaces ampersand in adjacent sibling selector', () => { run('div { & + & {} }', 'div + div {}') }) +test('replaces each ampersand with every parent selector', () => { + run('.a, .b { & + & {} }', '.a + .a, .a + .b, .b + .a, .b + .b {}') + run('.a, .b { &-x ~ & {} }', '.a-x ~ .a, .a-x ~ .b, .b-x ~ .a, .b-x ~ .b {}') +}) + test('replaces ampersands in not selector', () => { run('.a { &:not(&.no) {} }', '.a:not(.a.no) {}') })