Skip to content

Commit 85575a4

Browse files
authored
Add Maud templating support (#18988)
This PR adds support for Maud templates in Rust. We already had some pre-processing for Rust but for Leptos `class:` syntax. This PR now added a dedicated Rust pre-processor that handles Leptos and Maud syntax. We only start pre-processing Maud templates if the Rust file includes the `html!` macro. ## Test plan Looking at the extractor, you can see that we now do extract the proper classes in Maud templates: <img width="1076" height="1856" alt="image" src="https://github.com/user-attachments/assets/e649e1de-289e-466f-8fab-44a938a47dd5" /> Fixes: #18984
1 parent c6e0a55 commit 85575a4

File tree

4 files changed

+221
-1
lines changed

4 files changed

+221
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- Re-throw errors from PostCSS nodes ([#18373](https://github.com/tailwindlabs/tailwindcss/pull/18373))
2727
- Detect classes in markdown inline directives ([#18967](https://github.com/tailwindlabs/tailwindcss/pull/18967))
2828
- Ensure files with only `@theme` produce no output when built ([#18979](https://github.com/tailwindlabs/tailwindcss/pull/18979))
29+
- Support Maud templates when extracting classes ([#18988](https://github.com/tailwindlabs/tailwindcss/pull/18988))
2930

3031
## [4.1.13] - 2025-09-03
3132

crates/oxide/src/extractor/pre_processors/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod pre_processor;
77
pub mod pug;
88
pub mod razor;
99
pub mod ruby;
10+
pub mod rust;
1011
pub mod slim;
1112
pub mod svelte;
1213
pub mod vue;
@@ -20,6 +21,7 @@ pub use pre_processor::*;
2021
pub use pug::*;
2122
pub use razor::*;
2223
pub use ruby::*;
24+
pub use rust::*;
2325
pub use slim::*;
2426
pub use svelte::*;
2527
pub use vue::*;
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
use crate::extractor::bracket_stack;
2+
use crate::extractor::cursor;
3+
use crate::extractor::machine::Machine;
4+
use crate::extractor::pre_processors::pre_processor::PreProcessor;
5+
use crate::extractor::variant_machine::VariantMachine;
6+
use crate::extractor::MachineState;
7+
use bstr::ByteSlice;
8+
9+
#[derive(Debug, Default)]
10+
pub struct Rust;
11+
12+
impl PreProcessor for Rust {
13+
fn process(&self, content: &[u8]) -> Vec<u8> {
14+
// Leptos support: https://github.com/tailwindlabs/tailwindcss/pull/18093
15+
let replaced_content = content
16+
.replace(" class:", " class ")
17+
.replace("\tclass:", " class ")
18+
.replace("\nclass:", " class ");
19+
20+
if replaced_content.contains_str(b"html!") {
21+
self.process_maud_templates(&replaced_content)
22+
} else {
23+
replaced_content
24+
}
25+
}
26+
}
27+
28+
impl Rust {
29+
fn process_maud_templates(&self, replaced_content: &[u8]) -> Vec<u8> {
30+
let len = replaced_content.len();
31+
let mut result = replaced_content.to_vec();
32+
let mut cursor = cursor::Cursor::new(replaced_content);
33+
let mut bracket_stack = bracket_stack::BracketStack::default();
34+
35+
while cursor.pos < len {
36+
match cursor.curr {
37+
// Escaped character, skip ahead to the next character
38+
b'\\' => {
39+
cursor.advance_twice();
40+
continue;
41+
}
42+
43+
// Consume strings as-is
44+
b'"' => {
45+
result[cursor.pos] = b' ';
46+
cursor.advance();
47+
48+
while cursor.pos < len {
49+
match cursor.curr {
50+
// Escaped character, skip ahead to the next character
51+
b'\\' => cursor.advance_twice(),
52+
53+
// End of the string
54+
b'"' => {
55+
result[cursor.pos] = b' ';
56+
break;
57+
}
58+
59+
// Everything else is valid
60+
_ => cursor.advance(),
61+
};
62+
}
63+
}
64+
65+
// Only replace `.` with a space if it's not surrounded by numbers. E.g.:
66+
//
67+
// ```diff
68+
// - .flex.items-center
69+
// + flex items-center
70+
// ```
71+
//
72+
// But with numbers, it's allowed:
73+
//
74+
// ```diff
75+
// - px-2.5
76+
// + px-2.5
77+
// ```
78+
b'.' => {
79+
// Don't replace dots with spaces when inside of any type of brackets, because
80+
// this could be part of arbitrary values. E.g.: `bg-[url(https://example.com)]`
81+
// ^
82+
if !bracket_stack.is_empty() {
83+
cursor.advance();
84+
continue;
85+
}
86+
87+
// If the dot is surrounded by digits, we want to keep it. E.g.: `px-2.5`
88+
// EXCEPT if it's followed by a valid variant that happens to start with a
89+
// digit.
90+
// E.g.: `bg-red-500.2xl:flex`
91+
// ^^^
92+
if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() {
93+
let mut next_cursor = cursor.clone();
94+
next_cursor.advance();
95+
96+
let mut variant_machine = VariantMachine::default();
97+
if let MachineState::Done(_) = variant_machine.next(&mut next_cursor) {
98+
result[cursor.pos] = b' ';
99+
}
100+
} else {
101+
result[cursor.pos] = b' ';
102+
}
103+
}
104+
105+
b'[' => {
106+
bracket_stack.push(cursor.curr);
107+
}
108+
109+
b']' if !bracket_stack.is_empty() => {
110+
bracket_stack.pop(cursor.curr);
111+
}
112+
113+
// Consume everything else
114+
_ => {}
115+
};
116+
117+
cursor.advance();
118+
}
119+
120+
result
121+
}
122+
}
123+
124+
#[cfg(test)]
125+
mod tests {
126+
use super::Rust;
127+
use crate::extractor::pre_processors::pre_processor::PreProcessor;
128+
129+
#[test]
130+
fn test_leptos_extraction() {
131+
for (input, expected) in [
132+
// Spaces
133+
(
134+
"<div class:flex class:px-2.5={condition()}>",
135+
"<div class flex class px-2.5={condition()}>",
136+
),
137+
// Tabs
138+
(
139+
"<div\tclass:flex class:px-2.5={condition()}>",
140+
"<div class flex class px-2.5={condition()}>",
141+
),
142+
// Newlines
143+
(
144+
"<div\nclass:flex class:px-2.5={condition()}>",
145+
"<div class flex class px-2.5={condition()}>",
146+
),
147+
] {
148+
Rust::test(input, expected);
149+
}
150+
}
151+
152+
// https://github.com/tailwindlabs/tailwindcss/issues/18984
153+
#[test]
154+
fn test_maud_template_extraction() {
155+
let input = r#"
156+
use maud::{html, Markup};
157+
158+
pub fn main() -> Markup {
159+
html! {
160+
header.px-8.py-4.text-black {
161+
"Hello, world!"
162+
}
163+
}
164+
}
165+
"#;
166+
167+
Rust::test_extract_contains(input, vec!["px-8", "py-4", "text-black"]);
168+
169+
// https://maud.lambda.xyz/elements-attributes.html#classes-and-ids-foo-bar
170+
let input = r#"
171+
html! {
172+
input #cannon .big.scary.bright-red type="button" value="Launch Party Cannon";
173+
}
174+
"#;
175+
Rust::test_extract_contains(input, vec!["big", "scary", "bright-red"]);
176+
177+
let input = r#"
178+
html! {
179+
div."bg-[#0088cc]" { "Quotes for backticks" }
180+
}
181+
"#;
182+
Rust::test_extract_contains(input, vec!["bg-[#0088cc]"]);
183+
184+
let input = r#"
185+
html! {
186+
#main {
187+
"Main content!"
188+
.tip { "Storing food in a refrigerator can make it 20% cooler." }
189+
}
190+
}
191+
"#;
192+
Rust::test_extract_contains(input, vec!["tip"]);
193+
194+
let input = r#"
195+
html! {
196+
div."bg-[url(https://example.com)]" { "Arbitrary values" }
197+
}
198+
"#;
199+
Rust::test_extract_contains(input, vec!["bg-[url(https://example.com)]"]);
200+
201+
let input = r#"
202+
html! {
203+
div.px-4.text-black {
204+
"Some text, with unbalanced brackets ]["
205+
}
206+
div.px-8.text-white {
207+
"Some more text, with unbalanced brackets ]["
208+
}
209+
}
210+
"#;
211+
Rust::test_extract_contains(input, vec!["px-4", "text-black", "px-8", "text-white"]);
212+
213+
let input = r#"html! { \x.px-4.text-black { } }"#;
214+
Rust::test(input, r#"html! { \x px-4 text-black { } }"#);
215+
}
216+
}

crates/oxide/src/scanner/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,8 @@ pub fn pre_process_input(content: &[u8], extension: &str) -> Vec<u8> {
490490
"pug" => Pug.process(content),
491491
"rb" | "erb" => Ruby.process(content),
492492
"slim" | "slang" => Slim.process(content),
493-
"svelte" | "rs" => Svelte.process(content),
493+
"svelte" => Svelte.process(content),
494+
"rs" => Rust.process(content),
494495
"vue" => Vue.process(content),
495496
_ => content.to_vec(),
496497
}

0 commit comments

Comments
 (0)