From 7005ad7e00fc27889d3cfbc21ddc8b5cce96cfed Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 7 Mar 2025 22:32:15 +0100 Subject: [PATCH] Add Haml pre processor (#17051) This PR ensures we extract candidates from Haml files. Fixes: #17050 --- CHANGELOG.md | 6 +- crates/oxide/src/extractor/mod.rs | 50 +++++++ .../src/extractor/pre_processors/haml.rs | 123 ++++++++++++++++++ .../oxide/src/extractor/pre_processors/mod.rs | 2 + crates/oxide/src/lib.rs | 1 + 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 crates/oxide/src/extractor/pre_processors/haml.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cec2e2f58..1b4190422 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 + +- Fix `haml` pre-processing ([#17051](https://github.com/tailwindlabs/tailwindcss/pull/17051)) + ## [4.0.12] - 2025-03-07 ### Fixed @@ -26,7 +30,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 classes between `}` and `{` are properly extracted ([#17001](https://github.com/tailwindlabs/tailwindcss/pull/17001)) -- Add `razor`/`cshtml` pre processing ([#17027](https://github.com/tailwindlabs/tailwindcss/pull/17027)) +- Fix `razor`/`cshtml` pre-processing ([#17027](https://github.com/tailwindlabs/tailwindcss/pull/17027)) - Ensure extracting candidates from JS embedded in a PHP string works as expected ([#17031](https://github.com/tailwindlabs/tailwindcss/pull/17031)) ## [4.0.11] - 2025-03-06 diff --git a/crates/oxide/src/extractor/mod.rs b/crates/oxide/src/extractor/mod.rs index e6e88b8f1..5a9de6922 100644 --- a/crates/oxide/src/extractor/mod.rs +++ b/crates/oxide/src/extractor/mod.rs @@ -873,6 +873,55 @@ mod tests { ); } + // https://github.com/tailwindlabs/tailwindcss/issues/17050 + #[test] + fn test_haml_syntax() { + for (input, expected) in [ + // Element with classes + ( + "%body.flex.flex-col.items-center.justify-center", + vec!["flex", "flex-col", "items-center", "justify-center"], + ), + // Plain element + ( + ".text-slate-500.xl:text-gray-500", + vec!["text-slate-500", "xl:text-gray-500"], + ), + // Element with hash attributes + ( + ".text-black.xl:text-red-500{ data: { tailwind: 'css' } }", + vec!["text-black", "xl:text-red-500"], + ), + // Element with a boolean attribute + ( + ".text-green-500.xl:text-blue-500(data-sidebar)", + vec!["text-green-500", "xl:text-blue-500"], + ), + // Element with interpreted content + ( + ".text-yellow-500.xl:text-purple-500= 'Element with interpreted content'", + vec!["text-yellow-500", "xl:text-purple-500"], + ), + // Element with a hash at the end and an extra class. + ( + ".text-orange-500.xl:text-pink-500{ class: 'bg-slate-100' }", + vec!["text-orange-500", "xl:text-pink-500", "bg-slate-100"], + ), + // Object reference + ( + ".text-teal-500.xl:text-indigo-500[@user, :greeting]", + vec!["text-teal-500", "xl:text-indigo-500"], + ), + // Element with an ID + ( + ".text-lime-500.xl:text-emerald-500#root", + vec!["text-lime-500", "xl:text-emerald-500"], + ), + ] { + assert_extract_candidates_contains(&pre_process_input(input, "haml"), expected); + } + } + // https://github.com/tailwindlabs/tailwindcss/issues/16982 #[test] fn test_arbitrary_container_queries_syntax() { @@ -888,6 +937,7 @@ mod tests { ); } + // https://github.com/tailwindlabs/tailwindcss/issues/17023 #[test] fn test_js_embedded_in_php_syntax() { // Escaped single quotes diff --git a/crates/oxide/src/extractor/pre_processors/haml.rs b/crates/oxide/src/extractor/pre_processors/haml.rs new file mode 100644 index 000000000..4daed1c2b --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/haml.rs @@ -0,0 +1,123 @@ +use crate::cursor; +use crate::extractor::bracket_stack::BracketStack; +use crate::extractor::pre_processors::pre_processor::PreProcessor; + +#[derive(Debug, Default)] +pub struct Haml; + +impl PreProcessor for Haml { + fn process(&self, content: &[u8]) -> Vec { + let len = content.len(); + let mut result = content.to_vec(); + let mut cursor = cursor::Cursor::new(content); + let mut bracket_stack = BracketStack::default(); + + while cursor.pos < len { + match cursor.curr { + // Consume strings as-is + b'\'' | b'"' => { + let len = cursor.input.len(); + let end_char = cursor.curr; + + cursor.advance(); + + while cursor.pos < len { + match cursor.curr { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + + // End of the string + b'\'' | b'"' if cursor.curr == end_char => break, + + // Everything else is valid + _ => cursor.advance(), + }; + } + } + + // Replace following characters with spaces if they are not inside of brackets + b'.' | b'#' | b'=' if bracket_stack.is_empty() => { + result[cursor.pos] = b' '; + } + + b'(' | b'[' | b'{' => { + // Replace first bracket with a space + if bracket_stack.is_empty() { + result[cursor.pos] = b' '; + } + bracket_stack.push(cursor.curr); + } + + b')' | b']' | b'}' => { + bracket_stack.pop(cursor.curr); + + // Replace closing bracket with a space + if bracket_stack.is_empty() { + result[cursor.pos] = b' '; + } + } + + // Consume everything else + _ => {} + }; + + cursor.advance(); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::Haml; + use crate::extractor::pre_processors::pre_processor::PreProcessor; + + #[test] + fn test_haml_pre_processor() { + for (input, expected) in [ + // Element with classes + ( + "%body.flex.flex-col.items-center.justify-center", + "%body flex flex-col items-center justify-center", + ), + // Plain element + ( + ".text-slate-500.xl:text-gray-500", + " text-slate-500 xl:text-gray-500", + ), + // Element with hash attributes + ( + ".text-black.xl:text-red-500{ data: { tailwind: 'css' } }", + " text-black xl:text-red-500 data: { tailwind: 'css' } ", + ), + // Element with a boolean attribute + ( + ".text-green-500.xl:text-blue-500(data-sidebar)", + " text-green-500 xl:text-blue-500 data-sidebar ", + ), + // Element with interpreted content + ( + ".text-yellow-500.xl:text-purple-500= 'Element with interpreted content'", + " text-yellow-500 xl:text-purple-500 'Element with interpreted content'", + ), + // Element with a hash at the end and an extra class. + ( + ".text-orange-500.xl:text-pink-500{ class: 'bg-slate-100' }", + " text-orange-500 xl:text-pink-500 class: 'bg-slate-100' ", + ), + // Object reference + ( + ".text-teal-500.xl:text-indigo-500[@user, :greeting]", + " text-teal-500 xl:text-indigo-500 @user, :greeting ", + ), + // Element with an ID + ( + ".text-lime-500.xl:text-emerald-500#root", + " text-lime-500 xl:text-emerald-500 root", + ), + ] { + Haml::test(input, expected); + } + } +} diff --git a/crates/oxide/src/extractor/pre_processors/mod.rs b/crates/oxide/src/extractor/pre_processors/mod.rs index 8a3c4da10..1a7bbdd97 100644 --- a/crates/oxide/src/extractor/pre_processors/mod.rs +++ b/crates/oxide/src/extractor/pre_processors/mod.rs @@ -1,3 +1,4 @@ +pub mod haml; pub mod pre_processor; pub mod pug; pub mod razor; @@ -5,6 +6,7 @@ pub mod ruby; pub mod slim; pub mod svelte; +pub use haml::*; pub use pre_processor::*; pub use pug::*; pub use razor::*; diff --git a/crates/oxide/src/lib.rs b/crates/oxide/src/lib.rs index 93155b02e..1a1daf5d5 100644 --- a/crates/oxide/src/lib.rs +++ b/crates/oxide/src/lib.rs @@ -469,6 +469,7 @@ pub fn pre_process_input(content: &[u8], extension: &str) -> Vec { match extension { "cshtml" | "razor" => Razor.process(content), + "haml" => Haml.process(content), "pug" => Pug.process(content), "rb" | "erb" => Ruby.process(content), "slim" => Slim.process(content),