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, write_html_open_tag};
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    /// Further attributes on `<html>`, written after `lang` in insertion order. See
38    /// [`Document::html_attr`].
39    pub html_attributes: Vec<Attribute>,
40    /// The `<head>` element.
41    pub head: Element,
42    /// The `<body>` element.
43    pub body: Element,
44}
45
46impl Default for Document {
47    /// An empty document with no `lang`, same as `Document::new(None)`.
48    fn default() -> Self {
49        Self::new(None)
50    }
51}
52
53impl std::fmt::Display for Document {
54    /// The pretty render with doctype, same as [`Document::render`].
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(&self.render())
57    }
58}
59
60impl Document {
61    /// Creates an empty document with the given language.
62    #[must_use]
63    pub fn new(lang: Option<&str>) -> Self {
64        Self {
65            lang: lang.map(ToString::to_string),
66            html_attributes: Vec::new(),
67            head: head(),
68            body: body(),
69        }
70    }
71
72    /// Creates a document from an existing `<head>` and `<body>`.
73    #[must_use]
74    pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
75        Self {
76            lang: lang.map(ToString::to_string),
77            html_attributes: Vec::new(),
78            head,
79            body,
80        }
81    }
82
83    /// Appends `key="value"` to the `<html>` element, escaping the value.
84    ///
85    /// For what has to be there before any script runs, such as `data-theme="dark"` or
86    /// `dir="rtl"`. Attributes come after `lang`, in the order they were added, and nothing
87    /// is deduplicated, like [`Element::attr`]. A name that is not safe to write is omitted,
88    /// and a URL value is checked, as on any element.
89    ///
90    /// # Examples
91    /// ```
92    /// use molt::Document;
93    /// let page = Document::new(Some("pt-BR")).html_attr("data-theme", "dark");
94    /// assert!(page.render().starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\" data-theme=\"dark\">"));
95    /// ```
96    #[must_use]
97    pub fn html_attr(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
98        self.html_attributes.push(Attribute::new(key, value));
99        self
100    }
101
102    /// Appends several `<html>` attributes; see [`Document::html_attr`].
103    ///
104    /// # Examples
105    /// ```
106    /// use molt::Document;
107    /// let page = Document::new(None).html_attrs([("class", "no-js"), ("dir", "rtl")]);
108    /// assert!(page.render().contains("<html class=\"no-js\" dir=\"rtl\">"));
109    /// ```
110    #[must_use]
111    pub fn html_attrs<K: Into<String>, V: AsRef<str>>(
112        mut self,
113        attributes: impl IntoIterator<Item = (K, V)>,
114    ) -> Self {
115        self.html_attributes.extend(
116            attributes
117                .into_iter()
118                .map(|(key, value)| Attribute::new(key, value)),
119        );
120        self
121    }
122
123    /// Appends nodes to the `<head>`.
124    #[must_use]
125    pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
126        self.head = self.head.children_from(children);
127        self
128    }
129
130    /// Appends nodes to the `<body>`.
131    #[must_use]
132    pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
133        self.body = self.body.children_from(children);
134        self
135    }
136
137    /// The `<html>` element, **without** the doctype.
138    ///
139    /// Kept separate from [`render`](Self::render) because the static site generator and
140    /// several tests want the tree rather than the serialized page.
141    #[must_use]
142    pub fn root(&self) -> Element {
143        let mut root = match &self.lang {
144            Some(lang) => html_tag().attr("lang", lang),
145            None => html_tag(),
146        };
147        for attribute in &self.html_attributes {
148            root = root.add_attribute(attribute.clone());
149        }
150        root.child(self.head.clone()).child(self.body.clone())
151    }
152
153    /// Renders the document with the given options, prefixed by the doctype.
154    #[must_use]
155    pub fn render_with(&self, options: &RenderOptions) -> String {
156        let mut out = String::with_capacity(2048);
157        out.push_str("<!DOCTYPE html>\n");
158        // Not `self.root().write_into(..)`: that clones head and body, and `Clone` is
159        // recursive, so a deep body aborted the process before anything was written.
160        write_document_root(
161            self.lang.as_deref(),
162            &self.html_attributes,
163            &self.head,
164            &self.body,
165            &mut out,
166            options,
167        );
168        out
169    }
170
171    /// Renders the document **pretty-printed**, which is the default for a document.
172    #[must_use]
173    pub fn render(&self) -> String {
174        self.render_with(&RenderOptions::pretty())
175    }
176
177    /// Renders the document on a single line.
178    #[must_use]
179    pub fn render_compact(&self) -> String {
180        self.render_with(&RenderOptions::compact())
181    }
182
183    /// Writes the same bytes as [`render_with`](Self::render_with) to `sink`.
184    ///
185    /// When `<body>` is only a list of children, each child is rendered with
186    /// [`Render::write_into`] into its own buffer and then written out, so memory stays
187    /// bounded by the largest section rather than the whole page. A body with text
188    /// content, no children, or a self-closing flag is written in one piece — still the
189    /// same bytes.
190    ///
191    /// # Errors
192    /// Returns an error if `sink` does.
193    ///
194    /// # Examples
195    /// ```
196    /// use molt::prelude::*;
197    /// use molt::{Document, RenderOptions};
198    /// let page = Document::new(Some("en")).body_children([h1().text("Hi")]);
199    /// let mut buf = Vec::new();
200    /// page.render_to(&mut buf, &RenderOptions::compact())?;
201    /// assert_eq!(buf, page.render_compact().into_bytes());
202    /// # Ok::<(), std::io::Error>(())
203    /// ```
204    pub fn render_to(&self, mut sink: impl Write, options: &RenderOptions) -> io::Result<()> {
205        // A depth limit counts `<html>` and `<body>` as open levels, which the section-by-
206        // section path does not see, so it takes the whole-tree path to keep the bytes equal.
207        if options.max_depth.is_some() || !streams_by_section(&self.body) {
208            return sink.write_all(self.render_with(options).as_bytes());
209        }
210        sink.write_all(self.html_open().as_bytes())?;
211        if options.pretty {
212            self.write_pretty_sections(&mut sink, options)
213        } else {
214            self.write_compact_sections(&mut sink, options)
215        }
216    }
217
218    fn html_open(&self) -> String {
219        let mut open = String::from("<!DOCTYPE html>\n");
220        write_html_open_tag(self.lang.as_deref(), &self.html_attributes, &mut open);
221        open
222    }
223
224    fn write_pretty_sections(
225        &self,
226        sink: &mut impl Write,
227        options: &RenderOptions,
228    ) -> io::Result<()> {
229        let mut head_markup = String::from("\n");
230        self.head.write_into(&mut head_markup, options, 1);
231        sink.write_all(head_markup.as_bytes())?;
232
233        let mut body_open = String::from("\n");
234        options.write_indent(&mut body_open, 1);
235        write_open_tag(&self.body, &mut body_open);
236        sink.write_all(body_open.as_bytes())?;
237
238        for child in self.body.children() {
239            let mut rendered = String::new();
240            child.write_into(&mut rendered, options, 2);
241            if !rendered.is_empty() {
242                sink.write_all(b"\n")?;
243                sink.write_all(rendered.as_bytes())?;
244            }
245        }
246
247        let mut close = String::from("\n");
248        options.write_indent(&mut close, 1);
249        close.push_str("</body>\n</html>");
250        sink.write_all(close.as_bytes())
251    }
252
253    fn write_compact_sections(
254        &self,
255        sink: &mut impl Write,
256        options: &RenderOptions,
257    ) -> io::Result<()> {
258        let mut head_markup = String::new();
259        self.head.write_into(&mut head_markup, options, 0);
260        sink.write_all(head_markup.as_bytes())?;
261
262        let mut body_open = String::new();
263        write_open_tag(&self.body, &mut body_open);
264        sink.write_all(body_open.as_bytes())?;
265
266        for child in self.body.children() {
267            let mut rendered = String::new();
268            child.write_into(&mut rendered, options, 0);
269            sink.write_all(rendered.as_bytes())?;
270        }
271        sink.write_all(b"</body></html>")
272    }
273}
274
275/// A body made of sections can be written one child at a time. Anything else goes through
276/// [`Document::render_with`] so the bytes cannot drift.
277fn streams_by_section(body: &Element) -> bool {
278    body.tag() == "body"
279        && body.content().is_none()
280        && !body.children().is_empty()
281        && !body.is_self_closing()
282}
283
284fn write_open_tag(element: &Element, out: &mut String) {
285    out.push('<');
286    out.push_str(element.tag());
287    for attribute in element.attributes() {
288        attribute.write_into(out);
289    }
290    out.push('>');
291}
292
293#[cfg(test)]
294mod tests {
295    /// `render_to` streams head and body separately, which cannot see `<html>` and `<body>` as
296    /// open levels; with a depth limit it must still write the bytes `render_with` does.
297    #[test]
298    fn a_depth_limit_gives_the_same_bytes_streamed_and_whole() {
299        use crate::elements::{div, p, title};
300        let page = Document::new(Some("en"))
301            .head_children([title().text("T")])
302            .body_children([
303                div().child(div().child(p().text("deep"))),
304                div().text("flat"),
305            ]);
306        for depth in 0..6 {
307            for base in [RenderOptions::pretty(), RenderOptions::compact()] {
308                let options = base.with_max_depth(depth);
309                let mut streamed = Vec::new();
310                page.render_to(&mut streamed, &options)
311                    .expect("a Vec never fails");
312                assert_eq!(
313                    String::from_utf8(streamed).expect("utf-8"),
314                    page.render_with(&options),
315                    "depth {depth}"
316                );
317            }
318        }
319        // html is level 1, body level 2: a limit of 2 keeps both and drops their children.
320        let rendered = page.render_with(&RenderOptions::compact().with_max_depth(2));
321        assert_eq!(
322            rendered,
323            "<!DOCTYPE html>\n<html lang=\"en\"><head></head><body></body></html>"
324        );
325    }
326
327    use super::*;
328    use crate::core::Node;
329    use crate::elements::{body, h1, h2, head, img, meta, p, section, title};
330
331    /// The borrowed-parts writer must produce what rendering a cloned `<html>` tree did.
332    #[test]
333    fn rendering_matches_the_cloned_root_tree_byte_for_byte() {
334        use crate::elements::{div, p, pre, title};
335        let documents = [
336            Document::new(None),
337            Document::new(Some("pt-BR")).head_children([title().text("T")]),
338            Document::new(Some("a\"b")).body_children([div().child(p().text("x")), div()]),
339            Document::new(Some("en")).body_children([Node::fragment([]), Node::text("")]),
340            Document::with_parts(Some("en"), head(), pre().text("  keep  ")),
341            Document::with_parts(None, Element::new("bad tag"), body().text("t")),
342            Document::with_parts(None, head(), body().self_closing(true)),
343            Document::new(Some("en"))
344                .html_attr("data-theme", "dark")
345                .html_attr("class", "a\"b")
346                .body_children([div().child(p().text("x"))]),
347            Document::new(None).html_attr("on load", "x"),
348        ];
349        for document in &documents {
350            for options in [
351                RenderOptions::pretty(),
352                RenderOptions::compact(),
353                RenderOptions::pretty().with_xhtml_self_closing(true),
354                RenderOptions::compact().with_indent("\t"),
355            ] {
356                let mut expected = String::from("<!DOCTYPE html>\n");
357                document.root().write_into(&mut expected, &options, 0);
358                assert_eq!(document.render_with(&options), expected, "{document:?}");
359            }
360        }
361    }
362
363    #[test]
364    fn html_attributes_follow_lang_in_every_render_path() {
365        use crate::elements::h1;
366        let page = Document::new(Some("pt-BR"))
367            .html_attr("data-theme", "dark")
368            .html_attrs([("dir", "ltr")])
369            .body_children([h1().text("Hi")]);
370        let open = "<html lang=\"pt-BR\" data-theme=\"dark\" dir=\"ltr\">";
371
372        assert_eq!(
373            page.render_compact(),
374            format!("<!DOCTYPE html>\n{open}<head></head><body><h1>Hi</h1></body></html>")
375        );
376        assert_eq!(
377            page.render(),
378            format!(
379                "<!DOCTYPE html>\n{open}\n  <head></head>\n  <body>\n    <h1>Hi</h1>\n  </body>\n</html>"
380            )
381        );
382        assert_eq!(
383            page.root().render(),
384            format!("{open}<head></head><body><h1>Hi</h1></body></html>")
385        );
386        for options in [RenderOptions::pretty(), RenderOptions::compact()] {
387            let mut streamed = Vec::new();
388            page.render_to(&mut streamed, &options).unwrap();
389            assert_eq!(
390                String::from_utf8(streamed).unwrap(),
391                page.render_with(&options)
392            );
393        }
394    }
395
396    #[test]
397    fn an_unsafe_html_attribute_name_is_omitted_and_a_value_is_escaped() {
398        let page = Document::new(None)
399            .html_attr("on load=alert(1) x", "1")
400            .html_attr("data-x", "\"><script>");
401        assert!(
402            page.render_compact()
403                .starts_with("<!DOCTYPE html>\n<html data-x=\"&quot;><script>\">"),
404            "{}",
405            page.render_compact()
406        );
407    }
408
409    #[test]
410    fn a_document_without_html_attributes_is_unchanged() {
411        assert_eq!(
412            Document::new(Some("en")).render_compact(),
413            "<!DOCTYPE html>\n<html lang=\"en\"><head></head><body></body></html>"
414        );
415    }
416
417    #[test]
418    fn a_document_owns_the_doctype_and_the_lang_attribute() {
419        let page = Document::new(Some("pt-BR"));
420        assert!(
421            page.render()
422                .starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
423        );
424    }
425
426    #[test]
427    fn the_lang_attribute_is_omitted_when_absent() {
428        let page = Document::new(None);
429        assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
430    }
431
432    #[test]
433    fn root_returns_the_tree_without_the_doctype() {
434        let page = Document::new(Some("en"));
435        assert!(!page.root().render().contains("DOCTYPE"));
436        assert!(page.root().render().starts_with("<html lang=\"en\">"));
437    }
438
439    /// The asymmetry with `Element::render` is deliberate — see the type docs.
440    #[test]
441    fn a_document_renders_pretty_by_default_unlike_an_element() {
442        let page = Document::new(Some("en")).head_children([title().text("T")]);
443        assert!(page.render().contains('\n'));
444        assert!(
445            !page
446                .render_compact()
447                .trim_start_matches("<!DOCTYPE html>\n")
448                .contains('\n')
449        );
450    }
451
452    #[test]
453    fn head_and_body_children_land_in_the_right_place() {
454        let page = Document::new(Some("en"))
455            .head_children([title().text("T")])
456            .body_children([h1().text("H")]);
457        let rendered = page.render();
458        let head_at = rendered.find("<title>").expect("title rendered");
459        let body_at = rendered.find("<h1>").expect("h1 rendered");
460        assert!(head_at < body_at);
461    }
462
463    #[test]
464    fn a_document_is_a_value_and_can_be_shared_across_threads() {
465        fn assert_send_sync<T: Send + Sync>() {}
466        assert_send_sync::<Document>();
467
468        let page = Document::new(Some("en"));
469        let copy = page.clone().head_children([title().text("changed")]);
470        assert!(!page.render().contains("changed"));
471        assert!(copy.render().contains("changed"));
472    }
473
474    #[test]
475    fn body_children_accept_loops_and_conditions() {
476        let show_banner = false;
477        let page = Document::new(None)
478            .head_children([title().text("T")])
479            .body_children([crate::html! {
480                @if show_banner { div { "banner" } }
481                @for index in 1..=2 { p { "line " (index) } }
482            }]);
483
484        let rendered = page.render_compact();
485        assert!(!rendered.contains("banner"));
486        assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
487    }
488
489    /// Streamed output matches `render_with`, including for bodies that cannot be streamed.
490    #[test]
491    fn render_to_matches_render_with() {
492        let options = [
493            RenderOptions::pretty(),
494            RenderOptions::compact(),
495            RenderOptions::pretty()
496                .with_indent("\t")
497                .with_xhtml_self_closing(true),
498        ];
499        let documents = [
500            streaming_page(3, false),
501            streaming_page(400, false),
502            streaming_page(2, true),
503            Document::with_parts(None, head(), body()),
504            Document::with_parts(Some("en"), head(), body().raw_text("<raw>")),
505        ];
506        for page in &documents {
507            for options in &options {
508                let mut written = Vec::new();
509                page.render_to(&mut written, options).expect("write");
510                assert_eq!(written, page.render_with(options).into_bytes());
511            }
512        }
513    }
514
515    fn streaming_page(sections: usize, empty: bool) -> Document {
516        let mut nodes = Vec::new();
517        if empty {
518            nodes.push(Node::fragment([]));
519        }
520        for index in 0..sections {
521            nodes.push(
522                section()
523                    .add_class("s")
524                    .child(h2().text(format!("Section {index}")))
525                    .child(p().text("text & more ".repeat(50)))
526                    .child(img().attr("src", format!("/{index}.png")).attr("alt", ""))
527                    .into(),
528            );
529        }
530        if empty {
531            nodes.push(Node::fragment([]));
532        }
533        Document::new(Some("en"))
534            .head_children([title().text("Streaming"), meta().attr("charset", "UTF-8")])
535            .body_children(nodes)
536    }
537}