Skip to main content

molt/
document.rs

1//! Whole-page assembly.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/core/Document.swift`.
4
5use std::io::{self, Write};
6
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.** That asymmetry is in the Swift original and the golden fixtures depend on it,
19/// so it is preserved rather than 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)]
33pub struct Document {
34    /// The document language, rendered as `<html lang="…">`. Omitted when `None`.
35    pub lang: Option<String>,
36    /// The `<head>` element.
37    pub head: Element,
38    /// The `<body>` element.
39    pub body: Element,
40}
41
42impl Document {
43    /// Creates an empty document with the given language.
44    #[must_use]
45    pub fn new(lang: Option<&str>) -> Self {
46        Self {
47            lang: lang.map(ToString::to_string),
48            head: head(),
49            body: body(),
50        }
51    }
52
53    /// Creates a document from an existing `<head>` and `<body>`.
54    #[must_use]
55    pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
56        Self {
57            lang: lang.map(ToString::to_string),
58            head,
59            body,
60        }
61    }
62
63    /// Appends nodes to the `<head>`.
64    #[must_use]
65    pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
66        self.head = self.head.children_from(children);
67        self
68    }
69
70    /// Appends nodes to the `<body>`.
71    #[must_use]
72    pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
73        self.body = self.body.children_from(children);
74        self
75    }
76
77    /// The `<html>` element, **without** the doctype.
78    ///
79    /// Kept separate from [`render`](Self::render) because the static site generator and
80    /// several tests want the tree rather than the serialized page.
81    #[must_use]
82    pub fn root(&self) -> Element {
83        let root = match &self.lang {
84            Some(lang) => html_tag().attr("lang", lang),
85            None => html_tag(),
86        };
87        root.child(self.head.clone()).child(self.body.clone())
88    }
89
90    /// Renders the document with the given options, prefixed by the doctype.
91    #[must_use]
92    pub fn render_with(&self, options: &RenderOptions) -> String {
93        let mut out = String::with_capacity(2048);
94        out.push_str("<!DOCTYPE html>\n");
95        self.root().write_into(&mut out, options, 0);
96        out
97    }
98
99    /// Renders the document **pretty-printed** — the Swift default for a document.
100    #[must_use]
101    pub fn render(&self) -> String {
102        self.render_with(&RenderOptions::pretty())
103    }
104
105    /// Renders the document on a single line.
106    #[must_use]
107    pub fn render_compact(&self) -> String {
108        self.render_with(&RenderOptions::compact())
109    }
110
111    /// Writes the same bytes as [`render_with`](Self::render_with) to `sink`.
112    ///
113    /// When `<body>` is only a list of children, each child is rendered with
114    /// [`Render::write_into`] into its own buffer and then written out, so memory stays
115    /// bounded by the largest section rather than the whole page. A body with text
116    /// content, no children, or a self-closing flag is written in one piece — still the
117    /// same bytes.
118    ///
119    /// # Errors
120    /// Returns an error if `sink` does.
121    ///
122    /// # Examples
123    /// ```
124    /// use molt::prelude::*;
125    /// use molt::{Document, RenderOptions};
126    /// let page = Document::new(Some("en")).body_children([h1().text("Hi")]);
127    /// let mut buf = Vec::new();
128    /// page.render_to(&mut buf, &RenderOptions::compact())?;
129    /// assert_eq!(buf, page.render_compact().into_bytes());
130    /// # Ok::<(), std::io::Error>(())
131    /// ```
132    pub fn render_to(&self, mut sink: impl Write, options: &RenderOptions) -> io::Result<()> {
133        if !streams_by_section(&self.body) {
134            return sink.write_all(self.render_with(options).as_bytes());
135        }
136        sink.write_all(self.html_open().as_bytes())?;
137        if options.pretty {
138            self.write_pretty_sections(&mut sink, options)
139        } else {
140            self.write_compact_sections(&mut sink, options)
141        }
142    }
143
144    fn html_open(&self) -> String {
145        let mut open = String::from("<!DOCTYPE html>\n<html");
146        if let Some(lang) = &self.lang {
147            Attribute::new("lang", lang).write_into(&mut open);
148        }
149        open.push('>');
150        open
151    }
152
153    fn write_pretty_sections(
154        &self,
155        sink: &mut impl Write,
156        options: &RenderOptions,
157    ) -> io::Result<()> {
158        let mut head_markup = String::from("\n");
159        self.head.write_into(&mut head_markup, options, 1);
160        sink.write_all(head_markup.as_bytes())?;
161
162        let mut body_open = String::from("\n");
163        options.write_indent(&mut body_open, 1);
164        write_open_tag(&self.body, &mut body_open);
165        sink.write_all(body_open.as_bytes())?;
166
167        for child in self.body.children() {
168            let mut rendered = String::new();
169            child.write_into(&mut rendered, options, 2);
170            if !rendered.is_empty() {
171                sink.write_all(b"\n")?;
172                sink.write_all(rendered.as_bytes())?;
173            }
174        }
175
176        let mut close = String::from("\n");
177        options.write_indent(&mut close, 1);
178        close.push_str("</body>\n</html>");
179        sink.write_all(close.as_bytes())
180    }
181
182    fn write_compact_sections(
183        &self,
184        sink: &mut impl Write,
185        options: &RenderOptions,
186    ) -> io::Result<()> {
187        let mut head_markup = String::new();
188        self.head.write_into(&mut head_markup, options, 0);
189        sink.write_all(head_markup.as_bytes())?;
190
191        let mut body_open = String::new();
192        write_open_tag(&self.body, &mut body_open);
193        sink.write_all(body_open.as_bytes())?;
194
195        for child in self.body.children() {
196            let mut rendered = String::new();
197            child.write_into(&mut rendered, options, 0);
198            sink.write_all(rendered.as_bytes())?;
199        }
200        sink.write_all(b"</body></html>")
201    }
202}
203
204/// A body made of sections can be written one child at a time. Anything else goes through
205/// [`Document::render_with`] so the bytes cannot drift.
206fn streams_by_section(body: &Element) -> bool {
207    body.tag() == "body"
208        && body.content().is_none()
209        && !body.children().is_empty()
210        && !body.is_self_closing()
211}
212
213fn write_open_tag(element: &Element, out: &mut String) {
214    out.push('<');
215    out.push_str(element.tag());
216    for attribute in element.attributes() {
217        attribute.write_into(out);
218    }
219    out.push('>');
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::core::Node;
226    use crate::elements::{body, h1, h2, head, img, meta, p, section, title};
227
228    /// Ports `DocumentTests.rendersDoctypeAndLanguage`.
229    #[test]
230    fn a_document_owns_the_doctype_and_the_lang_attribute() {
231        let page = Document::new(Some("pt-BR"));
232        assert!(
233            page.render()
234                .starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
235        );
236    }
237
238    /// Ports `DocumentTests.languageIsOptional`.
239    #[test]
240    fn the_lang_attribute_is_omitted_when_absent() {
241        let page = Document::new(None);
242        assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
243    }
244
245    /// Ports `DocumentTests.rootExposesTheHTMLElementWithoutTheDoctype`.
246    #[test]
247    fn root_returns_the_tree_without_the_doctype() {
248        let page = Document::new(Some("en"));
249        assert!(!page.root().render().contains("DOCTYPE"));
250        assert!(page.root().render().starts_with("<html lang=\"en\">"));
251    }
252
253    /// Ports `DocumentTests.prettyIsTheDefaultForDocuments`. The asymmetry with `Element::render`
254    /// is deliberate — see the type docs.
255    #[test]
256    fn a_document_renders_pretty_by_default_unlike_an_element() {
257        let page = Document::new(Some("en")).head_children([title().text("T")]);
258        assert!(page.render().contains('\n'));
259        assert!(
260            !page
261                .render_compact()
262                .trim_start_matches("<!DOCTYPE html>\n")
263                .contains('\n')
264        );
265    }
266
267    /// Ports `DocumentTests.acceptsAnExistingHeadAndBody`.
268    #[test]
269    fn head_and_body_children_land_in_the_right_place() {
270        let page = Document::new(Some("en"))
271            .head_children([title().text("T")])
272            .body_children([h1().text("H")]);
273        let rendered = page.render();
274        let head_at = rendered.find("<title>").expect("title rendered");
275        let body_at = rendered.find("<h1>").expect("h1 rendered");
276        assert!(head_at < body_at);
277    }
278
279    /// Ports `DocumentTests.documentIsAValue`.
280    #[test]
281    fn a_document_is_a_value_and_can_be_shared_across_threads() {
282        fn assert_send_sync<T: Send + Sync>() {}
283        assert_send_sync::<Document>();
284
285        let page = Document::new(Some("en"));
286        let copy = page.clone().head_children([title().text("changed")]);
287        assert!(!page.render().contains("changed"));
288        assert!(copy.render().contains("changed"));
289    }
290
291    /// Ports `DocumentTests.buildersSupportLoopsAndConditions`.
292    #[test]
293    fn body_children_accept_loops_and_conditions() {
294        let show_banner = false;
295        let page = Document::new(None)
296            .head_children([title().text("T")])
297            .body_children([crate::html! {
298                @if show_banner { div { "banner" } }
299                @for index in 1..=2 { p { "line " (index) } }
300            }]);
301
302        let rendered = page.render_compact();
303        assert!(!rendered.contains("banner"));
304        assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
305    }
306
307    /// Ports `ThreeZeroTests.fileMatchesRender` and
308    /// `ThreeZeroTests.bodiesThatCannotStreamStillMatch`.
309    #[test]
310    fn render_to_matches_render_with() {
311        let options = [
312            RenderOptions::pretty(),
313            RenderOptions::compact(),
314            RenderOptions::pretty()
315                .with_indent("\t")
316                .with_xhtml_self_closing(true),
317        ];
318        let documents = [
319            streaming_page(3, false),
320            streaming_page(400, false),
321            streaming_page(2, true),
322            Document::with_parts(None, head(), body()),
323            Document::with_parts(Some("en"), head(), body().raw_text("<raw>")),
324        ];
325        for page in &documents {
326            for options in &options {
327                let mut written = Vec::new();
328                page.render_to(&mut written, options).expect("write");
329                assert_eq!(written, page.render_with(options).into_bytes());
330            }
331        }
332    }
333
334    fn streaming_page(sections: usize, empty: bool) -> Document {
335        let mut nodes = Vec::new();
336        if empty {
337            nodes.push(Node::fragment([]));
338        }
339        for index in 0..sections {
340            nodes.push(
341                section()
342                    .add_class("s")
343                    .child(h2().text(format!("Section {index}")))
344                    .child(p().text("text & more ".repeat(50)))
345                    .child(img().attr("src", format!("/{index}.png")).attr("alt", ""))
346                    .into(),
347            );
348        }
349        if empty {
350            nodes.push(Node::fragment([]));
351        }
352        Document::new(Some("en"))
353            .head_children([title().text("Streaming"), meta().attr("charset", "UTF-8")])
354            .body_children(nodes)
355    }
356}