Skip to main content

ferrum_email_render/
renderer.rs

1//! The core renderer — walks a Node tree and emits email-safe HTML.
2
3use ferrum_email_core::{Component, Node};
4
5use crate::RenderError;
6use crate::css_inliner;
7use crate::html_emitter::{doctype, escape_attr, escape_text};
8use crate::text_extractor;
9
10/// Configuration for the renderer.
11#[derive(Debug, Clone)]
12pub struct RenderConfig {
13    /// Whether to prepend `<!DOCTYPE html>` to the output.
14    pub include_doctype: bool,
15    /// Whether to pretty-print the HTML with indentation.
16    pub pretty_print: bool,
17    /// Indentation string for pretty-printing (default: "  ").
18    pub indent: String,
19}
20
21impl Default for RenderConfig {
22    fn default() -> Self {
23        RenderConfig {
24            include_doctype: true,
25            pretty_print: false,
26            indent: "  ".to_string(),
27        }
28    }
29}
30
31/// The Ferrum Email renderer.
32///
33/// Takes a `Component`, calls its `render()` method to produce a `Node` tree,
34/// inlines CSS styles, and emits email-safe HTML.
35pub struct Renderer {
36    pub config: RenderConfig,
37}
38
39impl Renderer {
40    /// Create a new renderer with default configuration.
41    pub fn new() -> Self {
42        Renderer {
43            config: RenderConfig::default(),
44        }
45    }
46
47    /// Create a new renderer with custom configuration.
48    pub fn with_config(config: RenderConfig) -> Self {
49        Renderer { config }
50    }
51
52    /// Render a component to an HTML string.
53    pub fn render_html(&self, component: &dyn Component) -> Result<String, RenderError> {
54        let node = component.render();
55        let inlined = css_inliner::inline_styles(&node);
56
57        let mut output = String::new();
58        if self.config.include_doctype {
59            output.push_str(doctype());
60            output.push('\n');
61        }
62
63        if self.config.pretty_print {
64            self.emit_node_pretty(&inlined, &mut output, 0);
65        } else {
66            self.emit_node(&inlined, &mut output);
67        }
68
69        Ok(output)
70    }
71
72    /// Render a component to a plain text string.
73    pub fn render_text(&self, component: &dyn Component) -> Result<String, RenderError> {
74        // Check if the component provides custom plain text
75        if let Some(custom_text) = component.plain_text() {
76            return Ok(custom_text);
77        }
78
79        let node = component.render();
80        Ok(text_extractor::extract_text(&node))
81    }
82
83    /// Render a single Node to an HTML string (without DOCTYPE).
84    pub fn render_node(&self, node: &Node) -> String {
85        let inlined = css_inliner::inline_styles(node);
86        let mut output = String::new();
87        self.emit_node(&inlined, &mut output);
88        output
89    }
90
91    /// Emit a node to the output string (compact mode).
92    fn emit_node(&self, node: &Node, output: &mut String) {
93        match node {
94            Node::Text(text) => {
95                output.push_str(&escape_text(text));
96            }
97            Node::Element(element) => {
98                let tag_name = element.tag.as_str();
99
100                // Open tag
101                output.push('<');
102                output.push_str(tag_name);
103
104                // Attributes
105                for attr in &element.attrs {
106                    output.push(' ');
107                    output.push_str(&attr.name);
108                    output.push_str("=\"");
109                    output.push_str(&escape_attr(&attr.value));
110                    output.push('"');
111                }
112
113                if element.tag.is_void() {
114                    output.push_str(" />");
115                    return;
116                }
117
118                output.push('>');
119
120                // Children
121                for child in &element.children {
122                    self.emit_node(child, output);
123                }
124
125                // Close tag
126                output.push_str("</");
127                output.push_str(tag_name);
128                output.push('>');
129            }
130            Node::Fragment(nodes) => {
131                for node in nodes {
132                    self.emit_node(node, output);
133                }
134            }
135            Node::None => {}
136        }
137    }
138
139    /// Emit a node with pretty-printing (indentation and newlines).
140    fn emit_node_pretty(&self, node: &Node, output: &mut String, depth: usize) {
141        let indent = self.config.indent.repeat(depth);
142
143        match node {
144            Node::Text(text) => {
145                let escaped = escape_text(text);
146                if !escaped.trim().is_empty() {
147                    output.push_str(&indent);
148                    output.push_str(&escaped);
149                    output.push('\n');
150                }
151            }
152            Node::Element(element) => {
153                let tag_name = element.tag.as_str();
154
155                // Open tag
156                output.push_str(&indent);
157                output.push('<');
158                output.push_str(tag_name);
159
160                for attr in &element.attrs {
161                    output.push(' ');
162                    output.push_str(&attr.name);
163                    output.push_str("=\"");
164                    output.push_str(&escape_attr(&attr.value));
165                    output.push('"');
166                }
167
168                if element.tag.is_void() {
169                    output.push_str(" />\n");
170                    return;
171                }
172
173                output.push_str(">\n");
174
175                // Children
176                for child in &element.children {
177                    self.emit_node_pretty(child, output, depth + 1);
178                }
179
180                // Close tag
181                output.push_str(&indent);
182                output.push_str("</");
183                output.push_str(tag_name);
184                output.push_str(">\n");
185            }
186            Node::Fragment(nodes) => {
187                for node in nodes {
188                    self.emit_node_pretty(node, output, depth);
189                }
190            }
191            Node::None => {}
192        }
193    }
194}
195
196impl Default for Renderer {
197    fn default() -> Self {
198        Renderer::new()
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use ferrum_email_core::*;
206
207    struct SimpleEmail;
208
209    impl Component for SimpleEmail {
210        fn render(&self) -> Node {
211            Node::Element(Element::new(Tag::P).child(Node::text("Hello, World!")))
212        }
213    }
214
215    #[test]
216    fn test_render_simple_html() {
217        let renderer = Renderer::new();
218        let html = renderer.render_html(&SimpleEmail).unwrap();
219        assert!(html.contains("<!DOCTYPE html>"));
220        assert!(html.contains("<p>Hello, World!</p>"));
221    }
222
223    #[test]
224    fn test_render_simple_text() {
225        let renderer = Renderer::new();
226        let text = renderer.render_text(&SimpleEmail).unwrap();
227        assert!(text.contains("Hello, World!"));
228        assert!(!text.contains('<'));
229    }
230
231    #[test]
232    fn test_html_escaping() {
233        struct EscapeEmail;
234        impl Component for EscapeEmail {
235            fn render(&self) -> Node {
236                Node::Element(Element::new(Tag::P).child(Node::text("1 < 2 & 3 > 2")))
237            }
238        }
239        let renderer = Renderer::new();
240        let html = renderer.render_html(&EscapeEmail).unwrap();
241        assert!(html.contains("1 &lt; 2 &amp; 3 &gt; 2"));
242    }
243
244    #[test]
245    fn test_void_elements() {
246        struct VoidEmail;
247        impl Component for VoidEmail {
248            fn render(&self) -> Node {
249                Node::Element(
250                    Element::new(Tag::Img)
251                        .attr("src", "test.png")
252                        .attr("alt", "test"),
253                )
254            }
255        }
256        let renderer = Renderer::new();
257        let html = renderer.render_html(&VoidEmail).unwrap();
258        assert!(html.contains("<img src=\"test.png\" alt=\"test\" />"));
259        assert!(!html.contains("</img>"));
260    }
261
262    #[test]
263    fn test_style_inlining() {
264        struct StyledEmail;
265        impl Component for StyledEmail {
266            fn render(&self) -> Node {
267                let mut style = Style::new();
268                style.color = Some(Color::hex("ff0000"));
269                style.font_size = Some(Px(16));
270
271                Node::Element(
272                    Element::new(Tag::P)
273                        .style(style)
274                        .child(Node::text("Red text")),
275                )
276            }
277        }
278        let renderer = Renderer::new();
279        let html = renderer.render_html(&StyledEmail).unwrap();
280        assert!(html.contains("style=\""));
281        assert!(html.contains("color:#ff0000"));
282        assert!(html.contains("font-size:16px"));
283    }
284}