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> ", " "] {
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}