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('&', "&")
127 .replace('<', "<")
128 .replace('>', ">")
129}
130
131fn escape_attr(s: &str) -> String {
132 s.replace('&', "&")
133 .replace('"', """)
134 .replace('<', "<")
135 .replace('>', ">")
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><script>alert('xss')</script></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}