Skip to main content

react_rs_dom/
render.rs

1use react_rs_elements::attributes::AttributeValue;
2use react_rs_elements::node::Node;
3use react_rs_elements::Element;
4
5pub struct RenderOutput {
6    pub html: String,
7}
8
9pub fn render_to_string(node: &Node) -> RenderOutput {
10    RenderOutput {
11        html: render_node(node),
12    }
13}
14
15fn render_node(node: &Node) -> String {
16    match node {
17        Node::Element(element) => render_element(element),
18        Node::Text(text) => escape_html(text),
19        Node::ReactiveText(reactive) => escape_html(&reactive.get()),
20        Node::Fragment(children) => children
21            .iter()
22            .map(render_node)
23            .collect::<Vec<_>>()
24            .join(""),
25        Node::Conditional(condition, then_node, else_node) => {
26            let show = condition.get();
27            let then_html = render_node(then_node);
28            let else_html = else_node
29                .as_ref()
30                .map(|n| render_node(n))
31                .unwrap_or_default();
32
33            let then_style = if show { "" } else { " style=\"display:none\"" };
34            let else_style = if show { " style=\"display:none\"" } else { "" };
35
36            if else_html.is_empty() {
37                format!(
38                    "<span data-cond style=\"display:contents\"><span{}>{}</span></span>",
39                    then_style, then_html
40                )
41            } else {
42                format!(
43                    "<span data-cond style=\"display:contents\"><span{}>{}</span><span{}>{}</span></span>",
44                    then_style, then_html, else_style, else_html
45                )
46            }
47        }
48        Node::ReactiveList(list_fn) => {
49            let items_html = list_fn()
50                .iter()
51                .map(render_node)
52                .collect::<Vec<_>>()
53                .join("");
54            format!(
55                "<span data-list style=\"display:contents\">{}</span>",
56                items_html
57            )
58        }
59        Node::KeyedList(list_fn) => {
60            let items_html = list_fn()
61                .iter()
62                .map(|(_, node)| render_node(node))
63                .collect::<Vec<_>>()
64                .join("");
65            format!(
66                "<span data-list style=\"display:contents\">{}</span>",
67                items_html
68            )
69        }
70        Node::Head(_) => String::new(),
71        Node::Suspense(sus) => {
72            if (sus.loading_signal)() {
73                render_node(&sus.fallback)
74            } else {
75                render_node(&sus.children)
76            }
77        }
78        Node::ErrorBoundary(eb) => {
79            if let Some(error) = (eb.error_signal)() {
80                render_node(&(eb.error_fallback)(error))
81            } else {
82                render_node(&eb.children)
83            }
84        }
85    }
86}
87
88fn render_element(element: &Element) -> String {
89    let tag = element.tag();
90    let attrs = render_attributes(element);
91    let children = element
92        .get_children()
93        .iter()
94        .map(render_node)
95        .collect::<Vec<_>>()
96        .join("");
97
98    if is_void_element(tag) {
99        format!("<{}{} />", tag, attrs)
100    } else {
101        format!("<{}{}>{}</{}>", tag, attrs, children, tag)
102    }
103}
104
105fn render_attributes(element: &Element) -> String {
106    let attrs: Vec<String> = element
107        .attributes()
108        .iter()
109        .filter_map(|attr| match &attr.value {
110            AttributeValue::String(s) => Some(format!(" {}=\"{}\"", attr.name, escape_attr(s))),
111            AttributeValue::Bool(b) => {
112                if *b {
113                    Some(format!(" {}", attr.name))
114                } else {
115                    None
116                }
117            }
118            AttributeValue::ReactiveString(reactive) => Some(format!(
119                " {}=\"{}\"",
120                attr.name,
121                escape_attr(&reactive.get())
122            )),
123            AttributeValue::ReactiveBool(reactive) => {
124                if reactive.get() {
125                    Some(format!(" {}", attr.name))
126                } else {
127                    None
128                }
129            }
130        })
131        .collect();
132
133    attrs.join("")
134}
135
136fn escape_html(s: &str) -> String {
137    s.replace('&', "&amp;")
138        .replace('<', "&lt;")
139        .replace('>', "&gt;")
140}
141
142fn escape_attr(s: &str) -> String {
143    s.replace('&', "&amp;")
144        .replace('"', "&quot;")
145        .replace('<', "&lt;")
146        .replace('>', "&gt;")
147}
148
149fn is_void_element(tag: &str) -> bool {
150    matches!(
151        tag,
152        "area"
153            | "base"
154            | "br"
155            | "col"
156            | "embed"
157            | "hr"
158            | "img"
159            | "input"
160            | "link"
161            | "meta"
162            | "param"
163            | "source"
164            | "track"
165            | "wbr"
166    )
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use react_rs_elements::html::*;
173    use react_rs_elements::node::IntoNode;
174
175    #[test]
176    fn test_render_simple_element() {
177        let element = div().class("container").text("Hello");
178        let output = render_to_string(&element.into_node());
179        assert_eq!(output.html, "<div class=\"container\">Hello</div>");
180    }
181
182    #[test]
183    fn test_render_nested_elements() {
184        let element = div()
185            .class("app")
186            .child(h1().text("Title"))
187            .child(p().text("Content"));
188        let output = render_to_string(&element.into_node());
189        assert_eq!(
190            output.html,
191            "<div class=\"app\"><h1>Title</h1><p>Content</p></div>"
192        );
193    }
194
195    #[test]
196    fn test_render_void_element() {
197        let element = input().type_("text").placeholder("Enter name");
198        let output = render_to_string(&element.into_node());
199        assert_eq!(
200            output.html,
201            "<input type=\"text\" placeholder=\"Enter name\" />"
202        );
203    }
204
205    #[test]
206    fn test_render_escapes_html() {
207        let element = p().text("<script>alert('xss')</script>");
208        let output = render_to_string(&element.into_node());
209        assert_eq!(
210            output.html,
211            "<p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>"
212        );
213    }
214
215    #[test]
216    fn test_render_boolean_attribute() {
217        let element = input().disabled(true);
218        let output = render_to_string(&element.into_node());
219        assert!(output.html.contains(" disabled"));
220
221        let element_enabled = input().disabled(false);
222        let output_enabled = render_to_string(&element_enabled.into_node());
223        assert!(!output_enabled.html.contains("disabled"));
224    }
225
226    #[test]
227    fn test_render_fragment() {
228        let fragment = vec![span().text("A"), span().text("B")];
229        let output = render_to_string(&fragment.into_node());
230        assert_eq!(output.html, "<span>A</span><span>B</span>");
231    }
232
233    #[test]
234    fn test_render_complex_structure() {
235        let view = html().child(head().child(title().text("My App"))).child(
236            body().child(
237                div()
238                    .id("root")
239                    .child(header().child(nav().child(a().href("/").text("Home"))))
240                    .child(main_el().child(h1().text("Welcome")))
241                    .child(footer().text("2024")),
242            ),
243        );
244        let output = render_to_string(&view.into_node());
245
246        assert!(output.html.contains("<html>"));
247        assert!(output.html.contains("<title>My App</title>"));
248        assert!(output.html.contains("<div id=\"root\">"));
249        assert!(output.html.contains("<a href=\"/\">Home</a>"));
250        assert!(output.html.contains("</html>"));
251    }
252}