From 6f04ab31235e2788ed5b08c94ec4b667815505d5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 5 Nov 2024 17:04:13 +0100 Subject: [PATCH 1/6] add failing test --- packages/tailwindcss/src/css-parser.test.ts | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 0da36c40a91a..a4b123b28fde 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -505,6 +505,59 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }, ]) }) + + it('should parse url(…) without quotes and special characters such as `;`, `{}`, and `[]`', () => { + expect( + parse(css` + .foo { + /* ';' should be valid inside the 'url(…)' function */ + background: url(data:image/png;base64,abc==); + + /* '{', '}', '[' and ']' should be valid inside the 'url(…)' function */ + /* '{' and '}' should not start a new block (nesting) */ + background: url(https://example-image-search.org?q={query;limit=5}&ids=[1,2,3]); + + /* '{' and '}' don't need to be balanced */ + background: url(https://example-image-search.org?curlies=}}); + + /* '(' and ')' are not valid, unless we are in a string with quotes */ + background: url('https://example-image-search.org?q={query;limit=5}&ids=[1,2,3]&format=(png|jpg)'); + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { + kind: 'declaration', + property: 'background', + value: 'url(data:image/png;base64,abc==)', + important: false, + }, + { + kind: 'declaration', + property: 'background', + value: 'url(https://example-image-search.org?q={query;limit=5}&ids=[1,2,3])', + important: false, + }, + { + kind: 'declaration', + property: 'background', + value: 'url(https://example-image-search.org?curlies=}})', + important: false, + }, + { + kind: 'declaration', + property: 'background', + value: + "url('https://example-image-search.org?q={query;limit=5}&ids=[1,2,3]&format=(png|jpg)')", + important: false, + }, + ], + }, + ]) + }) }) describe('selectors', () => { From c2434cafc8915999dea827f3dd42beb0b7fcac39 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 5 Nov 2024 17:08:57 +0100 Subject: [PATCH 2/6] ensure CSS functions can contain special characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When parsing the `url(…)`, then quotes to create a string are not required. If you use special characters inside the `url(…)` such as `;` then we should not end the declaration. Likewise, when the `url(…)` contains `{` or `}`, then we don't want to create a new block (for nesting) either. --- packages/tailwindcss/src/css-parser.ts | 33 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 8fb4a546d372..96b5ddae5d32 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -331,7 +331,10 @@ export function parse(input: string) { // } // ``` // - else if (currentChar === SEMICOLON) { + else if ( + currentChar === SEMICOLON && + (closingBracketStack.length === 0 || !closingBracketStack.includes(')')) + ) { let declaration = parseDeclaration(buffer) if (parent) { parent.nodes.push(declaration) @@ -343,7 +346,10 @@ export function parse(input: string) { } // Start of a block. - else if (currentChar === OPEN_CURLY) { + else if ( + currentChar === OPEN_CURLY && + (closingBracketStack.length === 0 || !closingBracketStack.includes(')')) + ) { closingBracketStack += '}' // At this point `buffer` should resemble a selector or an at-rule. @@ -368,7 +374,10 @@ export function parse(input: string) { } // End of a block. - else if (currentChar === CLOSE_CURLY) { + else if ( + currentChar === CLOSE_CURLY && + (closingBracketStack.length === 0 || !closingBracketStack.includes(')')) + ) { if (closingBracketStack === '') { throw new Error('Missing opening {') } @@ -456,6 +465,24 @@ export function parse(input: string) { node = null } + // + else if (currentChar === OPEN_CURLY) { + closingBracketStack += '}' + buffer += '{' + } else if (currentChar === OPEN_PAREN) { + closingBracketStack += ')' + buffer += '(' + } else if (currentChar === CLOSE_CURLY || currentChar === CLOSE_PAREN) { + if ( + closingBracketStack.length > 0 && + input[i] === closingBracketStack[closingBracketStack.length - 1] + ) { + closingBracketStack = closingBracketStack.slice(0, -1) + } + + buffer += String.fromCharCode(currentChar) + } + // Any other character is part of the current node. else { // Skip whitespace at the start of a new node. From 5207b84169fef0161097d480e11a2c87f5ba3073 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 5 Nov 2024 17:31:38 +0100 Subject: [PATCH 3/6] simplify, track parens via number Some characters, such as `{`, `}`, and `;` are allowed inside CSS functions, e.g.: `url(https://example?q={query;limit=5})`. Notice that the example is not in quotes, and this is a valid URL. If we don't handle these characters when inside of `(` and `)`, it means that the `{` would start a nested block, `}` would end the block, and `;` would end the current declaration. Instead, if we are in parens, we do allow the values. --- packages/tailwindcss/src/css-parser.ts | 40 ++++++++++---------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 96b5ddae5d32..b91011e0c7d3 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -42,6 +42,7 @@ export function parse(input: string) { let buffer = '' let closingBracketStack = '' + let parenStack = 0 let peekChar @@ -331,10 +332,7 @@ export function parse(input: string) { // } // ``` // - else if ( - currentChar === SEMICOLON && - (closingBracketStack.length === 0 || !closingBracketStack.includes(')')) - ) { + else if (currentChar === SEMICOLON && parenStack <= 0) { let declaration = parseDeclaration(buffer) if (parent) { parent.nodes.push(declaration) @@ -346,10 +344,7 @@ export function parse(input: string) { } // Start of a block. - else if ( - currentChar === OPEN_CURLY && - (closingBracketStack.length === 0 || !closingBracketStack.includes(')')) - ) { + else if (currentChar === OPEN_CURLY && parenStack <= 0) { closingBracketStack += '}' // At this point `buffer` should resemble a selector or an at-rule. @@ -374,10 +369,7 @@ export function parse(input: string) { } // End of a block. - else if ( - currentChar === CLOSE_CURLY && - (closingBracketStack.length === 0 || !closingBracketStack.includes(')')) - ) { + else if (currentChar === CLOSE_CURLY && parenStack <= 0) { if (closingBracketStack === '') { throw new Error('Missing opening {') } @@ -465,22 +457,20 @@ export function parse(input: string) { node = null } - // - else if (currentChar === OPEN_CURLY) { - closingBracketStack += '}' - buffer += '{' - } else if (currentChar === OPEN_PAREN) { - closingBracketStack += ')' + // `(` + else if (currentChar === OPEN_PAREN) { + parenStack += 1 buffer += '(' - } else if (currentChar === CLOSE_CURLY || currentChar === CLOSE_PAREN) { - if ( - closingBracketStack.length > 0 && - input[i] === closingBracketStack[closingBracketStack.length - 1] - ) { - closingBracketStack = closingBracketStack.slice(0, -1) + } + + // `)` + else if (currentChar === CLOSE_PAREN) { + if (parenStack <= 0) { + throw new Error('Missing opening (') } - buffer += String.fromCharCode(currentChar) + parenStack -= 1 + buffer += ')' } // Any other character is part of the current node. From 809ddade1e79b33f71841888a6bc89936c23725e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 5 Nov 2024 17:45:47 +0100 Subject: [PATCH 4/6] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49353b907fe8..a635b63ee758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix crash when using `@source` containing `..` ([#14831](https://github.com/tailwindlabs/tailwindcss/pull/14831)) - Ensure instances of the same variant with different values are always sorted deterministically (e.g. `data-focus:flex` and `data-active:flex`) ([#14835](https://github.com/tailwindlabs/tailwindcss/pull/14835)) - Ensure `--inset-ring=*` and `--inset-shadow-*` variables are ignored by `inset-*` utilities ([#14855](https://github.com/tailwindlabs/tailwindcss/pull/14855)) +- Fix parsing `url(…)` with special characters such as `;` or `{}` ([#14879](https://github.com/tailwindlabs/tailwindcss/pull/14879)) - _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830)) - _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838)) From 9177c686025334561b10df5e3d9d462cf1b3354b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 5 Nov 2024 22:46:55 +0100 Subject: [PATCH 5/6] use existing `closingBracketStack` We still apply the same rules where we don't really care about special characters such as `{`, `}` or `;` when we are inside of `(` and `)`. We can safely look at the last character in the `closingBracketStack` because when we are in parentheses, we don't update the `closingBracketStack` until we see a closing `)`. --- packages/tailwindcss/src/css-parser.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index b91011e0c7d3..3b6e283e5812 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -42,7 +42,6 @@ export function parse(input: string) { let buffer = '' let closingBracketStack = '' - let parenStack = 0 let peekChar @@ -332,7 +331,10 @@ export function parse(input: string) { // } // ``` // - else if (currentChar === SEMICOLON && parenStack <= 0) { + else if ( + currentChar === SEMICOLON && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { let declaration = parseDeclaration(buffer) if (parent) { parent.nodes.push(declaration) @@ -344,7 +346,10 @@ export function parse(input: string) { } // Start of a block. - else if (currentChar === OPEN_CURLY && parenStack <= 0) { + else if ( + currentChar === OPEN_CURLY && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { closingBracketStack += '}' // At this point `buffer` should resemble a selector or an at-rule. @@ -369,7 +374,10 @@ export function parse(input: string) { } // End of a block. - else if (currentChar === CLOSE_CURLY && parenStack <= 0) { + else if ( + currentChar === CLOSE_CURLY && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { if (closingBracketStack === '') { throw new Error('Missing opening {') } @@ -459,17 +467,17 @@ export function parse(input: string) { // `(` else if (currentChar === OPEN_PAREN) { - parenStack += 1 + closingBracketStack += ')' buffer += '(' } // `)` else if (currentChar === CLOSE_PAREN) { - if (parenStack <= 0) { + if (closingBracketStack[closingBracketStack.length - 1] !== ')') { throw new Error('Missing opening (') } - parenStack -= 1 + closingBracketStack = closingBracketStack.slice(0, -1) buffer += ')' } From 20850eebd5c9e2d57967359a874c0eb2121ede56 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 6 Nov 2024 12:06:41 +0100 Subject: [PATCH 6/6] Update CHANGELOG.md Co-authored-by: Philipp Spiess --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a635b63ee758..710e27792480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix crash when using `@source` containing `..` ([#14831](https://github.com/tailwindlabs/tailwindcss/pull/14831)) - Ensure instances of the same variant with different values are always sorted deterministically (e.g. `data-focus:flex` and `data-active:flex`) ([#14835](https://github.com/tailwindlabs/tailwindcss/pull/14835)) - Ensure `--inset-ring=*` and `--inset-shadow-*` variables are ignored by `inset-*` utilities ([#14855](https://github.com/tailwindlabs/tailwindcss/pull/14855)) -- Fix parsing `url(…)` with special characters such as `;` or `{}` ([#14879](https://github.com/tailwindlabs/tailwindcss/pull/14879)) +- Ensure `url(…)` containing special characters such as `;` or `{}` end up in one declaration ([#14879](https://github.com/tailwindlabs/tailwindcss/pull/14879)) - _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830)) - _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838))