windjammer_ui/
ssr.rs

1//! Server-Side Rendering (SSR) for web targets
2
3use crate::component::Component;
4use crate::vdom::{VElement, VNode, VText};
5use std::collections::HashMap;
6
7/// SSR renderer that converts VNodes to HTML strings
8pub struct SSRRenderer {
9    /// Generated HTML
10    html: String,
11    /// Component state for hydration
12    state: HashMap<String, String>,
13    /// Hydration script
14    hydration_script: String,
15}
16
17impl SSRRenderer {
18    /// Create a new SSR renderer
19    pub fn new() -> Self {
20        Self {
21            html: String::new(),
22            state: HashMap::new(),
23            hydration_script: String::new(),
24        }
25    }
26
27    /// Render a component to HTML string
28    pub fn render_to_string<C: Component>(&mut self, component: C) -> String {
29        let vnode = component.render();
30        self.render_vnode(&vnode);
31        self.html.clone()
32    }
33
34    /// Render a component to full HTML document with hydration
35    pub fn render_to_document<C: Component>(&mut self, component: C, title: &str) -> String {
36        let body_html = self.render_to_string(component);
37
38        format!(
39            r#"<!DOCTYPE html>
40<html lang="en">
41<head>
42    <meta charset="UTF-8">
43    <meta name="viewport" content="width=device-width, initial-scale=1.0">
44    <title>{}</title>
45    <script id="__WINDJAMMER_STATE__" type="application/json">
46    {}
47    </script>
48</head>
49<body>
50    <div id="app">{}</div>
51    <script>
52        {}
53    </script>
54</body>
55</html>"#,
56            Self::escape_html(title),
57            serde_json::to_string(&self.state).unwrap_or_default(),
58            body_html,
59            self.get_hydration_script()
60        )
61    }
62
63    /// Get the hydration script
64    pub fn get_hydration_script(&self) -> String {
65        if !self.hydration_script.is_empty() {
66            return self.hydration_script.clone();
67        }
68
69        // Default hydration script
70        r#"
71        (function() {
72            // Hydration: Attach event listeners to server-rendered HTML
73            const state = JSON.parse(document.getElementById('__WINDJAMMER_STATE__').textContent);
74            
75            // Store state for client-side hydration
76            window.__WINDJAMMER_HYDRATION_STATE__ = state;
77            
78            // Mark as hydrated
79            document.getElementById('app').setAttribute('data-hydrated', 'true');
80            
81            console.log('Windjammer: Hydration complete', state);
82        })();
83        "#
84        .to_string()
85    }
86
87    /// Render a VNode to HTML
88    fn render_vnode(&mut self, vnode: &VNode) {
89        match vnode {
90            VNode::Element(element) => self.render_element(element),
91            VNode::Text(text) => self.render_text(text),
92            VNode::Component(_) => {
93                // Components should be expanded before SSR
94                self.html.push_str("<!-- Component not expanded -->");
95            }
96            VNode::Empty => {}
97        }
98    }
99
100    /// Render an element
101    fn render_element(&mut self, element: &VElement) {
102        // Opening tag
103        self.html.push('<');
104        self.html.push_str(&element.tag);
105
106        // Attributes
107        for (key, value) in &element.attrs {
108            self.html.push(' ');
109            self.html.push_str(&Self::escape_attribute(key));
110            self.html.push_str("=\"");
111            self.html.push_str(&Self::escape_attribute(value));
112            self.html.push('"');
113        }
114
115        // Self-closing tags
116        if element.children.is_empty() && Self::is_void_element(&element.tag) {
117            self.html.push_str(" />");
118            return;
119        }
120
121        self.html.push('>');
122
123        // Children
124        for child in &element.children {
125            self.render_vnode(child);
126        }
127
128        // Closing tag
129        self.html.push_str("</");
130        self.html.push_str(&element.tag);
131        self.html.push('>');
132    }
133
134    /// Render text
135    fn render_text(&mut self, text: &VText) {
136        self.html.push_str(&Self::escape_html(&text.content));
137    }
138
139    /// Check if element is self-closing (void element)
140    fn is_void_element(tag: &str) -> bool {
141        matches!(
142            tag,
143            "area"
144                | "base"
145                | "br"
146                | "col"
147                | "embed"
148                | "hr"
149                | "img"
150                | "input"
151                | "link"
152                | "meta"
153                | "param"
154                | "source"
155                | "track"
156                | "wbr"
157        )
158    }
159
160    /// Escape HTML special characters
161    fn escape_html(text: &str) -> String {
162        text.replace('&', "&amp;")
163            .replace('<', "&lt;")
164            .replace('>', "&gt;")
165            .replace('"', "&quot;")
166            .replace('\'', "&#x27;")
167    }
168
169    /// Escape attribute values
170    fn escape_attribute(text: &str) -> String {
171        text.replace('&', "&amp;")
172            .replace('"', "&quot;")
173            .replace('<', "&lt;")
174            .replace('>', "&gt;")
175    }
176
177    /// Add component state for hydration
178    pub fn add_state(&mut self, key: String, value: String) {
179        self.state.insert(key, value);
180    }
181
182    /// Set custom hydration script
183    pub fn set_hydration_script(&mut self, script: String) {
184        self.hydration_script = script;
185    }
186}
187
188impl Default for SSRRenderer {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194/// Streaming SSR renderer for large pages
195pub struct StreamingSSRRenderer {
196    chunk_size: usize,
197    chunks: Vec<String>,
198}
199
200impl StreamingSSRRenderer {
201    /// Create a new streaming SSR renderer
202    pub fn new(chunk_size: usize) -> Self {
203        Self {
204            chunk_size,
205            chunks: Vec::new(),
206        }
207    }
208
209    /// Render a VNode and return chunks
210    pub fn render_vnode(&mut self, vnode: &VNode) -> Vec<String> {
211        self.chunks.clear();
212        let mut buffer = String::new();
213
214        self.render_vnode_to_buffer(vnode, &mut buffer);
215
216        // Split into chunks
217        for chunk in buffer.as_bytes().chunks(self.chunk_size) {
218            self.chunks.push(String::from_utf8_lossy(chunk).to_string());
219        }
220
221        self.chunks.clone()
222    }
223
224    #[allow(clippy::only_used_in_recursion)]
225    fn render_vnode_to_buffer(&self, vnode: &VNode, buffer: &mut String) {
226        match vnode {
227            VNode::Element(element) => {
228                buffer.push('<');
229                buffer.push_str(&element.tag);
230
231                for (key, value) in &element.attrs {
232                    buffer.push(' ');
233                    buffer.push_str(key);
234                    buffer.push_str("=\"");
235                    buffer.push_str(&SSRRenderer::escape_attribute(value));
236                    buffer.push('"');
237                }
238
239                if element.children.is_empty() && SSRRenderer::is_void_element(&element.tag) {
240                    buffer.push_str(" />");
241                } else {
242                    buffer.push('>');
243                    for child in &element.children {
244                        self.render_vnode_to_buffer(child, buffer);
245                    }
246                    buffer.push_str("</");
247                    buffer.push_str(&element.tag);
248                    buffer.push('>');
249                }
250            }
251            VNode::Text(text) => {
252                buffer.push_str(&SSRRenderer::escape_html(&text.content));
253            }
254            VNode::Component(_) => {
255                buffer.push_str("<!-- Component -->");
256            }
257            VNode::Empty => {}
258        }
259    }
260}
261
262/// Hydration helper for client-side
263pub struct Hydration {
264    state: HashMap<String, String>,
265}
266
267impl Hydration {
268    /// Create from serialized state
269    pub fn from_state(state_json: &str) -> Result<Self, String> {
270        let state: HashMap<String, String> =
271            serde_json::from_str(state_json).map_err(|e| e.to_string())?;
272        Ok(Self { state })
273    }
274
275    /// Get state value
276    pub fn get(&self, key: &str) -> Option<&String> {
277        self.state.get(key)
278    }
279
280    /// Check if element is hydrated
281    pub fn is_hydrated(&self) -> bool {
282        !self.state.is_empty()
283    }
284
285    /// Helper extension for render_to_document_with_html
286    #[allow(dead_code)]
287    fn render_to_document_with_html(&self, title: &str, body_html: &str) -> String {
288        format!(
289            r#"<!DOCTYPE html>
290<html lang="en">
291<head>
292    <meta charset="UTF-8">
293    <meta name="viewport" content="width=device-width, initial-scale=1.0">
294    <title>{}</title>
295</head>
296<body>
297    {}
298</body>
299</html>"#,
300            title, body_html
301        )
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_ssr_render_simple_element() {
311        let mut renderer = SSRRenderer::new();
312        let vnode = VNode::Element(VElement::new("div").child(VNode::Text(VText::new("Hello"))));
313
314        renderer.render_vnode(&vnode);
315        assert_eq!(renderer.html, "<div>Hello</div>");
316    }
317
318    #[test]
319    fn test_ssr_render_with_attributes() {
320        let mut renderer = SSRRenderer::new();
321        let vnode = VNode::Element(
322            VElement::new("div")
323                .attr("class", "container")
324                .child(VNode::Text(VText::new("Content"))),
325        );
326
327        renderer.render_vnode(&vnode);
328        assert!(renderer.html.contains("class=\"container\""));
329        assert!(renderer.html.contains("Content"));
330    }
331
332    #[test]
333    fn test_ssr_render_void_element() {
334        let mut renderer = SSRRenderer::new();
335        let vnode = VNode::Element(VElement::new("br"));
336
337        renderer.render_vnode(&vnode);
338        assert_eq!(renderer.html, "<br />");
339    }
340
341    #[test]
342    fn test_ssr_escape_html() {
343        let mut renderer = SSRRenderer::new();
344        let vnode = VNode::Element(
345            VElement::new("div").child(VNode::Text(VText::new("<script>alert('xss')</script>"))),
346        );
347
348        renderer.render_vnode(&vnode);
349        assert!(renderer.html.contains("&lt;script&gt;"));
350        assert!(!renderer.html.contains("<script>"));
351    }
352
353    #[test]
354    fn test_ssr_nested_elements() {
355        let mut renderer = SSRRenderer::new();
356        let vnode = VNode::Element(
357            VElement::new("div")
358                .child(VNode::Element(
359                    VElement::new("p").child(VNode::Text(VText::new("Nested"))),
360                ))
361                .child(VNode::Element(
362                    VElement::new("span").child(VNode::Text(VText::new("Content"))),
363                )),
364        );
365
366        renderer.render_vnode(&vnode);
367        assert!(renderer.html.contains("<div>"));
368        assert!(renderer.html.contains("<p>Nested</p>"));
369        assert!(renderer.html.contains("<span>Content</span>"));
370        assert!(renderer.html.contains("</div>"));
371    }
372
373    #[test]
374    fn test_ssr_document_generation() {
375        let mut renderer = SSRRenderer::new();
376
377        // Create a simple component render result
378        let vnode = VNode::Element(VElement::new("h1").child(VNode::Text(VText::new("Hello SSR"))));
379        renderer.render_vnode(&vnode);
380
381        // Test that HTML was generated
382        assert!(renderer.html.contains("<h1>Hello SSR</h1>"));
383    }
384
385    #[test]
386    fn test_streaming_ssr() {
387        let mut renderer = StreamingSSRRenderer::new(10); // Small chunk size for testing
388        let vnode = VNode::Element(
389            VElement::new("div").child(VNode::Text(VText::new("This is a longer text"))),
390        );
391
392        let chunks = renderer.render_vnode(&vnode);
393        assert!(!chunks.is_empty());
394
395        // Recombine chunks
396        let combined: String = chunks.into_iter().collect();
397        assert!(combined.contains("<div>"));
398        assert!(combined.contains("This is a longer text"));
399        assert!(combined.contains("</div>"));
400    }
401
402    #[test]
403    fn test_hydration_state() {
404        let mut renderer = SSRRenderer::new();
405        renderer.add_state("count".to_string(), "42".to_string());
406        renderer.add_state("name".to_string(), "Alice".to_string());
407
408        let state_json = serde_json::to_string(&renderer.state).unwrap();
409        let hydration = Hydration::from_state(&state_json).unwrap();
410
411        assert_eq!(hydration.get("count"), Some(&"42".to_string()));
412        assert_eq!(hydration.get("name"), Some(&"Alice".to_string()));
413        assert!(hydration.is_hydrated());
414    }
415
416    #[test]
417    fn test_attribute_escaping() {
418        let mut renderer = SSRRenderer::new();
419        let vnode = VNode::Element(
420            VElement::new("input")
421                .attr("value", "Test \"quoted\" value")
422                .attr("data-test", "<script>"),
423        );
424
425        renderer.render_vnode(&vnode);
426        assert!(renderer.html.contains("&quot;"));
427        assert!(renderer.html.contains("&lt;script&gt;"));
428    }
429}