Skip to main content

molt/core/
element.rs

1//! The [`Element`] type and its fluent builder API.
2//!
3//! The chainable surface is this file and nothing else: the attribute, class, style, role,
4//! `data-*` and `aria-*` helpers, text and children.
5
6use std::borrow::Cow;
7
8use crate::core::attribute::Attribute;
9use crate::core::escape::write_escaped_text;
10use crate::core::node::{Node, Source};
11use crate::core::render::{Render, RenderOptions};
12
13/// An HTML element: a tag name, attributes, optional text content, and children.
14///
15/// Every builder method takes `self` and returns `Self`, so they chain. Content and
16/// attribute values are escaped as they go in, not when the element is rendered.
17///
18/// # Examples
19/// ```
20/// use molt::prelude::*;
21/// let card = div()
22///     .add_class("card")
23///     .set_id("hero")
24///     .child(h1().text("Welcome"))
25///     .child(p().text("Fuel, tyres & chain."));
26/// assert_eq!(
27///     card.render(),
28///     r#"<div class="card" id="hero"><h1>Welcome</h1><p>Fuel, tyres &amp; chain.</p></div>"#
29/// );
30/// ```
31#[derive(Debug)]
32pub struct Element {
33    // `Cow` for the same reason as `Attribute::key`: the ~90 built-in constructors hand over
34    // a `'static` literal, so `div()` allocates no tag name.
35    tag: Cow<'static, str>,
36    attributes: Vec<Attribute>,
37    content: Option<String>,
38    children: Vec<Node>,
39    self_closing: bool,
40}
41
42impl Clone for Element {
43    fn clone(&self) -> Self {
44        match clone_tree(Source::Element(self)) {
45            Node::Element(element) => element,
46            _ => unreachable!("cloning an element yields an element"),
47        }
48    }
49}
50
51impl PartialEq for Element {
52    fn eq(&self, other: &Self) -> bool {
53        trees_equal(Source::Element(self), Source::Element(other))
54    }
55}
56
57impl Eq for Element {}
58
59impl std::fmt::Display for Element {
60    /// The compact render, or the pretty one with the alternate flag: `{el}` / `{el:#}`.
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(&if f.alternate() {
63            self.render_pretty()
64        } else {
65            self.render()
66        })
67    }
68}
69
70impl std::hash::Hash for Element {
71    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
72        hash_tree(Source::Element(self), state);
73    }
74}
75
76/// Appends nodes as children, so `element.extend(items)` works beside `children_from`.
77impl<N: Into<Node>> Extend<N> for Element {
78    fn extend<I: IntoIterator<Item = N>>(&mut self, iter: I) {
79        self.children.extend(iter.into_iter().map(Into::into));
80    }
81}
82
83/// Feeds a tree to a hasher with an explicit stack, matching [`trees_equal`]: trees that
84/// compare equal hash equal, and a deep tree cannot overflow the stack on the way.
85pub(crate) fn hash_tree<H: std::hash::Hasher>(root: Source<'_>, state: &mut H) {
86    use std::hash::Hash;
87
88    let mut pending = vec![root];
89    while let Some(source) = pending.pop() {
90        match source {
91            Source::Element(element) | Source::Node(Node::Element(element)) => {
92                0_u8.hash(state);
93                element.tag.hash(state);
94                element.attributes.hash(state);
95                element.content.hash(state);
96                element.self_closing.hash(state);
97                element.children.len().hash(state);
98                pending.extend(element.children.iter().rev().map(Source::Node));
99            }
100            Source::Node(Node::Fragment(children)) => {
101                1_u8.hash(state);
102                children.len().hash(state);
103                pending.extend(children.iter().rev().map(Source::Node));
104            }
105            Source::Node(Node::Text(text)) => {
106                2_u8.hash(state);
107                text.hash(state);
108            }
109            Source::Node(Node::Raw(text)) => {
110                3_u8.hash(state);
111                text.hash(state);
112            }
113            Source::Node(Node::Comment(text)) => {
114                4_u8.hash(state);
115                text.hash(state);
116            }
117        }
118    }
119}
120
121/// A copy of `element` with everything but its children.
122fn shell(element: &Element) -> Element {
123    Element {
124        tag: element.tag.clone(),
125        attributes: element.attributes.clone(),
126        content: element.content.clone(),
127        children: Vec::with_capacity(element.children.len()),
128        self_closing: element.self_closing,
129    }
130}
131
132/// An element or fragment whose children are being copied one at a time.
133enum CloneFrame<'a> {
134    Element {
135        source: &'a [Node],
136        built: Element,
137    },
138    Fragment {
139        source: &'a [Node],
140        built: Vec<Node>,
141    },
142}
143
144/// Deep-copies a tree with an explicit stack, in the same shape as the writer.
145pub(crate) fn clone_tree(root: Source<'_>) -> Node {
146    let mut stack: Vec<CloneFrame<'_>> = Vec::new();
147    let mut descend = Some(root);
148
149    loop {
150        // A finished subtree, once there is one, goes into the frame that was waiting for it.
151        let mut finished = None;
152        if let Some(source) = descend.take() {
153            match source {
154                Source::Element(element) | Source::Node(Node::Element(element)) => {
155                    stack.push(CloneFrame::Element {
156                        source: &element.children,
157                        built: shell(element),
158                    });
159                }
160                Source::Node(Node::Fragment(children)) => stack.push(CloneFrame::Fragment {
161                    source: children,
162                    built: Vec::with_capacity(children.len()),
163                }),
164                Source::Node(Node::Text(text)) => finished = Some(Node::Text(text.clone())),
165                Source::Node(Node::Raw(text)) => finished = Some(Node::Raw(text.clone())),
166                Source::Node(Node::Comment(text)) => finished = Some(Node::Comment(text.clone())),
167            }
168        }
169
170        if finished.is_none() {
171            // Take the next child of the innermost open frame, or close the frame.
172            let Some(frame) = stack.last_mut() else {
173                // Only reachable if `root` was a leaf that `finished` already carried out.
174                return Node::Fragment(Vec::new());
175            };
176            let (source, built_len) = match frame {
177                CloneFrame::Element { source, built } => (*source, built.children.len()),
178                CloneFrame::Fragment { source, built } => (*source, built.len()),
179            };
180            if let Some(next) = source.get(built_len) {
181                descend = Some(Source::Node(next));
182                continue;
183            }
184            finished = stack.pop().map(|frame| match frame {
185                CloneFrame::Element { built, .. } => Node::Element(built),
186                CloneFrame::Fragment { built, .. } => Node::Fragment(built),
187            });
188        }
189
190        let Some(done) = finished else { continue };
191        match stack.last_mut() {
192            None => return done,
193            Some(CloneFrame::Element { built, .. }) => built.children.push(done),
194            Some(CloneFrame::Fragment { built, .. }) => built.push(done),
195        }
196    }
197}
198
199/// Structural equality with an explicit stack of pairs still to compare.
200pub(crate) fn trees_equal(left: Source<'_>, right: Source<'_>) -> bool {
201    let mut pending = vec![(left, right)];
202    while let Some((left, right)) = pending.pop() {
203        match (left, right) {
204            (
205                Source::Element(a) | Source::Node(Node::Element(a)),
206                Source::Element(b) | Source::Node(Node::Element(b)),
207            ) => {
208                if a.tag != b.tag
209                    || a.attributes != b.attributes
210                    || a.content != b.content
211                    || a.self_closing != b.self_closing
212                    || a.children.len() != b.children.len()
213                {
214                    return false;
215                }
216                pending.extend(
217                    a.children
218                        .iter()
219                        .zip(&b.children)
220                        .map(|(a, b)| (Source::Node(a), Source::Node(b))),
221                );
222            }
223            (Source::Node(Node::Fragment(a)), Source::Node(Node::Fragment(b))) => {
224                if a.len() != b.len() {
225                    return false;
226                }
227                pending.extend(
228                    a.iter()
229                        .zip(b)
230                        .map(|(a, b)| (Source::Node(a), Source::Node(b))),
231                );
232            }
233            (Source::Node(Node::Text(a)), Source::Node(Node::Text(b)))
234            | (Source::Node(Node::Raw(a)), Source::Node(Node::Raw(b)))
235            | (Source::Node(Node::Comment(a)), Source::Node(Node::Comment(b))) => {
236                if a != b {
237                    return false;
238                }
239            }
240            _ => return false,
241        }
242    }
243    true
244}
245
246impl Element {
247    /// Creates an element with the given tag name.
248    pub fn new(tag: impl Into<String>) -> Self {
249        Self::with_tag(Cow::Owned(tag.into()))
250    }
251
252    /// Creates an element whose tag is a `'static` string, without allocating it.
253    ///
254    /// What the built-in constructors (`div()`, `p()`, …) use. Reach for it when you define
255    /// your own element helpers with a literal tag, such as a custom element.
256    ///
257    /// # Examples
258    /// ```
259    /// use molt::prelude::*;
260    /// assert_eq!(Element::from_static("my-card").text("hi").render(), "<my-card>hi</my-card>");
261    /// ```
262    #[must_use]
263    pub fn from_static(tag: &'static str) -> Self {
264        Self::with_tag(Cow::Borrowed(tag))
265    }
266
267    fn with_tag(tag: Cow<'static, str>) -> Self {
268        Self {
269            tag,
270            attributes: Vec::new(),
271            content: None,
272            children: Vec::new(),
273            self_closing: false,
274        }
275    }
276
277    // MARK: - Accessors
278
279    /// The tag name.
280    #[must_use]
281    pub fn tag(&self) -> &str {
282        &self.tag
283    }
284
285    /// The attributes, in insertion order.
286    #[must_use]
287    pub fn attributes(&self) -> &[Attribute] {
288        &self.attributes
289    }
290
291    /// The escaped text content, if any.
292    #[must_use]
293    pub fn content(&self) -> Option<&str> {
294        self.content.as_deref()
295    }
296
297    /// The child nodes.
298    #[must_use]
299    pub fn children(&self) -> &[Node] {
300        &self.children
301    }
302
303    /// Whether this element closes without an end tag, dropping content and children.
304    ///
305    /// Void tags do this on their own. [`Self::self_closing`] opts any other tag into the
306    /// same shape.
307    ///
308    /// # Examples
309    /// ```
310    /// use molt::prelude::*;
311    /// assert!(!div().is_self_closing());
312    /// assert!(div().self_closing(true).is_self_closing());
313    /// ```
314    #[must_use]
315    pub fn is_self_closing(&self) -> bool {
316        self.self_closing
317    }
318
319    // MARK: - Content
320
321    /// Sets the text content, escaping it.
322    ///
323    /// Replaces any content already set.
324    #[must_use]
325    pub fn text(mut self, content: impl AsRef<str>) -> Self {
326        let raw = content.as_ref();
327        let mut escaped = String::with_capacity(raw.len());
328        write_escaped_text(&mut escaped, raw, false);
329        self.content = Some(escaped);
330        self
331    }
332
333    /// Sets the text content **without** escaping it.
334    ///
335    /// The documented escape hatch for markup you already trust. `<script>` and `<style>`
336    /// use this by default.
337    ///
338    /// **Do not put untrusted data in a `<script>` or `<style>` through this.** The element
339    /// ends at the first `</script` (or `</style`) wherever it appears, including inside a
340    /// JSON string. For JSON data use [`seo::json_ld`](crate::seo::json_ld) or
341    /// [`seo::script_json`](crate::seo::script_json), which escape it; there is no safe way to
342    /// embed untrusted text in script code.
343    #[must_use]
344    pub fn raw_text(mut self, content: impl Into<String>) -> Self {
345        self.content = Some(content.into());
346        self
347    }
348
349    /// Appends a child node.
350    #[must_use]
351    pub fn child(mut self, child: impl Into<Node>) -> Self {
352        self.children.push(child.into());
353        self
354    }
355
356    /// Appends several child nodes.
357    #[must_use]
358    pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
359        self.children.extend(children.into_iter().map(Into::into));
360        self
361    }
362
363    // MARK: - Attributes
364
365    /// Appends an attribute. Does not deduplicate.
366    #[must_use]
367    pub fn add_attribute(mut self, attribute: Attribute) -> Self {
368        self.attributes.push(attribute);
369        self
370    }
371
372    /// Appends `key="value"`, escaping the value. Does not deduplicate.
373    ///
374    #[must_use]
375    pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
376        self.add_attribute(Attribute::new(key, value))
377    }
378
379    /// Appends a boolean attribute, which renders as a bare key.
380    #[must_use]
381    pub fn bool_attr(self, key: impl Into<String>) -> Self {
382        self.add_attribute(Attribute::boolean(key))
383    }
384
385    /// Replaces `key` with a URL that is written as given, even if it is a script URL.
386    ///
387    /// The value is still attribute-escaped. Use this only for a URL you wrote yourself.
388    ///
389    /// # Examples
390    /// ```
391    /// use molt::prelude::*;
392    /// assert_eq!(
393    ///     a().attr("href", "javascript:x")
394    ///         .trusted_url("href", "javascript:print()")
395    ///         .render(),
396    ///     "<a href=\"javascript:print()\"></a>"
397    /// );
398    /// ```
399    #[must_use]
400    pub fn trusted_url(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
401        let attribute = Attribute::trusted_url(key, value);
402        // Compare against the borrowed key: copying it into a `String` just to call `retain`
403        // was an allocation per call.
404        self.attributes
405            .retain(|existing| existing.key() != attribute.key());
406        self.attributes.push(attribute);
407        self
408    }
409
410    /// Closes this element without an end tag, and does not render its content or children.
411    ///
412    /// `true` emits `>` or, when [`RenderOptions::xhtml_self_closing`] is set, ` />`, even
413    /// when the tag is not void. The default is `false`, so existing markup is unchanged.
414    /// Attributes are still written, which is what keeps the URL policy in front of a tag
415    /// that would otherwise have been emitted as raw markup.
416    ///
417    /// # Examples
418    /// ```
419    /// use molt::core::RenderOptions;
420    /// use molt::prelude::*;
421    /// let field = div().attr("id", "x").self_closing(true).child(span().text("no"));
422    /// assert_eq!(field.render(), "<div id=\"x\">");
423    /// assert_eq!(
424    ///     field.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
425    ///     "<div id=\"x\" />"
426    /// );
427    /// ```
428    #[must_use]
429    pub fn self_closing(mut self, yes: bool) -> Self {
430        self.self_closing = yes;
431        self
432    }
433
434    /// Appends `data-{key}="{value}"`.
435    #[must_use]
436    pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
437        self.attr(format!("data-{}", key.as_ref()), value)
438    }
439
440    /// Appends several `data-*` attributes.
441    ///
442    /// Takes an ordered iterator rather than a map: a hash map would render its
443    /// attributes in a different order from one run to the next, and this makes the output
444    /// stable.
445    #[must_use]
446    pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
447        mut self,
448        data: impl IntoIterator<Item = (K, V)>,
449    ) -> Self {
450        for (key, value) in data {
451            self = self.data_attr(key, value);
452        }
453        self
454    }
455
456    /// Appends `aria-{key}="{value}"`.
457    #[must_use]
458    pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
459        self.attr(format!("aria-{}", key.as_ref()), value)
460    }
461
462    /// Appends several `aria-*` attributes, in the order given.
463    #[must_use]
464    pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
465        mut self,
466        aria: impl IntoIterator<Item = (K, V)>,
467    ) -> Self {
468        for (key, value) in aria {
469            self = self.aria_attr(key, value);
470        }
471        self
472    }
473
474    // MARK: - Replacing helpers
475    //
476    // `set_id`, `set_style` and `set_role` remove any existing value before appending.
477    // Every other helper appends without deduplication — that asymmetry is deliberate.
478
479    /// Sets `id`, replacing any existing one.
480    #[must_use]
481    pub fn set_id(self, id: impl AsRef<str>) -> Self {
482        self.replace_attribute("id", id.as_ref())
483    }
484
485    /// Sets `style`, replacing any existing one.
486    #[must_use]
487    pub fn set_style(self, style: impl AsRef<str>) -> Self {
488        self.replace_attribute("style", style.as_ref())
489    }
490
491    /// Sets `role`, replacing any existing one.
492    #[must_use]
493    pub fn set_role(self, role: impl AsRef<str>) -> Self {
494        self.replace_attribute("role", role.as_ref())
495    }
496
497    fn replace_attribute(mut self, key: &'static str, value: &str) -> Self {
498        self.attributes.retain(|a| a.key() != key);
499        self.attributes.push(Attribute::new_static(key, value));
500        self
501    }
502
503    // MARK: - Classes
504
505    /// Appends a class name to `class`, space-joined.
506    ///
507    /// Only the new value is escaped — the existing attribute is already escaped, and
508    /// re-escaping it would double-encode, and a test pins exactly that.
509    #[must_use]
510    pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
511        let raw = class_name.as_ref();
512        if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
513            existing.push_escaped_word(raw);
514        } else {
515            self.attributes.push(Attribute::new_static("class", raw));
516        }
517        self
518    }
519
520    /// Appends several class names.
521    #[must_use]
522    pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
523        for name in class_names {
524            self = self.add_class(name);
525        }
526        self
527    }
528}
529
530impl Render for Element {
531    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
532        crate::core::node::write_element_tree(self, out, options, depth);
533    }
534}
535
536/// Tears the subtree down iteratively.
537///
538/// The derived drop glue recurses once per nesting level, so a tree deep enough to need
539/// the iterative writer would abort while being *freed* instead — after rendering fine.
540/// Draining into an explicit worklist keeps teardown flat.
541///
542/// Each node has its children moved out before it goes out of scope, so the `Drop` that
543/// runs for it finds nothing left to recurse into.
544impl Drop for Element {
545    fn drop(&mut self) {
546        let mut pending = core::mem::take(&mut self.children);
547        while let Some(node) = pending.pop() {
548            match node {
549                Node::Element(mut element) => pending.append(&mut element.children),
550                Node::Fragment(mut children) => pending.append(&mut children),
551                Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
552            }
553        }
554    }
555}
556
557#[cfg(test)]
558mod tests {
559
560    /// The hand-written `Clone`/`PartialEq` must agree with what the derives did.
561    #[test]
562    fn a_clone_equals_its_source_and_is_independent() {
563        let source = div()
564            .attr("id", "a")
565            .add_class("x y")
566            .child(p().text("t").child(Node::comment("c")))
567            .child(Node::fragment([
568                Node::text("a"),
569                Node::raw("<b>"),
570                Node::fragment([]),
571                span().into(),
572            ]))
573            .child(Node::text("tail"));
574        let copy = source.clone();
575        assert_eq!(source, copy);
576        assert_eq!(source.render(), copy.render());
577
578        let changed = copy.clone().child(Node::text("more"));
579        assert_ne!(source, changed);
580        assert_eq!(source.render(), copy.render(), "the original is untouched");
581    }
582
583    #[test]
584    fn equality_sees_every_field() {
585        let base = div().attr("id", "a").text("t");
586        assert_eq!(base, div().attr("id", "a").text("t"));
587        assert_ne!(base, div().attr("id", "b").text("t"));
588        assert_ne!(base, div().attr("id", "a").text("u"));
589        assert_ne!(base, span().attr("id", "a").text("t"));
590        assert_ne!(base, div().attr("id", "a").text("t").self_closing(true));
591        assert_ne!(Node::text("a"), Node::raw("a"));
592        assert_ne!(Node::text("a"), Node::comment("a"));
593        assert_ne!(Node::fragment([]), Node::fragment([Node::text("")]));
594        assert_eq!(Node::from(base.clone()), Node::from(base));
595    }
596
597    use super::*;
598    use crate::core::RenderOptions;
599    use crate::elements::{
600        a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
601        nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
602    };
603
604    /// `trusted_url` on the element builder.
605    #[test]
606    fn trusted_url_replaces_a_blocked_value() {
607        assert_eq!(
608            a().attr("href", "javascript:x")
609                .trusted_url("href", "javascript:print()")
610                .render(),
611            r#"<a href="javascript:print()"></a>"#
612        );
613    }
614
615    #[test]
616    fn a_non_void_tag_marked_self_closing_drops_its_children() {
617        let tag = div()
618            .attr("id", "x")
619            .self_closing(true)
620            .child(span().text("no"));
621        assert_eq!(tag.render(), r#"<div id="x">"#);
622        assert_eq!(
623            tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
624            r#"<div id="x" />"#
625        );
626        assert_eq!(tag.render_pretty(), r#"<div id="x">"#);
627        assert!(!div().is_self_closing());
628    }
629
630    #[test]
631    fn add_class_appends_to_one_attribute() {
632        assert_eq!(
633            div().add_class("a").add_class("b").render(),
634            r#"<div class="a b"></div>"#
635        );
636    }
637
638    #[test]
639    fn chaining_add_class_does_not_double_escape() {
640        let rendered = div().add_class("a&b").add_class("c").render();
641        assert_eq!(rendered, r#"<div class="a&amp;b c"></div>"#);
642        assert!(!rendered.contains("&amp;amp;"));
643    }
644
645    #[test]
646    fn a_quote_in_a_class_name_cannot_break_out() {
647        let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
648        assert!(!rendered.contains("onload=\""));
649        assert!(rendered.contains("&quot;"));
650    }
651
652    #[test]
653    fn add_classes_appends_all_of_them_in_order() {
654        assert_eq!(
655            div().add_classes(["card", "p-4", "shadow"]).render(),
656            r#"<div class="card p-4 shadow"></div>"#
657        );
658    }
659
660    #[test]
661    fn set_id_replaces_rather_than_appending() {
662        let rendered = div().set_id("first").set_id("second").render();
663        assert_eq!(rendered, r#"<div id="second"></div>"#);
664    }
665
666    #[test]
667    fn set_style_and_set_role_also_replace() {
668        let rendered = div()
669            .set_style("color:red")
670            .set_style("color:blue")
671            .set_role("main")
672            .render();
673        assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
674    }
675
676    #[test]
677    fn data_and_aria_attributes_get_their_prefixes() {
678        let rendered = span()
679            .data_attr("id", "7")
680            .aria_attr("label", "Close")
681            .render();
682        assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
683    }
684
685    /// Ordered input means stable output.
686    #[test]
687    fn bulk_attribute_order_is_stable() {
688        let build = || {
689            div()
690                .data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
691                .render()
692        };
693        let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
694        for _ in 0..16 {
695            assert_eq!(build(), expected);
696        }
697    }
698
699    /// Plain attributes are appended and never deduplicated; only `set_id`, `set_style` and
700    /// `set_role` replace.
701    #[test]
702    fn plain_attributes_are_appended_without_deduplication() {
703        assert_eq!(
704            div().attr("data-x", "1").attr("data-x", "2").render(),
705            r#"<div data-x="1" data-x="2"></div>"#
706        );
707    }
708
709    /// Escaping is the default, and turning it off is a separate method, so the escape hatch
710    /// is greppable rather than hidden behind a default argument.
711    #[test]
712    fn text_is_escaped_and_raw_text_is_not() {
713        assert_eq!(p().text("<b>").render(), "<p>&lt;b&gt;</p>");
714        assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
715    }
716
717    #[test]
718    fn boolean_attributes_render_bare() {
719        assert_eq!(
720            button().bool_attr("disabled").render(),
721            "<button disabled></button>"
722        );
723    }
724
725    #[test]
726    fn children_from_appends_a_sequence() {
727        let list = div().children_from([p().text("a"), p().text("b")]);
728        assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
729    }
730
731    #[test]
732    fn a_tag_renders_its_attributes_then_its_content() {
733        let tag = Element::new("p")
734            .attr("class", "text")
735            .text("Hello, World!");
736
737        assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
738    }
739
740    /// Extra attributes can come before the typed ones. The `image(src, alt)` constructor puts
741    /// `src` and `alt` first, so this order needs the generic builder.
742    #[test]
743    fn extra_attributes_can_precede_the_typed_ones() {
744        let tag = img()
745            .attr("width", "100")
746            .attr("height", "100")
747            .attr("src", "image.png")
748            .attr("alt", "An image");
749
750        assert_eq!(
751            tag.render(),
752            r#"<img width="100" height="100" src="image.png" alt="An image">"#
753        );
754    }
755
756    #[test]
757    fn nested_children_render_in_order() {
758        let document = html_tag().child(
759            div()
760                .child(p().text("This is a paragraph."))
761                .child(img().attr("src", "image.png").attr("alt", "An image")),
762        );
763
764        assert_eq!(
765            document.render(),
766            concat!(
767                "<html><div><p>This is a paragraph.</p>",
768                r#"<img src="image.png" alt="An image"></div></html>"#,
769            )
770        );
771    }
772
773    #[test]
774    fn a_container_renders_its_class_before_its_children() {
775        let document = html_tag().child(
776            div()
777                .add_class("main-body")
778                .child(p().text("Title"))
779                .child(p().text("This is a paragraph.")),
780        );
781
782        assert_eq!(
783            document.render(),
784            r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
785        );
786    }
787
788    #[test]
789    fn a_table_renders_its_rows_and_cells() {
790        let document = html_tag().child(
791            table()
792                .add_class("table")
793                .child(
794                    tr().child(th().text("Header 1"))
795                        .child(th().text("Header 2")),
796                )
797                .child(
798                    tr().child(td().text("Row 1, Cell 1"))
799                        .child(td().text("Row 1, Cell 2")),
800                )
801                .child(
802                    tr().child(td().text("Row 2, Cell 1"))
803                        .child(td().text("Row 2, Cell 2")),
804                ),
805        );
806
807        assert_eq!(
808            document.render(),
809            concat!(
810                r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
811                "<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
812                "<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
813            )
814        );
815    }
816
817    #[test]
818    fn an_unordered_list_renders_its_items() {
819        let document = html_tag().child(
820            ul().add_class("unordered-list")
821                .child(li().text("Item 1"))
822                .child(li().text("Item 2"))
823                .child(li().text("Item 3")),
824        );
825
826        assert_eq!(
827            document.render(),
828            concat!(
829                r#"<html><ul class="unordered-list">"#,
830                "<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
831            )
832        );
833    }
834
835    #[test]
836    fn an_ordered_list_renders_its_items() {
837        let document = html_tag().child(
838            ol().add_class("ordered-list")
839                .child(li().text("First"))
840                .child(li().text("Second"))
841                .child(li().text("Third")),
842        );
843
844        assert_eq!(
845            document.render(),
846            concat!(
847                r#"<html><ol class="ordered-list">"#,
848                "<li>First</li><li>Second</li><li>Third</li></ol></html>",
849            )
850        );
851    }
852
853    /// Built with `Element::new` rather than `dl()`/`dt()`/`dd()`: the point of the test is that
854    /// the untyped escape hatch renders like any generated constructor.
855    #[test]
856    fn a_description_list_renders_terms_and_descriptions() {
857        let document = html_tag().child(
858            Element::new("dl")
859                .add_class("description-list")
860                .child(Element::new("dt").text("Term 1"))
861                .child(Element::new("dd").text("Description 1"))
862                .child(Element::new("dt").text("Term 2"))
863                .child(Element::new("dd").text("Description 2")),
864        );
865
866        assert_eq!(
867            document.render(),
868            concat!(
869                r#"<html><dl class="description-list">"#,
870                "<dt>Term 1</dt><dd>Description 1</dd>",
871                "<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
872            )
873        );
874    }
875
876    #[test]
877    fn the_structural_tags_nest_into_a_page() {
878        let document = html_tag()
879            .child(
880                head()
881                    .child(
882                        meta()
883                            .attr("name", "description")
884                            .attr("content", "A description of the page"),
885                    )
886                    .child(stylesheet("styles.css")),
887            )
888            .child(
889                body()
890                    .child(
891                        header().child(
892                            nav()
893                                .child(a().attr("href", "#home").text("Home"))
894                                .child(a().attr("href", "#about").text("About"))
895                                .child(a().attr("href", "#contact").text("Contact")),
896                        ),
897                    )
898                    .child(main_tag().child(p().text("Welcome to our website!")))
899                    .child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
900            );
901
902        assert_eq!(
903            document.render(),
904            concat!(
905                r#"<html><head><meta name="description" content="A description of the page">"#,
906                r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
907                r##"<a href="#home">Home</a><a href="#about">About</a>"##,
908                r##"<a href="#contact">Contact</a></nav></header>"##,
909                "<main><p>Welcome to our website!</p></main>",
910                "<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
911            )
912        );
913    }
914
915    /// `raw_text`, not `text`: escaping the body would turn `'Hello World'` into
916    /// `&#39;Hello World&#39;` and the browser would run that literally.
917    #[test]
918    fn a_script_body_is_not_escaped() {
919        let document = html_tag().child(
920            script()
921                .attr("type", "text/javascript")
922                .raw_text("alert('Hello World');"),
923        );
924
925        assert_eq!(
926            document.render(),
927            r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
928        );
929    }
930
931    #[test]
932    fn meta_renders_both_the_named_and_the_charset_form() {
933        let document = html_tag()
934            .child(
935                meta()
936                    .attr("name", "description")
937                    .attr("content", "A description of the page"),
938            )
939            .child(meta().attr("charset", "utf-8"));
940
941        assert_eq!(
942            document.render(),
943            concat!(
944                r#"<html><meta name="description" content="A description of the page">"#,
945                r#"<meta charset="utf-8"></html>"#,
946            )
947        );
948    }
949
950    #[test]
951    fn a_title_renders_its_text() {
952        let document = html_tag().child(title().text("Title my site"));
953
954        assert_eq!(
955            document.render(),
956            "<html><title>Title my site</title></html>"
957        );
958    }
959
960    #[test]
961    fn a_span_renders_its_attributes_and_content() {
962        let tag = span().attr("class", "text").text("Hello, World!");
963
964        assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
965    }
966
967    /// `button_typed` puts the type first, so a type after the class comes from the generic
968    /// builder.
969    #[test]
970    fn a_button_carries_its_type_after_its_class() {
971        let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
972
973        assert_eq!(
974            document.render(),
975            r#"<html><button class="button-class" type="button"></button></html>"#
976        );
977    }
978
979    #[test]
980    fn a_button_renders_its_children() {
981        let document = html_tag().child(
982            button()
983                .add_class("button-class")
984                .attr("type", "button")
985                .child(span().add_class("icon-bar")),
986        );
987
988        assert_eq!(
989            document.render(),
990            concat!(
991                r#"<html><button class="button-class" type="button">"#,
992                r#"<span class="icon-bar"></span></button></html>"#,
993            )
994        );
995    }
996
997    /// The helpers take `self` and return `Self`, so the type survives by construction — what
998    /// is worth pinning is the attribute order the chain produces.
999    #[test]
1000    fn chaining_helpers_keeps_the_element_usable() {
1001        let card: Element = div().add_class("card").set_id("hero").set_role("region");
1002
1003        assert_eq!(
1004            card.render(),
1005            r#"<div class="card" id="hero" role="region"></div>"#
1006        );
1007    }
1008
1009    #[test]
1010    fn set_style_escapes_quotes() {
1011        let rendered = div()
1012            .set_style(r#"font-family: "Inter", sans-serif"#)
1013            .render();
1014
1015        assert_eq!(
1016            rendered,
1017            r#"<div style="font-family: &quot;Inter&quot;, sans-serif"></div>"#
1018        );
1019    }
1020
1021    #[test]
1022    fn a_single_class_renders_on_its_own() {
1023        assert_eq!(
1024            div().add_class("container").render(),
1025            r#"<div class="container"></div>"#
1026        );
1027    }
1028
1029    #[test]
1030    fn set_id_renders_an_id_attribute() {
1031        assert_eq!(
1032            div().set_id("main-content").render(),
1033            r#"<div id="main-content"></div>"#
1034        );
1035    }
1036
1037    #[test]
1038    fn the_helpers_chain_in_the_order_they_are_called() {
1039        let rendered = div()
1040            .set_id("content")
1041            .add_class("container")
1042            .add_class("active")
1043            .set_style("padding: 20px;")
1044            .render();
1045
1046        assert_eq!(
1047            rendered,
1048            r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
1049        );
1050    }
1051
1052    /// `data_attrs` takes an ordered iterator precisely so the output is deterministic, which
1053    /// is what this pins.
1054    #[test]
1055    fn several_data_attributes_keep_the_order_they_are_given() {
1056        let rendered = div()
1057            .data_attrs([("id", "123"), ("type", "product")])
1058            .render();
1059
1060        assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
1061    }
1062
1063    #[test]
1064    fn several_aria_attributes_keep_the_order_they_are_given() {
1065        let rendered = nav()
1066            .aria_attrs([("label", "Main navigation"), ("expanded", "true")])
1067            .render();
1068
1069        assert_eq!(
1070            rendered,
1071            r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
1072        );
1073    }
1074
1075    /// `attr` appends like every other helper — it does not replace an existing key; use
1076    /// `set_id`, `set_style` or `set_role` for that.
1077    #[test]
1078    fn attr_appends_arbitrary_attributes() {
1079        let rendered = input_named("text", "email")
1080            .attr("placeholder", "Enter email")
1081            .attr("required", "true")
1082            .render();
1083
1084        assert_eq!(
1085            rendered,
1086            r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
1087        );
1088    }
1089}