From 91c7ac0995736d0099a04682abc60d516ba0093c Mon Sep 17 00:00:00 2001 From: Cyril Duez Date: Sat, 7 Jun 2025 12:30:32 +0100 Subject: [PATCH 1/4] Fix string in custom properties. --- packages/tailwindcss/src/css-parser.test.ts | 26 ++++ packages/tailwindcss/src/css-parser.ts | 145 +++++++++++--------- 2 files changed, 105 insertions(+), 66 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index f7ee47a61145..b477758ae92b 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -457,6 +457,21 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { `), ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }]) }) + + it('should parse custom properties with data URL value', () => { + expect( + parse(css` + --foo: 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='; + `), + ).toEqual([ + { + kind: 'declaration', + property: '--foo', + value: "'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='", + important: false, + }, + ]) + }) }) it('should parse multiple declarations', () => { @@ -1132,6 +1147,17 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ) }) + it('should error when an unterminated string is used in a custom property', () => { + expect(() => + parse(css` + .foo { + --bar: "Hello world! + /* ^ missing " */ + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`) + }) + it('should error when a declaration is incomplete', () => { expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot( `[Error: Invalid declaration: \`bar\`]`, diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 6a5ce83204bd..94db4b6e422f 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -138,74 +138,11 @@ export function parse(input: string, opts?: ParseOptions) { // Start of a string. else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { - let start = i - - // We need to ensure that the closing quote is the same as the opening - // quote. - // - // E.g.: - // - // ```css - // .foo { - // content: "This is a string with a 'quote' in it"; - // ^ ^ -> These are not the end of the string. - // } - // ``` - for (let j = i + 1; j < input.length; j++) { - peekChar = input.charCodeAt(j) - // Current character is a `\` therefore the next character is escaped. - if (peekChar === BACKSLASH) { - j += 1 - } - - // End of the string. - else if (peekChar === currentChar) { - i = j - break - } - - // End of the line without ending the string but with a `;` at the end. - // - // E.g.: - // - // ```css - // .foo { - // content: "This is a string with a; - // ^ Missing " - // } - // ``` - else if ( - peekChar === SEMICOLON && - (input.charCodeAt(j + 1) === LINE_BREAK || - (input.charCodeAt(j + 1) === CARRIAGE_RETURN && input.charCodeAt(j + 2) === LINE_BREAK)) - ) { - throw new Error( - `Unterminated string: ${input.slice(start, j + 1) + String.fromCharCode(currentChar)}`, - ) - } - - // End of the line without ending the string. - // - // E.g.: - // - // ```css - // .foo { - // content: "This is a string with a - // ^ Missing " - // } - // ``` - else if ( - peekChar === LINE_BREAK || - (peekChar === CARRIAGE_RETURN && input.charCodeAt(j + 1) === LINE_BREAK) - ) { - throw new Error( - `Unterminated string: ${input.slice(start, j) + String.fromCharCode(currentChar)}`, - ) - } - } + let endStringIdx = findEndStringIdx(input, i, currentChar) // Adjust `buffer` to include the string. - buffer += input.slice(start, i + 1) + buffer += input.slice(i, endStringIdx + 1) + i = endStringIdx } // Skip whitespace if the next character is also whitespace. This allows us @@ -253,6 +190,11 @@ export function parse(input: string, opts?: ParseOptions) { j += 1 } + // Start of a string. + else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) { + j = findEndStringIdx(input, j, peekChar) + } + // Start of a comment. else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) { for (let k = j + 2; k < input.length; k++) { @@ -651,3 +593,74 @@ function parseDeclaration( importantIdx !== -1, ) } + +function findEndStringIdx(input: string, startQuoteIdx: number, quoteChar: number): number { + let peekChar + let endQuoteIdx = startQuoteIdx + + // We need to ensure that the closing quote is the same as the opening + // quote. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a 'quote' in it"; + // ^ ^ -> These are not the end of the string. + // } + // ``` + for (let j = startQuoteIdx + 1; j < input.length; j++) { + peekChar = input.charCodeAt(j) + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + j += 1 + } + + // End of the string. + else if (peekChar === quoteChar) { + endQuoteIdx = j + break + } + + // End of the line without ending the string but with a `;` at the end. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a; + // ^ Missing " + // } + // ``` + else if ( + peekChar === SEMICOLON && + (input.charCodeAt(j + 1) === LINE_BREAK || + (input.charCodeAt(j + 1) === CARRIAGE_RETURN && input.charCodeAt(j + 2) === LINE_BREAK)) + ) { + throw new Error( + `Unterminated string: ${input.slice(startQuoteIdx, j + 1) + String.fromCharCode(quoteChar)}`, + ) + } + + // End of the line without ending the string. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a + // ^ Missing " + // } + // ``` + else if ( + peekChar === LINE_BREAK || + (peekChar === CARRIAGE_RETURN && input.charCodeAt(j + 1) === LINE_BREAK) + ) { + throw new Error( + `Unterminated string: ${input.slice(startQuoteIdx, j) + String.fromCharCode(quoteChar)}`, + ) + } + } + + return endQuoteIdx +} From f6c993c86c7673909abcbe6007eed2962affecb2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 Jun 2025 09:33:44 -0400 Subject: [PATCH 2/4] tweak fn name --- packages/tailwindcss/src/css-parser.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 94db4b6e422f..b00a157d6b2d 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -138,11 +138,11 @@ export function parse(input: string, opts?: ParseOptions) { // Start of a string. else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { - let endStringIdx = findEndStringIdx(input, i, currentChar) + let end = parseString(input, i, currentChar) // Adjust `buffer` to include the string. - buffer += input.slice(i, endStringIdx + 1) - i = endStringIdx + buffer += input.slice(i, end + 1) + i = end } // Skip whitespace if the next character is also whitespace. This allows us @@ -192,7 +192,7 @@ export function parse(input: string, opts?: ParseOptions) { // Start of a string. else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) { - j = findEndStringIdx(input, j, peekChar) + j = parseString(input, j, peekChar) } // Start of a comment. @@ -594,7 +594,7 @@ function parseDeclaration( ) } -function findEndStringIdx(input: string, startQuoteIdx: number, quoteChar: number): number { +function parseString(input: string, startQuoteIdx: number, quoteChar: number): number { let peekChar let endQuoteIdx = startQuoteIdx From e02fa62504ec98a3d97812f1f1116ad8b8341e9c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 Jun 2025 09:39:15 -0400 Subject: [PATCH 3/4] Cleanup --- packages/tailwindcss/src/css-parser.ts | 27 +++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index b00a157d6b2d..a59e34721b86 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -594,9 +594,8 @@ function parseDeclaration( ) } -function parseString(input: string, startQuoteIdx: number, quoteChar: number): number { - let peekChar - let endQuoteIdx = startQuoteIdx +function parseString(input: string, startIdx: number, quoteChar: number): number { + let peekChar: number // We need to ensure that the closing quote is the same as the opening // quote. @@ -609,17 +608,17 @@ function parseString(input: string, startQuoteIdx: number, quoteChar: number): n // ^ ^ -> These are not the end of the string. // } // ``` - for (let j = startQuoteIdx + 1; j < input.length; j++) { - peekChar = input.charCodeAt(j) + for (let i = startIdx + 1; i < input.length; i++) { + peekChar = input.charCodeAt(i) + // Current character is a `\` therefore the next character is escaped. if (peekChar === BACKSLASH) { - j += 1 + i += 1 } // End of the string. else if (peekChar === quoteChar) { - endQuoteIdx = j - break + return i } // End of the line without ending the string but with a `;` at the end. @@ -634,11 +633,11 @@ function parseString(input: string, startQuoteIdx: number, quoteChar: number): n // ``` else if ( peekChar === SEMICOLON && - (input.charCodeAt(j + 1) === LINE_BREAK || - (input.charCodeAt(j + 1) === CARRIAGE_RETURN && input.charCodeAt(j + 2) === LINE_BREAK)) + (input.charCodeAt(i + 1) === LINE_BREAK || + (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) ) { throw new Error( - `Unterminated string: ${input.slice(startQuoteIdx, j + 1) + String.fromCharCode(quoteChar)}`, + `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, ) } @@ -654,13 +653,13 @@ function parseString(input: string, startQuoteIdx: number, quoteChar: number): n // ``` else if ( peekChar === LINE_BREAK || - (peekChar === CARRIAGE_RETURN && input.charCodeAt(j + 1) === LINE_BREAK) + (peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK) ) { throw new Error( - `Unterminated string: ${input.slice(startQuoteIdx, j) + String.fromCharCode(quoteChar)}`, + `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, ) } } - return endQuoteIdx + return startIdx } From e026899a80ed37601cd7a290cec143dccd09daab Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 Jun 2025 09:51:54 -0400 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1694dfe4da..f75160c1b0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Correctly parse custom properties with strings containing semicolons ([#18251](https://github.com/tailwindlabs/tailwindcss/pull/18251)) - Upgrade: migrate arbitrary modifiers with values without percentage sign to bare values `/[0.16]` -> `/16` ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184)) - Upgrade: migrate CSS variable shorthand if fallback value contains function call ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184)) - Upgrade: Migrate negative arbitrary values to negative bare values, e.g.: `mb-[-32rem]` → `-mb-128` ([#18212](https://github.com/tailwindlabs/tailwindcss/pull/18212))