Skip to content

Commit

Permalink
Fix @apply selector rewriting when multiple classes are involved (#…
Browse files Browse the repository at this point in the history
…9107)

* Rewrite `replaceSelector` using `postcss-selector-parser`

* Sort classes between tags and pseudos when rewriting selectors

* Update changelog
  • Loading branch information
thecrypticace committed Aug 15, 2022
1 parent b0018e2 commit ef74fd3
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 19 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Use absolute paths when resolving changed files for resilience against working directory changes ([#9032](https://github.com/tailwindlabs/tailwindcss/pull/9032))
- Fix ring color utility generation when using `respectDefaultRingColorOpacity` ([#9070](https://github.com/tailwindlabs/tailwindcss/pull/9070))
- Replace all occurrences of a class in a selector when using `@apply` ([#9107](https://github.com/tailwindlabs/tailwindcss/pull/9107))
- Sort tags before classes when `@applying` a selector with joined classes ([#9107](https://github.com/tailwindlabs/tailwindcss/pull/9107))

## [3.1.8] - 2022-08-05

Expand Down
69 changes: 50 additions & 19 deletions src/lib/expandApplyAtRules.js
Expand Up @@ -34,13 +34,13 @@ function extractClasses(node) {
return Object.assign(classes, { groups: normalizedGroups })
}

let selectorExtractor = parser((root) => root.nodes.map((node) => node.toString()))
let selectorExtractor = parser()

/**
* @param {string} ruleSelectors
*/
function extractSelectors(ruleSelectors) {
return selectorExtractor.transformSync(ruleSelectors)
return selectorExtractor.astSync(ruleSelectors)
}

function extractBaseCandidates(candidates, separator) {
Expand Down Expand Up @@ -299,30 +299,61 @@ function processApply(root, context, localCache) {
* What happens in this function is that we prepend a `.` and escape the candidate.
* This will result in `.hover\:font-bold`
* Which means that we can replace `.hover\:font-bold` with `.abc` in `.hover\:font-bold:hover` resulting in `.abc:hover`
*
* @param {string} selector
* @param {string} utilitySelectors
* @param {string} candidate
*/
// TODO: Should we use postcss-selector-parser for this instead?
function replaceSelector(selector, utilitySelectors, candidate) {
let needle = `.${escapeClassName(candidate)}`
let needles = [...new Set([needle, needle.replace(/\\2c /g, '\\,')])]
let selectorList = extractSelectors(selector)
let utilitySelectorsList = extractSelectors(utilitySelectors)
let candidateList = extractSelectors(`.${escapeClassName(candidate)}`)
let candidateClass = candidateList.nodes[0].nodes[0]

return extractSelectors(selector)
.map((s) => {
let replaced = []
selectorList.each((sel) => {
/** @type {Set<import('postcss-selector-parser').Selector>} */
let replaced = new Set()

for (let utilitySelector of utilitySelectorsList) {
let replacedSelector = utilitySelector
for (const needle of needles) {
replacedSelector = replacedSelector.replace(needle, s)
}
if (replacedSelector === utilitySelector) {
continue
utilitySelectorsList.each((utilitySelector) => {
utilitySelector = utilitySelector.clone()

utilitySelector.walkClasses((node) => {
if (node.value !== candidateClass.value) {
return
}
replaced.push(replacedSelector)
}
return replaced.join(', ')

// Since you can only `@apply` class names this is sufficient
// We want to replace the matched class name with the selector the user is using
// Ex: Replace `.text-blue-500` with `.foo.bar:is(.something-cool)`
node.replaceWith(...sel.nodes.map((node) => node.clone()))

// Record that we did something and we want to use this new selector
replaced.add(utilitySelector)
})
})
.join(', ')

// Sort tag names before class names
// This happens when replacing `.bar` in `.foo.bar` with a tag like `section`
for (const sel of replaced) {
sel.sort((a, b) => {
if (a.type === 'tag' && b.type === 'class') {
return -1
} else if (a.type === 'class' && b.type === 'tag') {
return 1
} else if (a.type === 'class' && b.type === 'pseudo') {
return -1
} else if (a.type === 'pseudo' && b.type === 'class') {
return 1
}

return sel.index(a) - sel.index(b)
})
}

sel.replaceWith(...replaced)
})

return selectorList.toString()
}

let perParentApplies = new Map()
Expand Down
102 changes: 102 additions & 0 deletions tests/apply.test.js
Expand Up @@ -1584,3 +1584,105 @@ it('can apply user utilities that start with a dash', async () => {
}
`)
})

it('can apply joined classes when using elements', async () => {
let config = {
content: [{ raw: html`<div class="foo-1 -foo-1 new-class"></div>` }],
plugins: [],
}

let input = css`
.foo.bar {
color: red;
}
.bar.foo {
color: green;
}
header:nth-of-type(odd) {
@apply foo;
}
main {
@apply foo bar;
}
footer {
@apply bar;
}
`

let result = await run(input, config)

expect(result.css).toMatchFormattedCss(css`
.foo.bar {
color: red;
}
.bar.foo {
color: green;
}
header.bar:nth-of-type(odd) {
color: red;
color: green;
}
main.bar {
color: red;
}
main.foo {
color: red;
}
main.bar {
color: green;
}
main.foo {
color: green;
}
footer.foo {
color: red;
color: green;
}
`)
})

it('can produce selectors that replace multiple instances of the same class', async () => {
let config = {
content: [{ raw: html`<div class="foo-1 -foo-1 new-class"></div>` }],
plugins: [],
}

let input = css`
.foo + .foo {
color: blue;
}
.bar + .bar {
color: fuchsia;
}
header {
@apply foo;
}
main {
@apply foo bar;
}
footer {
@apply bar;
}
`

let result = await run(input, config)

expect(result.css).toMatchFormattedCss(css`
.foo + .foo {
color: blue;
}
.bar + .bar {
color: fuchsia;
}
header + header {
color: blue;
}
main + main {
color: blue;
color: fuchsia;
}
footer + footer {
color: fuchsia;
}
`)
})

0 comments on commit ef74fd3

Please sign in to comment.