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/// ```
31pub struct Element {
32    // `Cow` for the same reason as `Attribute::key`: the ~90 built-in constructors hand over
33    // a `'static` literal, so `div()` allocates no tag name.
34    tag: Cow<'static, str>,
35    attributes: Vec<Attribute>,
36    content: Option<String>,
37    children: Vec<Node>,
38    self_closing: bool,
39}
40
41/// How many levels of nesting `{:?}` writes before it writes `...` instead.
42///
43/// `Debug` is the one traversal that is not an explicit stack, because it has to print in
44/// the shape `Formatter` expects, and `debug_struct` nests. The depth limit is what makes that
45/// safe: the recursion is at most this deep, whatever the tree is, so a 100,000-level tree
46/// costs the same stack as a 128-level one. It also keeps `assert_eq!` useful, since both
47/// trees are formatted when it fails and the output of a tree that deep would be unreadable
48/// and, pretty-printed, quadratic.
49const DEBUG_MAX_DEPTH: usize = 128;
50
51/// An [`Element`] being formatted, `depth` levels into the tree that `{:?}` started at.
52pub(crate) struct DebugElement<'a>(pub(crate) &'a Element, pub(crate) usize);
53
54impl std::fmt::Debug for DebugElement<'_> {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        let Self(element, depth) = *self;
57        f.debug_struct("Element")
58            .field("tag", &element.tag)
59            .field("attributes", &element.attributes)
60            .field("content", &element.content)
61            .field("children", &DebugChildren(&element.children, depth + 1))
62            .field("self_closing", &element.self_closing)
63            .finish()
64    }
65}
66
67/// A list of nodes `depth` levels in. Every path from a node to its descendants goes through
68/// here, so this is the one place the limit is checked.
69pub(crate) struct DebugChildren<'a>(pub(crate) &'a [Node], pub(crate) usize);
70
71impl std::fmt::Debug for DebugChildren<'_> {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        let Self(children, depth) = *self;
74        if depth > DEBUG_MAX_DEPTH {
75            return f.write_str("...");
76        }
77        f.debug_list()
78            .entries(children.iter().map(|child| DebugNode(child, depth)))
79            .finish()
80    }
81}
82
83/// A [`Node`] `depth` levels in.
84pub(crate) struct DebugNode<'a>(pub(crate) &'a Node, pub(crate) usize);
85
86impl std::fmt::Debug for DebugNode<'_> {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        let Self(node, depth) = *self;
89        match node {
90            Node::Element(element) => f
91                .debug_tuple("Element")
92                .field(&DebugElement(element, depth))
93                .finish(),
94            Node::Text(text) => f.debug_tuple("Text").field(text).finish(),
95            Node::Raw(text) => f.debug_tuple("Raw").field(text).finish(),
96            Node::Comment(text) => f.debug_tuple("Comment").field(text).finish(),
97            Node::Fragment(children) => f
98                .debug_tuple("Fragment")
99                .field(&DebugChildren(children, depth + 1))
100                .finish(),
101        }
102    }
103}
104
105/// Writes the same text the derive did for a tree up to [`DEBUG_MAX_DEPTH`] levels deep, with
106/// `{:#?}` for the multi-line form. Below that depth the children are written as `...`, so
107/// formatting a tree of any depth (a failing `assert_eq!` on one, say) cannot overflow the
108/// stack. The text is for people, not a format to parse, and may change.
109///
110/// # Examples
111/// ```
112/// use molt::prelude::*;
113///
114/// let mut tree = p().text("bottom");
115/// for _ in 0..100_000 {
116///     tree = div().child(tree);
117/// }
118/// let shown = format!("{tree:?}");
119/// assert!(shown.starts_with(r#"Element { tag: "div""#));
120/// assert!(shown.contains("children: ..."));
121/// ```
122impl std::fmt::Debug for Element {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        DebugElement(self, 0).fmt(f)
125    }
126}
127
128impl Clone for Element {
129    fn clone(&self) -> Self {
130        match clone_tree(Source::Element(self)) {
131            Node::Element(element) => element,
132            _ => unreachable!("cloning an element yields an element"),
133        }
134    }
135}
136
137impl PartialEq for Element {
138    fn eq(&self, other: &Self) -> bool {
139        trees_equal(Source::Element(self), Source::Element(other))
140    }
141}
142
143impl Eq for Element {}
144
145impl std::fmt::Display for Element {
146    /// The compact render, or the pretty one with the alternate flag: `{el}` / `{el:#}`.
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.write_str(&if f.alternate() {
149            self.render_pretty()
150        } else {
151            self.render()
152        })
153    }
154}
155
156impl std::hash::Hash for Element {
157    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
158        hash_tree(Source::Element(self), state);
159    }
160}
161
162/// Appends nodes as children, so `element.extend(items)` works beside `children_from`.
163impl<N: Into<Node>> Extend<N> for Element {
164    fn extend<I: IntoIterator<Item = N>>(&mut self, iter: I) {
165        self.children.extend(iter.into_iter().map(Into::into));
166    }
167}
168
169/// The elements below some children, in document order, found with an explicit stack. A
170/// fragment is transparent: its children count as if they stood where it does.
171pub(crate) struct Descendants<'a> {
172    pending: Vec<&'a Node>,
173}
174
175impl<'a> Descendants<'a> {
176    pub(crate) fn new(children: &'a [Node]) -> Self {
177        Self {
178            pending: children.iter().rev().collect(),
179        }
180    }
181}
182
183impl<'a> Iterator for Descendants<'a> {
184    type Item = &'a Element;
185
186    fn next(&mut self) -> Option<&'a Element> {
187        while let Some(node) = self.pending.pop() {
188            match node {
189                Node::Element(element) => {
190                    self.pending.extend(element.children.iter().rev());
191                    return Some(element);
192                }
193                Node::Fragment(children) => self.pending.extend(children.iter().rev()),
194                _ => {}
195            }
196        }
197        None
198    }
199}
200
201/// Feeds a tree to a hasher with an explicit stack, matching [`trees_equal`]: trees that
202/// compare equal hash equal, and a deep tree cannot overflow the stack on the way.
203pub(crate) fn hash_tree<H: std::hash::Hasher>(root: Source<'_>, state: &mut H) {
204    use std::hash::Hash;
205
206    let mut pending = vec![root];
207    while let Some(source) = pending.pop() {
208        match source {
209            Source::Element(element) | Source::Node(Node::Element(element)) => {
210                0_u8.hash(state);
211                element.tag.hash(state);
212                element.attributes.hash(state);
213                element.content.hash(state);
214                element.self_closing.hash(state);
215                element.children.len().hash(state);
216                pending.extend(element.children.iter().rev().map(Source::Node));
217            }
218            Source::Node(Node::Fragment(children)) => {
219                1_u8.hash(state);
220                children.len().hash(state);
221                pending.extend(children.iter().rev().map(Source::Node));
222            }
223            Source::Node(Node::Text(text)) => {
224                2_u8.hash(state);
225                text.hash(state);
226            }
227            Source::Node(Node::Raw(text)) => {
228                3_u8.hash(state);
229                text.hash(state);
230            }
231            Source::Node(Node::Comment(text)) => {
232                4_u8.hash(state);
233                text.hash(state);
234            }
235        }
236    }
237}
238
239/// A copy of `element` with everything but its children.
240fn shell(element: &Element) -> Element {
241    Element {
242        tag: element.tag.clone(),
243        attributes: element.attributes.clone(),
244        content: element.content.clone(),
245        children: Vec::with_capacity(element.children.len()),
246        self_closing: element.self_closing,
247    }
248}
249
250/// An element or fragment whose children are being copied one at a time.
251enum CloneFrame<'a> {
252    Element {
253        source: &'a [Node],
254        built: Element,
255    },
256    Fragment {
257        source: &'a [Node],
258        built: Vec<Node>,
259    },
260}
261
262/// Deep-copies a tree with an explicit stack, in the same shape as the writer.
263pub(crate) fn clone_tree(root: Source<'_>) -> Node {
264    let mut stack: Vec<CloneFrame<'_>> = Vec::new();
265    let mut descend = Some(root);
266
267    loop {
268        // A finished subtree, once there is one, goes into the frame that was waiting for it.
269        let mut finished = None;
270        if let Some(source) = descend.take() {
271            match source {
272                Source::Element(element) | Source::Node(Node::Element(element)) => {
273                    stack.push(CloneFrame::Element {
274                        source: &element.children,
275                        built: shell(element),
276                    });
277                }
278                Source::Node(Node::Fragment(children)) => stack.push(CloneFrame::Fragment {
279                    source: children,
280                    built: Vec::with_capacity(children.len()),
281                }),
282                Source::Node(Node::Text(text)) => finished = Some(Node::Text(text.clone())),
283                Source::Node(Node::Raw(text)) => finished = Some(Node::Raw(text.clone())),
284                Source::Node(Node::Comment(text)) => finished = Some(Node::Comment(text.clone())),
285            }
286        }
287
288        if finished.is_none() {
289            // Take the next child of the innermost open frame, or close the frame.
290            let Some(frame) = stack.last_mut() else {
291                // Only reachable if `root` was a leaf that `finished` already carried out.
292                return Node::Fragment(Vec::new());
293            };
294            let (source, built_len) = match frame {
295                CloneFrame::Element { source, built } => (*source, built.children.len()),
296                CloneFrame::Fragment { source, built } => (*source, built.len()),
297            };
298            if let Some(next) = source.get(built_len) {
299                descend = Some(Source::Node(next));
300                continue;
301            }
302            finished = stack.pop().map(|frame| match frame {
303                CloneFrame::Element { built, .. } => Node::Element(built),
304                CloneFrame::Fragment { built, .. } => Node::Fragment(built),
305            });
306        }
307
308        let Some(done) = finished else { continue };
309        match stack.last_mut() {
310            None => return done,
311            Some(CloneFrame::Element { built, .. }) => built.children.push(done),
312            Some(CloneFrame::Fragment { built, .. }) => built.push(done),
313        }
314    }
315}
316
317/// Structural equality with an explicit stack of pairs still to compare.
318pub(crate) fn trees_equal(left: Source<'_>, right: Source<'_>) -> bool {
319    let mut pending = vec![(left, right)];
320    while let Some((left, right)) = pending.pop() {
321        match (left, right) {
322            (
323                Source::Element(a) | Source::Node(Node::Element(a)),
324                Source::Element(b) | Source::Node(Node::Element(b)),
325            ) => {
326                if a.tag != b.tag
327                    || a.attributes != b.attributes
328                    || a.content != b.content
329                    || a.self_closing != b.self_closing
330                    || a.children.len() != b.children.len()
331                {
332                    return false;
333                }
334                pending.extend(
335                    a.children
336                        .iter()
337                        .zip(&b.children)
338                        .map(|(a, b)| (Source::Node(a), Source::Node(b))),
339                );
340            }
341            (Source::Node(Node::Fragment(a)), Source::Node(Node::Fragment(b))) => {
342                if a.len() != b.len() {
343                    return false;
344                }
345                pending.extend(
346                    a.iter()
347                        .zip(b)
348                        .map(|(a, b)| (Source::Node(a), Source::Node(b))),
349                );
350            }
351            (Source::Node(Node::Text(a)), Source::Node(Node::Text(b)))
352            | (Source::Node(Node::Raw(a)), Source::Node(Node::Raw(b)))
353            | (Source::Node(Node::Comment(a)), Source::Node(Node::Comment(b))) => {
354                if a != b {
355                    return false;
356                }
357            }
358            _ => return false,
359        }
360    }
361    true
362}
363
364impl Element {
365    /// Creates an element with the given tag name.
366    pub fn new(tag: impl Into<String>) -> Self {
367        Self::with_tag(Cow::Owned(tag.into()))
368    }
369
370    /// Creates an element whose tag is a `'static` string, without allocating it.
371    ///
372    /// What the built-in constructors (`div()`, `p()`, …) use. Reach for it when you define
373    /// your own element helpers with a literal tag, such as a custom element.
374    ///
375    /// # Examples
376    /// ```
377    /// use molt::prelude::*;
378    /// assert_eq!(Element::from_static("my-card").text("hi").render(), "<my-card>hi</my-card>");
379    /// ```
380    #[must_use]
381    pub fn from_static(tag: &'static str) -> Self {
382        Self::with_tag(Cow::Borrowed(tag))
383    }
384
385    fn with_tag(tag: Cow<'static, str>) -> Self {
386        Self {
387            tag,
388            attributes: Vec::new(),
389            content: None,
390            children: Vec::new(),
391            self_closing: false,
392        }
393    }
394
395    // MARK: - Accessors
396
397    /// The tag name.
398    #[must_use]
399    pub fn tag(&self) -> &str {
400        &self.tag
401    }
402
403    /// The attributes, in insertion order.
404    #[must_use]
405    pub fn attributes(&self) -> &[Attribute] {
406        &self.attributes
407    }
408
409    /// The escaped text content, if any.
410    #[must_use]
411    pub fn content(&self) -> Option<&str> {
412        self.content.as_deref()
413    }
414
415    /// The child nodes.
416    #[must_use]
417    pub fn children(&self) -> &[Node] {
418        &self.children
419    }
420
421    /// Whether this element closes without an end tag, dropping content and children.
422    ///
423    /// Void tags do this on their own. [`Self::self_closing`] opts any other tag into the
424    /// same shape.
425    ///
426    /// # Examples
427    /// ```
428    /// use molt::prelude::*;
429    /// assert!(!div().is_self_closing());
430    /// assert!(div().self_closing(true).is_self_closing());
431    /// ```
432    #[must_use]
433    pub fn is_self_closing(&self) -> bool {
434        self.self_closing
435    }
436
437    // MARK: - Query
438
439    /// The first element below this one, in document order, with this tag.
440    ///
441    /// For asserting on a tree in a test without searching its rendered string. The search
442    /// uses an explicit stack, so a deep tree cannot overflow it, and it borrows: nothing is
443    /// cloned. This element itself is not a match, only what is inside it, and `tag` is
444    /// compared as written (`"div"`, not `"DIV"`).
445    ///
446    /// # Examples
447    /// ```
448    /// use molt::prelude::*;
449    ///
450    /// let page = div()
451    ///     .child(nav().child(a().attr("href", "/docs").text("Docs")))
452    ///     .child(a().attr("href", "/blog").text("Blog"));
453    ///
454    /// let first = page.find("a").expect("a link");
455    /// assert_eq!(first.attribute("href"), Some("/docs"));
456    /// assert_eq!(first.render(), r#"<a href="/docs">Docs</a>"#);
457    /// assert!(page.find("table").is_none());
458    /// ```
459    #[must_use]
460    pub fn find(&self, tag: &str) -> Option<&Element> {
461        Descendants::new(&self.children).find(|element| element.tag() == tag)
462    }
463
464    /// Every element below this one with this tag, in document order. See [`Self::find`].
465    ///
466    /// # Examples
467    /// ```
468    /// use molt::prelude::*;
469    ///
470    /// let list = ul().children_from(["a", "b", "c"].map(|x| li().text(x)));
471    /// assert_eq!(list.find_all("li").count(), 3);
472    /// let last = list.find_all("li").last().expect("three items");
473    /// assert_eq!(last.render(), "<li>c</li>");
474    /// ```
475    pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Element> + 'a {
476        Descendants::new(&self.children).filter(move |element| element.tag() == tag)
477    }
478
479    /// The value of the first attribute with this name, **as it is written** into the markup.
480    ///
481    /// The value was escaped when it was set, so `attr("title", "a & b")` reads back as
482    /// `a &amp; b`. A boolean attribute reads as `""`; use [`Self::attributes`] and
483    /// [`Attribute::is_boolean`] to tell it from an empty value. The name is compared as
484    /// written. The URL policy is applied at render time, so a URL that will be written as
485    /// `#` still reads as it was set.
486    ///
487    /// # Examples
488    /// ```
489    /// use molt::prelude::*;
490    ///
491    /// let link = a().attr("href", "/docs?x=1&y=2").bool_attr("download");
492    /// assert_eq!(link.attribute("href"), Some("/docs?x=1&amp;y=2"));
493    /// assert_eq!(link.attribute("download"), Some(""));
494    /// assert_eq!(link.attribute("target"), None);
495    /// ```
496    #[must_use]
497    pub fn attribute(&self, name: &str) -> Option<&str> {
498        self.attributes
499            .iter()
500            .find(|attribute| attribute.key() == name)
501            .map(Attribute::value)
502    }
503
504    // MARK: - Content
505
506    /// Appends text, escaping it, at the position of the call.
507    ///
508    /// Text added before any child lands in the element's **content**, which is the cheap
509    /// shape for a leaf (`p().text("x")` allocates no child list). Once the element has a
510    /// child, text becomes a text child instead, so the order of the calls is the order in the
511    /// markup: `p().child(strong()).text("after")` renders the text after the `<strong>`.
512    /// Calling it twice appends twice; it never replaces.
513    ///
514    /// # Examples
515    /// ```
516    /// use molt::prelude::*;
517    /// let line = p()
518    ///     .text("Owner: ")
519    ///     .child(strong().text("Ada"))
520    ///     .text(" (admin)");
521    /// assert_eq!(line.render(), "<p>Owner: <strong>Ada</strong> (admin)</p>");
522    /// ```
523    #[must_use]
524    pub fn text(mut self, content: impl AsRef<str>) -> Self {
525        let raw = content.as_ref();
526        if self.children.is_empty() {
527            let slot = self
528                .content
529                .get_or_insert_with(|| String::with_capacity(raw.len()));
530            write_escaped_text(slot, raw, false);
531        } else {
532            self.children.push(Node::text(raw));
533        }
534        self
535    }
536
537    /// Appends text **without** escaping it, at the position of the call.
538    ///
539    /// The documented escape hatch for markup you already trust. `<script>` and `<style>`
540    /// use this by default. Position follows [`Self::text`]: content before the first child,
541    /// a raw child after it.
542    ///
543    /// **Do not put untrusted data in a `<script>` or `<style>` through this.** The element
544    /// ends at the first `</script` (or `</style`) wherever it appears, including inside a
545    /// JSON string. For JSON data use [`seo::json_ld`](crate::seo::json_ld) or
546    /// [`seo::script_json`](crate::seo::script_json), which escape it; there is no safe way to
547    /// embed untrusted text in script code.
548    ///
549    /// # Examples
550    /// ```
551    /// use molt::prelude::*;
552    /// // The string is trusted markup written right here.
553    /// assert_eq!(p().raw_text("a &amp; b").render(), "<p>a &amp; b</p>");
554    /// ```
555    #[must_use]
556    pub fn raw_text(mut self, content: impl Into<String>) -> Self {
557        let content = content.into();
558        if !self.children.is_empty() {
559            self.children.push(Node::raw(content));
560        } else if let Some(slot) = &mut self.content {
561            slot.push_str(&content);
562        } else {
563            self.content = Some(content);
564        }
565        self
566    }
567
568    /// Appends a child node.
569    #[must_use]
570    pub fn child(mut self, child: impl Into<Node>) -> Self {
571        self.children.push(child.into());
572        self
573    }
574
575    /// Appends several child nodes.
576    #[must_use]
577    pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
578        self.children.extend(children.into_iter().map(Into::into));
579        self
580    }
581
582    // MARK: - Content-Security-Policy
583
584    /// The CSP hash of this `<script>` or `<style>`: `sha256-` and the base64 SHA-256 of what
585    /// the browser reads between the tags. `None` for any other tag, and for a tag that is
586    /// self-closing.
587    ///
588    /// A page with `script-src 'self'` and no `'unsafe-inline'` still runs an inline script
589    /// whose hash is listed in the policy, and the same goes for `style-src`. Put the result
590    /// in single quotes there: `script-src 'self' 'sha256-…'`. Without the quotes it is also
591    /// the value of an `integrity` attribute.
592    ///
593    /// The hash covers the content **as it is written into the page**: text set with
594    /// [`Self::text`] is hashed after escaping, and [`Self::raw_text`] as it is. The child
595    /// nodes are hashed as the compact render writes them, so for an element with children the
596    /// pretty render, which indents them, does not match. The usual element, a single text,
597    /// renders the same in both. Change one byte and the hash changes, which is the point.
598    ///
599    /// # Examples
600    /// ```
601    /// use molt::prelude::*;
602    ///
603    /// // The script is code written right here, so `raw_text` is correct.
604    /// let theme = script().raw_text("document.documentElement.dataset.theme='dark';");
605    /// let hash = theme.csp_hash().expect("a script has a hash");
606    /// assert_eq!(hash, "sha256-IL7rwv2VOjU6vInkRwJTvFERJLVY80azmQFKaT/moJg=");
607    ///
608    /// let policy = format!("script-src 'self' '{hash}'");
609    /// assert!(policy.starts_with("script-src 'self' 'sha256-"));
610    /// assert_eq!(div().csp_hash(), None);
611    /// ```
612    #[must_use]
613    pub fn csp_hash(&self) -> Option<String> {
614        if !matches!(self.tag(), "script" | "style") || self.self_closing {
615            return None;
616        }
617        let mut body = String::new();
618        if let Some(content) = &self.content {
619            body.push_str(content);
620        }
621        let options = RenderOptions::compact();
622        for child in &self.children {
623            child.write_into(&mut body, &options, 0);
624        }
625        let digest = crate::core::digest::sha256(body.as_bytes());
626        Some(format!("sha256-{}", crate::core::digest::base64(&digest)))
627    }
628
629    // MARK: - Attributes
630
631    /// Appends an attribute. Does not deduplicate.
632    #[must_use]
633    pub fn add_attribute(mut self, attribute: Attribute) -> Self {
634        self.attributes.push(attribute);
635        self
636    }
637
638    /// Appends `key="value"`, escaping the value. Does not deduplicate.
639    ///
640    #[must_use]
641    pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
642        self.add_attribute(Attribute::new(key, value))
643    }
644
645    /// Appends a boolean attribute, which renders as a bare key.
646    #[must_use]
647    pub fn bool_attr(self, key: impl Into<String>) -> Self {
648        self.add_attribute(Attribute::boolean(key))
649    }
650
651    /// Replaces `key` with a URL that is written as given, even if it is a script URL.
652    ///
653    /// The value is still attribute-escaped. Use this only for a URL you wrote yourself.
654    ///
655    /// # Examples
656    /// ```
657    /// use molt::prelude::*;
658    /// assert_eq!(
659    ///     a().attr("href", "javascript:x")
660    ///         .trusted_url("href", "javascript:print()")
661    ///         .render(),
662    ///     "<a href=\"javascript:print()\"></a>"
663    /// );
664    /// ```
665    #[must_use]
666    pub fn trusted_url(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
667        let attribute = Attribute::trusted_url(key, value);
668        // Compare against the borrowed key: copying it into a `String` just to call `retain`
669        // was an allocation per call.
670        self.attributes
671            .retain(|existing| existing.key() != attribute.key());
672        self.attributes.push(attribute);
673        self
674    }
675
676    /// Closes this element without an end tag, and does not render its content or children.
677    ///
678    /// `true` emits `>` or, when [`RenderOptions::xhtml_self_closing`] is set, ` />`, even
679    /// when the tag is not void. The default is `false`, so existing markup is unchanged.
680    /// Attributes are still written, which is what keeps the URL policy in front of a tag
681    /// that would otherwise have been emitted as raw markup.
682    ///
683    /// # Examples
684    /// ```
685    /// use molt::core::RenderOptions;
686    /// use molt::prelude::*;
687    /// let field = div().attr("id", "x").self_closing(true).child(span().text("no"));
688    /// assert_eq!(field.render(), "<div id=\"x\">");
689    /// assert_eq!(
690    ///     field.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
691    ///     "<div id=\"x\" />"
692    /// );
693    /// ```
694    #[must_use]
695    pub fn self_closing(mut self, yes: bool) -> Self {
696        self.self_closing = yes;
697        self
698    }
699
700    /// Appends `data-{key}="{value}"`.
701    #[must_use]
702    pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
703        self.attr(format!("data-{}", key.as_ref()), value)
704    }
705
706    /// Appends several `data-*` attributes.
707    ///
708    /// Takes an ordered iterator rather than a map: a hash map would render its
709    /// attributes in a different order from one run to the next, and this makes the output
710    /// stable.
711    #[must_use]
712    pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
713        mut self,
714        data: impl IntoIterator<Item = (K, V)>,
715    ) -> Self {
716        for (key, value) in data {
717            self = self.data_attr(key, value);
718        }
719        self
720    }
721
722    /// Appends `aria-{key}="{value}"`.
723    #[must_use]
724    pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
725        self.attr(format!("aria-{}", key.as_ref()), value)
726    }
727
728    /// Appends several `aria-*` attributes, in the order given.
729    #[must_use]
730    pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
731        mut self,
732        aria: impl IntoIterator<Item = (K, V)>,
733    ) -> Self {
734        for (key, value) in aria {
735            self = self.aria_attr(key, value);
736        }
737        self
738    }
739
740    // MARK: - Replacing helpers
741    //
742    // `set_id`, `set_style` and `set_role` remove any existing value before appending.
743    // Every other helper appends without deduplication — that asymmetry is deliberate.
744
745    /// Sets `id`, replacing any existing one.
746    #[must_use]
747    pub fn set_id(self, id: impl AsRef<str>) -> Self {
748        self.replace_attribute("id", id.as_ref())
749    }
750
751    /// Sets `style`, replacing any existing one.
752    #[must_use]
753    pub fn set_style(self, style: impl AsRef<str>) -> Self {
754        self.replace_attribute("style", style.as_ref())
755    }
756
757    /// Sets `role`, replacing any existing one.
758    #[must_use]
759    pub fn set_role(self, role: impl AsRef<str>) -> Self {
760        self.replace_attribute("role", role.as_ref())
761    }
762
763    fn replace_attribute(mut self, key: &'static str, value: &str) -> Self {
764        self.attributes.retain(|a| a.key() != key);
765        self.attributes.push(Attribute::new_static(key, value));
766        self
767    }
768
769    // MARK: - Classes
770
771    /// Appends a class name to `class`, space-joined.
772    ///
773    /// Only the new value is escaped — the existing attribute is already escaped, and
774    /// re-escaping it would double-encode, and a test pins exactly that.
775    #[must_use]
776    pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
777        let raw = class_name.as_ref();
778        if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
779            existing.push_escaped_word(raw);
780        } else {
781            self.attributes.push(Attribute::new_static("class", raw));
782        }
783        self
784    }
785
786    /// Appends several class names.
787    #[must_use]
788    pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
789        for name in class_names {
790            self = self.add_class(name);
791        }
792        self
793    }
794}
795
796impl Render for Element {
797    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
798        crate::core::node::write_element_tree(self, out, options, depth);
799    }
800}
801
802/// Tears the subtree down iteratively.
803///
804/// The derived drop glue recurses once per nesting level, so a tree deep enough to need
805/// the iterative writer would abort while being *freed* instead — after rendering fine.
806/// Draining into an explicit worklist keeps teardown flat.
807///
808/// Each node has its children moved out before it goes out of scope, so the `Drop` that
809/// runs for it finds nothing left to recurse into.
810impl Drop for Element {
811    fn drop(&mut self) {
812        let mut pending = core::mem::take(&mut self.children);
813        while let Some(node) = pending.pop() {
814            match node {
815                Node::Element(mut element) => pending.append(&mut element.children),
816                Node::Fragment(mut children) => pending.append(&mut children),
817                Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
818            }
819        }
820    }
821}
822
823#[cfg(test)]
824mod tests {
825
826    /// The hand-written `Clone`/`PartialEq` must agree with what the derives did.
827    #[test]
828    fn a_clone_equals_its_source_and_is_independent() {
829        let source = div()
830            .attr("id", "a")
831            .add_class("x y")
832            .child(p().text("t").child(Node::comment("c")))
833            .child(Node::fragment([
834                Node::text("a"),
835                Node::raw("<b>"),
836                Node::fragment([]),
837                span().into(),
838            ]))
839            .child(Node::text("tail"));
840        let copy = source.clone();
841        assert_eq!(source, copy);
842        assert_eq!(source.render(), copy.render());
843
844        let changed = copy.clone().child(Node::text("more"));
845        assert_ne!(source, changed);
846        assert_eq!(source.render(), copy.render(), "the original is untouched");
847    }
848
849    #[test]
850    fn equality_sees_every_field() {
851        let base = div().attr("id", "a").text("t");
852        assert_eq!(base, div().attr("id", "a").text("t"));
853        assert_ne!(base, div().attr("id", "b").text("t"));
854        assert_ne!(base, div().attr("id", "a").text("u"));
855        assert_ne!(base, span().attr("id", "a").text("t"));
856        assert_ne!(base, div().attr("id", "a").text("t").self_closing(true));
857        assert_ne!(Node::text("a"), Node::raw("a"));
858        assert_ne!(Node::text("a"), Node::comment("a"));
859        assert_ne!(Node::fragment([]), Node::fragment([Node::text("")]));
860        assert_eq!(Node::from(base.clone()), Node::from(base));
861    }
862
863    use super::*;
864
865    /// What the derive wrote for this tree, captured before it was replaced, so the manual impl
866    /// is the same text wherever the depth limit does not apply.
867    #[test]
868    fn debug_writes_what_the_derive_wrote() {
869        let tree = div()
870            .attr("id", "x")
871            .bool_attr("hidden")
872            .text("lead")
873            .child(p().text("a<b"))
874            .child(Node::fragment([
875                Node::raw("<i>"),
876                Node::comment("c"),
877                Node::text("t"),
878            ]));
879        assert_eq!(
880            format!("{tree:?}"),
881            r#"Element { tag: "div", attributes: [Attribute { key: "id", value: "x", is_boolean: false, is_trusted_url: false }, Attribute { key: "hidden", value: "", is_boolean: true, is_trusted_url: false }], content: Some("lead"), children: [Element(Element { tag: "p", attributes: [], content: Some("a&lt;b"), children: [], self_closing: false }), Fragment([Raw(Markup("<i>")), Comment(Markup("c")), Text(Markup("t"))])], self_closing: false }"#
882        );
883        assert_eq!(
884            format!("{:#?}", p().text("x").attr("id", "y")),
885            "Element {\n    tag: \"p\",\n    attributes: [\n        Attribute {\n            key: \"id\",\n            value: \"y\",\n            is_boolean: false,\n            is_trusted_url: false,\n        },\n    ],\n    content: Some(\n        \"x\",\n    ),\n    children: [],\n    self_closing: false,\n}"
886        );
887    }
888
889    /// A chain of `levels` nested `div`s.
890    fn chain(levels: usize) -> Element {
891        let mut tree = crate::elements::div();
892        for _ in 1..levels {
893            tree = crate::elements::div().child(tree);
894        }
895        tree
896    }
897
898    #[test]
899    fn debug_shows_the_whole_tree_up_to_the_depth_limit_and_dots_below_it() {
900        let at_the_limit = format!("{:?}", chain(DEBUG_MAX_DEPTH));
901        assert!(!at_the_limit.contains("..."), "{at_the_limit}");
902
903        let one_past = format!("{:?}", chain(DEBUG_MAX_DEPTH + 1));
904        assert_eq!(one_past.matches("...").count(), 1, "{one_past}");
905        assert!(one_past.contains("children: ..."), "{one_past}");
906    }
907
908    #[test]
909    fn debug_of_a_fragment_chain_is_bounded_too() {
910        let mut node = Node::fragment([]);
911        for _ in 0..100_000 {
912            node = Node::fragment([node]);
913        }
914        // Under an element, which frees what it holds iteratively; a bare chain of fragments is
915        // the documented exception and would overflow on drop, not on `Debug`.
916        let node = Node::from(crate::elements::div().child(node));
917        let shown = format!("{node:?}");
918        assert!(shown.contains("..."));
919        assert!(shown.len() < 100_000, "{} bytes", shown.len());
920    }
921
922    #[test]
923    fn csp_hash_covers_the_content_as_it_is_written_into_the_page() {
924        // Reference values from an independent SHA-256 (Python's hashlib).
925        assert_eq!(
926            crate::elements::style()
927                .raw_text("body{color:red}")
928                .csp_hash()
929                .as_deref(),
930            Some("sha256-FcQqt3aNlV7AZnGV4zkQRVeCeJOxbMPnQSx258L803E=")
931        );
932        // `text` escapes, and the browser reads the escaped bytes, so that is what is hashed.
933        assert_eq!(
934            script().text("a<b").csp_hash().as_deref(),
935            Some("sha256-ElKEL8tlu/71khLOyHevXk0zDglt1/FzVBvpN3U1gyU=")
936        );
937    }
938
939    #[test]
940    fn csp_hash_is_the_hash_of_what_sits_between_the_tags() {
941        let element = script()
942            .raw_text("let a = 1;")
943            .child(Node::raw(" let b = 2;"));
944        let rendered = element.render();
945        let inner = rendered
946            .strip_prefix("<script>")
947            .and_then(|rest| rest.strip_suffix("</script>"))
948            .expect("one script");
949        assert_eq!(inner, "let a = 1; let b = 2;");
950        let digest = crate::core::digest::sha256(inner.as_bytes());
951        assert_eq!(
952            element.csp_hash(),
953            Some(format!("sha256-{}", crate::core::digest::base64(&digest)))
954        );
955    }
956
957    #[test]
958    fn csp_hash_is_none_outside_script_and_style_and_tracks_every_byte() {
959        assert_eq!(div().text("x").csp_hash(), None);
960        assert_eq!(script().self_closing(true).csp_hash(), None);
961        assert_ne!(
962            crate::elements::style().raw_text("a{}").csp_hash(),
963            crate::elements::style().raw_text("a{} ").csp_hash()
964        );
965        assert!(
966            script().csp_hash().is_some(),
967            "an empty script still has a hash"
968        );
969    }
970    use crate::core::RenderOptions;
971    use crate::elements::{
972        a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
973        nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
974    };
975
976    /// `trusted_url` on the element builder.
977    #[test]
978    fn trusted_url_replaces_a_blocked_value() {
979        assert_eq!(
980            a().attr("href", "javascript:x")
981                .trusted_url("href", "javascript:print()")
982                .render(),
983            r#"<a href="javascript:print()"></a>"#
984        );
985    }
986
987    #[test]
988    fn a_non_void_tag_marked_self_closing_drops_its_children() {
989        let tag = div()
990            .attr("id", "x")
991            .self_closing(true)
992            .child(span().text("no"));
993        assert_eq!(tag.render(), r#"<div id="x">"#);
994        assert_eq!(
995            tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
996            r#"<div id="x" />"#
997        );
998        assert_eq!(tag.render_pretty(), r#"<div id="x">"#);
999        assert!(!div().is_self_closing());
1000    }
1001
1002    #[test]
1003    fn add_class_appends_to_one_attribute() {
1004        assert_eq!(
1005            div().add_class("a").add_class("b").render(),
1006            r#"<div class="a b"></div>"#
1007        );
1008    }
1009
1010    #[test]
1011    fn chaining_add_class_does_not_double_escape() {
1012        let rendered = div().add_class("a&b").add_class("c").render();
1013        assert_eq!(rendered, r#"<div class="a&amp;b c"></div>"#);
1014        assert!(!rendered.contains("&amp;amp;"));
1015    }
1016
1017    #[test]
1018    fn a_quote_in_a_class_name_cannot_break_out() {
1019        let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
1020        assert!(!rendered.contains("onload=\""));
1021        assert!(rendered.contains("&quot;"));
1022    }
1023
1024    #[test]
1025    fn add_classes_appends_all_of_them_in_order() {
1026        assert_eq!(
1027            div().add_classes(["card", "p-4", "shadow"]).render(),
1028            r#"<div class="card p-4 shadow"></div>"#
1029        );
1030    }
1031
1032    #[test]
1033    fn set_id_replaces_rather_than_appending() {
1034        let rendered = div().set_id("first").set_id("second").render();
1035        assert_eq!(rendered, r#"<div id="second"></div>"#);
1036    }
1037
1038    #[test]
1039    fn set_style_and_set_role_also_replace() {
1040        let rendered = div()
1041            .set_style("color:red")
1042            .set_style("color:blue")
1043            .set_role("main")
1044            .render();
1045        assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
1046    }
1047
1048    #[test]
1049    fn data_and_aria_attributes_get_their_prefixes() {
1050        let rendered = span()
1051            .data_attr("id", "7")
1052            .aria_attr("label", "Close")
1053            .render();
1054        assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
1055    }
1056
1057    /// Ordered input means stable output.
1058    #[test]
1059    fn bulk_attribute_order_is_stable() {
1060        let build = || {
1061            div()
1062                .data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
1063                .render()
1064        };
1065        let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
1066        for _ in 0..16 {
1067            assert_eq!(build(), expected);
1068        }
1069    }
1070
1071    /// Plain attributes are appended and never deduplicated; only `set_id`, `set_style` and
1072    /// `set_role` replace.
1073    #[test]
1074    fn plain_attributes_are_appended_without_deduplication() {
1075        assert_eq!(
1076            div().attr("data-x", "1").attr("data-x", "2").render(),
1077            r#"<div data-x="1" data-x="2"></div>"#
1078        );
1079    }
1080
1081    /// Escaping is the default, and turning it off is a separate method, so the escape hatch
1082    /// is greppable rather than hidden behind a default argument.
1083    #[test]
1084    fn text_is_escaped_and_raw_text_is_not() {
1085        assert_eq!(p().text("<b>").render(), "<p>&lt;b&gt;</p>");
1086        assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
1087    }
1088
1089    #[test]
1090    fn boolean_attributes_render_bare() {
1091        assert_eq!(
1092            button().bool_attr("disabled").render(),
1093            "<button disabled></button>"
1094        );
1095    }
1096
1097    #[test]
1098    fn children_from_appends_a_sequence() {
1099        let list = div().children_from([p().text("a"), p().text("b")]);
1100        assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
1101    }
1102
1103    #[test]
1104    fn a_tag_renders_its_attributes_then_its_content() {
1105        let tag = Element::new("p")
1106            .attr("class", "text")
1107            .text("Hello, World!");
1108
1109        assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
1110    }
1111
1112    /// Extra attributes can come before the typed ones. The `image(src, alt)` constructor puts
1113    /// `src` and `alt` first, so this order needs the generic builder.
1114    #[test]
1115    fn extra_attributes_can_precede_the_typed_ones() {
1116        let tag = img()
1117            .attr("width", "100")
1118            .attr("height", "100")
1119            .attr("src", "image.png")
1120            .attr("alt", "An image");
1121
1122        assert_eq!(
1123            tag.render(),
1124            r#"<img width="100" height="100" src="image.png" alt="An image">"#
1125        );
1126    }
1127
1128    #[test]
1129    fn nested_children_render_in_order() {
1130        let document = html_tag().child(
1131            div()
1132                .child(p().text("This is a paragraph."))
1133                .child(img().attr("src", "image.png").attr("alt", "An image")),
1134        );
1135
1136        assert_eq!(
1137            document.render(),
1138            concat!(
1139                "<html><div><p>This is a paragraph.</p>",
1140                r#"<img src="image.png" alt="An image"></div></html>"#,
1141            )
1142        );
1143    }
1144
1145    #[test]
1146    fn a_container_renders_its_class_before_its_children() {
1147        let document = html_tag().child(
1148            div()
1149                .add_class("main-body")
1150                .child(p().text("Title"))
1151                .child(p().text("This is a paragraph.")),
1152        );
1153
1154        assert_eq!(
1155            document.render(),
1156            r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
1157        );
1158    }
1159
1160    #[test]
1161    fn a_table_renders_its_rows_and_cells() {
1162        let document = html_tag().child(
1163            table()
1164                .add_class("table")
1165                .child(
1166                    tr().child(th().text("Header 1"))
1167                        .child(th().text("Header 2")),
1168                )
1169                .child(
1170                    tr().child(td().text("Row 1, Cell 1"))
1171                        .child(td().text("Row 1, Cell 2")),
1172                )
1173                .child(
1174                    tr().child(td().text("Row 2, Cell 1"))
1175                        .child(td().text("Row 2, Cell 2")),
1176                ),
1177        );
1178
1179        assert_eq!(
1180            document.render(),
1181            concat!(
1182                r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
1183                "<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
1184                "<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
1185            )
1186        );
1187    }
1188
1189    #[test]
1190    fn an_unordered_list_renders_its_items() {
1191        let document = html_tag().child(
1192            ul().add_class("unordered-list")
1193                .child(li().text("Item 1"))
1194                .child(li().text("Item 2"))
1195                .child(li().text("Item 3")),
1196        );
1197
1198        assert_eq!(
1199            document.render(),
1200            concat!(
1201                r#"<html><ul class="unordered-list">"#,
1202                "<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
1203            )
1204        );
1205    }
1206
1207    #[test]
1208    fn an_ordered_list_renders_its_items() {
1209        let document = html_tag().child(
1210            ol().add_class("ordered-list")
1211                .child(li().text("First"))
1212                .child(li().text("Second"))
1213                .child(li().text("Third")),
1214        );
1215
1216        assert_eq!(
1217            document.render(),
1218            concat!(
1219                r#"<html><ol class="ordered-list">"#,
1220                "<li>First</li><li>Second</li><li>Third</li></ol></html>",
1221            )
1222        );
1223    }
1224
1225    /// Built with `Element::new` rather than `dl()`/`dt()`/`dd()`: the point of the test is that
1226    /// the untyped escape hatch renders like any generated constructor.
1227    #[test]
1228    fn a_description_list_renders_terms_and_descriptions() {
1229        let document = html_tag().child(
1230            Element::new("dl")
1231                .add_class("description-list")
1232                .child(Element::new("dt").text("Term 1"))
1233                .child(Element::new("dd").text("Description 1"))
1234                .child(Element::new("dt").text("Term 2"))
1235                .child(Element::new("dd").text("Description 2")),
1236        );
1237
1238        assert_eq!(
1239            document.render(),
1240            concat!(
1241                r#"<html><dl class="description-list">"#,
1242                "<dt>Term 1</dt><dd>Description 1</dd>",
1243                "<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
1244            )
1245        );
1246    }
1247
1248    #[test]
1249    fn the_structural_tags_nest_into_a_page() {
1250        let document = html_tag()
1251            .child(
1252                head()
1253                    .child(
1254                        meta()
1255                            .attr("name", "description")
1256                            .attr("content", "A description of the page"),
1257                    )
1258                    .child(stylesheet("styles.css")),
1259            )
1260            .child(
1261                body()
1262                    .child(
1263                        header().child(
1264                            nav()
1265                                .child(a().attr("href", "#home").text("Home"))
1266                                .child(a().attr("href", "#about").text("About"))
1267                                .child(a().attr("href", "#contact").text("Contact")),
1268                        ),
1269                    )
1270                    .child(main_tag().child(p().text("Welcome to our website!")))
1271                    .child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
1272            );
1273
1274        assert_eq!(
1275            document.render(),
1276            concat!(
1277                r#"<html><head><meta name="description" content="A description of the page">"#,
1278                r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
1279                r##"<a href="#home">Home</a><a href="#about">About</a>"##,
1280                r##"<a href="#contact">Contact</a></nav></header>"##,
1281                "<main><p>Welcome to our website!</p></main>",
1282                "<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
1283            )
1284        );
1285    }
1286
1287    /// `raw_text`, not `text`: escaping the body would turn `'Hello World'` into
1288    /// `&#39;Hello World&#39;` and the browser would run that literally.
1289    #[test]
1290    fn a_script_body_is_not_escaped() {
1291        let document = html_tag().child(
1292            script()
1293                .attr("type", "text/javascript")
1294                .raw_text("alert('Hello World');"),
1295        );
1296
1297        assert_eq!(
1298            document.render(),
1299            r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
1300        );
1301    }
1302
1303    #[test]
1304    fn meta_renders_both_the_named_and_the_charset_form() {
1305        let document = html_tag()
1306            .child(
1307                meta()
1308                    .attr("name", "description")
1309                    .attr("content", "A description of the page"),
1310            )
1311            .child(meta().attr("charset", "utf-8"));
1312
1313        assert_eq!(
1314            document.render(),
1315            concat!(
1316                r#"<html><meta name="description" content="A description of the page">"#,
1317                r#"<meta charset="utf-8"></html>"#,
1318            )
1319        );
1320    }
1321
1322    #[test]
1323    fn a_title_renders_its_text() {
1324        let document = html_tag().child(title().text("Title my site"));
1325
1326        assert_eq!(
1327            document.render(),
1328            "<html><title>Title my site</title></html>"
1329        );
1330    }
1331
1332    #[test]
1333    fn a_span_renders_its_attributes_and_content() {
1334        let tag = span().attr("class", "text").text("Hello, World!");
1335
1336        assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
1337    }
1338
1339    /// `button_typed` puts the type first, so a type after the class comes from the generic
1340    /// builder.
1341    #[test]
1342    fn a_button_carries_its_type_after_its_class() {
1343        let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
1344
1345        assert_eq!(
1346            document.render(),
1347            r#"<html><button class="button-class" type="button"></button></html>"#
1348        );
1349    }
1350
1351    #[test]
1352    fn a_button_renders_its_children() {
1353        let document = html_tag().child(
1354            button()
1355                .add_class("button-class")
1356                .attr("type", "button")
1357                .child(span().add_class("icon-bar")),
1358        );
1359
1360        assert_eq!(
1361            document.render(),
1362            concat!(
1363                r#"<html><button class="button-class" type="button">"#,
1364                r#"<span class="icon-bar"></span></button></html>"#,
1365            )
1366        );
1367    }
1368
1369    /// The helpers take `self` and return `Self`, so the type survives by construction — what
1370    /// is worth pinning is the attribute order the chain produces.
1371    #[test]
1372    fn chaining_helpers_keeps_the_element_usable() {
1373        let card: Element = div().add_class("card").set_id("hero").set_role("region");
1374
1375        assert_eq!(
1376            card.render(),
1377            r#"<div class="card" id="hero" role="region"></div>"#
1378        );
1379    }
1380
1381    #[test]
1382    fn set_style_escapes_quotes() {
1383        let rendered = div()
1384            .set_style(r#"font-family: "Inter", sans-serif"#)
1385            .render();
1386
1387        assert_eq!(
1388            rendered,
1389            r#"<div style="font-family: &quot;Inter&quot;, sans-serif"></div>"#
1390        );
1391    }
1392
1393    #[test]
1394    fn a_single_class_renders_on_its_own() {
1395        assert_eq!(
1396            div().add_class("container").render(),
1397            r#"<div class="container"></div>"#
1398        );
1399    }
1400
1401    #[test]
1402    fn set_id_renders_an_id_attribute() {
1403        assert_eq!(
1404            div().set_id("main-content").render(),
1405            r#"<div id="main-content"></div>"#
1406        );
1407    }
1408
1409    #[test]
1410    fn the_helpers_chain_in_the_order_they_are_called() {
1411        let rendered = div()
1412            .set_id("content")
1413            .add_class("container")
1414            .add_class("active")
1415            .set_style("padding: 20px;")
1416            .render();
1417
1418        assert_eq!(
1419            rendered,
1420            r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
1421        );
1422    }
1423
1424    /// `data_attrs` takes an ordered iterator precisely so the output is deterministic, which
1425    /// is what this pins.
1426    #[test]
1427    fn several_data_attributes_keep_the_order_they_are_given() {
1428        let rendered = div()
1429            .data_attrs([("id", "123"), ("type", "product")])
1430            .render();
1431
1432        assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
1433    }
1434
1435    #[test]
1436    fn several_aria_attributes_keep_the_order_they_are_given() {
1437        let rendered = nav()
1438            .aria_attrs([("label", "Main navigation"), ("expanded", "true")])
1439            .render();
1440
1441        assert_eq!(
1442            rendered,
1443            r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
1444        );
1445    }
1446
1447    /// `attr` appends like every other helper — it does not replace an existing key; use
1448    /// `set_id`, `set_style` or `set_role` for that.
1449    #[test]
1450    fn attr_appends_arbitrary_attributes() {
1451        let rendered = input_named("text", "email")
1452            .attr("placeholder", "Enter email")
1453            .attr("required", "true")
1454            .render();
1455
1456        assert_eq!(
1457            rendered,
1458            r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
1459        );
1460    }
1461}