From 59764f515d7f451ba9f6ca5a3d274f167c8e9125 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Mon, 25 May 2026 06:56:31 +0530 Subject: [PATCH] Handle Live Component classes in Twig templates --- crates/oxide/src/extractor/mod.rs | 11 ++ .../oxide/src/extractor/pre_processors/mod.rs | 2 + .../src/extractor/pre_processors/twig.rs | 111 ++++++++++++++++++ crates/oxide/src/scanner/mod.rs | 1 + 4 files changed, 125 insertions(+) create mode 100644 crates/oxide/src/extractor/pre_processors/twig.rs diff --git a/crates/oxide/src/extractor/mod.rs b/crates/oxide/src/extractor/mod.rs index a37d6a0a49b3..19ee491e77b9 100644 --- a/crates/oxide/src/extractor/mod.rs +++ b/crates/oxide/src/extractor/mod.rs @@ -906,6 +906,17 @@ mod tests { r#"
"#, vec!["flex", "block"], ); + + // Symfony Live Components loading directives. + // + // https://github.com/tailwindlabs/tailwindcss/issues/19458 + assert_extract_candidates_contains( + &pre_process_input( + r#"
"#, + "twig", + ), + vec!["opacity-50", "pointer-events-none", "hidden"], + ); } // https://github.com/tailwindlabs/tailwindcss/issues/17050 diff --git a/crates/oxide/src/extractor/pre_processors/mod.rs b/crates/oxide/src/extractor/pre_processors/mod.rs index efcbc53d86d2..1876ec6aaac1 100644 --- a/crates/oxide/src/extractor/pre_processors/mod.rs +++ b/crates/oxide/src/extractor/pre_processors/mod.rs @@ -10,6 +10,7 @@ pub mod ruby; pub mod rust; pub mod slim; pub mod svelte; +pub mod twig; pub mod vue; pub use clojure::*; @@ -24,4 +25,5 @@ pub use ruby::*; pub use rust::*; pub use slim::*; pub use svelte::*; +pub use twig::*; pub use vue::*; diff --git a/crates/oxide/src/extractor/pre_processors/twig.rs b/crates/oxide/src/extractor/pre_processors/twig.rs new file mode 100644 index 000000000000..7eb4859cad6d --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/twig.rs @@ -0,0 +1,111 @@ +use crate::extractor::pre_processors::pre_processor::PreProcessor; + +#[derive(Debug, Default)] +pub struct Twig; + +impl PreProcessor for Twig { + fn process(&self, content: &[u8]) -> Vec { + let mut result = content.to_vec(); + let mut cursor = 0; + + while cursor < content.len() { + let Some(directive_len) = directive_at(content, cursor) else { + cursor += 1; + continue; + }; + + result[cursor..cursor + directive_len].fill(b' '); + + let mut depth = 1; + let mut end = cursor + directive_len; + + while end < content.len() { + match content[end] { + b'\\' => { + end += 2; + continue; + } + b'(' => depth += 1, + b')' => { + depth -= 1; + + if depth == 0 { + result[end] = b' '; + cursor = end + 1; + break; + } + } + _ => {} + } + + end += 1; + } + + if end >= content.len() { + break; + } + } + + result + } +} + +fn directive_at(content: &[u8], offset: usize) -> Option { + if !is_directive_boundary(content, offset) { + return None; + } + + for directive in [b"addClass(".as_slice(), b"removeClass(".as_slice()] { + if content[offset..].starts_with(directive) { + return Some(directive.len()); + } + } + + None +} + +fn is_directive_boundary(content: &[u8], offset: usize) -> bool { + if offset == 0 { + return true; + } + + matches!( + content[offset - 1], + b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'"' | b'\'' | b'`' | b'|' | b'=' + ) +} + +#[cfg(test)] +mod tests { + use super::Twig; + use crate::extractor::pre_processors::pre_processor::PreProcessor; + + #[test] + fn test_live_component_loading_directives() { + Twig::test("addClass(opacity-50)", " opacity-50 "); + Twig::test( + "data-loading=\"delay|addClass(opacity-50)|removeClass(hidden)\"", + "data-loading=\"delay| opacity-50 | hidden \"", + ); + Twig::test( + "data-loading=\"model(user.email)|addClass(bg-(--loading-color))\"", + "data-loading=\"model(user.email)| bg-(--loading-color) \"", + ); + } + + #[test] + fn test_extract_live_component_classes() { + Twig::test_extract_contains( + r#"
"#, + vec!["opacity-50"], + ); + Twig::test_extract_contains( + r#"
"#, + vec!["opacity-50", "pointer-events-none", "hidden"], + ); + Twig::test_extract_contains( + r#"
"#, + vec!["bg-(--loading-color)", "bg-[url(https://example.com)]"], + ); + } +} diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 30724f162188..a9b611fc0e22 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -480,6 +480,7 @@ pub fn pre_process_input(content: Vec, extension: &str) -> Vec { "rb" | "erb" => Ruby.process(&content), "slim" | "slang" => Slim.process(&content), "svelte" => Svelte.process(&content), + "twig" => Twig.process(&content), "rs" => Rust.process(&content), "vue" => Vue.process(&content), _ => content,