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 <declaration-value> 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 <jordan@cryptica.me>
This commit is contained in:
Cyril Duez 2025-06-09 16:03:44 +02:00 • committed by GitHub
parent fd95af4284
commit bea843c90a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 66 deletions

View file

@ -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))

View file

@ -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\`]`,

View file

@ -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
}