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::Head(_) => String::new(),
60        Node::Suspense(sus) => {
61            if (sus.loading_signal)() {
62                render_node(&sus.fallback)
63            } else {
64                render_node(&sus.children)
65            }
66        }
67        Node::ErrorBoundary(eb) => {
68            if let Some(error) = (eb.error_signal)() {
69                render_node(&(eb.error_fallback)(error))
70            } else {
71                render_node(&eb.children)
72            }
73        }
74    }
75}
76
77fn render_element(element: &Element) -> String {
78    let tag = element.tag();
79    let attrs = render_attributes(element);
80    let children = element
81        .get_children()
82        .iter()
83        .map(render_node)
84        .collect::<Vec<_>>()
85        .join("");
86
87    if is_void_element(tag) {
88        format!("<{}{} />", tag, attrs)
89    } else {
90        format!("<{}{}>{}</{}>", tag, attrs, children, tag)
91    }
92}
93
94fn render_attributes(element: &Element) -> String {
95    let attrs: Vec<String> = element
96        .attributes()
97        .iter()
98        .filter_map(|attr| match &attr.value {
99            AttributeValue::String(s) => Some(format!(" {}=\"{}\"", attr.name, escape_attr(s))),
100            AttributeValue::Bool(b) => {
101                if *b {
102                    Some(format!(" {}", attr.name))
103                } else {
104                    None
105                }
106            }
107            AttributeValue::ReactiveString(reactive) => Some(format!(
108                " {}=\"{}\"",
109                attr.name,
110                escape_attr(&reactive.get())
111            )),
112            AttributeValue::ReactiveBool(reactive) => {
113                if reactive.get() {
114                    Some(format!(" {}", attr.name))
115                } else {
116                    None
117                }
118            }
119        })
120        .collect();
121
122    attrs.join("")
123}
124
125fn escape_html(s: &str) -> String {
126    s.replace('&', "&amp;")
127        .replace('<', "&lt;")
128        .replace('>', "&gt;")
129}
130
131fn escape_attr(s: &str) -> String {
132    s.replace('&', "&amp;")
133        .replace('"', "&quot;")
134        .replace('<', "&lt;")
135        .replace('>', "&gt;")
136}
137
138fn is_void_element(tag: &str) -> bool {
139    matches!(
140        tag,
141        "area"
142            | "base"
143            | "br"
144            | "col"
145            | "embed"
146            | "hr"
147            | "img"
148            | "input"
149            | "link"
150            | "meta"
151            | "param"
152            | "source"
153            | "track"
154            | "wbr"
155    )
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use react_rs_elements::html::*;
162    use react_rs_elements::node::IntoNode;
163
164    #[test]
165    fn test_render_simple_element() {
166        let element = div().class("container").text("Hello");
167        let output = render_to_string(&element.into_node());
168        assert_eq!(output.html, "<div class=\"container\">Hello</div>");
169    }
170
171    #[test]
172    fn test_render_nested_elements() {
173        let element = div()
174            .class("app")
175            .child(h1().text("Title"))
176            .child(p().text("Content"));
177        let output = render_to_string(&element.into_node());
178        assert_eq!(
179            output.html,
180            "<div class=\"app\"><h1>Title</h1><p>Content</p></div>"
181        );
182    }
183
184    #[test]
185    fn test_render_void_element() {
186        let element = input().type_("text").placeholder("Enter name");
187        let output = render_to_string(&element.into_node());
188        assert_eq!(
189            output.html,
190            "<input type=\"text\" placeholder=\"Enter name\" />"
191        );
192    }
193
194    #[test]
195    fn test_render_escapes_html() {
196        let element = p().text("<script>alert('xss')</script>");
197        let output = render_to_string(&element.into_node());
198        assert_eq!(
199            output.html,
200            "<p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>"
201        );
202    }
203
204    #[test]
205    fn test_render_boolean_attribute() {
206        let element = input().disabled(true);
207        let output = render_to_string(&element.into_node());
208        assert!(output.html.contains(" disabled"));
209
210        let element_enabled = input().disabled(false);
211        let output_enabled = render_to_string(&element_enabled.into_node());
212        assert!(!output_enabled.html.contains("disabled"));
213    }
214
215    #[test]
216    fn test_render_fragment() {
217        let fragment = vec![span().text("A"), span().text("B")];
218        let output = render_to_string(&fragment.into_node());
219        assert_eq!(output.html, "<span>A</span><span>B</span>");
220    }
221
222    #[test]
223    fn test_render_complex_structure() {
224        let view = html().child(head().child(title().text("My App"))).child(
225            body().child(
226                div()
227                    .id("root")
228                    .child(header().child(nav().child(a().href("/").text("Home"))))
229                    .child(main_el().child(h1().text("Welcome")))
230                    .child(footer().text("2024")),
231            ),
232        );
233        let output = render_to_string(&view.into_node());
234
235        assert!(output.html.contains("<html>"));
236        assert!(output.html.contains("<title>My App</title>"));
237        assert!(output.html.contains("<div id=\"root\">"));
238        assert!(output.html.contains("<a href=\"/\">Home</a>"));
239        assert!(output.html.contains("</html>"));
240    }
241}