Skip to main content

molt/
document.rs

1//! Whole-page assembly.
2//!
3
4use std::io::{self, Write};
5
6use crate::core::node::write_document_root;
7use crate::core::{Attribute, Element, Node, Render, RenderOptions};
8use crate::elements::{body, head, html_tag};
9
10/// A complete HTML document: a language, a `<head>` and a `<body>`.
11///
12/// The `Document` owns the doctype — [`Document::render`] prepends `<!DOCTYPE html>\n`,
13/// while [`Document::root`] gives you the bare `<html>` element without it.
14///
15/// # Rendering default
16///
17/// **`Document::render` defaults to pretty, while [`Element::render`] defaults to
18/// compact.** The asymmetry is deliberate and the golden fixtures depend on it, so it is not
19/// tidied up.
20///
21/// # Examples
22/// ```
23/// use molt::prelude::*;
24/// use molt::Document;
25///
26/// let page = Document::new(Some("pt-BR"))
27///     .head_children([title().text("RideKeeper")])
28///     .body_children([h1().text("Track every service")]);
29///
30/// assert!(page.render().starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">"));
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct Document {
35    /// The document language, rendered as `<html lang="…">`. Omitted when `None`.
36    pub lang: Option<String>,
37    /// The `<head>` element.
38    pub head: Element,
39    /// The `<body>` element.
40    pub body: Element,
41}
42
43impl Default for Document {
44    /// An empty document with no `lang`, same as `Document::new(None)`.
45    fn default() -> Self {
46        Self::new(None)
47    }
48}
49
50impl std::fmt::Display for Document {
51    /// The pretty render with doctype, same as [`Document::render`].
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str(&self.render())
54    }
55}
56
57impl Document {
58    /// Creates an empty document with the given language.
59    #[must_use]
60    pub fn new(lang: Option<&str>) -> Self {
61        Self {
62            lang: lang.map(ToString::to_string),
63            head: head(),
64            body: body(),
65        }
66    }
67
68    /// Creates a document from an existing `<head>` and `<body>`.
69    #[must_use]
70    pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
71        Self {
72            lang: lang.map(ToString::to_string),
73            head,
74            body,
75        }
76    }
77
78    /// Appends nodes to the `<head>`.
79    #[must_use]
80    pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
81        self.head = self.head.children_from(children);
82        self
83    }
84
85    /// Appends nodes to the `<body>`.
86    #[must_use]
87    pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
88        self.body = self.body.children_from(children);
89        self
90    }
91
92    /// The `<html>` element, **without** the doctype.
93    ///
94    /// Kept separate from [`render`](Self::render) because the static site generator and
95    /// several tests want the tree rather than the serialized page.
96    #[must_use]
97    pub fn root(&self) -> Element {
98        let root = match &self.lang {
99            Some(lang) => html_tag().attr("lang", lang),
100            None => html_tag(),
101        };
102        root.child(self.head.clone()).child(self.body.clone())
103    }
104
105    /// Renders the document with the given options, prefixed by the doctype.
106    #[must_use]
107    pub fn render_with(&self, options: &RenderOptions) -> String {
108        let mut out = String::with_capacity(2048);
109        out.push_str("<!DOCTYPE html>\n");
110        // Not `self.root().write_into(..)`: that clones head and body, and `Clone` is
111        // recursive, so a deep body aborted the process before anything was written.
112        write_document_root(
113            self.lang.as_deref(),
114            &self.head,
115            &self.body,
116            &mut out,
117            options,
118        );
119        out
120    }
121
122    /// Renders the document **pretty-printed**, which is the default for a document.
123    #[must_use]
124    pub fn render(&self) -> String {
125        self.render_with(&RenderOptions::pretty())
126    }
127
128    /// Renders the document on a single line.
129    #[must_use]
130    pub fn render_compact(&self) -> String {
131        self.render_with(&RenderOptions::compact())
132    }
133
134    /// Writes the same bytes as [`render_with`](Self::render_with) to `sink`.
135    ///
136    /// When `<body>` is only a list of children, each child is rendered with
137    /// [`Render::write_into`] into its own buffer and then written out, so memory stays
138    /// bounded by the largest section rather than the whole page. A body with text
139    /// content, no children, or a self-closing flag is written in one piece — still the
140    /// same bytes.
141    ///
142    /// # Errors
143    /// Returns an error if `sink` does.
144    ///
145    /// # Examples
146    /// ```
147    /// use molt::prelude::*;
148    /// use molt::{Document, RenderOptions};
149    /// let page = Document::new(Some("en")).body_children([h1().text("Hi")]);
150    /// let mut buf = Vec::new();
151    /// page.render_to(&mut buf, &RenderOptions::compact())?;
152    /// assert_eq!(buf, page.render_compact().into_bytes());
153    /// # Ok::<(), std::io::Error>(())
154    /// ```
155    pub fn render_to(&self, mut sink: impl Write, options: &RenderOptions) -> io::Result<()> {
156        // A depth limit counts `<html>` and `<body>` as open levels, which the section-by-
157        // section path does not see, so it takes the whole-tree path to keep the bytes equal.
158        if options.max_depth.is_some() || !streams_by_section(&self.body) {
159            return sink.write_all(self.render_with(options).as_bytes());
160        }
161        sink.write_all(self.html_open().as_bytes())?;
162        if options.pretty {
163            self.write_pretty_sections(&mut sink, options)
164        } else {
165            self.write_compact_sections(&mut sink, options)
166        }
167    }
168
169    fn html_open(&self) -> String {
170        let mut open = String::from("<!DOCTYPE html>\n<html");
171        if let Some(lang) = &self.lang {
172            Attribute::new_static("lang", lang).write_into(&mut open);
173        }
174        open.push('>');
175        open
176    }
177
178    fn write_pretty_sections(
179        &self,
180        sink: &mut impl Write,
181        options: &RenderOptions,
182    ) -> io::Result<()> {
183        let mut head_markup = String::from("\n");
184        self.head.write_into(&mut head_markup, options, 1);
185        sink.write_all(head_markup.as_bytes())?;
186
187        let mut body_open = String::from("\n");
188        options.write_indent(&mut body_open, 1);
189        write_open_tag(&self.body, &mut body_open);
190        sink.write_all(body_open.as_bytes())?;
191
192        for child in self.body.children() {
193            let mut rendered = String::new();
194            child.write_into(&mut rendered, options, 2);
195            if !rendered.is_empty() {
196                sink.write_all(b"\n")?;
197                sink.write_all(rendered.as_bytes())?;
198            }
199        }
200
201        let mut close = String::from("\n");
202        options.write_indent(&mut close, 1);
203        close.push_str("</body>\n</html>");
204        sink.write_all(close.as_bytes())
205    }
206
207    fn write_compact_sections(
208        &self,
209        sink: &mut impl Write,
210        options: &RenderOptions,
211    ) -> io::Result<()> {
212        let mut head_markup = String::new();
213        self.head.write_into(&mut head_markup, options, 0);
214        sink.write_all(head_markup.as_bytes())?;
215
216        let mut body_open = String::new();
217        write_open_tag(&self.body, &mut body_open);
218        sink.write_all(body_open.as_bytes())?;
219
220        for child in self.body.children() {
221            let mut rendered = String::new();
222            child.write_into(&mut rendered, options, 0);
223            sink.write_all(rendered.as_bytes())?;
224        }
225        sink.write_all(b"</body></html>")
226    }
227}
228
229/// A body made of sections can be written one child at a time. Anything else goes through
230/// [`Document::render_with`] so the bytes cannot drift.
231fn streams_by_section(body: &Element) -> bool {
232    body.tag() == "body"
233        && body.content().is_none()
234        && !body.children().is_empty()
235        && !body.is_self_closing()
236}
237
238fn write_open_tag(element: &Element, out: &mut String) {
239    out.push('<');
240    out.push_str(element.tag());
241    for attribute in element.attributes() {
242        attribute.write_into(out);
243    }
244    out.push('>');
245}
246
247#[cfg(test)]
248mod tests {
249    /// `render_to` streams head and body separately, which cannot see `<html>` and `<body>` as
250    /// open levels; with a depth limit it must still write the bytes `render_with` does.
251    #[test]
252    fn a_depth_limit_gives_the_same_bytes_streamed_and_whole() {
253        use crate::elements::{div, p, title};
254        let page = Document::new(Some("en"))
255            .head_children([title().text("T")])
256            .body_children([
257                div().child(div().child(p().text("deep"))),
258                div().text("flat"),
259            ]);
260        for depth in 0..6 {
261            for base in [RenderOptions::pretty(), RenderOptions::compact()] {
262                let options = base.with_max_depth(depth);
263                let mut streamed = Vec::new();
264                page.render_to(&mut streamed, &options)
265                    .expect("a Vec never fails");
266                assert_eq!(
267                    String::from_utf8(streamed).expect("utf-8"),
268                    page.render_with(&options),
269                    "depth {depth}"
270                );
271            }
272        }
273        // html is level 1, body level 2: a limit of 2 keeps both and drops their children.
274        let rendered = page.render_with(&RenderOptions::compact().with_max_depth(2));
275        assert_eq!(
276            rendered,
277            "<!DOCTYPE html>\n<html lang=\"en\"><head></head><body></body></html>"
278        );
279    }
280
281    use super::*;
282    use crate::core::Node;
283    use crate::elements::{body, h1, h2, head, img, meta, p, section, title};
284
285    /// The borrowed-parts writer must produce what rendering a cloned `<html>` tree did.
286    #[test]
287    fn rendering_matches_the_cloned_root_tree_byte_for_byte() {
288        use crate::elements::{div, p, pre, title};
289        let documents = [
290            Document::new(None),
291            Document::new(Some("pt-BR")).head_children([title().text("T")]),
292            Document::new(Some("a\"b")).body_children([div().child(p().text("x")), div()]),
293            Document::new(Some("en")).body_children([Node::fragment([]), Node::text("")]),
294            Document::with_parts(Some("en"), head(), pre().text("  keep  ")),
295            Document::with_parts(None, Element::new("bad tag"), body().text("t")),
296            Document::with_parts(None, head(), body().self_closing(true)),
297        ];
298        for document in &documents {
299            for options in [
300                RenderOptions::pretty(),
301                RenderOptions::compact(),
302                RenderOptions::pretty().with_xhtml_self_closing(true),
303                RenderOptions::compact().with_indent("\t"),
304            ] {
305                let mut expected = String::from("<!DOCTYPE html>\n");
306                document.root().write_into(&mut expected, &options, 0);
307                assert_eq!(document.render_with(&options), expected, "{document:?}");
308            }
309        }
310    }
311
312    #[test]
313    fn a_document_owns_the_doctype_and_the_lang_attribute() {
314        let page = Document::new(Some("pt-BR"));
315        assert!(
316            page.render()
317                .starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
318        );
319    }
320
321    #[test]
322    fn the_lang_attribute_is_omitted_when_absent() {
323        let page = Document::new(None);
324        assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
325    }
326
327    #[test]
328    fn root_returns_the_tree_without_the_doctype() {
329        let page = Document::new(Some("en"));
330        assert!(!page.root().render().contains("DOCTYPE"));
331        assert!(page.root().render().starts_with("<html lang=\"en\">"));
332    }
333
334    /// The asymmetry with `Element::render` is deliberate — see the type docs.
335    #[test]
336    fn a_document_renders_pretty_by_default_unlike_an_element() {
337        let page = Document::new(Some("en")).head_children([title().text("T")]);
338        assert!(page.render().contains('\n'));
339        assert!(
340            !page
341                .render_compact()
342                .trim_start_matches("<!DOCTYPE html>\n")
343                .contains('\n')
344        );
345    }
346
347    #[test]
348    fn head_and_body_children_land_in_the_right_place() {
349        let page = Document::new(Some("en"))
350            .head_children([title().text("T")])
351            .body_children([h1().text("H")]);
352        let rendered = page.render();
353        let head_at = rendered.find("<title>").expect("title rendered");
354        let body_at = rendered.find("<h1>").expect("h1 rendered");
355        assert!(head_at < body_at);
356    }
357
358    #[test]
359    fn a_document_is_a_value_and_can_be_shared_across_threads() {
360        fn assert_send_sync<T: Send + Sync>() {}
361        assert_send_sync::<Document>();
362
363        let page = Document::new(Some("en"));
364        let copy = page.clone().head_children([title().text("changed")]);
365        assert!(!page.render().contains("changed"));
366        assert!(copy.render().contains("changed"));
367    }
368
369    #[test]
370    fn body_children_accept_loops_and_conditions() {
371        let show_banner = false;
372        let page = Document::new(None)
373            .head_children([title().text("T")])
374            .body_children([crate::html! {
375                @if show_banner { div { "banner" } }
376                @for index in 1..=2 { p { "line " (index) } }
377            }]);
378
379        let rendered = page.render_compact();
380        assert!(!rendered.contains("banner"));
381        assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
382    }
383
384    /// Streamed output matches `render_with`, including for bodies that cannot be streamed.
385    #[test]
386    fn render_to_matches_render_with() {
387        let options = [
388            RenderOptions::pretty(),
389            RenderOptions::compact(),
390            RenderOptions::pretty()
391                .with_indent("\t")
392                .with_xhtml_self_closing(true),
393        ];
394        let documents = [
395            streaming_page(3, false),
396            streaming_page(400, false),
397            streaming_page(2, true),
398            Document::with_parts(None, head(), body()),
399            Document::with_parts(Some("en"), head(), body().raw_text("<raw>")),
400        ];
401        for page in &documents {
402            for options in &options {
403                let mut written = Vec::new();
404                page.render_to(&mut written, options).expect("write");
405                assert_eq!(written, page.render_with(options).into_bytes());
406            }
407        }
408    }
409
410    fn streaming_page(sections: usize, empty: bool) -> Document {
411        let mut nodes = Vec::new();
412        if empty {
413            nodes.push(Node::fragment([]));
414        }
415        for index in 0..sections {
416            nodes.push(
417                section()
418                    .add_class("s")
419                    .child(h2().text(format!("Section {index}")))
420                    .child(p().text("text & more ".repeat(50)))
421                    .child(img().attr("src", format!("/{index}.png")).attr("alt", ""))
422                    .into(),
423            );
424        }
425        if empty {
426            nodes.push(Node::fragment([]));
427        }
428        Document::new(Some("en"))
429            .head_children([title().text("Streaming"), meta().attr("charset", "UTF-8")])
430            .body_children(nodes)
431    }
432}