Skip to main content

molt/core/
node.rs

1//! The node tree.
2//!
3//! Ports `core/HTMLTag.swift` (state), `core/Fragment.swift` and `core/RawHTML.swift`.
4//!
5//! Winged-Swift models fragments and raw markup as **subclasses** of `HTMLTag` that
6//! override `write(into:)`. Rust models the same thing as a closed enum with a `match` in
7//! the writer. That is strictly better here: the tree becomes `Send + Sync` for free,
8//! which Winged-Swift's own `ROADMAP.md` lists as an unresolved 3.0 problem ("tag trees
9//! are not `Sendable`"), and which is what makes the `parallel` feature sound.
10
11use crate::core::attribute::Attribute;
12use crate::core::element::Element;
13use crate::core::escape::write_escaped_text;
14use crate::core::render::{Render, RenderOptions};
15use crate::core::tags::{is_void, is_whitespace_sensitive};
16
17/// A node in the HTML tree.
18///
19/// # Examples
20/// ```
21/// use molt::prelude::*;
22/// let node = Node::from(div().text("hi"));
23/// assert_eq!(node.render(), "<div>hi</div>");
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Node {
27    /// An element with a tag name, attributes and children.
28    Element(Element),
29    /// Escaped text. The escaping already happened — see [`crate::core::escape`].
30    Text(String),
31    /// Verbatim markup, never escaped. For imported snippets: an SVG, an embed code.
32    ///
33    /// Unlike [`Node::Fragment`], this is an opaque string, so pretty printing can only
34    /// indent the whole blob. Reach for a fragment when you want the children indented.
35    Raw(String),
36    /// An HTML comment. **New in the Rust port** — Winged-Swift has no comment node.
37    ///
38    /// The content has `--` neutralised so the comment cannot be closed early.
39    Comment(String),
40    /// A transparent group that renders its children with no wrapper element.
41    ///
42    /// Use it to return several nodes from one expression — a `map` of cards, the body of
43    /// an `if`, a group of `<meta>` tags — without introducing an extra `<div>`.
44    Fragment(Vec<Node>),
45}
46
47impl Node {
48    /// Creates a text node, escaping the content.
49    #[must_use]
50    pub fn text(content: impl AsRef<str>) -> Self {
51        let raw = content.as_ref();
52        let mut escaped = String::with_capacity(raw.len());
53        write_escaped_text(&mut escaped, raw, false);
54        Self::Text(escaped)
55    }
56
57    /// Creates a raw markup node. The content is **not** escaped.
58    #[must_use]
59    pub fn raw(content: impl Into<String>) -> Self {
60        Self::Raw(content.into())
61    }
62
63    /// Creates a comment node.
64    ///
65    /// Any `--` in the content is replaced with `- -`, so the comment cannot terminate
66    /// early and inject markup.
67    ///
68    /// # Examples
69    /// ```
70    /// use molt::prelude::*;
71    /// assert_eq!(Node::comment("a -- b").render(), "<!-- a - - b -->");
72    /// ```
73    #[must_use]
74    pub fn comment(content: impl AsRef<str>) -> Self {
75        Self::Comment(content.as_ref().replace("--", "- -"))
76    }
77
78    /// Creates a transparent group of nodes.
79    #[must_use]
80    pub fn fragment(children: impl IntoIterator<Item = Node>) -> Self {
81        Self::Fragment(children.into_iter().collect())
82    }
83
84    /// Whether this node renders to nothing.
85    ///
86    /// The pretty-print writer arrives at the same answer without calling this: it lets a
87    /// child write straight into the output and rolls the separator back if the child wrote
88    /// nothing, which is what stops an empty fragment — the body of a false `if` — from
89    /// leaving a blank line behind.
90    #[must_use]
91    pub fn is_empty(&self) -> bool {
92        match self {
93            Self::Element(_) | Self::Comment(_) => false,
94            Self::Text(s) | Self::Raw(s) => s.is_empty(),
95            Self::Fragment(children) => children.iter().all(Self::is_empty),
96        }
97    }
98}
99
100impl From<Element> for Node {
101    fn from(element: Element) -> Self {
102        Self::Element(element)
103    }
104}
105
106impl From<&str> for Node {
107    fn from(text: &str) -> Self {
108        Self::text(text)
109    }
110}
111
112impl From<String> for Node {
113    fn from(text: String) -> Self {
114        Self::text(text)
115    }
116}
117
118impl Render for Node {
119    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
120        write_tree(
121            if options.pretty {
122                Step::Pretty(self, depth)
123            } else {
124                Step::Compact(self)
125            },
126            out,
127            options,
128        );
129    }
130}
131
132/// Renders an element without wrapping it in a [`Node`] first.
133///
134/// [`Render for Element`](Element) used to do `Node::Element(self.clone()).write_into(..)`,
135/// which deep-copied the whole subtree on every render — and cloning is itself recursive,
136/// so a deep tree aborted in `Clone` before the writer ever saw it.
137pub(crate) fn write_element_tree(
138    element: &Element,
139    out: &mut String,
140    options: &RenderOptions,
141    depth: usize,
142) {
143    write_tree(
144        if options.pretty {
145            Step::PrettyElement(element, depth)
146        } else {
147            Step::CompactElement(element)
148        },
149        out,
150        options,
151    );
152}
153
154/// One unit of work for the writer.
155///
156/// The writer used to recurse once per nesting level, which made tree depth a stack
157/// limit: roughly 2,000 levels aborted the process, and a stack overflow is an abort, not
158/// a panic anything can catch. Each variant here is what one of those recursive calls
159/// used to do, held on an explicit stack instead — so depth costs heap, bounded by a tree
160/// that is already in memory.
161enum Step<'a> {
162    /// Write this node on one line.
163    Compact(&'a Node),
164    /// Write this node indented to `depth`.
165    Pretty(&'a Node, usize),
166    /// Write this element on one line.
167    CompactElement(&'a Element),
168    /// Write this element indented to `depth`.
169    PrettyElement(&'a Element, usize),
170    /// `</tag>`.
171    Close(&'a str),
172    /// A newline, indentation to `depth`, then `</tag>`.
173    ClosePretty(&'a str, usize),
174    /// A child in pretty mode: writes the separating newline, renders the child, and then
175    /// takes both back out if the child rendered to nothing.
176    ///
177    /// `group_start` is `Some` inside a fragment, where the separator is only written once
178    /// something has already been emitted, and `None` inside an element, where every child
179    /// gets one.
180    PrettyChild {
181        node: &'a Node,
182        depth: usize,
183        group_start: Option<usize>,
184    },
185    /// Truncates back to `mark` when nothing was appended after `after`.
186    ///
187    /// This replaces rendering each child into a scratch buffer to test it for emptiness:
188    /// the child writes straight into the output and its separator is rolled back if it
189    /// wrote nothing, which is what still keeps an empty fragment — the body of a false
190    /// `@if` — from leaving a blank line behind.
191    DropIfEmpty { mark: usize, after: usize },
192}
193
194/// Drives the steps until the tree is written.
195fn write_tree(start: Step<'_>, out: &mut String, options: &RenderOptions) {
196    let mut stack = Vec::with_capacity(16);
197    stack.push(start);
198
199    while let Some(step) = stack.pop() {
200        match step {
201            // Escaped text and verbatim markup write the same way; they differ only in
202            // when the escaping happened, which is at construction.
203            Step::Compact(Node::Text(text) | Node::Raw(text)) => out.push_str(text),
204            Step::Compact(Node::Comment(content)) => write_comment(out, content),
205            Step::Compact(Node::Fragment(children)) => {
206                stack.extend(children.iter().rev().map(Step::Compact));
207            }
208            Step::Compact(Node::Element(element)) => stack.push(Step::CompactElement(element)),
209
210            Step::Pretty(Node::Text(text) | Node::Raw(text), depth) => {
211                options.write_indent(out, depth);
212                out.push_str(text);
213            }
214            Step::Pretty(Node::Comment(content), depth) => {
215                options.write_indent(out, depth);
216                write_comment(out, content);
217            }
218            Step::Pretty(Node::Fragment(children), depth) => {
219                // The children sit at the *parent's* depth, joined by newlines, with empty
220                // ones skipped entirely.
221                let group_start = out.len();
222                stack.extend(children.iter().rev().map(|child| Step::PrettyChild {
223                    node: child,
224                    depth,
225                    group_start: Some(group_start),
226                }));
227            }
228            Step::Pretty(Node::Element(element), depth) => {
229                stack.push(Step::PrettyElement(element, depth));
230            }
231
232            Step::CompactElement(element) => {
233                push_compact_element(element, out, options, &mut stack);
234            }
235            Step::PrettyElement(element, depth) => {
236                push_pretty_element(element, depth, out, options, &mut stack);
237            }
238
239            Step::Close(tag) => {
240                out.push_str("</");
241                out.push_str(tag);
242                out.push('>');
243            }
244            Step::ClosePretty(tag, depth) => {
245                out.push('\n');
246                options.write_indent(out, depth);
247                out.push_str("</");
248                out.push_str(tag);
249                out.push('>');
250            }
251
252            Step::PrettyChild {
253                node,
254                depth,
255                group_start,
256            } => {
257                let mark = out.len();
258                let needs_separator = group_start.is_none_or(|start| out.len() > start);
259                if needs_separator {
260                    out.push('\n');
261                }
262                let after = out.len();
263                // Pushed first so it runs last: the child goes on top of it.
264                stack.push(Step::DropIfEmpty { mark, after });
265                stack.push(Step::Pretty(node, depth));
266            }
267            Step::DropIfEmpty { mark, after } => {
268                if out.len() == after {
269                    out.truncate(mark);
270                }
271            }
272        }
273    }
274}
275
276/// Writes an element's opening token and queues everything that follows it.
277fn push_compact_element<'a>(
278    element: &'a Element,
279    out: &mut String,
280    options: &RenderOptions,
281    stack: &mut Vec<Step<'a>>,
282) {
283    out.push('<');
284    out.push_str(element.tag());
285    write_attributes(element.attributes(), out);
286
287    // A void element takes no content and no children — both are silently dropped, which
288    // is what Winged-Swift does. `Element::self_closing` opts a non-void tag into the same
289    // shape, so a caller can emit `<input />` without building a `Node::Raw` that would
290    // skip the URL policy in `Attribute::write_into`.
291    if is_void(element.tag()) || element.is_self_closing() {
292        write_void_suffix(out, options);
293        return;
294    }
295
296    out.push('>');
297    if let Some(content) = element.content() {
298        out.push_str(content);
299    }
300    stack.push(Step::Close(element.tag()));
301    stack.extend(element.children().iter().rev().map(Step::Compact));
302}
303
304/// The same, indented, with the children queued one level deeper.
305fn push_pretty_element<'a>(
306    element: &'a Element,
307    depth: usize,
308    out: &mut String,
309    options: &RenderOptions,
310    stack: &mut Vec<Step<'a>>,
311) {
312    // `<pre>`, `<code>` and `<textarea>` render every whitespace character they contain, so
313    // indenting their children would change what the browser shows.
314    if is_whitespace_sensitive(element.tag()) {
315        options.write_indent(out, depth);
316        stack.push(Step::CompactElement(element));
317        return;
318    }
319
320    options.write_indent(out, depth);
321    out.push('<');
322    out.push_str(element.tag());
323    write_attributes(element.attributes(), out);
324
325    if is_void(element.tag()) || element.is_self_closing() {
326        write_void_suffix(out, options);
327        return;
328    }
329
330    out.push('>');
331
332    if element.children().is_empty() {
333        if let Some(content) = element.content() {
334            out.push_str(content);
335        }
336        out.push_str("</");
337        out.push_str(element.tag());
338        out.push('>');
339        return;
340    }
341
342    if let Some(content) = element.content() {
343        out.push('\n');
344        options.write_indent(out, depth + 1);
345        out.push_str(content);
346    }
347
348    stack.push(Step::ClosePretty(element.tag(), depth));
349    stack.extend(
350        element
351            .children()
352            .iter()
353            .rev()
354            .map(|child| Step::PrettyChild {
355                node: child,
356                depth: depth + 1,
357                group_start: None,
358            }),
359    );
360}
361
362fn write_comment(out: &mut String, content: &str) {
363    out.push_str("<!-- ");
364    out.push_str(content);
365    out.push_str(" -->");
366}
367
368fn write_attributes(attributes: &[Attribute], out: &mut String) {
369    for attribute in attributes {
370        attribute.write_into(out);
371    }
372}
373
374/// Appends the closing token of a void element.
375fn write_void_suffix(out: &mut String, options: &RenderOptions) {
376    out.push_str(if options.xhtml_self_closing {
377        " />"
378    } else {
379        ">"
380    });
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::elements::{
387        body, code, div, h1, head, html_tag, i, img, li, p, pre, span, textarea, title, ul,
388    };
389    // `macros` is declared after `core` in lib.rs, so `html!` is not in scope by position;
390    // it is `#[macro_export]`ed, which puts it at the crate root.
391    use crate::html;
392
393    #[test]
394    fn the_tree_is_send_and_sync() {
395        fn assert_send_sync<T: Send + Sync>() {}
396        assert_send_sync::<Node>();
397        assert_send_sync::<Element>();
398    }
399
400    #[test]
401    fn text_nodes_are_escaped() {
402        assert_eq!(Node::text("a & b").render(), "a &amp; b");
403    }
404
405    /// No direct Swift counterpart: Winged-Swift turns escaping off per tag with
406    /// `escapeContent:`, which `Element::raw_text` ports. This is the node-level version,
407    /// which has no equivalent because Swift models raw markup as an `HTMLTag` subclass.
408    #[test]
409    fn raw_nodes_are_never_escaped() {
410        assert_eq!(Node::raw("<b>bold</b>").render(), "<b>bold</b>");
411        assert_eq!(Node::raw("<b>bold</b>").render_pretty(), "<b>bold</b>");
412    }
413
414    #[test]
415    fn a_comment_cannot_close_itself_early() {
416        let rendered = Node::comment("a --> b").render();
417        assert_eq!(rendered.matches("-->").count(), 1);
418        assert!(rendered.ends_with("-->"));
419    }
420
421    /// Ports `FragmentTests.testEmptyFragmentDoesNotLeaveBlankLines`.
422    #[test]
423    fn an_empty_fragment_leaves_no_blank_line() {
424        let tree = div()
425            .child(p().text("a"))
426            .child(Node::fragment([]))
427            .child(p().text("b"));
428        assert_eq!(
429            tree.render_pretty(),
430            "<div>\n  <p>a</p>\n  <p>b</p>\n</div>"
431        );
432    }
433
434    /// Ports `FragmentTests.testFragmentRendersChildrenWithoutWrapper`.
435    #[test]
436    fn a_fragment_renders_its_children_without_a_wrapper() {
437        let tree = Node::fragment([p().text("a").into(), p().text("b").into()]);
438        assert_eq!(tree.render(), "<p>a</p><p>b</p>");
439        assert_eq!(tree.render_pretty(), "<p>a</p>\n<p>b</p>");
440    }
441
442    /// Ports the void-element cases in `TagCatalogTests`.
443    #[test]
444    fn void_elements_take_no_children() {
445        let tree = img().attr("src", "/a.png").child(span().text("dropped"));
446        assert_eq!(tree.render(), r#"<img src="/a.png">"#);
447    }
448
449    #[test]
450    fn xhtml_mode_closes_void_elements_with_a_slash() {
451        let options = RenderOptions::compact().with_xhtml_self_closing(true);
452        assert_eq!(
453            img().attr("src", "/a.png").render_with(&options),
454            r#"<img src="/a.png" />"#
455        );
456    }
457
458    /// Ports `WhitespaceTests.testPreWithCodeChildIsNotIndented` and
459    /// `WhitespaceTests.testPrettyMatchesCompactForWhitespaceSensitiveTags`. Indenting inside `<pre>` would change what the browser shows.
460    #[test]
461    fn whitespace_sensitive_tags_are_not_indented_inside() {
462        let tree = div().child(pre().child(code().text("let page = html { }")));
463        assert_eq!(
464            tree.render_pretty(),
465            "<div>\n  <pre><code>let page = html { }</code></pre>\n</div>"
466        );
467    }
468
469    /// No Swift counterpart: its `PrettyPrintTests` never mixes content with children.
470    #[test]
471    fn content_goes_on_its_own_line_before_children() {
472        let tree = div().text("lead").child(p().text("body"));
473        assert_eq!(tree.render_pretty(), "<div>\n  lead\n  <p>body</p>\n</div>");
474    }
475
476    #[test]
477    fn an_element_with_content_and_no_children_stays_on_one_line() {
478        assert_eq!(p().text("hi").render_pretty(), "<p>hi</p>");
479    }
480
481    #[test]
482    fn compact_is_the_default_for_an_element() {
483        let tree = div().child(p().text("a"));
484        assert_eq!(tree.render(), "<div><p>a</p></div>");
485    }
486
487    // Ports the `RawHTML` and fragment half of `HTML14FeaturesTests`; the element half
488    // lives in `crate::elements`.
489
490    /// Ports `HTML14FeaturesTests.testRawHTMLRendersWithoutWrapper`.
491    #[test]
492    fn raw_markup_renders_with_no_wrapper_around_it() {
493        let raw = Node::raw(r#"<span class="x">hi</span>"#);
494
495        assert_eq!(raw.render(), r#"<span class="x">hi</span>"#);
496        assert!(!raw.render().contains("<div"));
497    }
498
499    /// Ports `HTML14FeaturesTests.testRawHTMLAsChildHasNoWrapper`.
500    #[test]
501    fn raw_markup_as_a_child_adds_no_wrapper() {
502        let markup = div()
503            .child(Node::raw(r#"<i class="fa fa-home"></i>"#))
504            .child(span().text("Home"));
505
506        assert_eq!(
507            markup.render(),
508            r#"<div><i class="fa fa-home"></i><span>Home</span></div>"#
509        );
510    }
511
512    /// Ports `HTML14FeaturesTests.testFragmentHelper`.
513    #[test]
514    fn a_fragment_renders_its_children_with_no_wrapper() {
515        let fragment = Node::fragment([
516            i().add_class("fa fa-star").into(),
517            span().text(" Featured").into(),
518        ]);
519
520        assert_eq!(
521            fragment.render(),
522            r#"<i class="fa fa-star"></i><span> Featured</span>"#
523        );
524    }
525
526    /// Ports `HTML14FeaturesTests.testFragmentBuilderBuildArray`.
527    #[test]
528    fn a_fragment_takes_a_mapped_sequence() {
529        let fragment = Node::fragment(
530            ["One", "Two", "Three"].map(|title| div().add_class("card").text(title).into()),
531        );
532
533        assert_eq!(
534            fragment.render(),
535            concat!(
536                r#"<div class="card">One</div><div class="card">Two</div>"#,
537                r#"<div class="card">Three</div>"#,
538            )
539        );
540    }
541
542    // Ports `FragmentTests`. This is the suite that pins the empty-fragment behaviour the
543    // pretty writer exists to preserve, so it is also the independent check on the
544    // iterative rewrite.
545
546    /// Ports `FragmentTests.testEmptyFragmentRendersNothing`.
547    #[test]
548    fn an_empty_fragment_renders_nothing_in_either_mode() {
549        assert_eq!(Node::fragment([]).render(), "");
550        assert_eq!(Node::fragment([]).render_pretty(), "");
551    }
552
553    /// Ports `FragmentTests.testFragmentKeepsPrettyIndentation`.
554    #[test]
555    fn a_fragments_children_are_indented_as_the_parents_own() {
556        let list = ul().child(Node::fragment([
557            li().text("a").into(),
558            li().text("b").into(),
559        ]));
560
561        assert_eq!(
562            list.render_pretty(),
563            "<ul>\n  <li>a</li>\n  <li>b</li>\n</ul>"
564        );
565    }
566
567    /// Ports `FragmentTests.testFragmentBuilderSupportsMapAndFilter`.
568    #[test]
569    fn a_fragment_takes_a_filtered_and_mapped_sequence() {
570        let names = ["Ana", "Bruno", "Carla"];
571        let group = Node::fragment(
572            names
573                .iter()
574                .filter(|name| name.len() > 3)
575                .map(|name| li().text(name).into()),
576        );
577
578        assert_eq!(group.render(), "<li>Bruno</li><li>Carla</li>");
579    }
580
581    /// Ports `FragmentTests.testFalseConditionDoesNotEmitStrayHTMLNode`.
582    #[test]
583    fn a_false_condition_emits_no_stray_node() {
584        let show_banner = false;
585        let page = html_tag()
586            .child(head().child(title().text("Home")))
587            .child(html! { @if show_banner { div { "banner" } } })
588            .child(body().child(h1().text("Hi")));
589
590        assert_eq!(
591            page.render(),
592            "<html><head><title>Home</title></head><body><h1>Hi</h1></body></html>"
593        );
594    }
595
596    /// Ports `FragmentTests.testTrueConditionEmitsTheBranch`.
597    #[test]
598    fn a_true_condition_emits_its_branch() {
599        let show_banner = true;
600        let page = html_tag().child(html! { @if show_banner { div { "banner" } } });
601
602        assert_eq!(page.render(), "<html><div>banner</div></html>");
603    }
604
605    /// Ports `FragmentTests.testLoopInsideHTMLBuilder`.
606    #[test]
607    fn a_loop_emits_one_node_per_iteration() {
608        let page = html_tag().child(html! {
609            @for index in 1..=3 { p { "line " (index) } }
610        });
611
612        assert_eq!(
613            page.render(),
614            "<html><p>line 1</p><p>line 2</p><p>line 3</p></html>"
615        );
616    }
617
618    /// Ports `FragmentTests.testRawHTMLIsIndentedInsideAPrettyTree`.
619    #[test]
620    fn raw_markup_is_indented_as_one_blob() {
621        let container = div().child(Node::raw("<custom-element></custom-element>"));
622
623        assert_eq!(
624            container.render_pretty(),
625            "<div>\n  <custom-element></custom-element>\n</div>"
626        );
627    }
628
629    /// Ports `FragmentTests.testFragmentBuilderTakesBothBranchesOfAnIf`.
630    #[test]
631    fn a_condition_can_pick_either_branch() {
632        fn badge(is_beta: bool) -> Node {
633            html! { @if is_beta { span { "beta" } } @else { span { "stable" } } }
634        }
635
636        assert_eq!(badge(true).render(), "<span>beta</span>");
637        assert_eq!(badge(false).render(), "<span>stable</span>");
638    }
639
640    /// Ports `FragmentTests.testNestedFragmentsFlattenInPrettyOutput`.
641    ///
642    /// Two levels of nesting with an empty fragment between them: the rollback has to
643    /// survive being nested inside another rollback.
644    #[test]
645    fn nested_fragments_flatten_without_leaving_gaps() {
646        let list = ul().child(Node::fragment([
647            Node::fragment([li().text("a").into()]),
648            Node::fragment([]),
649            li().text("b").into(),
650        ]));
651
652        assert_eq!(
653            list.render_pretty(),
654            "<ul>\n  <li>a</li>\n  <li>b</li>\n</ul>"
655        );
656    }
657
658    /// Ports `FragmentTests.testRawHTMLStillEmitsMarkupVerbatim`.
659    #[test]
660    fn raw_markup_is_emitted_verbatim() {
661        let raw = Node::raw(r#"<custom-element data-x="1"></custom-element>"#);
662
663        assert_eq!(
664            raw.render(),
665            r#"<custom-element data-x="1"></custom-element>"#
666        );
667    }
668
669    /// Ports `PrettyPrintTests.testPrettyPrintSimpleTag`.
670    #[test]
671    fn a_tag_with_only_content_stays_on_one_line_when_pretty() {
672        assert_eq!(
673            div().text("Hello World").render_pretty(),
674            "<div>Hello World</div>"
675        );
676    }
677
678    /// Ports `PrettyPrintTests.testPrettyPrintWithChildren`.
679    #[test]
680    fn children_each_get_their_own_line_when_pretty() {
681        let tree = div()
682            .child(p().text("Paragraph 1"))
683            .child(p().text("Paragraph 2"));
684
685        assert_eq!(
686            tree.render_pretty(),
687            "<div>\n  <p>Paragraph 1</p>\n  <p>Paragraph 2</p>\n</div>"
688        );
689    }
690
691    /// Ports `PrettyPrintTests.testCompactRenderStillWorks`.
692    #[test]
693    fn compact_keeps_everything_on_one_line() {
694        assert_eq!(
695            div().child(p().text("Test")).render(),
696            "<div><p>Test</p></div>"
697        );
698    }
699
700    /// Ports `PrettyPrintTests.testSelfClosingTagPrettyPrint`.
701    #[test]
702    fn a_void_element_does_not_self_close_when_pretty() {
703        let rendered = img()
704            .attr("src", "test.jpg")
705            .attr("alt", "Test")
706            .render_pretty();
707
708        assert!(rendered.starts_with("<img"));
709        assert!(!rendered.contains("/>"));
710        assert!(rendered.ends_with('>'));
711    }
712
713    /// Ports `WhitespaceTests.testTextareaKeepsItsContentIntact`.
714    #[test]
715    fn a_textarea_keeps_its_newlines() {
716        let field = textarea().attr("name", "bio").text("line 1\nline 2");
717
718        assert_eq!(
719            field.render_pretty(),
720            "<textarea name=\"bio\">line 1\nline 2</textarea>"
721        );
722    }
723
724    /// Ports `WhitespaceTests.testNestedInsideAPrettyDocumentKeepsOuterIndentation`.
725    #[test]
726    fn a_pre_block_is_indented_from_the_outside_but_not_within() {
727        let container = div().child(pre().child(code().text("swift build")));
728
729        assert_eq!(
730            container.render_pretty(),
731            "<div>\n  <pre><code>swift build</code></pre>\n</div>"
732        );
733    }
734}