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

Collect user-defined CSS that can be used in @apply #13349

Open
wants to merge 10 commits into
base: next
Choose a base branch
from
64 changes: 64 additions & 0 deletions packages/tailwindcss/src/index.test.ts
Expand Up @@ -308,6 +308,70 @@ describe('@apply', () => {
}"
`)
})

it('should be possible to apply user-defined CSS', () => {
expect(
compileCss(css`
@theme {
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--color-red-600: #e53e3e;
}

.btn {
@apply py-2 px-3;
}

.btn-red {
@apply btn bg-red-600;
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing-2: .5rem;
--spacing-3: .75rem;
--color-red-600: #e53e3e;
}

.btn {
padding-top: var(--spacing-2, .5rem);
padding-bottom: var(--spacing-2, .5rem);
padding-left: var(--spacing-3, .75rem);
padding-right: var(--spacing-3, .75rem);
}

.btn-red {
padding-top: var(--spacing-2, .5rem);
padding-bottom: var(--spacing-2, .5rem);
padding-left: var(--spacing-3, .75rem);
padding-right: var(--spacing-3, .75rem);
background-color: var(--color-red-600, #e53e3e);
}"
`)
})

it('should apply user-defined CSS that happens to be a utility class', () => {
expect(
compileCss(css`
.flex {
--display-mode: flex;
}

.example {
@apply flex;
}
`),
).toMatchInlineSnapshot(`
".flex {
--display-mode: flex;
}

.example {
--display-mode: flex;
display: flex;
}"
`)
})
})

describe('arbitrary variants', () => {
Expand Down
67 changes: 65 additions & 2 deletions packages/tailwindcss/src/index.ts
Expand Up @@ -4,6 +4,7 @@ import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
import { isSimpleClassSelector } from './utils/is-simple-class-selector'

export function compile(css: string): {
build(candidates: string[]): string
Expand All @@ -20,6 +21,10 @@ export function compile(css: string): {
invalidCandidates.add(candidate)
}

// Track `@apply` information
let containsAtApply = css.includes('@apply')
let userDefinedApplyables = new Map<string, AstNode[]>()

// Find all `@theme` declarations
let theme = new Theme()
let firstThemeRule: Rule | null = null
Expand All @@ -28,6 +33,33 @@ export function compile(css: string): {
walk(ast, (node, { replaceWith }) => {
if (node.kind !== 'rule') return

// Track all user-defined classes for `@apply` support
if (
containsAtApply &&
// Verify that it is a valid applyable-class. An applyable class is a
// class that is a very simple selector, like `.foo` or `.bar`, but doesn't
// contain any spaces, combinators, pseudo-selectors, pseudo-elements, or
// attribute selectors.
node.selector[0] === '.' &&

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you don't need node.selector[0] === '.' ?

Because inside isSimpleClassSelector, you perform this test with an early return:
if (selector[0] !== '.') return false

I would suggest to remove this line

Suggested change
node.selector[0] === '.' &&

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still required, if you comment this out a test should fail. This is basically for the scenario where you have .foo.bar which contains 2 classes which is not allowed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 😉

isSimpleClassSelector(node.selector)
) {
// Convert the class `.foo` into a candidate `foo`
let candidate = node.selector.slice(1)

// It could be that multiple definitions exist for the same class, so we
// need to track all of them.
let nodes = userDefinedApplyables.get(candidate) ?? []

// Add all children of the current rule to the list of nodes for the
// current candidate.
for (let child of node.nodes) {
nodes.push(child)
}

// Store the list of nodes for the current candidate
userDefinedApplyables.set(candidate, nodes)
}

// Drop instances of `@media reference`
//
// We support `@import "tailwindcss/theme" reference` as a way to import an external theme file
Expand Down Expand Up @@ -143,7 +175,7 @@ export function compile(css: string): {
})

// Replace `@apply` rules with the actual utility classes.
if (css.includes('@apply')) {
if (containsAtApply) {
walk(ast, (node, { replaceWith }) => {
if (node.kind === 'rule' && node.selector[0] === '@' && node.selector.startsWith('@apply')) {
let candidates = node.selector
Expand All @@ -153,17 +185,48 @@ export function compile(css: string): {

// Replace the `@apply` rule with the actual utility classes
{
let newNodes: AstNode[] = []

// Collect all user-defined classes for the current candidates that we
// need to apply.
for (let candidate of candidates) {
let nodes = userDefinedApplyables.get(candidate)
if (!nodes) continue

for (let child of nodes) {
newNodes.push(structuredClone(child))
}
}

// Parse the candidates to an AST that we can replace the `@apply` rule with.
let candidateAst = compileCandidates(candidates, designSystem, {
onInvalidCandidate: (candidate) => {
// When a candidate is invalid, we want to first verify that the
// candidate is a user-defined class or not. If it is, then we can
// safely ignore this. If it's not, then we throw an error because
// the candidate is unknown.
//
// The reason we even have to check user-defined classes is
// because it could be that the user defined CSS like that is also
// a known utility class. For example, the following CSS would be:
//
// ```css
// .flex {
// --display-mode: flex;
// }
// ```
//
// When the user then uses `@apply flex`, we want to both apply
// the user-defined class and the utility class.
RobinMalfait marked this conversation as resolved.
Show resolved Hide resolved
if (userDefinedApplyables.has(candidate)) return

throw new Error(`Cannot apply unknown utility class: ${candidate}`)
},
}).astNodes

// Collect the nodes to insert in place of the `@apply` rule. When a
// rule was used, we want to insert its children instead of the rule
// because we don't want the wrapping selector.
let newNodes: AstNode[] = []
for (let candidateNode of candidateAst) {
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
for (let child of candidateNode.nodes) {
Expand Down
42 changes: 42 additions & 0 deletions packages/tailwindcss/src/utils/is-simple-class-selector.test.ts
@@ -0,0 +1,42 @@
import { expect, it } from 'vitest'
import { isSimpleClassSelector } from './is-simple-class-selector'

it.each([
// Simple class selector
['.foo', true],

// Class selectors with escaped characters
['.w-\\[123px\\]', true],
['.content-\\[\\+\\>\\~\\*\\]', true],

// ID selector
['#foo', false],
['.foo#foo', false],

// Element selector
['h1', false],
['h1.foo', false],

// Attribute selector
['[data-foo]', false],
['.foo[data-foo]', false],
['[data-foo].foo', false],

// Pseudo-class selector
['.foo:hover', false],

// Additional class selector
['.foo.bar', false],

// Combinator
['.foo>.bar', false],
['.foo+.bar', false],
['.foo~.bar', false],
['.foo .bar', false],

// Selector list
['.foo, .bar', false],
['.foo,.bar', false],
])('should validate %s', (selector, expected) => {
expect(isSimpleClassSelector(selector)).toBe(expected)
})
33 changes: 33 additions & 0 deletions packages/tailwindcss/src/utils/is-simple-class-selector.ts
@@ -0,0 +1,33 @@
/**
* Check if a selector is a simple class selector.
*
* A simple class selector is a class selector that doesn't contain any other
* selector types, such as ID selectors, element selectors, attribute selectors,
* pseudo-classes, combinators, or selector lists.
*/
export function isSimpleClassSelector(selector: string): boolean {
// The selector must start with a dot, otherwise it's not a class selector.
if (selector[0] !== '.') return false

for (let i = 1; i < selector.length; i++) {
switch (selector[i]) {
// The character is escaped, skip the next character
case '\\':
i += 1
continue

case ' ': // Descendat combinator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case ' ': // Descendat combinator
case ' ': // Descendant combinator

case '.': // Class selector
case '#': // ID selector
case '[': // Attribute selector
case ':': // Pseudo-classes and pseudo-elements
case '>': // Child combinator
case '+': // Next-sibling combinator
case '~': // Subsequent-sibling combinator
case ',': // Selector list
return false
}
}

return true
}