proc_virtual_dom/
lib.rs

1use std::{collections::HashMap, str::Chars};
2
3use proc_macro2::{Span, TokenStream, TokenTree};
4use quote::quote;
5use syn::{parse::Parse, parse_macro_input, token::Brace, Block, Expr, Ident, Stmt};
6use virtual_dom::{parse_html, Html};
7
8// using https://github.com/chinedufn/percy/blob/master/crates/html-macro/src/lib.rs as example
9/// Parse a string into virtual_dom::DomNode's with minimal variable interpolation
10///
11/// Because of the nature of macros whitespace is fairly arbitrary and might spawn spaces or
12/// newlines in between text one way to prevent this it to explicitly add quotes around your text.
13///
14/// eg.
15///
16/// ```
17/// use proc_virtual_dom::dom;
18/// dom!(<div>" This is my text with preserved whitespace "</div>);
19/// ```
20///
21/// # Examples
22///
23/// ```
24/// use proc_virtual_dom::dom;
25/// let title = "My beautiful website";
26/// let content = dom! {
27///     <div>{title}</div>
28/// };
29/// ```
30#[proc_macro]
31pub fn dom(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
32    let parsed_content = input
33        .to_string()
34        // Normalize newlines within HTML tags to spaces to fix parsing issues
35        // when TokenStream.to_string() inserts newlines in tag attributes
36        .replace("\n", " ");
37    let template = parse_macro_input!(input as Template);
38
39    let tokens = match parse_html(parsed_content.to_string().as_bytes()) {
40        Ok(t) => t,
41        Err(e) => {
42            let e = syn::Error::new(Span::call_site(), e);
43            return proc_macro::TokenStream::from(e.to_compile_error());
44        }
45    };
46
47    let html = to_tokens(&tokens, &template, None, 0);
48
49    if tokens.len() > 1 {
50        let children = quote!(vec![#({#html},)*]);
51        return quote! {
52            {
53                use ::std::collections::HashMap;
54                use ::virtual_dom::*;
55                #children
56            }
57        }
58        .into();
59    }
60
61    let html = html.into_iter().next().expect("no html given");
62    quote! {
63        {
64            use ::std::collections::HashMap;
65            use ::virtual_dom::*;
66            #html
67        }
68    }
69    .into()
70}
71
72/// collect all interpolated variables
73#[derive(Clone)]
74struct Template {
75    variables: HashMap<String, Ident>,
76}
77
78impl Parse for Template {
79    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
80        let mut variables = HashMap::new();
81
82        while !input.is_empty() {
83            if input.peek(Brace) {
84                let content;
85                syn::braced!(content in input);
86                for s in content.call(Block::parse_within)? {
87                    match s {
88                        Stmt::Expr(e, _) => {
89                            if let Expr::Path(p) = e {
90                                let a = p.path.segments[0].ident.to_string();
91                                let ident = p
92                                    .path
93                                    .segments
94                                    .first()
95                                    .expect("path does not include ident")
96                                    .ident
97                                    .clone();
98                                variables.insert(a, ident);
99                            }
100                        },
101                        Stmt::Local(_) | Stmt::Item(_) | Stmt::Macro(_) => {
102                            return Err(input.error("unexpected statement"));
103                        }
104                    }
105                }
106
107                continue;
108            }
109            // parse other expressions
110            let t = input.parse::<TokenTree>()?;
111
112            // also check literals for brackets
113            if let TokenTree::Literal(l) = t {
114                let text = l.to_string();
115                let mut chars = text.chars();
116                while let Some(c) = chars.next() {
117                    if c == '{' {
118                        if let Ok(var) = parse_braces(&mut chars) {
119                            let ident = Ident::new(&var, l.span());
120                            variables.insert(var, ident);
121                        }
122                    }
123                }
124            }
125        }
126
127        Ok(Template { variables })
128    }
129}
130
131/// parse a text with braces and return the variable name if syntax is valid otherwise return raw
132/// string
133fn parse_braces(chars: &mut Chars) -> Result<String, String> {
134    let mut raw = String::new();
135    let mut variable_name = String::new();
136    let mut var_has_whitespace = false;
137    for c in chars.by_ref() {
138        raw.push(c);
139        match c {
140            // if whitespace in between alphabetical not a valid block
141            ' ' | '\n' if !variable_name.is_empty() => {
142                var_has_whitespace = true;
143            }
144            // ignore whitespace
145            ' ' | '\n' => {}
146            '}' => {
147                return Ok(variable_name);
148            }
149            // if not alphatic character not valid interpolation
150            c if !c.is_alphabetic() => {
151                return Err(raw);
152            }
153            _ => {
154                // if whitespace in between characters not valid
155                if var_has_whitespace {
156                    return Err(raw);
157                }
158                variable_name.push(c)
159            }
160        }
161    }
162
163    Err(raw)
164}
165
166/// check if string has interpolated character if so add it
167fn interpolate_string(text: &str, template: &Template) -> TokenStream {
168    let mut chars = text.chars();
169    let mut variables = vec![];
170    let mut text = String::new();
171    // if text has interpolated variables add
172    while let Some(c) = chars.next() {
173        if c == '{' {
174            match parse_braces(&mut chars) {
175                Ok(variable_name) => {
176                    let variable = template.variables.get(&variable_name).unwrap_or_else(|| panic!(
177                        "failed to parse or find variable '{variable_name}'"
178                    ));
179                    text.push_str("{}");
180                    variables.push(quote!(#variable));
181                }
182                Err(t) => text.push_str(&t),
183            }
184        } else {
185            text.push(c);
186        }
187    }
188    if !variables.is_empty() {
189        return quote!(format!(#text, #(#variables,)*));
190    }
191    quote!(String::from(#text))
192}
193
194fn to_tokens(
195    tokens: &Vec<Html>,
196    template: &Template,
197    parent: Option<&Ident>,
198    mut i: usize,
199) -> Vec<TokenStream> {
200    let mut items = vec![];
201
202    for t in tokens {
203        i += 1;
204        match t {
205            Html::Comment { .. } => {}
206            Html::Text { text } => {
207                // if text starts with quotes and ends with quotes remove quotes
208                let text = if text.starts_with("\"") && text.ends_with("\"") {
209                    let text = text.get(1..text.len() - 1).unwrap_or("");
210                    if text.is_empty() {
211                        continue;
212                    }
213                    text
214                } else {
215                    text
216                };
217
218                let mut chars = text.chars();
219                let mut text = String::new();
220                while let Some(c) = chars.next() {
221                    if c == '{' {
222                        match parse_braces(&mut chars) {
223                            Ok(variable_name) => {
224                                let variable = template.variables.get(&variable_name).unwrap_or_else(|| panic!(
225                                    "failed to parse or find variable '{variable_name}'"
226                                ));
227                                if let Some(parent) = parent {
228                                    if !text.is_empty() {
229                                        items.push(
230                                            quote!(#parent.append_child(DomNode::create_text(#text))),
231                                        );
232                                        text.clear();
233                                    }
234                                    items.push(quote!(#parent.append_child(#variable)));
235                                } else {
236                                    if !text.is_empty() {
237                                        items.push(quote!(DomNode::create_text(#text)));
238                                        text.clear();
239                                    }
240                                    items.push(quote!(#variable));
241                                }
242                            }
243                            Err(t) => text.push_str(&t),
244                        }
245                    } else {
246                        text.push(c);
247                    }
248                }
249                if !text.is_empty() {
250                    if let Some(parent) = parent {
251                        items.push(quote!(#parent.append_child(DomNode::create_text(#text))));
252                    } else {
253                        items.push(quote!(DomNode::create_text(#text)));
254                    }
255                }
256            }
257            Html::Element {
258                tag,
259                attributes,
260                children,
261            } => {
262                let attributes_values = attributes.iter().map(|(key, value)| {
263                    let key = interpolate_string(key, template);
264                    let value = interpolate_string(value, template);
265                    quote! {
266                        attributes.insert(#key, #value);
267                    }
268                });
269
270                let id = Ident::new(&format!("node_{i}"), Span::call_site());
271
272                let el = if !children.is_empty() {
273                    let children = to_tokens(children, template, Some(&id), i + tokens.len());
274                    quote!(
275                        let mut attributes = HashMap::new();
276                        #(#attributes_values)*
277                        let #id = DomNode::create_element_with_attributes(#tag, attributes);
278                        #({#children})*;
279                    )
280                } else {
281                    quote!(
282                        let mut attributes = HashMap::new();
283                        #(#attributes_values)*
284                        let #id = DomNode::create_element_with_attributes(#tag, attributes);
285                    )
286                };
287
288                if let Some(parent) = parent {
289                    items.push(quote!(
290                        #el
291                        #parent.append_child(#id);
292                    ))
293                } else {
294                    items.push(quote!(
295                        #el
296                        #id
297                    ))
298                }
299            }
300        }
301    }
302
303    items
304}