Discard invalid declarations when parsing CSS (#16093)

I discovered this when triaging an error someone had on Tailwind Play.

1. When we see a `;` we often assume a valid declaration precedes it but
that may not be the case
2. When we see the name of a custom property we assume everything that
follows will be a valid declaration but that is not necessarily the case
3. A bare identifier inside of a rule is treated as a declaration which
is not the case

This PR fixes all three of these by ignoring these invalid cases. Though
some should probably be turned into errors.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Jordan Pittman 2025-01-31 09:53:02 -05:00 committed by GitHub
parent 95722020fe
commit 35a5e8cb64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 73 additions and 2 deletions

View File

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Vite: Transform `<style>` blocks in HTML files ([#16069](https://github.com/tailwindlabs/tailwindcss/pull/16069))
- Prevent camelCasing CSS custom properties added by JavaScript plugins ([#16103](https://github.com/tailwindlabs/tailwindcss/pull/16103))
- Do not emit `@keyframes` in `@theme reference` ([#16120](https://github.com/tailwindlabs/tailwindcss/pull/16120))
- Discard invalid declarations when parsing CSS ([#16093](https://github.com/tailwindlabs/tailwindcss/pull/16093))
## [4.0.1] - 2025-01-29

View File

@ -329,6 +329,28 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
])
})
it('should parse a custom property with an empty value', () => {
expect(parse('--foo:;')).toEqual([
{
kind: 'declaration',
property: '--foo',
value: '',
important: false,
},
])
})
it('should parse a custom property with a space value', () => {
expect(parse('--foo: ;')).toEqual([
{
kind: 'declaration',
property: '--foo',
value: '',
important: false,
},
])
})
it('should parse a custom property with a block including nested "css"', () => {
expect(
parse(css`
@ -1097,5 +1119,39 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`)
})
it('should error when incomplete custom properties are used', () => {
expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot(
`[Error: Invalid custom property, expected a value]`,
)
})
it('should error when incomplete custom properties are used inside rules', () => {
expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(
`[Error: Invalid custom property, expected a value]`,
)
})
it('should error when a declaration is incomplete', () => {
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
`[Error: Invalid declaration: \`bar\`]`,
)
})
it('should error when a semicolon exists after an at-rule with a body', () => {
expect(() => parse('@plugin "foo" {} ;')).toThrowErrorMatchingInlineSnapshot(
`[Error: Unexpected semicolon]`,
)
})
it('should error when consecutive semicolons exist', () => {
expect(() => parse(';;;')).toThrowErrorMatchingInlineSnapshot(`[Error: Unexpected semicolon]`)
})
it('should error when consecutive semicolons exist after a declaration', () => {
expect(() => parse('.foo { color: red;;; }')).toThrowErrorMatchingInlineSnapshot(
`[Error: Unexpected semicolon]`,
)
})
})
})

View File

@ -286,6 +286,8 @@ export function parse(input: string) {
}
let declaration = parseDeclaration(buffer, colonIdx)
if (!declaration) throw new Error(`Invalid custom property, expected a value`)
if (parent) {
parent.nodes.push(declaration)
} else {
@ -337,6 +339,11 @@ export function parse(input: string) {
closingBracketStack[closingBracketStack.length - 1] !== ')'
) {
let declaration = parseDeclaration(buffer)
if (!declaration) {
if (buffer.length === 0) throw new Error('Unexpected semicolon')
throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
}
if (parent) {
parent.nodes.push(declaration)
} else {
@ -435,7 +442,10 @@ export function parse(input: string) {
// Attach the declaration to the parent.
if (parent) {
parent.nodes.push(parseDeclaration(buffer, colonIdx))
let node = parseDeclaration(buffer, colonIdx)
if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
parent.nodes.push(node)
}
}
}
@ -543,7 +553,11 @@ export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule {
return atRule(buffer.trim(), '', nodes)
}
function parseDeclaration(buffer: string, colonIdx: number = buffer.indexOf(':')): Declaration {
function parseDeclaration(
buffer: string,
colonIdx: number = buffer.indexOf(':'),
): Declaration | null {
if (colonIdx === -1) return null
let importantIdx = buffer.indexOf('!important', colonIdx + 1)
return decl(
buffer.slice(0, colonIdx).trim(),