Skip to main content

molt/core/
render.rs

1//! Rendering options and the [`Render`] trait.
2//!
3//! [`Render::write_into`] is the single primitive: everything else is a provided method on top
4//! of it, so the whole tree renders into one buffer with one allocation.
5
6/// How a node tree is turned into markup.
7///
8/// A value passed into the render call, never global state — two threads can render the
9/// same tree with different settings at the same time. There is deliberately no process-wide
10/// switch.
11///
12/// # Examples
13/// ```
14/// use molt::core::RenderOptions;
15/// let compact = RenderOptions::compact();
16/// let pretty = RenderOptions::pretty();
17/// let four_spaces = RenderOptions::pretty().with_indent("    ");
18/// # let _ = (compact, pretty, four_spaces);
19/// ```
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct RenderOptions {
23    /// When true, children go on their own lines and are indented.
24    pub pretty: bool,
25    /// One indentation level, only used when `pretty` is true. Private so it can only be set
26    /// through [`RenderOptions::with_indent`], which keeps it to spaces and tabs.
27    indent: String,
28    /// When true, void elements close with ` />` instead of `>`.
29    pub xhtml_self_closing: bool,
30    /// Elements nested deeper than this are not written. `None` (the default) writes any
31    /// depth. See [`RenderOptions::with_max_depth`].
32    pub max_depth: Option<usize>,
33}
34
35/// The longest indentation level kept. Wider than this is not indentation, and it is
36/// multiplied by depth on every line.
37const MAX_INDENT_LEN: usize = 16;
38
39impl Default for RenderOptions {
40    fn default() -> Self {
41        Self {
42            pretty: false,
43            indent: "  ".to_string(),
44            xhtml_self_closing: false,
45            max_depth: None,
46        }
47    }
48}
49
50impl RenderOptions {
51    /// Minified output on a single line. The default, and what you should ship.
52    #[must_use]
53    pub fn compact() -> Self {
54        Self::default()
55    }
56
57    /// Indented, human-readable output.
58    #[must_use]
59    pub fn pretty() -> Self {
60        Self {
61            pretty: true,
62            ..Self::default()
63        }
64    }
65
66    /// Sets the indentation string.
67    ///
68    /// Only spaces and tabs are kept, and at most 16 of them; anything else is dropped. The
69    /// indent is written verbatim at the start of every line, so a string with markup in it
70    /// (`renderWithIndent(untrusted)` from JavaScript, say) would put that markup on every
71    /// line of the page. An indent that is not whitespace is not an indent.
72    ///
73    /// # Examples
74    /// ```
75    /// use molt::core::RenderOptions;
76    /// assert_eq!(RenderOptions::pretty().with_indent("\t").indent(), "\t");
77    /// assert_eq!(RenderOptions::pretty().with_indent("<script>").indent(), "");
78    /// assert_eq!(RenderOptions::pretty().with_indent("  <b> ").indent(), "   ");
79    /// ```
80    #[must_use]
81    pub fn with_indent(mut self, indent: impl AsRef<str>) -> Self {
82        self.indent = indent
83            .as_ref()
84            .chars()
85            .filter(|c| matches!(c, ' ' | '\t'))
86            .take(MAX_INDENT_LEN)
87            .collect();
88        self
89    }
90
91    /// The indentation string for one level: spaces and tabs only.
92    #[must_use]
93    pub fn indent(&self) -> &str {
94        &self.indent
95    }
96
97    /// Stops writing elements nested deeper than `max_depth`; the subtree is dropped.
98    ///
99    /// Pretty output grows with the *square* of the depth (one indent per level on every
100    /// line), so a tree whose depth comes from untrusted input can ask for gigabytes. This
101    /// bounds it. Browsers flatten nesting beyond roughly 512 elements, so a limit of 512 or
102    /// less loses nothing a page could show. Text and fragments do not count as a level.
103    ///
104    /// # Examples
105    /// ```
106    /// use molt::prelude::*;
107    /// let tree = div().child(div().child(div().child(p().text("deep"))));
108    /// let shallow = RenderOptions::compact().with_max_depth(2);
109    /// assert_eq!(tree.render_with(&shallow), "<div><div></div></div>");
110    /// ```
111    #[must_use]
112    pub fn with_max_depth(mut self, max_depth: usize) -> Self {
113        self.max_depth = Some(max_depth);
114        self
115    }
116
117    /// Closes void elements with ` />` instead of `>`.
118    #[must_use]
119    pub fn with_xhtml_self_closing(mut self, yes: bool) -> Self {
120        self.xhtml_self_closing = yes;
121        self
122    }
123
124    /// Appends `depth` levels of indentation to a buffer.
125    pub(crate) fn write_indent(&self, out: &mut String, depth: usize) {
126        // The early return is not just a micro-optimisation: with an empty indent the loop
127        // below still runs once per level, which is quadratic in depth and costs minutes on
128        // a deeply nested tree while producing nothing.
129        if depth == 0 || self.indent.is_empty() {
130            return;
131        }
132
133        out.reserve(self.indent.len() * depth);
134        for _ in 0..depth {
135            out.push_str(&self.indent);
136        }
137    }
138}
139
140/// Anything that can be written as HTML.
141///
142/// Implement [`write_into`](Render::write_into); the rest comes free.
143///
144/// # Implementing it for your own type
145///
146/// Most types should not: build an [`Element`](crate::core::Element) and render that. Do it
147/// when a type *is* a piece of markup and callers want to pass it where a node is expected,
148/// by delegating to the tree it builds.
149///
150/// ```
151/// use molt::prelude::*;
152///
153/// struct Badge(&'static str);
154///
155/// impl Render for Badge {
156///     fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
157///         span().add_class("badge").text(self.0).write_into(out, options, depth);
158///     }
159/// }
160///
161/// assert_eq!(Badge("new").render(), r#"<span class="badge">new</span>"#);
162/// ```
163///
164/// Whatever you write must already be escaped: use [`Element`](crate::core::Element) and
165/// `text()` rather than pushing user text into `out` yourself.
166///
167/// # Stability
168///
169/// `Render` is part of the 1.x API. `write_into(&self, &mut String, &RenderOptions, usize)`
170/// will not change, and no **required** method will be added; provided methods may be. The
171/// buffer is a `String` on purpose, so a whole tree renders into one allocation, and
172/// `depth` is the indentation level the output starts at (`0` for a root). Implementations
173/// for `&T`, `Box<T>` and `Option<T>` exist; `Document` deliberately has none, because its
174/// inherent `render()` is pretty and this trait's is compact.
175///
176/// # Depth
177///
178/// The provided implementations walk an explicit work stack rather than recursing, so
179/// nesting depth costs heap rather than stack and there is no depth at which rendering
180/// aborts. A 100,000-level tree is covered by a test.
181///
182/// Pretty mode still writes one indent string per level on every line, which makes its
183/// *output* quadratic in depth. That is a size to be aware of when depth comes from
184/// untrusted input — see `SECURITY.md` — not a limit on what renders.
185pub trait Render {
186    /// Writes this node and its subtree into an existing buffer.
187    ///
188    /// This is the primitive every other method here is built on. Writing into a shared
189    /// buffer avoids allocating an intermediate string per node.
190    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize);
191
192    /// Renders as a single line of markup.
193    ///
194    /// Note the asymmetry with [`crate::Document::render`], which defaults to *pretty*.
195    /// The golden fixtures depend on that difference.
196    fn render(&self) -> String {
197        self.render_with(&RenderOptions::compact())
198    }
199
200    /// Renders with indentation and line breaks.
201    fn render_pretty(&self) -> String {
202        self.render_with(&RenderOptions::pretty())
203    }
204
205    /// Renders with the given options.
206    fn render_with(&self, options: &RenderOptions) -> String {
207        let mut out = String::with_capacity(1024);
208        self.write_into(&mut out, options, 0);
209        out
210    }
211}
212
213impl<T: Render + ?Sized> Render for &T {
214    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
215        (**self).write_into(out, options, depth);
216    }
217}
218
219impl<T: Render + ?Sized> Render for Box<T> {
220    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
221        (**self).write_into(out, options, depth);
222    }
223}
224
225/// `None` writes nothing.
226impl<T: Render> Render for Option<T> {
227    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
228        if let Some(inner) = self {
229            inner.write_into(out, options, depth);
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    /// The indent is written at the start of every line, so markup in it lands on every line.
237    #[test]
238    fn an_indent_that_is_not_whitespace_is_dropped() {
239        for hostile in ["<script>alert(1)</script>", "\n", "x", "  <b>  ", "&nbsp;"] {
240            let kept: String = hostile
241                .chars()
242                .filter(|c| matches!(c, ' ' | '\t'))
243                .collect();
244            let options = RenderOptions::pretty().with_indent(hostile);
245            assert_eq!(options.indent(), kept, "{hostile:?}");
246            let rendered = div().child(p().text("x")).render_with(&options);
247            assert!(
248                !rendered.contains('<') || rendered.matches('<').count() == 4,
249                "{rendered}"
250            );
251            assert!(!rendered.contains("script"), "{rendered}");
252        }
253        assert_eq!(RenderOptions::pretty().with_indent("\t").indent(), "\t");
254    }
255
256    #[test]
257    fn an_indent_is_capped_at_sixteen_columns() {
258        let options = RenderOptions::pretty().with_indent(" ".repeat(10_000));
259        assert_eq!(options.indent().len(), 16);
260    }
261
262    #[test]
263    fn a_depth_limit_drops_what_is_deeper_in_both_modes() {
264        let tree = div()
265            .child(div().child(div().child(p().text("deep"))))
266            .child(p().text("side"));
267        let compact = RenderOptions::compact().with_max_depth(2);
268        assert_eq!(
269            tree.render_with(&compact),
270            "<div><div></div><p>side</p></div>"
271        );
272        // The dropped subtree leaves its parent open and closed on separate lines, but no
273        // blank line between them: the child's separator is rolled back, same as for an
274        // invalid tag.
275        let pretty = RenderOptions::pretty().with_max_depth(2);
276        assert_eq!(
277            tree.render_with(&pretty),
278            "<div>\n  <div>\n  </div>\n  <p>side</p>\n</div>"
279        );
280        assert_eq!(
281            tree.render_with(&RenderOptions::compact().with_max_depth(0)),
282            ""
283        );
284        assert_eq!(
285            tree.render_with(&RenderOptions::compact().with_max_depth(99)),
286            tree.render()
287        );
288    }
289
290    #[test]
291    fn a_depth_limit_bounds_the_quadratic_pretty_output() {
292        let mut node = p().text("bottom");
293        for _ in 0..50_000 {
294            node = div().child(node);
295        }
296        let limited = node.render_with(&RenderOptions::pretty().with_max_depth(512));
297        // 512 levels, an open and a close line each, at most 512 * 16 columns of indent.
298        assert!(
299            limited.len() < 512 * 2 * (512 * 2 + 16),
300            "{} bytes",
301            limited.len()
302        );
303    }
304
305    use super::*;
306    use crate::elements::{div, img, p};
307
308    #[test]
309    fn compact_is_the_default() {
310        let options = RenderOptions::default();
311        assert!(!options.pretty);
312        assert_eq!(options.indent, "  ");
313        assert!(!options.xhtml_self_closing);
314    }
315
316    #[test]
317    fn the_indent_string_is_configurable() {
318        let options = RenderOptions::pretty().with_indent("    ");
319        let mut out = String::new();
320        options.write_indent(&mut out, 2);
321        assert_eq!(out, "        ");
322    }
323
324    /// Options are a value: changing a copy must not affect the original.
325    #[test]
326    fn options_have_value_semantics() {
327        let base = RenderOptions::compact();
328        let derived = base.clone().with_xhtml_self_closing(true);
329        assert!(!base.xhtml_self_closing);
330        assert!(derived.xhtml_self_closing);
331    }
332
333    #[test]
334    fn pretty_indents_children_by_two_spaces() {
335        let tree = div().child(p().text("Hi"));
336
337        assert_eq!(tree.render_pretty(), "<div>\n  <p>Hi</p>\n</div>");
338    }
339
340    /// XHTML-style closing is a field on the options value, never a process-wide switch, so a
341    /// render cannot leak into the next one.
342    #[test]
343    fn xhtml_self_closing_is_per_call() {
344        let tag = img().attr("src", "a.png");
345
346        assert_eq!(tag.render(), r#"<img src="a.png">"#);
347        assert_eq!(
348            tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
349            r#"<img src="a.png" />"#
350        );
351        assert_eq!(tag.render(), r#"<img src="a.png">"#);
352    }
353
354    #[test]
355    fn write_into_appends_rather_than_replacing() {
356        let mut buffer = String::from("<!-- header -->");
357        div()
358            .text("x")
359            .write_into(&mut buffer, &RenderOptions::compact(), 0);
360
361        assert_eq!(buffer, "<!-- header --><div>x</div>");
362    }
363
364    /// The tree is `Send + Sync` and the options are a value, so this is a guard against ever
365    /// reintroducing shared state.
366    #[test]
367    fn concurrent_renders_do_not_share_state() {
368        let results: Vec<(usize, String)> = std::thread::scope(|scope| {
369            let handles: Vec<_> = (0..8)
370                .map(|index| {
371                    scope.spawn(move || {
372                        let tag = img().attr("src", format!("{index}.png"));
373                        let options =
374                            RenderOptions::compact().with_xhtml_self_closing(index % 2 == 0);
375                        (index, tag.render_with(&options))
376                    })
377                })
378                .collect();
379            handles
380                .into_iter()
381                .map(|h| h.join().expect("thread"))
382                .collect()
383        });
384
385        for (index, rendered) in results {
386            assert_eq!(
387                rendered.ends_with(" />"),
388                index % 2 == 0,
389                "{index} rendered {rendered:?}"
390            );
391        }
392    }
393}