ironhtml_macro/
lib.rs

1//! # ironhtml-macro
2//!
3//! Procedural macro for type-safe HTML construction with Rust-like syntax.
4//!
5//! This crate provides the [`html!`] macro that generates type-safe HTML
6//! using the `ironhtml` and `ironhtml-elements` crates. Most users should
7//! depend on `ironhtml` with the `macros` feature instead of using this
8//! crate directly.
9//!
10//! See [`ironhtml::html!`](https://docs.rs/ironhtml/latest/ironhtml/macro.html.html)
11//! for full documentation and tested examples covering elements, attributes,
12//! text content, Rust expressions, loops, and conditionals.
13
14use proc_macro::TokenStream;
15use proc_macro2::TokenStream as TokenStream2;
16use quote::{quote, ToTokens};
17use syn::parse::discouraged::Speculative;
18use syn::parse::{Parse, ParseStream};
19use syn::{braced, token, Expr, Ident, LitStr, Result, Token};
20
21/// The main HTML macro for type-safe HTML construction.
22///
23/// See the [crate-level documentation](crate) for syntax and examples.
24#[proc_macro]
25pub fn html(input: TokenStream) -> TokenStream {
26    let node = syn::parse_macro_input!(input as Node);
27    let expanded = node.to_token_stream();
28    expanded.into()
29}
30
31/// A node in the HTML tree: element, text, expression, loop, or conditional.
32enum Node {
33    Element(ElementNode),
34    Text(LitStr),
35    Expr(Expr),
36    For(ForLoop),
37    If(IfNode),
38}
39
40impl Parse for Node {
41    fn parse(input: ParseStream) -> Result<Self> {
42        if input.peek(LitStr) {
43            Ok(Self::Text(input.parse()?))
44        } else if input.peek(Token![#]) {
45            input.parse::<Token![#]>()?;
46            Ok(Self::Expr(input.parse()?))
47        } else if input.peek(Token![for]) {
48            Ok(Self::For(input.parse()?))
49        } else if input.peek(Token![if]) {
50            Ok(Self::If(input.parse()?))
51        } else if input.peek(Ident) {
52            Ok(Self::Element(input.parse()?))
53        } else {
54            Err(input.error("expected element, text literal, or expression"))
55        }
56    }
57}
58
59impl ToTokens for Node {
60    fn to_tokens(&self, tokens: &mut TokenStream2) {
61        match self {
62            Self::Element(elem) => elem.to_tokens(tokens),
63            Self::Text(lit) => {
64                tokens.extend(quote! { .text(#lit) });
65            }
66            Self::Expr(expr) => {
67                tokens.extend(quote! { .text(#expr) });
68            }
69            Self::For(for_loop) => for_loop.to_tokens(tokens),
70            Self::If(if_node) => if_node.to_tokens(tokens),
71        }
72    }
73}
74
75/// An HTML element with tag, attributes, and children.
76struct ElementNode {
77    tag: Ident,
78    attrs: Vec<Attribute>,
79    children: Vec<Node>,
80}
81
82impl Parse for ElementNode {
83    fn parse(input: ParseStream) -> Result<Self> {
84        let tag: Ident = input.parse()?;
85
86        // Parse attributes (method chain style: .class("x").id("y"))
87        let mut attrs = Vec::new();
88        while input.peek(Token![.]) {
89            input.parse::<Token![.]>()?;
90            attrs.push(input.parse()?);
91        }
92
93        // Parse children (inside braces)
94        let children = if input.peek(token::Brace) {
95            let content;
96            braced!(content in input);
97            let mut children = Vec::new();
98            while !content.is_empty() {
99                children.push(content.parse()?);
100            }
101            children
102        } else {
103            Vec::new()
104        };
105
106        Ok(Self {
107            tag,
108            attrs,
109            children,
110        })
111    }
112}
113
114impl ToTokens for ElementNode {
115    fn to_tokens(&self, tokens: &mut TokenStream2) {
116        let tag = &self.tag;
117        let tag_pascal = to_pascal_case(&tag.to_string());
118        let tag_ident = Ident::new(&tag_pascal, tag.span());
119
120        // Generate attribute calls
121        let attr_calls: Vec<_> = self
122            .attrs
123            .iter()
124            .map(quote::ToTokens::to_token_stream)
125            .collect();
126
127        if self.children.is_empty() {
128            // No children - just create element with attributes
129            tokens.extend(quote! {
130                ::ironhtml::typed::Element::<::ironhtml_elements::#tag_ident>::new()
131                    #(#attr_calls)*
132            });
133        } else {
134            // Has children - need to generate child calls
135            let mut child_tokens = TokenStream2::new();
136
137            for child in &self.children {
138                match child {
139                    Node::Element(elem) => {
140                        let child_tag = &elem.tag;
141                        let child_pascal = to_pascal_case(&child_tag.to_string());
142                        let child_ident = Ident::new(&child_pascal, child_tag.span());
143
144                        let child_attrs: Vec<_> = elem
145                            .attrs
146                            .iter()
147                            .map(quote::ToTokens::to_token_stream)
148                            .collect();
149
150                        if elem.children.is_empty() {
151                            child_tokens.extend(quote! {
152                                .child::<::ironhtml_elements::#child_ident, _>(|e| e #(#child_attrs)*)
153                            });
154                        } else {
155                            let nested = generate_children(&elem.children);
156                            child_tokens.extend(quote! {
157                                .child::<::ironhtml_elements::#child_ident, _>(|e| e #(#child_attrs)* #nested)
158                            });
159                        }
160                    }
161                    Node::Text(lit) => {
162                        child_tokens.extend(quote! { .text(#lit) });
163                    }
164                    Node::Expr(expr) => {
165                        child_tokens.extend(quote! { .text(#expr) });
166                    }
167                    Node::For(for_loop) => {
168                        for_loop.to_tokens(&mut child_tokens);
169                    }
170                    Node::If(if_node) => {
171                        if_node.to_tokens(&mut child_tokens);
172                    }
173                }
174            }
175
176            tokens.extend(quote! {
177                ::ironhtml::typed::Element::<::ironhtml_elements::#tag_ident>::new()
178                    #(#attr_calls)*
179                    #child_tokens
180            });
181        }
182    }
183}
184
185/// Generate token stream for a list of child nodes.
186fn generate_children(children: &[Node]) -> TokenStream2 {
187    let mut tokens = TokenStream2::new();
188
189    for child in children {
190        match child {
191            Node::Element(elem) => {
192                let child_tag = &elem.tag;
193                let child_pascal = to_pascal_case(&child_tag.to_string());
194                let child_ident = Ident::new(&child_pascal, child_tag.span());
195
196                let child_attrs: Vec<_> = elem
197                    .attrs
198                    .iter()
199                    .map(quote::ToTokens::to_token_stream)
200                    .collect();
201
202                if elem.children.is_empty() {
203                    tokens.extend(quote! {
204                        .child::<::ironhtml_elements::#child_ident, _>(|e| e #(#child_attrs)*)
205                    });
206                } else {
207                    let nested = generate_children(&elem.children);
208                    tokens.extend(quote! {
209                        .child::<::ironhtml_elements::#child_ident, _>(|e| e #(#child_attrs)* #nested)
210                    });
211                }
212            }
213            Node::Text(lit) => {
214                tokens.extend(quote! { .text(#lit) });
215            }
216            Node::Expr(expr) => {
217                tokens.extend(quote! { .text(#expr) });
218            }
219            Node::For(for_loop) => {
220                for_loop.to_tokens(&mut tokens);
221            }
222            Node::If(if_node) => {
223                if_node.to_tokens(&mut tokens);
224            }
225        }
226    }
227
228    tokens
229}
230
231/// An attribute on an element: name(value) or name (boolean).
232struct Attribute {
233    name: Ident,
234    value: Option<AttrValue>,
235}
236
237enum AttrValue {
238    Lit(LitStr),
239    Expr(Expr),
240}
241
242impl Parse for Attribute {
243    fn parse(input: ParseStream) -> Result<Self> {
244        let name: Ident = input.parse()?;
245
246        let value = if input.peek(token::Paren) {
247            let content;
248            syn::parenthesized!(content in input);
249
250            if content.peek(Token![#]) {
251                content.parse::<Token![#]>()?;
252                Some(AttrValue::Expr(content.parse()?))
253            } else if content.peek(LitStr) {
254                Some(AttrValue::Lit(content.parse()?))
255            } else {
256                Some(AttrValue::Expr(content.parse()?))
257            }
258        } else {
259            None
260        };
261
262        Ok(Self { name, value })
263    }
264}
265
266impl ToTokens for Attribute {
267    fn to_tokens(&self, tokens: &mut TokenStream2) {
268        let name = &self.name;
269        let name_str = name.to_string();
270
271        // Handle special attribute names
272        let method_name = match name_str.as_str() {
273            "class" | "id" => name.clone(),
274            _ => Ident::new("attr", name.span()),
275        };
276
277        // Convert attribute name: remove trailing underscore and replace underscores with hyphens
278        // e.g., type_ -> type, data_id -> data-id, aria_label -> aria-label
279        let convert_attr_name = |s: &str| -> String { s.trim_end_matches('_').replace('_', "-") };
280
281        match &self.value {
282            Some(AttrValue::Lit(lit)) => {
283                if name_str == "class" || name_str == "id" {
284                    tokens.extend(quote! { .#method_name(#lit) });
285                } else {
286                    let attr_name = convert_attr_name(&name_str);
287                    tokens.extend(quote! { .#method_name(#attr_name, #lit) });
288                }
289            }
290            Some(AttrValue::Expr(expr)) => {
291                if name_str == "class" || name_str == "id" {
292                    tokens.extend(quote! { .#method_name(#expr) });
293                } else {
294                    let attr_name = convert_attr_name(&name_str);
295                    tokens.extend(quote! { .#method_name(#attr_name, #expr) });
296                }
297            }
298            None => {
299                // Boolean attribute
300                let attr_name = convert_attr_name(&name_str);
301                tokens.extend(quote! { .bool_attr(#attr_name) });
302            }
303        }
304    }
305}
306
307/// A for loop: for item in #expr { children }
308struct ForLoop {
309    pat: syn::Pat,
310    expr: Expr,
311    children: Vec<Node>,
312}
313
314impl Parse for ForLoop {
315    fn parse(input: ParseStream) -> Result<Self> {
316        let for_token: Token![for] = input.parse()?;
317        let pat = syn::Pat::parse_single(input)?;
318        input.parse::<Token![in]>()?;
319        input.parse::<Token![#]>()?;
320
321        // Parse expression but stop before brace (use ExprPath or similar)
322        // We need to be careful not to consume the following brace
323        let expr = parse_expr_before_brace(input)?;
324
325        let content;
326        braced!(content in input);
327        let mut children = Vec::new();
328        while !content.is_empty() {
329            children.push(content.parse()?);
330        }
331
332        if children.len() != 1 || !matches!(children.first(), Some(Node::Element(_))) {
333            return Err(syn::Error::new(
334                for_token.span,
335                "for loop body must contain exactly one element",
336            ));
337        }
338
339        Ok(Self {
340            pat,
341            expr,
342            children,
343        })
344    }
345}
346
347/// Parse an expression that stops before a brace.
348fn parse_expr_before_brace(input: ParseStream) -> Result<Expr> {
349    // Fork to try parsing without consuming
350    let fork = input.fork();
351
352    // Try to parse as a simple path/ident first
353    if let Ok(path) = fork.parse::<syn::ExprPath>() {
354        // Check if next token is brace
355        if fork.peek(token::Brace) {
356            input.advance_to(&fork);
357            return Ok(Expr::Path(path));
358        }
359    }
360
361    // Otherwise parse a full expression
362    input.parse()
363}
364
365impl ToTokens for ForLoop {
366    fn to_tokens(&self, tokens: &mut TokenStream2) {
367        let pat = &self.pat;
368        let expr = &self.expr;
369
370        // For loops need to know the child element type
371        // We expect exactly one child element in the loop body
372        if let Some(Node::Element(elem)) = self.children.first() {
373            let child_tag = &elem.tag;
374            let child_pascal = to_pascal_case(&child_tag.to_string());
375            let child_ident = Ident::new(&child_pascal, child_tag.span());
376
377            let child_attrs: Vec<_> = elem
378                .attrs
379                .iter()
380                .map(quote::ToTokens::to_token_stream)
381                .collect();
382            let nested = generate_children(&elem.children);
383
384            tokens.extend(quote! {
385                .children(#expr, |#pat, e: ::ironhtml::typed::Element<::ironhtml_elements::#child_ident>| {
386                    e #(#child_attrs)* #nested
387                })
388            });
389        }
390    }
391}
392
393/// An if conditional: if #expr { children }
394struct IfNode {
395    cond: Expr,
396    children: Vec<Node>,
397}
398
399impl Parse for IfNode {
400    fn parse(input: ParseStream) -> Result<Self> {
401        input.parse::<Token![if]>()?;
402        input.parse::<Token![#]>()?;
403
404        // Parse expression but stop before brace
405        let cond = parse_expr_before_brace(input)?;
406
407        let content;
408        braced!(content in input);
409        let mut children = Vec::new();
410        while !content.is_empty() {
411            children.push(content.parse()?);
412        }
413
414        Ok(Self { cond, children })
415    }
416}
417
418impl ToTokens for IfNode {
419    fn to_tokens(&self, tokens: &mut TokenStream2) {
420        let cond = &self.cond;
421        let child_tokens = generate_children(&self.children);
422
423        tokens.extend(quote! {
424            .when(#cond, |e| e #child_tokens)
425        });
426    }
427}
428
429/// Convert `snake_case` or lowercase to `PascalCase`.
430fn to_pascal_case(s: &str) -> String {
431    let mut result = String::new();
432    let mut capitalize_next = true;
433
434    for c in s.chars() {
435        if c == '_' {
436            capitalize_next = true;
437        } else if capitalize_next {
438            result.push(c.to_ascii_uppercase());
439            capitalize_next = false;
440        } else {
441            result.push(c);
442        }
443    }
444
445    // Handle special cases for HTML elements
446    match result.as_str() {
447        "A" => "A".to_string(),
448        "B" => "B".to_string(),
449        "I" => "I".to_string(),
450        "P" => "P".to_string(),
451        "Q" => "Q".to_string(),
452        "S" => "S".to_string(),
453        "U" => "U".to_string(),
454        "Br" => "Br".to_string(),
455        "Hr" => "Hr".to_string(),
456        "H1" => "H1".to_string(),
457        "H2" => "H2".to_string(),
458        "H3" => "H3".to_string(),
459        "H4" => "H4".to_string(),
460        "H5" => "H5".to_string(),
461        "H6" => "H6".to_string(),
462        "Dl" => "Dl".to_string(),
463        "Dt" => "Dt".to_string(),
464        "Dd" => "Dd".to_string(),
465        "Li" => "Li".to_string(),
466        "Ol" => "Ol".to_string(),
467        "Ul" => "Ul".to_string(),
468        "Td" => "Td".to_string(),
469        "Th" => "Th".to_string(),
470        "Tr" => "Tr".to_string(),
471        "Em" => "Em".to_string(),
472        "Rp" => "Rp".to_string(),
473        "Rt" => "Rt".to_string(),
474        "Wbr" => "Wbr".to_string(),
475        "Kbd" => "Kbd".to_string(),
476        "Pre" => "Pre".to_string(),
477        "Sub" => "Sub".to_string(),
478        "Sup" => "Sup".to_string(),
479        "Var" => "Var".to_string(),
480        "Bdi" => "Bdi".to_string(),
481        "Bdo" => "Bdo".to_string(),
482        "Col" => "Col".to_string(),
483        "Del" => "Del".to_string(),
484        "Dfn" => "Dfn".to_string(),
485        "Div" => "Div".to_string(),
486        "Img" => "Img".to_string(),
487        "Ins" => "Ins".to_string(),
488        "Map" => "Map".to_string(),
489        "Nav" => "Nav".to_string(),
490        "Svg" => "Svg".to_string(),
491        // Option is special - ironhtml-elements uses Option_
492        "Option" => "Option_".to_string(),
493        _ => result,
494    }
495}