Ensure that @utility is top-level and cannot be nested (#14525)

This PR fixes an issue where we expect the `@utility` to be top-level,
but we didn't enforce it. This PR enforces that the `@utility` is
top-level.
This commit is contained in:
Robin Malfait 2024-09-26 17:53:41 +02:00 • committed by GitHub
parent 89f0047c0d
commit db9cbf7c08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 86 additions and 37 deletions

View file

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
- _Experimental_: Do not wrap comment nodes in `@layer` when running codemods ([#14517](https://github.com/tailwindlabs/tailwindcss/pull/14517))
- _Experimental_: Ensure we don't lose selectors when running codemods ([#14518](https://github.com/tailwindlabs/tailwindcss/pull/14518))
- Ensure that `@utility` is top-level and cannot be nested ([#14525](https://github.com/tailwindlabs/tailwindcss/pull/14525))
## [4.0.0-alpha.25] - 2024-09-24

View file

@ -56,8 +56,8 @@ describe('compiling CSS', () => {
`)
})
test('that only CSS variables are allowed', () =>
expect(
test('that only CSS variables are allowed', () => {
return expect(
compileCss(
css`
@theme {
@ -79,7 +79,8 @@ describe('compiling CSS', () => {
> }
}
]
`))
`)
})
test('`@tailwind utilities` is only processed once', async () => {
expect(
@ -290,7 +291,7 @@ describe('@apply', () => {
})
it('should error when using @apply with a utility that does not exist', () => {
expect(
return expect(
compileCss(css`
@tailwind utilities;
@ -304,7 +305,7 @@ describe('@apply', () => {
})
it('should error when using @apply with a variant that does not exist', () => {
expect(
return expect(
compileCss(css`
@tailwind utilities;
@ -1184,8 +1185,8 @@ describe('Parsing themes values from CSS', () => {
`)
})
test('`@media theme(…)` can only contain `@theme` rules', () =>
expect(
test('`@media theme(…)` can only contain `@theme` rules', () => {
return expect(
compileCss(
css`
@media theme(reference) {
@ -1199,7 +1200,8 @@ describe('Parsing themes values from CSS', () => {
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Files imported with \`@import "…" theme(…)\` must only contain \`@theme\` blocks.]`,
))
)
})
test('theme values added as `inline` are not wrapped in `var(…)` when used as utility values', async () => {
expect(
@ -1550,8 +1552,8 @@ describe('Parsing themes values from CSS', () => {
})
describe('plugins', () => {
test('@plugin need a path', () =>
expect(
test('@plugin need a path', () => {
return expect(
compile(
css`
@plugin;
@ -1565,10 +1567,11 @@ describe('plugins', () => {
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`)
})
test('@plugin can not have an empty path', () =>
expect(
test('@plugin can not have an empty path', () => {
return expect(
compile(
css`
@plugin '';
@ -1582,10 +1585,11 @@ describe('plugins', () => {
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`)
})
test('@plugin cannot be nested.', () =>
expect(
test('@plugin cannot be nested.', () => {
return expect(
compile(
css`
div {
@ -1601,7 +1605,8 @@ describe('plugins', () => {
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`))
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`)
})
test('@plugin can accept options', async () => {
expect.hasAssertions()
@ -1694,7 +1699,7 @@ describe('plugins', () => {
})
test('@plugin options can only be simple key/value pairs', () => {
expect(
return expect(
compile(
css`
@plugin "my-plugin" {
@ -1736,7 +1741,7 @@ describe('plugins', () => {
})
test('@plugin options can only be provided to plugins using withOptions', () => {
expect(
return expect(
compile(
css`
@plugin "my-plugin" {
@ -1762,7 +1767,7 @@ describe('plugins', () => {
})
test('@plugin errors on array-like syntax', () => {
expect(
return expect(
compile(
css`
@plugin "my-plugin" {
@ -1779,7 +1784,7 @@ describe('plugins', () => {
})
test('@plugin errors on object-like syntax', () => {
expect(
return expect(
compile(
css`
@plugin "my-plugin" {
@ -1794,8 +1799,7 @@ describe('plugins', () => {
loadModule: async () => ({ module: plugin(() => {}), base: '/root' }),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`
).rejects.toThrowErrorMatchingInlineSnapshot(`
[Error: Unexpected \`@plugin\` option: Value of declaration \`--color: {
red: 100;
green: 200;
@ -1803,8 +1807,7 @@ describe('plugins', () => {
};\` is not supported.
Using an object as a plugin option is currently only supported in JavaScript configuration files.]
`,
)
`)
})
test('addVariant with string selector', async () => {
@ -2066,36 +2069,39 @@ describe('@source', () => {
})
describe('@variant', () => {
test('@variant must be top-level and cannot be nested', () =>
expect(
test('@variant must be top-level and cannot be nested', () => {
return expect(
compileCss(css`
.foo {
@variant hocus (&:hover, &:focus);
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`))
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`)
})
test('@variant with no body must include a selector', () =>
expect(
test('@variant with no body must include a selector', () => {
return expect(
compileCss(css`
@variant hocus;
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
'[Error: `@variant hocus` has no selector or body.]',
))
)
})
test('@variant with selector must include a body', () =>
expect(
test('@variant with selector must include a body', () => {
return expect(
compileCss(css`
@variant hocus {
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
'[Error: `@variant hocus` has no selector or body.]',
))
)
})
test('@variant cannot have both a selector and a body', () =>
expect(
test('@variant cannot have both a selector and a body', () => {
return expect(
compileCss(css`
@variant hocus (&:hover, &:focus) {
&:is(.potato) {
@ -2105,7 +2111,8 @@ describe('@variant', () => {
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: \`@variant hocus\` cannot have both a selector and a body.]`,
))
)
})
describe('body-less syntax', () => {
test('selector variant', async () => {
@ -2573,6 +2580,43 @@ describe('@variant', () => {
})
})
describe('@utility', () => {
test('@utility must be top-level and cannot be nested', () => {
return expect(
compileCss(css`
.foo {
@utility foo {
color: red;
}
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@utility\` cannot be nested.]`)
})
test('@utility must include a body', () => {
return expect(
compileCss(css`
@utility foo {
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: \`@utility foo\` is empty. Utilities should include at least one property.]`,
)
})
test('@utility cannot contain any special characters', () => {
return expect(
compileCss(css`
@utility 💨 {
color: red;
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: \`@utility 💨\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.]`,
)
})
})
test('addBase', async () => {
let { build } = await compile(
css`

View file

@ -88,6 +88,10 @@ async function parseCss(
// Collect custom `@utility` at-rules
if (node.selector.startsWith('@utility ')) {
if (parent !== null) {
throw new Error('`@utility` cannot be nested.')
}
let name = node.selector.slice(9).trim()
if (!IS_VALID_UTILITY_NAME.test(name)) {