diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c3ea1823..23cd3eac1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure utilities are sorted based on their actual property order ([#16995](https://github.com/tailwindlabs/tailwindcss/pull/16995))
- Ensure strings in Pug and Slim templates are handled correctly ([#17000](https://github.com/tailwindlabs/tailwindcss/pull/17000))
+- Ensure `}` and `{` are valid boundary characters when extracting candidates ([#17001](https://github.com/tailwindlabs/tailwindcss/pull/17001))
## [4.0.11] - 2025-03-06
diff --git a/crates/oxide/src/extractor/candidate_machine.rs b/crates/oxide/src/extractor/candidate_machine.rs
index 7ba8441a0..e998c17d5 100644
--- a/crates/oxide/src/extractor/candidate_machine.rs
+++ b/crates/oxide/src/extractor/candidate_machine.rs
@@ -191,7 +191,7 @@ fn is_valid_common_boundary(c: &u8) -> bool {
/// A candidate must be preceded by any of these characters.
#[inline(always)]
fn is_valid_before_boundary(c: &u8) -> bool {
- is_valid_common_boundary(c) || matches!(c, b'.')
+ is_valid_common_boundary(c) || matches!(c, b'.' | b'}')
}
/// A candidate must be followed by any of these characters.
@@ -200,8 +200,8 @@ fn is_valid_before_boundary(c: &u8) -> bool {
/// E.g.: `
` Svelte
/// ^
#[inline(always)]
-fn is_valid_after_boundary(c: &u8) -> bool {
- is_valid_common_boundary(c) || matches!(c, b'}' | b']' | b'=')
+pub fn is_valid_after_boundary(c: &u8) -> bool {
+ is_valid_common_boundary(c) || matches!(c, b'}' | b']' | b'=' | b'{')
}
#[inline(always)]
@@ -316,13 +316,16 @@ mod tests {
//
// HTML
// Inside a class (on its own)
- (r#"
"#, vec![]),
+ (r#"
"#, vec!["class"]),
// Inside a class (first)
- (r#"
"#, vec!["foo"]),
+ (r#"
"#, vec!["class", "foo"]),
// Inside a class (second)
- (r#"
"#, vec!["foo"]),
+ (r#"
"#, vec!["class", "foo"]),
// Inside a class (surrounded)
- (r#"
"#, vec!["foo", "bar"]),
+ (
+ r#"
"#,
+ vec!["class", "foo", "bar"],
+ ),
// --------------------------
//
// JavaScript
diff --git a/crates/oxide/src/extractor/mod.rs b/crates/oxide/src/extractor/mod.rs
index 8f71f27eb..a3e16c6ca 100644
--- a/crates/oxide/src/extractor/mod.rs
+++ b/crates/oxide/src/extractor/mod.rs
@@ -248,6 +248,22 @@ mod tests {
assert_eq!(actual, expected);
}
+ fn assert_extract_candidates_contains(input: &str, expected: Vec<&str>) {
+ let actual = extract_sorted_candidates(input);
+
+ let mut missing = vec![];
+ for item in &expected {
+ if !actual.contains(item) {
+ missing.push(item);
+ }
+ }
+
+ if !missing.is_empty() {
+ dbg!(&actual, &missing);
+ panic!("Missing some items");
+ }
+ }
+
fn assert_extract_sorted_css_variables(input: &str, expected: Vec<&str>) {
let actual = extract_sorted_css_variables(input);
@@ -311,6 +327,7 @@ mod tests {
(
r#"
"#,
vec![
+ "class",
"flex",
"items-center",
"px-2.5",
@@ -363,7 +380,7 @@ mod tests {
("{ underline: true }", vec!["underline", "true"]),
(
r#"
"#,
- vec!["h-4", "w-4", "invisible", "index"],
+ vec!["className", "h-4", "w-4", "invisible", "index"],
),
// You can have variants but in a string. Vue example.
(
@@ -480,13 +497,16 @@ mod tests {
//
// HTML
// Inside a class (on its own)
- (r#"
"#, vec![]),
+ (r#"
"#, vec!["class"]),
// Inside a class (first)
- (r#"
"#, vec!["foo"]),
+ (r#"
"#, vec!["class", "foo"]),
// Inside a class (second)
- (r#"
"#, vec!["foo"]),
+ (r#"
"#, vec!["class", "foo"]),
// Inside a class (surrounded)
- (r#"
"#, vec!["foo", "bar"]),
+ (
+ r#"
"#,
+ vec!["class", "foo", "bar"],
+ ),
// --------------------------
//
// JavaScript
@@ -590,7 +610,7 @@ mod tests {
// Quoted attribute
(
r#"input(type="checkbox" class="px-2.5")"#,
- vec!["checkbox", "px-2.5"],
+ vec!["checkbox", "class", "px-2.5"],
),
] {
assert_extract_sorted_candidates(&pre_process_input(input, "pug"), expected);
@@ -611,7 +631,7 @@ mod tests {
vec!["bg-blue-100", "2xl:bg-red-100"],
),
// Quoted attribute
- (r#"div class="px-2.5""#, vec!["div", "px-2.5"]),
+ (r#"div class="px-2.5""#, vec!["div", "class", "px-2.5"]),
] {
assert_extract_sorted_candidates(&pre_process_input(input, "slim"), expected);
}
@@ -831,6 +851,25 @@ mod tests {
&pre_process_input(r#"
"#, "svelte"),
vec!["class", "px-4", "condition"],
);
+ assert_extract_sorted_candidates(
+ &pre_process_input(r#"
"#, "svelte"),
+ vec!["class", "flex", "condition"],
+ );
+ }
+
+ // https://github.com/tailwindlabs/tailwindcss/issues/16999
+ #[test]
+ fn test_twig_syntax() {
+ assert_extract_candidates_contains(
+ r#"
"#,
+ vec!["flex", "items-center", "mx-4", "h-4"],
+ );
+
+ // With touching both `}` and `{`
+ assert_extract_candidates_contains(
+ r#"
"#,
+ vec!["flex", "block"],
+ );
}
// https://github.com/tailwindlabs/tailwindcss/issues/16982
@@ -839,6 +878,7 @@ mod tests {
assert_extract_sorted_candidates(
r#"
"#,
vec![
+ "class",
"@md:flex",
"@max-md:flex",
"@-[36rem]:flex",
@@ -852,7 +892,7 @@ mod tests {
fn test_classes_containing_number_followed_by_dash_or_underscore() {
assert_extract_sorted_candidates(
r#"
"#,
- vec!["text-Title1_Strong"],
+ vec!["class", "text-Title1_Strong"],
);
}
@@ -861,7 +901,11 @@ mod tests {
fn test_arbitrary_variable_with_data_type() {
assert_extract_sorted_candidates(
r#"
"#,
- vec!["bg-(length:--my-length)", "bg-[color:var(--my-color)]"],
+ vec![
+ "class",
+ "bg-(length:--my-length)",
+ "bg-[color:var(--my-color)]",
+ ],
);
}
diff --git a/crates/oxide/src/extractor/named_utility_machine.rs b/crates/oxide/src/extractor/named_utility_machine.rs
index 3483abe29..a4d3b9de0 100644
--- a/crates/oxide/src/extractor/named_utility_machine.rs
+++ b/crates/oxide/src/extractor/named_utility_machine.rs
@@ -1,6 +1,7 @@
use crate::cursor;
use crate::extractor::arbitrary_value_machine::ArbitraryValueMachine;
use crate::extractor::arbitrary_variable_machine::ArbitraryVariableMachine;
+use crate::extractor::candidate_machine::is_valid_after_boundary;
use crate::extractor::machine::{Machine, MachineState};
use classification_macros::ClassifyBytes;
@@ -120,19 +121,22 @@ impl Machine for NamedUtilityMachine {
// E.g.: `:div="{ flex: true }"` (JavaScript object syntax)
// ^
Class::AlphaLower | Class::AlphaUpper => {
- match cursor.next.into() {
- Class::Quote
- | Class::Whitespace
- | Class::CloseBracket
- | Class::Dot
- | Class::Colon
- | Class::End
- | Class::Slash
- | Class::Exclamation => return self.done(self.start_pos, cursor),
-
- // Still valid characters
- _ => cursor.advance(),
+ if is_valid_after_boundary(&cursor.next) || {
+ // Or any of these characters
+ //
+ // - `:`, because of JS object keys
+ // - `/`, because of modifiers
+ // - `!`, because of important
+ matches!(
+ cursor.next.into(),
+ Class::Colon | Class::Slash | Class::Exclamation
+ )
+ } {
+ return self.done(self.start_pos, cursor);
}
+
+ // Still valid characters
+ cursor.advance()
}
Class::Dash => match cursor.next.into() {
@@ -213,14 +217,20 @@ impl Machine for NamedUtilityMachine {
// ^
// E.g.: `:div="{ flex: true }"` (JavaScript object syntax)
// ^
- Class::Quote
- | Class::Whitespace
- | Class::CloseBracket
- | Class::Dot
- | Class::Colon
- | Class::End
- | Class::Slash
- | Class::Exclamation => return self.done(self.start_pos, cursor),
+ _ if is_valid_after_boundary(&cursor.next) || {
+ // Or any of these characters
+ //
+ // - `:`, because of JS object keys
+ // - `/`, because of modifiers
+ // - `!`, because of important
+ matches!(
+ cursor.next.into(),
+ Class::Colon | Class::Slash | Class::Exclamation
+ )
+ } =>
+ {
+ return self.done(self.start_pos, cursor)
+ }
// Everything else is invalid
_ => return self.restart(),
@@ -454,15 +464,15 @@ mod tests {
//
// HTML
// Inside a class (on its own)
- (r#"
"#, vec!["div"]),
+ (r#"
"#, vec!["div", "class"]),
// Inside a class (first)
- (r#"
"#, vec!["div", "foo"]),
+ (r#"
"#, vec!["div", "class", "foo"]),
// Inside a class (second)
- (r#"
"#, vec!["div", "foo"]),
+ (r#"
"#, vec!["div", "class", "foo"]),
// Inside a class (surrounded)
(
r#"
"#,
- vec!["div", "foo", "bar"],
+ vec!["div", "class", "foo", "bar"],
),
// --------------------------
//
@@ -475,7 +485,10 @@ mod tests {
vec!["let", "classes", "true"],
),
// Inside an object (no spaces, key)
- (r#"let classes = {'{}':true};"#, vec!["let", "classes"]),
+ (
+ r#"let classes = {'{}':true};"#,
+ vec!["let", "classes", "true"],
+ ),
// Inside an object (value)
(
r#"let classes = { primary: '{}' };"#,
diff --git a/crates/oxide/src/extractor/utility_machine.rs b/crates/oxide/src/extractor/utility_machine.rs
index ea5241a2d..5f8c74aa7 100644
--- a/crates/oxide/src/extractor/utility_machine.rs
+++ b/crates/oxide/src/extractor/utility_machine.rs
@@ -266,8 +266,6 @@ mod tests {
"bg-(--my-color) flex px-(--my-padding)",
vec!["bg-(--my-color)", "flex", "px-(--my-padding)"],
),
- // Pug syntax
- (".flex.bg-red-500", vec!["flex", "bg-red-500"]),
// --------------------------------------------------------
// Exceptions:
@@ -293,15 +291,15 @@ mod tests {
//
// HTML
// Inside a class (on its own)
- (r#"
"#, vec!["div"]),
+ (r#"
"#, vec!["div", "class"]),
// Inside a class (first)
- (r#"
"#, vec!["div", "foo"]),
+ (r#"
"#, vec!["div", "class", "foo"]),
// Inside a class (second)
- (r#"
"#, vec!["div", "foo"]),
+ (r#"
"#, vec!["div", "class", "foo"]),
// Inside a class (surrounded)
(
r#"
"#,
- vec!["div", "foo", "bar"],
+ vec!["div", "class", "foo", "bar"],
),
// --------------------------
//
@@ -314,7 +312,10 @@ mod tests {
vec!["let", "classes", "true"],
),
// Inside an object (no spaces, key)
- (r#"let classes = {'{}':true};"#, vec!["let", "classes"]),
+ (
+ r#"let classes = {'{}':true};"#,
+ vec!["let", "classes", "true"],
+ ),
// Inside an object (value)
(
r#"let classes = { primary: '{}' };"#,
diff --git a/crates/oxide/src/lib.rs b/crates/oxide/src/lib.rs
index d8398478f..de1d06d31 100644
--- a/crates/oxide/src/lib.rs
+++ b/crates/oxide/src/lib.rs
@@ -3,7 +3,6 @@ use crate::scanner::allowed_paths::resolve_paths;
use crate::scanner::detect_sources::DetectSources;
use bexpand::Expression;
use bstr::ByteSlice;
-use extractor::string_machine::StringMachine;
use extractor::{Extracted, Extractor};
use fast_glob::glob_match;
use fxhash::{FxHashMap, FxHashSet};
@@ -541,6 +540,7 @@ mod tests {
(
r#"
"#,
vec![
+ ("class".to_string(), 5),
("tw:flex!".to_string(), 12),
("tw:sm:block!".to_string(), 21),
("tw:bg-linear-to-t".to_string(), 34),