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)) 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..a59e34721b86 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 +}