From bea843c90ab77b47622200daafb918f7044d9f88 Mon Sep 17 00:00:00 2001 From: Cyril Duez Date: Mon, 9 Jun 2025 16:03:44 +0200 Subject: [PATCH] CSS Parser: Handle string with semi-colon in custom properties. (#18251) Strings are not parsed correctly for custom properties which makes the following CSS raise an `Unterminated string: ";"` error: ```css :root { --custom: 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='; } ``` According to the spec, we should accept semi-colon as long as they are not at the top level. > The allowed syntax for [custom properties](https://drafts.csswg.org/css-variables/#custom-property) is extremely permissive. The production matches any sequence of one or more tokens, so long as the sequence does not contain bad-string-token, bad-url-token, unmatched )-token, ]-token, or }-token, or top-level semicolon-token tokens or delim-token tokens with a value of "!". Extract from: https://drafts.csswg.org/css-variables/#syntax I was only able to reproduce with **tailwindcss v4**, the previous version seems to support this. This issue is mitigated by the fact that even if you want to use a data URL in a custom property, you would need to wrap the value in a `url()` anyway: ```css :root { --my-icon-url: url('data:image/svg+xml;base64,...=='); } .icon { background-image: var(--my-icon-url); } ``` Which works perfectly fine with the current/latest version (v4.1.8). The fix suggested is to share the same code between regular property and custom property when it comes to detect that the value is a string starting with a `SINGLE_QUOTE` or `DOUBLE_QUOTE`. I have moved the existing code in a `findEndStringIdx` which returns the position of the ending single/double quote. --------- Co-authored-by: Jordan Pittman --- CHANGELOG.md | 1 + packages/tailwindcss/src/css-parser.test.ts | 26 ++++ packages/tailwindcss/src/css-parser.ts | 144 +++++++++++--------- 3 files changed, 105 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1694dfe..f75160c1b 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)) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index f7ee47a61..b477758ae 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 6a5ce8320..a59e34721 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 end = parseString(input, i, currentChar) // Adjust `buffer` to include the string. - buffer += input.slice(start, i + 1) + buffer += input.slice(i, end + 1) + i = end } // 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 = parseString(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,73 @@ function parseDeclaration( importantIdx !== -1, ) } + +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. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a 'quote' in it"; + // ^ ^ -> These are not the end of the string. + // } + // ``` + 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) { + i += 1 + } + + // End of the string. + else if (peekChar === quoteChar) { + return i + } + + // 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(i + 1) === LINE_BREAK || + (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) + ) { + throw new Error( + `Unterminated string: ${input.slice(startIdx, i + 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(i + 1) === LINE_BREAK) + ) { + throw new Error( + `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, + ) + } + } + + return startIdx +}