Skip to content

Commit bea843c

Browse files
cduezthecrypticace
andauthored
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>
1 parent fd95af4 commit bea843c

File tree

3 files changed

+105
-66
lines changed

3 files changed

+105
-66
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- Correctly parse custom properties with strings containing semicolons ([#18251](https://github.com/tailwindlabs/tailwindcss/pull/18251))
1213
- Upgrade: migrate arbitrary modifiers with values without percentage sign to bare values `/[0.16]` -> `/16` ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
1314
- Upgrade: migrate CSS variable shorthand if fallback value contains function call ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
1415
- Upgrade: Migrate negative arbitrary values to negative bare values, e.g.: `mb-[-32rem]``-mb-128` ([#18212](https://github.com/tailwindlabs/tailwindcss/pull/18212))

packages/tailwindcss/src/css-parser.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,21 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
457457
`),
458458
).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }])
459459
})
460+
461+
it('should parse custom properties with data URL value', () => {
462+
expect(
463+
parse(css`
464+
--foo: 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==';
465+
`),
466+
).toEqual([
467+
{
468+
kind: 'declaration',
469+
property: '--foo',
470+
value: "'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='",
471+
important: false,
472+
},
473+
])
474+
})
460475
})
461476

462477
it('should parse multiple declarations', () => {
@@ -1132,6 +1147,17 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
11321147
)
11331148
})
11341149

1150+
it('should error when an unterminated string is used in a custom property', () => {
1151+
expect(() =>
1152+
parse(css`
1153+
.foo {
1154+
--bar: "Hello world!
1155+
/* ^ missing " */
1156+
}
1157+
`),
1158+
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`)
1159+
})
1160+
11351161
it('should error when a declaration is incomplete', () => {
11361162
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
11371163
`[Error: Invalid declaration: \`bar\`]`,

packages/tailwindcss/src/css-parser.ts

Lines changed: 78 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -138,74 +138,11 @@ export function parse(input: string, opts?: ParseOptions) {
138138

139139
// Start of a string.
140140
else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) {
141-
let start = i
142-
143-
// We need to ensure that the closing quote is the same as the opening
144-
// quote.
145-
//
146-
// E.g.:
147-
//
148-
// ```css
149-
// .foo {
150-
// content: "This is a string with a 'quote' in it";
151-
// ^ ^ -> These are not the end of the string.
152-
// }
153-
// ```
154-
for (let j = i + 1; j < input.length; j++) {
155-
peekChar = input.charCodeAt(j)
156-
// Current character is a `\` therefore the next character is escaped.
157-
if (peekChar === BACKSLASH) {
158-
j += 1
159-
}
160-
161-
// End of the string.
162-
else if (peekChar === currentChar) {
163-
i = j
164-
break
165-
}
166-
167-
// End of the line without ending the string but with a `;` at the end.
168-
//
169-
// E.g.:
170-
//
171-
// ```css
172-
// .foo {
173-
// content: "This is a string with a;
174-
// ^ Missing "
175-
// }
176-
// ```
177-
else if (
178-
peekChar === SEMICOLON &&
179-
(input.charCodeAt(j + 1) === LINE_BREAK ||
180-
(input.charCodeAt(j + 1) === CARRIAGE_RETURN && input.charCodeAt(j + 2) === LINE_BREAK))
181-
) {
182-
throw new Error(
183-
`Unterminated string: ${input.slice(start, j + 1) + String.fromCharCode(currentChar)}`,
184-
)
185-
}
186-
187-
// End of the line without ending the string.
188-
//
189-
// E.g.:
190-
//
191-
// ```css
192-
// .foo {
193-
// content: "This is a string with a
194-
// ^ Missing "
195-
// }
196-
// ```
197-
else if (
198-
peekChar === LINE_BREAK ||
199-
(peekChar === CARRIAGE_RETURN && input.charCodeAt(j + 1) === LINE_BREAK)
200-
) {
201-
throw new Error(
202-
`Unterminated string: ${input.slice(start, j) + String.fromCharCode(currentChar)}`,
203-
)
204-
}
205-
}
141+
let end = parseString(input, i, currentChar)
206142

207143
// Adjust `buffer` to include the string.
208-
buffer += input.slice(start, i + 1)
144+
buffer += input.slice(i, end + 1)
145+
i = end
209146
}
210147

211148
// Skip whitespace if the next character is also whitespace. This allows us
@@ -253,6 +190,11 @@ export function parse(input: string, opts?: ParseOptions) {
253190
j += 1
254191
}
255192

193+
// Start of a string.
194+
else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) {
195+
j = parseString(input, j, peekChar)
196+
}
197+
256198
// Start of a comment.
257199
else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) {
258200
for (let k = j + 2; k < input.length; k++) {
@@ -651,3 +593,73 @@ function parseDeclaration(
651593
importantIdx !== -1,
652594
)
653595
}
596+
597+
function parseString(input: string, startIdx: number, quoteChar: number): number {
598+
let peekChar: number
599+
600+
// We need to ensure that the closing quote is the same as the opening
601+
// quote.
602+
//
603+
// E.g.:
604+
//
605+
// ```css
606+
// .foo {
607+
// content: "This is a string with a 'quote' in it";
608+
// ^ ^ -> These are not the end of the string.
609+
// }
610+
// ```
611+
for (let i = startIdx + 1; i < input.length; i++) {
612+
peekChar = input.charCodeAt(i)
613+
614+
// Current character is a `\` therefore the next character is escaped.
615+
if (peekChar === BACKSLASH) {
616+
i += 1
617+
}
618+
619+
// End of the string.
620+
else if (peekChar === quoteChar) {
621+
return i
622+
}
623+
624+
// End of the line without ending the string but with a `;` at the end.
625+
//
626+
// E.g.:
627+
//
628+
// ```css
629+
// .foo {
630+
// content: "This is a string with a;
631+
// ^ Missing "
632+
// }
633+
// ```
634+
else if (
635+
peekChar === SEMICOLON &&
636+
(input.charCodeAt(i + 1) === LINE_BREAK ||
637+
(input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK))
638+
) {
639+
throw new Error(
640+
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
641+
)
642+
}
643+
644+
// End of the line without ending the string.
645+
//
646+
// E.g.:
647+
//
648+
// ```css
649+
// .foo {
650+
// content: "This is a string with a
651+
// ^ Missing "
652+
// }
653+
// ```
654+
else if (
655+
peekChar === LINE_BREAK ||
656+
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK)
657+
) {
658+
throw new Error(
659+
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
660+
)
661+
}
662+
}
663+
664+
return startIdx
665+
}

0 commit comments

Comments
 (0)