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

Fix @apply selector rewriting when multiple classes are involved #9107

Merged
merged 3 commits into from Aug 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
}
`)
})