diff --git a/CHANGELOG.md b/CHANGELOG.md index d85a5da4b39b..261ecb8e503f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _Experimental_: Add `user-valid` and `user-invalid` variants ([#12370](https://github.com/tailwindlabs/tailwindcss/pull/12370)) - _Experimental_: Add `wrap-anywhere`, `wrap-break-word`, and `wrap-normal` utilities ([#12128](https://github.com/tailwindlabs/tailwindcss/pull/12128)) +### Fixed + +- Do not extract candidates with JS string interpolation `${` ([#17142](https://github.com/tailwindlabs/tailwindcss/pull/17142)) + ## [4.0.13] - 2025-03-11 ### Fixed diff --git a/crates/oxide/src/extractor/arbitrary_property_machine.rs b/crates/oxide/src/extractor/arbitrary_property_machine.rs index 947073c2fb99..bf5373a3d333 100644 --- a/crates/oxide/src/extractor/arbitrary_property_machine.rs +++ b/crates/oxide/src/extractor/arbitrary_property_machine.rs @@ -226,6 +226,11 @@ impl Machine for ArbitraryPropertyMachine { // URLs are not allowed Class::Slash if start_of_value_pos == cursor.pos => return self.restart(), + // String interpolation-like syntax is not allowed. E.g.: `[${x}]` + Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => { + return self.restart() + } + // Everything else is valid _ => cursor.advance(), }; @@ -276,6 +281,9 @@ enum Class { #[bytes(b'-')] Dash, + #[bytes(b'$')] + Dollar, + #[bytes_range(b'a'..=b'z')] AlphaLower, @@ -411,4 +419,26 @@ mod tests { } } } + + #[test] + fn test_exceptions() { + for (input, expected) in [ + // JS string interpolation + // In key + ("[${x}:value]", vec![]), + // As part of the key + ("[background-${property}:value]", vec![]), + // In value + ("[key:${x}]", vec![]), + // As part of the value + ("[key:value-${x}]", vec![]), + // Allowed in strings + ("[--img:url('${x}')]", vec!["[--img:url('${x}')]"]), + ] { + assert_eq!( + ArbitraryPropertyMachine::::test_extract_all(input), + expected + ); + } + } } diff --git a/crates/oxide/src/extractor/arbitrary_value_machine.rs b/crates/oxide/src/extractor/arbitrary_value_machine.rs index c99f7f54ad5f..b4370c2d2d2b 100644 --- a/crates/oxide/src/extractor/arbitrary_value_machine.rs +++ b/crates/oxide/src/extractor/arbitrary_value_machine.rs @@ -95,6 +95,11 @@ impl Machine for ArbitraryValueMachine { // Any kind of whitespace is not allowed Class::Whitespace => return self.restart(), + // String interpolation-like syntax is not allowed. E.g.: `[${x}]` + Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => { + return self.restart() + } + // Everything else is valid _ => cursor.advance(), }; @@ -133,6 +138,9 @@ enum Class { #[bytes(b' ', b'\t', b'\n', b'\r', b'\x0C')] Whitespace, + #[bytes(b'$')] + Dollar, + #[fallback] Other, } @@ -188,4 +196,17 @@ mod tests { assert_eq!(ArbitraryValueMachine::test_extract_all(input), expected); } } + + #[test] + fn test_exceptions() { + for (input, expected) in [ + // JS string interpolation + ("[${x}]", vec![]), + ("[url(${x})]", vec![]), + // Allowed in strings + ("[url('${x}')]", vec!["[url('${x}')]"]), + ] { + assert_eq!(ArbitraryValueMachine::test_extract_all(input), expected); + } + } } diff --git a/crates/oxide/src/extractor/arbitrary_variable_machine.rs b/crates/oxide/src/extractor/arbitrary_variable_machine.rs index 5c26b0659c51..355572769d11 100644 --- a/crates/oxide/src/extractor/arbitrary_variable_machine.rs +++ b/crates/oxide/src/extractor/arbitrary_variable_machine.rs @@ -252,6 +252,11 @@ impl Machine for ArbitraryVariableMachine { // Any kind of whitespace is not allowed Class::Whitespace => return self.restart(), + // String interpolation-like syntax is not allowed. E.g.: `[${x}]` + Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => { + return self.restart() + } + // Everything else is valid _ => cursor.advance(), }; @@ -284,6 +289,9 @@ enum Class { #[bytes(b'.')] Dot, + #[bytes(b'$')] + Dollar, + #[bytes(b'\\')] Escape, @@ -380,4 +388,25 @@ mod tests { ); } } + + #[test] + fn test_exceptions() { + for (input, expected) in [ + // JS string interpolation + // As part of the variable + ("(--my-${var})", vec![]), + // As the fallback + ("(--my-variable,${var})", vec![]), + // As the fallback in strings + ( + "(--my-variable,url('${var}'))", + vec!["(--my-variable,url('${var}'))"], + ), + ] { + assert_eq!( + ArbitraryVariableMachine::::test_extract_all(input), + expected + ); + } + } } diff --git a/crates/oxide/src/extractor/candidate_machine.rs b/crates/oxide/src/extractor/candidate_machine.rs index 1d92ea153afc..33b67098feef 100644 --- a/crates/oxide/src/extractor/candidate_machine.rs +++ b/crates/oxide/src/extractor/candidate_machine.rs @@ -327,4 +327,34 @@ mod tests { ); } } + + #[test] + fn test_js_interpolation() { + for (input, expected) in [ + // Utilities + // Arbitrary value + ("bg-[${color}]", vec![]), + // Arbitrary property + ("[color:${value}]", vec![]), + ("[${key}:value]", vec![]), + ("[${key}:${value}]", vec![]), + // Arbitrary property for CSS variables + ("[--color:${value}]", vec![]), + ("[--color-${name}:value]", vec![]), + // Arbitrary variable + ("bg-(--my-${name})", vec![]), + ("bg-(--my-variable,${fallback})", vec![]), + ( + "bg-(--my-image,url('https://example.com?q=${value}'))", + vec!["bg-(--my-image,url('https://example.com?q=${value}'))"], + ), + // Variants + ("data-[state=${state}]:flex", vec![]), + ("support-(--my-${value}):flex", vec![]), + ("support-(--my-variable,${fallback}):flex", vec![]), + ("[@media(width>=${value})]:flex", vec![]), + ] { + assert_eq!(CandidateMachine::test_extract_all(input), expected); + } + } }