diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc2c8629be8..881d9d04851f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correctly handle duplicate CLI arguments ([#19416](https://github.com/tailwindlabs/tailwindcss/pull/19416)) - Don’t emit color-mix fallback rules inside `@keyframes` ([#19419](https://github.com/tailwindlabs/tailwindcss/pull/19419)) - CLI: Don't hang when output is `/dev/stdout` ([#19421](https://github.com/tailwindlabs/tailwindcss/pull/19421)) +- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427)) ### Added diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index c7bcc5e28801..2d5e5b847bbc 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -163,13 +163,52 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { let parts = child.params.split(/(\s+)/g) let candidateOffsets: Record = {} + let normalIdents: string[] = [] + let dashedIdents: string[] = [] let offset = 0 for (let [idx, part] of parts.entries()) { - if (idx % 2 === 0) candidateOffsets[part] = offset + if (idx % 2 === 0) { + if (part[0] === '-' && part[1] === '-') { + dashedIdents.push(part) + } else { + normalIdents.push(part) + } + + candidateOffsets[part] = offset + } + offset += part.length } + if (dashedIdents.length) { + // If we have an `@apply` that only consists of dashed idents then the + // user is intending to use a CSS mixin: + // https://drafts.csswg.org/css-mixins-1/#apply-rule + // + // These are not considered utilities and need to be emitted literally. + if (normalIdents.length === 0) return WalkAction.Skip + + // If we find a dashed ident *here* it means that someone is trying + // to use mixins and our `@apply` behavior together. + // + // This is invalid and the rules must be written separately. Let the + // user know they need to move them into a separate rule. + let list = dashedIdents.join(' ') + + throw new Error( + `You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply ${list}\` into a separate rule.`, + ) + } + + let hasBody = child.nodes.length > 0 + + if (hasBody && normalIdents.length) { + let list = normalIdents.join(' ') + + throw new Error(`The rule \`@apply ${list}\` must not have a body.`) + } + // Replace the `@apply` rule with the actual utility classes { // Parse the candidates to an AST that we can replace the `@apply` rule diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index ab0a4fb4d572..04a954c1023c 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -400,7 +400,8 @@ export function optimizeAst( copy.name === '@charset' || copy.name === '@custom-media' || copy.name === '@namespace' || - copy.name === '@import' + copy.name === '@import' || + copy.name === '@apply' ) { parent.push(copy) } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 5cd5282b00f7..e1697800fdde 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -855,6 +855,74 @@ describe('@apply', () => { }" `) }) + + it('should be usable with CSS mixins', async () => { + let compiler = await compile(css` + .foo { + /* Utility usage */ + @apply underline; + + /* CSS mixin usage */ + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } + } + `) + + expect(compiler.build([])).toMatchInlineSnapshot(` + ".foo { + text-decoration-line: underline; + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } + } + " + `) + }) + + it('should error when trying to use mixins and utilities together', async () => { + await expect( + compile(css` + .foo { + @apply underline --my-mixin-1; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, + ) + + await expect( + compile(css` + .foo { + @apply --my-mixin-1 underline; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, + ) + }) + + it('should error when used with a body', async () => { + await expect( + compile(css` + .foo { + @apply underline { + color: red; + } + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The rule \`@apply underline\` must not have a body.]`, + ) + }) }) describe('arbitrary variants', () => {