molt-framework 1.1.0

Fast, type-safe HTML DSL and static site generation engine in Rust and WebAssembly
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Whole-page assembly.
//!

use std::io::{self, Write};

use crate::core::node::write_document_root;
use crate::core::{Attribute, Element, Node, Render, RenderOptions};
use crate::elements::{body, head, html_tag};

/// A complete HTML document: a language, a `<head>` and a `<body>`.
///
/// The `Document` owns the doctype — [`Document::render`] prepends `<!DOCTYPE html>\n`,
/// while [`Document::root`] gives you the bare `<html>` element without it.
///
/// # Rendering default
///
/// **`Document::render` defaults to pretty, while [`Element::render`] defaults to
/// compact.** The asymmetry is deliberate and the golden fixtures depend on it, so it is not
/// tidied up.
///
/// # Examples
/// ```
/// use molt::prelude::*;
/// use molt::Document;
///
/// let page = Document::new(Some("pt-BR"))
///     .head_children([title().text("RideKeeper")])
///     .body_children([h1().text("Track every service")]);
///
/// assert!(page.render().starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">"));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Document {
    /// The document language, rendered as `<html lang="…">`. Omitted when `None`.
    pub lang: Option<String>,
    /// The `<head>` element.
    pub head: Element,
    /// The `<body>` element.
    pub body: Element,
}

impl Default for Document {
    /// An empty document with no `lang`, same as `Document::new(None)`.
    fn default() -> Self {
        Self::new(None)
    }
}

impl std::fmt::Display for Document {
    /// The pretty render with doctype, same as [`Document::render`].
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.render())
    }
}

impl Document {
    /// Creates an empty document with the given language.
    #[must_use]
    pub fn new(lang: Option<&str>) -> Self {
        Self {
            lang: lang.map(ToString::to_string),
            head: head(),
            body: body(),
        }
    }

    /// Creates a document from an existing `<head>` and `<body>`.
    #[must_use]
    pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
        Self {
            lang: lang.map(ToString::to_string),
            head,
            body,
        }
    }

    /// Appends nodes to the `<head>`.
    #[must_use]
    pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
        self.head = self.head.children_from(children);
        self
    }

    /// Appends nodes to the `<body>`.
    #[must_use]
    pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
        self.body = self.body.children_from(children);
        self
    }

    /// The `<html>` element, **without** the doctype.
    ///
    /// Kept separate from [`render`](Self::render) because the static site generator and
    /// several tests want the tree rather than the serialized page.
    #[must_use]
    pub fn root(&self) -> Element {
        let root = match &self.lang {
            Some(lang) => html_tag().attr("lang", lang),
            None => html_tag(),
        };
        root.child(self.head.clone()).child(self.body.clone())
    }

    /// Renders the document with the given options, prefixed by the doctype.
    #[must_use]
    pub fn render_with(&self, options: &RenderOptions) -> String {
        let mut out = String::with_capacity(2048);
        out.push_str("<!DOCTYPE html>\n");
        // Not `self.root().write_into(..)`: that clones head and body, and `Clone` is
        // recursive, so a deep body aborted the process before anything was written.
        write_document_root(
            self.lang.as_deref(),
            &self.head,
            &self.body,
            &mut out,
            options,
        );
        out
    }

    /// Renders the document **pretty-printed**, which is the default for a document.
    #[must_use]
    pub fn render(&self) -> String {
        self.render_with(&RenderOptions::pretty())
    }

    /// Renders the document on a single line.
    #[must_use]
    pub fn render_compact(&self) -> String {
        self.render_with(&RenderOptions::compact())
    }

    /// Writes the same bytes as [`render_with`](Self::render_with) to `sink`.
    ///
    /// When `<body>` is only a list of children, each child is rendered with
    /// [`Render::write_into`] into its own buffer and then written out, so memory stays
    /// bounded by the largest section rather than the whole page. A body with text
    /// content, no children, or a self-closing flag is written in one piece — still the
    /// same bytes.
    ///
    /// # Errors
    /// Returns an error if `sink` does.
    ///
    /// # Examples
    /// ```
    /// use molt::prelude::*;
    /// use molt::{Document, RenderOptions};
    /// let page = Document::new(Some("en")).body_children([h1().text("Hi")]);
    /// let mut buf = Vec::new();
    /// page.render_to(&mut buf, &RenderOptions::compact())?;
    /// assert_eq!(buf, page.render_compact().into_bytes());
    /// # Ok::<(), std::io::Error>(())
    /// ```
    pub fn render_to(&self, mut sink: impl Write, options: &RenderOptions) -> io::Result<()> {
        // A depth limit counts `<html>` and `<body>` as open levels, which the section-by-
        // section path does not see, so it takes the whole-tree path to keep the bytes equal.
        if options.max_depth.is_some() || !streams_by_section(&self.body) {
            return sink.write_all(self.render_with(options).as_bytes());
        }
        sink.write_all(self.html_open().as_bytes())?;
        if options.pretty {
            self.write_pretty_sections(&mut sink, options)
        } else {
            self.write_compact_sections(&mut sink, options)
        }
    }

    fn html_open(&self) -> String {
        let mut open = String::from("<!DOCTYPE html>\n<html");
        if let Some(lang) = &self.lang {
            Attribute::new_static("lang", lang).write_into(&mut open);
        }
        open.push('>');
        open
    }

    fn write_pretty_sections(
        &self,
        sink: &mut impl Write,
        options: &RenderOptions,
    ) -> io::Result<()> {
        let mut head_markup = String::from("\n");
        self.head.write_into(&mut head_markup, options, 1);
        sink.write_all(head_markup.as_bytes())?;

        let mut body_open = String::from("\n");
        options.write_indent(&mut body_open, 1);
        write_open_tag(&self.body, &mut body_open);
        sink.write_all(body_open.as_bytes())?;

        for child in self.body.children() {
            let mut rendered = String::new();
            child.write_into(&mut rendered, options, 2);
            if !rendered.is_empty() {
                sink.write_all(b"\n")?;
                sink.write_all(rendered.as_bytes())?;
            }
        }

        let mut close = String::from("\n");
        options.write_indent(&mut close, 1);
        close.push_str("</body>\n</html>");
        sink.write_all(close.as_bytes())
    }

    fn write_compact_sections(
        &self,
        sink: &mut impl Write,
        options: &RenderOptions,
    ) -> io::Result<()> {
        let mut head_markup = String::new();
        self.head.write_into(&mut head_markup, options, 0);
        sink.write_all(head_markup.as_bytes())?;

        let mut body_open = String::new();
        write_open_tag(&self.body, &mut body_open);
        sink.write_all(body_open.as_bytes())?;

        for child in self.body.children() {
            let mut rendered = String::new();
            child.write_into(&mut rendered, options, 0);
            sink.write_all(rendered.as_bytes())?;
        }
        sink.write_all(b"</body></html>")
    }
}

/// A body made of sections can be written one child at a time. Anything else goes through
/// [`Document::render_with`] so the bytes cannot drift.
fn streams_by_section(body: &Element) -> bool {
    body.tag() == "body"
        && body.content().is_none()
        && !body.children().is_empty()
        && !body.is_self_closing()
}

fn write_open_tag(element: &Element, out: &mut String) {
    out.push('<');
    out.push_str(element.tag());
    for attribute in element.attributes() {
        attribute.write_into(out);
    }
    out.push('>');
}

#[cfg(test)]
mod tests {
    /// `render_to` streams head and body separately, which cannot see `<html>` and `<body>` as
    /// open levels; with a depth limit it must still write the bytes `render_with` does.
    #[test]
    fn a_depth_limit_gives_the_same_bytes_streamed_and_whole() {
        use crate::elements::{div, p, title};
        let page = Document::new(Some("en"))
            .head_children([title().text("T")])
            .body_children([
                div().child(div().child(p().text("deep"))),
                div().text("flat"),
            ]);
        for depth in 0..6 {
            for base in [RenderOptions::pretty(), RenderOptions::compact()] {
                let options = base.with_max_depth(depth);
                let mut streamed = Vec::new();
                page.render_to(&mut streamed, &options)
                    .expect("a Vec never fails");
                assert_eq!(
                    String::from_utf8(streamed).expect("utf-8"),
                    page.render_with(&options),
                    "depth {depth}"
                );
            }
        }
        // html is level 1, body level 2: a limit of 2 keeps both and drops their children.
        let rendered = page.render_with(&RenderOptions::compact().with_max_depth(2));
        assert_eq!(
            rendered,
            "<!DOCTYPE html>\n<html lang=\"en\"><head></head><body></body></html>"
        );
    }

    use super::*;
    use crate::core::Node;
    use crate::elements::{body, h1, h2, head, img, meta, p, section, title};

    /// The borrowed-parts writer must produce what rendering a cloned `<html>` tree did.
    #[test]
    fn rendering_matches_the_cloned_root_tree_byte_for_byte() {
        use crate::elements::{div, p, pre, title};
        let documents = [
            Document::new(None),
            Document::new(Some("pt-BR")).head_children([title().text("T")]),
            Document::new(Some("a\"b")).body_children([div().child(p().text("x")), div()]),
            Document::new(Some("en")).body_children([Node::fragment([]), Node::text("")]),
            Document::with_parts(Some("en"), head(), pre().text("  keep  ")),
            Document::with_parts(None, Element::new("bad tag"), body().text("t")),
            Document::with_parts(None, head(), body().self_closing(true)),
        ];
        for document in &documents {
            for options in [
                RenderOptions::pretty(),
                RenderOptions::compact(),
                RenderOptions::pretty().with_xhtml_self_closing(true),
                RenderOptions::compact().with_indent("\t"),
            ] {
                let mut expected = String::from("<!DOCTYPE html>\n");
                document.root().write_into(&mut expected, &options, 0);
                assert_eq!(document.render_with(&options), expected, "{document:?}");
            }
        }
    }

    #[test]
    fn a_document_owns_the_doctype_and_the_lang_attribute() {
        let page = Document::new(Some("pt-BR"));
        assert!(
            page.render()
                .starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
        );
    }

    #[test]
    fn the_lang_attribute_is_omitted_when_absent() {
        let page = Document::new(None);
        assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
    }

    #[test]
    fn root_returns_the_tree_without_the_doctype() {
        let page = Document::new(Some("en"));
        assert!(!page.root().render().contains("DOCTYPE"));
        assert!(page.root().render().starts_with("<html lang=\"en\">"));
    }

    /// The asymmetry with `Element::render` is deliberate — see the type docs.
    #[test]
    fn a_document_renders_pretty_by_default_unlike_an_element() {
        let page = Document::new(Some("en")).head_children([title().text("T")]);
        assert!(page.render().contains('\n'));
        assert!(
            !page
                .render_compact()
                .trim_start_matches("<!DOCTYPE html>\n")
                .contains('\n')
        );
    }

    #[test]
    fn head_and_body_children_land_in_the_right_place() {
        let page = Document::new(Some("en"))
            .head_children([title().text("T")])
            .body_children([h1().text("H")]);
        let rendered = page.render();
        let head_at = rendered.find("<title>").expect("title rendered");
        let body_at = rendered.find("<h1>").expect("h1 rendered");
        assert!(head_at < body_at);
    }

    #[test]
    fn a_document_is_a_value_and_can_be_shared_across_threads() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<Document>();

        let page = Document::new(Some("en"));
        let copy = page.clone().head_children([title().text("changed")]);
        assert!(!page.render().contains("changed"));
        assert!(copy.render().contains("changed"));
    }

    #[test]
    fn body_children_accept_loops_and_conditions() {
        let show_banner = false;
        let page = Document::new(None)
            .head_children([title().text("T")])
            .body_children([crate::html! {
                @if show_banner { div { "banner" } }
                @for index in 1..=2 { p { "line " (index) } }
            }]);

        let rendered = page.render_compact();
        assert!(!rendered.contains("banner"));
        assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
    }

    /// Streamed output matches `render_with`, including for bodies that cannot be streamed.
    #[test]
    fn render_to_matches_render_with() {
        let options = [
            RenderOptions::pretty(),
            RenderOptions::compact(),
            RenderOptions::pretty()
                .with_indent("\t")
                .with_xhtml_self_closing(true),
        ];
        let documents = [
            streaming_page(3, false),
            streaming_page(400, false),
            streaming_page(2, true),
            Document::with_parts(None, head(), body()),
            Document::with_parts(Some("en"), head(), body().raw_text("<raw>")),
        ];
        for page in &documents {
            for options in &options {
                let mut written = Vec::new();
                page.render_to(&mut written, options).expect("write");
                assert_eq!(written, page.render_with(options).into_bytes());
            }
        }
    }

    fn streaming_page(sections: usize, empty: bool) -> Document {
        let mut nodes = Vec::new();
        if empty {
            nodes.push(Node::fragment([]));
        }
        for index in 0..sections {
            nodes.push(
                section()
                    .add_class("s")
                    .child(h2().text(format!("Section {index}")))
                    .child(p().text("text & more ".repeat(50)))
                    .child(img().attr("src", format!("/{index}.png")).attr("alt", ""))
                    .into(),
            );
        }
        if empty {
            nodes.push(Node::fragment([]));
        }
        Document::new(Some("en"))
            .head_children([title().text("Streaming"), meta().attr("charset", "UTF-8")])
            .body_children(nodes)
    }
}