Skip to main content

molt/core/
element.rs

1//! The [`Element`] type and its fluent builder API.
2//!
3//! Ports the state half of `core/HTMLTag.swift`, all of `core/CSSHelpers.swift` and all of
4//! `core/AttributeHelpers.swift`. Those three files are the *complete* chainable surface
5//! of Winged-Swift — there is nothing else.
6
7use crate::core::attribute::Attribute;
8use crate::core::escape::{write_escaped_attribute, write_escaped_text};
9use crate::core::node::Node;
10use crate::core::render::{Render, RenderOptions};
11
12/// An HTML element: a tag name, attributes, optional text content, and children.
13///
14/// Every builder method takes `self` and returns `Self`, so they chain. Content and
15/// attribute values are escaped as they go in, not when the element is rendered.
16///
17/// # Examples
18/// ```
19/// use molt::prelude::*;
20/// let card = div()
21///     .add_class("card")
22///     .set_id("hero")
23///     .child(h1().text("Welcome"))
24///     .child(p().text("Fuel, tyres & chain."));
25/// assert_eq!(
26///     card.render(),
27///     r#"<div class="card" id="hero"><h1>Welcome</h1><p>Fuel, tyres &amp; chain.</p></div>"#
28/// );
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Element {
32    tag: String,
33    attributes: Vec<Attribute>,
34    content: Option<String>,
35    children: Vec<Node>,
36    self_closing: bool,
37}
38
39impl Element {
40    /// Creates an element with the given tag name.
41    pub fn new(tag: impl Into<String>) -> Self {
42        Self {
43            tag: tag.into(),
44            attributes: Vec::new(),
45            content: None,
46            children: Vec::new(),
47            self_closing: false,
48        }
49    }
50
51    // MARK: - Accessors
52
53    /// The tag name.
54    #[must_use]
55    pub fn tag(&self) -> &str {
56        &self.tag
57    }
58
59    /// The attributes, in insertion order.
60    #[must_use]
61    pub fn attributes(&self) -> &[Attribute] {
62        &self.attributes
63    }
64
65    /// The escaped text content, if any.
66    #[must_use]
67    pub fn content(&self) -> Option<&str> {
68        self.content.as_deref()
69    }
70
71    /// The child nodes.
72    #[must_use]
73    pub fn children(&self) -> &[Node] {
74        &self.children
75    }
76
77    /// Whether this element closes without an end tag, dropping content and children.
78    ///
79    /// Void tags do this on their own. [`Self::self_closing`] opts any other tag into the
80    /// same shape.
81    ///
82    /// # Examples
83    /// ```
84    /// use molt::prelude::*;
85    /// assert!(!div().is_self_closing());
86    /// assert!(div().self_closing(true).is_self_closing());
87    /// ```
88    #[must_use]
89    pub fn is_self_closing(&self) -> bool {
90        self.self_closing
91    }
92
93    // MARK: - Content
94
95    /// Sets the text content, escaping it.
96    ///
97    /// Replaces any content already set.
98    #[must_use]
99    pub fn text(mut self, content: impl AsRef<str>) -> Self {
100        let raw = content.as_ref();
101        let mut escaped = String::with_capacity(raw.len());
102        write_escaped_text(&mut escaped, raw, false);
103        self.content = Some(escaped);
104        self
105    }
106
107    /// Sets the text content **without** escaping it.
108    ///
109    /// The documented escape hatch for markup you already trust. `<script>` and `<style>`
110    /// use this by default.
111    #[must_use]
112    pub fn raw_text(mut self, content: impl Into<String>) -> Self {
113        self.content = Some(content.into());
114        self
115    }
116
117    /// Appends a child node.
118    #[must_use]
119    pub fn child(mut self, child: impl Into<Node>) -> Self {
120        self.children.push(child.into());
121        self
122    }
123
124    /// Appends several child nodes.
125    #[must_use]
126    pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
127        self.children.extend(children.into_iter().map(Into::into));
128        self
129    }
130
131    // MARK: - Attributes
132
133    /// Appends an attribute. Does not deduplicate.
134    #[must_use]
135    pub fn add_attribute(mut self, attribute: Attribute) -> Self {
136        self.attributes.push(attribute);
137        self
138    }
139
140    /// Appends `key="value"`, escaping the value. Does not deduplicate.
141    ///
142    /// Ports `setAttribute(key:value:)`.
143    #[must_use]
144    pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
145        self.add_attribute(Attribute::new(key, value))
146    }
147
148    /// Appends a boolean attribute, which renders as a bare key.
149    #[must_use]
150    pub fn bool_attr(self, key: impl Into<String>) -> Self {
151        self.add_attribute(Attribute::boolean(key))
152    }
153
154    /// Replaces `key` with a URL that is written as given, even if it is a script URL.
155    ///
156    /// The value is still attribute-escaped. Use this only for a URL you wrote yourself.
157    ///
158    /// # Examples
159    /// ```
160    /// use molt::prelude::*;
161    /// assert_eq!(
162    ///     a().attr("href", "javascript:x")
163    ///         .trusted_url("href", "javascript:print()")
164    ///         .render(),
165    ///     "<a href=\"javascript:print()\"></a>"
166    /// );
167    /// ```
168    #[must_use]
169    pub fn trusted_url(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
170        let attribute = Attribute::trusted_url(key, value);
171        let name = attribute.key().to_string();
172        self.attributes.retain(|existing| existing.key() != name);
173        self.attributes.push(attribute);
174        self
175    }
176
177    /// Closes this element without an end tag, and does not render its content or children.
178    ///
179    /// `true` emits `>` or, when [`RenderOptions::xhtml_self_closing`] is set, ` />`, even
180    /// when the tag is not void. The default is `false`, so existing markup is unchanged.
181    /// Attributes are still written, which is what keeps the URL policy in front of a tag
182    /// that would otherwise have been emitted as raw markup.
183    ///
184    /// # Examples
185    /// ```
186    /// use molt::core::RenderOptions;
187    /// use molt::prelude::*;
188    /// let field = div().attr("id", "x").self_closing(true).child(span().text("no"));
189    /// assert_eq!(field.render(), "<div id=\"x\">");
190    /// assert_eq!(
191    ///     field.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
192    ///     "<div id=\"x\" />"
193    /// );
194    /// ```
195    #[must_use]
196    pub fn self_closing(mut self, yes: bool) -> Self {
197        self.self_closing = yes;
198        self
199    }
200
201    /// Appends `data-{key}="{value}"`.
202    #[must_use]
203    pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
204        self.attr(format!("data-{}", key.as_ref()), value)
205    }
206
207    /// Appends several `data-*` attributes.
208    ///
209    /// Takes an ordered iterator rather than a map. Winged-Swift's `dataAttributes(_:)`
210    /// takes a Swift `Dictionary`, so its rendered attribute order is nondeterministic
211    /// between runs; this signature makes the output stable. See `PORTING.md`.
212    #[must_use]
213    pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
214        mut self,
215        data: impl IntoIterator<Item = (K, V)>,
216    ) -> Self {
217        for (key, value) in data {
218            self = self.data_attr(key, value);
219        }
220        self
221    }
222
223    /// Appends `aria-{key}="{value}"`.
224    #[must_use]
225    pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
226        self.attr(format!("aria-{}", key.as_ref()), value)
227    }
228
229    /// Appends several `aria-*` attributes, in the order given.
230    #[must_use]
231    pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
232        mut self,
233        aria: impl IntoIterator<Item = (K, V)>,
234    ) -> Self {
235        for (key, value) in aria {
236            self = self.aria_attr(key, value);
237        }
238        self
239    }
240
241    // MARK: - Replacing helpers
242    //
243    // `set_id`, `set_style` and `set_role` remove any existing value before appending.
244    // Every other helper appends without deduplication — that asymmetry is Winged-Swift's.
245
246    /// Sets `id`, replacing any existing one.
247    #[must_use]
248    pub fn set_id(self, id: impl AsRef<str>) -> Self {
249        self.replace_attribute("id", id.as_ref())
250    }
251
252    /// Sets `style`, replacing any existing one.
253    #[must_use]
254    pub fn set_style(self, style: impl AsRef<str>) -> Self {
255        self.replace_attribute("style", style.as_ref())
256    }
257
258    /// Sets `role`, replacing any existing one.
259    #[must_use]
260    pub fn set_role(self, role: impl AsRef<str>) -> Self {
261        self.replace_attribute("role", role.as_ref())
262    }
263
264    fn replace_attribute(mut self, key: &str, value: &str) -> Self {
265        self.attributes.retain(|a| a.key() != key);
266        self.attributes.push(Attribute::new(key, value));
267        self
268    }
269
270    // MARK: - Classes
271
272    /// Appends a class name to `class`, space-joined.
273    ///
274    /// Only the new value is escaped — the existing attribute is already escaped, and
275    /// re-escaping it would double-encode. `Tests/WingedSwiftTests/CSSHelpersTests.swift`
276    /// has a regression test for exactly that.
277    #[must_use]
278    pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
279        let raw = class_name.as_ref();
280        if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
281            let mut merged = existing.value().to_string();
282            merged.push(' ');
283            write_escaped_attribute(&mut merged, raw);
284            // `Attribute::raw` because `merged` is already escaped.
285            *existing = Attribute::raw("class", merged);
286        } else {
287            self.attributes.push(Attribute::new("class", raw));
288        }
289        self
290    }
291
292    /// Appends several class names.
293    #[must_use]
294    pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
295        for name in class_names {
296            self = self.add_class(name);
297        }
298        self
299    }
300}
301
302impl Render for Element {
303    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
304        crate::core::node::write_element_tree(self, out, options, depth);
305    }
306}
307
308/// Tears the subtree down iteratively.
309///
310/// The derived drop glue recurses once per nesting level, so a tree deep enough to need
311/// the iterative writer would abort while being *freed* instead — after rendering fine.
312/// Draining into an explicit worklist keeps teardown flat.
313///
314/// Each node has its children moved out before it goes out of scope, so the `Drop` that
315/// runs for it finds nothing left to recurse into.
316impl Drop for Element {
317    fn drop(&mut self) {
318        let mut pending = core::mem::take(&mut self.children);
319        while let Some(node) = pending.pop() {
320            match node {
321                Node::Element(mut element) => pending.append(&mut element.children),
322                Node::Fragment(mut children) => pending.append(&mut children),
323                Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
324            }
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::core::RenderOptions;
333    use crate::elements::{
334        a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
335        nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
336    };
337
338    /// Ports `ThreeZeroTests.trustedURLsRenderAsGiven` on the element builder.
339    #[test]
340    fn trusted_url_replaces_a_blocked_value() {
341        assert_eq!(
342            a().attr("href", "javascript:x")
343                .trusted_url("href", "javascript:print()")
344                .render(),
345            r#"<a href="javascript:print()"></a>"#
346        );
347    }
348
349    #[test]
350    fn a_non_void_tag_marked_self_closing_drops_its_children() {
351        let tag = div()
352            .attr("id", "x")
353            .self_closing(true)
354            .child(span().text("no"));
355        assert_eq!(tag.render(), r#"<div id="x">"#);
356        assert_eq!(
357            tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
358            r#"<div id="x" />"#
359        );
360        assert_eq!(tag.render_pretty(), r#"<div id="x">"#);
361        assert!(!div().is_self_closing());
362    }
363
364    /// Ports `CSSHelpersTests.testAddMultipleClasses`.
365    #[test]
366    fn add_class_appends_to_one_attribute() {
367        assert_eq!(
368            div().add_class("a").add_class("b").render(),
369            r#"<div class="a b"></div>"#
370        );
371    }
372
373    /// Ports `CSSHelpersTests.testAddClassDoesNotDoubleEscapeExistingValues`.
374    #[test]
375    fn chaining_add_class_does_not_double_escape() {
376        let rendered = div().add_class("a&b").add_class("c").render();
377        assert_eq!(rendered, r#"<div class="a&amp;b c"></div>"#);
378        assert!(!rendered.contains("&amp;amp;"));
379    }
380
381    /// Ports `CSSHelpersTests.testAddClassEscapesQuotesInsteadOfBreakingOutOfTheAttribute`.
382    #[test]
383    fn a_quote_in_a_class_name_cannot_break_out() {
384        let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
385        assert!(!rendered.contains("onload=\""));
386        assert!(rendered.contains("&quot;"));
387    }
388
389    /// Ports `CSSHelpersTests.testAddClassesArray`.
390    #[test]
391    fn add_classes_appends_all_of_them_in_order() {
392        assert_eq!(
393            div().add_classes(["card", "p-4", "shadow"]).render(),
394            r#"<div class="card p-4 shadow"></div>"#
395        );
396    }
397
398    /// Ports `CSSHelpersTests.testSetIdReplacesExisting`.
399    #[test]
400    fn set_id_replaces_rather_than_appending() {
401        let rendered = div().set_id("first").set_id("second").render();
402        assert_eq!(rendered, r#"<div id="second"></div>"#);
403    }
404
405    /// Ports `CSSHelpersTests.testSetStyle` and `AttributeHelpersTests.testSetRole`.
406    #[test]
407    fn set_style_and_set_role_also_replace() {
408        let rendered = div()
409            .set_style("color:red")
410            .set_style("color:blue")
411            .set_role("main")
412            .render();
413        assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
414    }
415
416    /// Ports `AttributeHelpersTests.testDataAttribute` and
417    /// `AttributeHelpersTests.testAriaAttribute`.
418    #[test]
419    fn data_and_aria_attributes_get_their_prefixes() {
420        let rendered = span()
421            .data_attr("id", "7")
422            .aria_attr("label", "Close")
423            .render();
424        assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
425    }
426
427    /// The improvement over Swift: ordered input means stable output.
428    #[test]
429    fn bulk_attribute_order_is_stable() {
430        let build = || {
431            div()
432                .data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
433                .render()
434        };
435        let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
436        for _ in 0..16 {
437            assert_eq!(build(), expected);
438        }
439    }
440
441    /// No Swift counterpart: `HTMLTag` has no deduplication either, but nothing in its
442    /// suite pins it. Kept as a Rust-side guarantee.
443    #[test]
444    fn plain_attributes_are_appended_without_deduplication() {
445        assert_eq!(
446            div().attr("data-x", "1").attr("data-x", "2").render(),
447            r#"<div data-x="1" data-x="2"></div>"#
448        );
449    }
450
451    /// Ports `HTMLEscapeTests.testHTMLTagEscapesContentByDefault` and
452    /// `HTMLEscapeTests.testHTMLTagCanDisableEscape`. Swift turns escaping off with an
453    /// `escapeContent:`
454    /// flag on the initialiser; Rust has a separate method instead, so the escape hatch is
455    /// greppable rather than hidden behind a default argument.
456    #[test]
457    fn text_is_escaped_and_raw_text_is_not() {
458        assert_eq!(p().text("<b>").render(), "<p>&lt;b&gt;</p>");
459        assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
460    }
461
462    #[test]
463    fn boolean_attributes_render_bare() {
464        assert_eq!(
465            button().bool_attr("disabled").render(),
466            "<button disabled></button>"
467        );
468    }
469
470    #[test]
471    fn children_from_appends_a_sequence() {
472        let list = div().children_from([p().text("a"), p().text("b")]);
473        assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
474    }
475
476    // Ports `HTMLTagTests`. Swift builds these through a result builder — `html { ... }` —
477    // which wraps everything in `<html>`; the Rust equivalent is an explicit `html_tag()`.
478
479    /// Ports `HTMLTagTests.testHTMLTagCreation`.
480    #[test]
481    fn a_tag_renders_its_attributes_then_its_content() {
482        let tag = Element::new("p")
483            .attr("class", "text")
484            .text("Hello, World!");
485
486        assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
487    }
488
489    /// Ports `HTMLTagTests.testHTMLTagWithMultipleAttributes`.
490    ///
491    /// Swift's `Img(src:alt:attributes:)` emits the extra attributes *before* `src` and
492    /// `alt`. The Rust `image(src, alt)` constructor puts them first instead, so matching
493    /// this byte for byte means going through the generic builder.
494    #[test]
495    fn extra_attributes_can_precede_the_typed_ones() {
496        let tag = img()
497            .attr("width", "100")
498            .attr("height", "100")
499            .attr("src", "image.png")
500            .attr("alt", "An image");
501
502        assert_eq!(
503            tag.render(),
504            r#"<img width="100" height="100" src="image.png" alt="An image">"#
505        );
506    }
507
508    /// Ports `HTMLTagTests.testHTMLBuilder`.
509    #[test]
510    fn nested_children_render_in_order() {
511        let document = html_tag().child(
512            div()
513                .child(p().text("This is a paragraph."))
514                .child(img().attr("src", "image.png").attr("alt", "An image")),
515        );
516
517        assert_eq!(
518            document.render(),
519            concat!(
520                "<html><div><p>This is a paragraph.</p>",
521                r#"<img src="image.png" alt="An image"></div></html>"#,
522            )
523        );
524    }
525
526    /// Ports `HTMLTagTests.testHTMLBuilderWithAttributes`.
527    #[test]
528    fn a_container_renders_its_class_before_its_children() {
529        let document = html_tag().child(
530            div()
531                .add_class("main-body")
532                .child(p().text("Title"))
533                .child(p().text("This is a paragraph.")),
534        );
535
536        assert_eq!(
537            document.render(),
538            r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
539        );
540    }
541
542    /// Ports `HTMLTagTests.testHTMLTable`.
543    #[test]
544    fn a_table_renders_its_rows_and_cells() {
545        let document = html_tag().child(
546            table()
547                .add_class("table")
548                .child(
549                    tr().child(th().text("Header 1"))
550                        .child(th().text("Header 2")),
551                )
552                .child(
553                    tr().child(td().text("Row 1, Cell 1"))
554                        .child(td().text("Row 1, Cell 2")),
555                )
556                .child(
557                    tr().child(td().text("Row 2, Cell 1"))
558                        .child(td().text("Row 2, Cell 2")),
559                ),
560        );
561
562        assert_eq!(
563            document.render(),
564            concat!(
565                r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
566                "<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
567                "<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
568            )
569        );
570    }
571
572    /// Ports `HTMLTagTests.testHTMLList`.
573    #[test]
574    fn an_unordered_list_renders_its_items() {
575        let document = html_tag().child(
576            ul().add_class("unordered-list")
577                .child(li().text("Item 1"))
578                .child(li().text("Item 2"))
579                .child(li().text("Item 3")),
580        );
581
582        assert_eq!(
583            document.render(),
584            concat!(
585                r#"<html><ul class="unordered-list">"#,
586                "<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
587            )
588        );
589    }
590
591    /// Ports `HTMLTagTests.testHTMLOrderedList`.
592    #[test]
593    fn an_ordered_list_renders_its_items() {
594        let document = html_tag().child(
595            ol().add_class("ordered-list")
596                .child(li().text("First"))
597                .child(li().text("Second"))
598                .child(li().text("Third")),
599        );
600
601        assert_eq!(
602            document.render(),
603            concat!(
604                r#"<html><ol class="ordered-list">"#,
605                "<li>First</li><li>Second</li><li>Third</li></ol></html>",
606            )
607        );
608    }
609
610    /// Ports `HTMLTagTests.testHTMLDescriptionList`.
611    ///
612    /// Swift reaches for the untyped `HTMLTag("dt", content:)` here; `Element::new` is the
613    /// same escape hatch in Rust, and the point of the test is that it renders like any
614    /// generated constructor.
615    #[test]
616    fn a_description_list_renders_terms_and_descriptions() {
617        let document = html_tag().child(
618            Element::new("dl")
619                .add_class("description-list")
620                .child(Element::new("dt").text("Term 1"))
621                .child(Element::new("dd").text("Description 1"))
622                .child(Element::new("dt").text("Term 2"))
623                .child(Element::new("dd").text("Description 2")),
624        );
625
626        assert_eq!(
627            document.render(),
628            concat!(
629                r#"<html><dl class="description-list">"#,
630                "<dt>Term 1</dt><dd>Description 1</dd>",
631                "<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
632            )
633        );
634    }
635
636    /// Ports `HTMLTagTests.testHTMLStructuralTags`.
637    #[test]
638    fn the_structural_tags_nest_into_a_page() {
639        let document = html_tag()
640            .child(
641                head()
642                    .child(
643                        meta()
644                            .attr("name", "description")
645                            .attr("content", "A description of the page"),
646                    )
647                    .child(stylesheet("styles.css")),
648            )
649            .child(
650                body()
651                    .child(
652                        header().child(
653                            nav()
654                                .child(a().attr("href", "#home").text("Home"))
655                                .child(a().attr("href", "#about").text("About"))
656                                .child(a().attr("href", "#contact").text("Contact")),
657                        ),
658                    )
659                    .child(main_tag().child(p().text("Welcome to our website!")))
660                    .child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
661            );
662
663        assert_eq!(
664            document.render(),
665            concat!(
666                r#"<html><head><meta name="description" content="A description of the page">"#,
667                r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
668                r##"<a href="#home">Home</a><a href="#about">About</a>"##,
669                r##"<a href="#contact">Contact</a></nav></header>"##,
670                "<main><p>Welcome to our website!</p></main>",
671                "<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
672            )
673        );
674    }
675
676    /// Ports `HTMLTagTests.testHTMLScript`.
677    ///
678    /// `raw_text`, not `text`: escaping the body would turn `'Hello World'` into
679    /// `&#39;Hello World&#39;` and the browser would run that literally. Swift's `Script`
680    /// has the same carve-out built into the type.
681    #[test]
682    fn a_script_body_is_not_escaped() {
683        let document = html_tag().child(
684            script()
685                .attr("type", "text/javascript")
686                .raw_text("alert('Hello World');"),
687        );
688
689        assert_eq!(
690            document.render(),
691            r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
692        );
693    }
694
695    /// Ports `HTMLTagTests.testMetaWithName`.
696    #[test]
697    fn meta_renders_both_the_named_and_the_charset_form() {
698        let document = html_tag()
699            .child(
700                meta()
701                    .attr("name", "description")
702                    .attr("content", "A description of the page"),
703            )
704            .child(meta().attr("charset", "utf-8"));
705
706        assert_eq!(
707            document.render(),
708            concat!(
709                r#"<html><meta name="description" content="A description of the page">"#,
710                r#"<meta charset="utf-8"></html>"#,
711            )
712        );
713    }
714
715    /// Ports `HTMLTagTests.testHTMLTitle`.
716    #[test]
717    fn a_title_renders_its_text() {
718        let document = html_tag().child(title().text("Title my site"));
719
720        assert_eq!(
721            document.render(),
722            "<html><title>Title my site</title></html>"
723        );
724    }
725
726    /// Ports `HTMLTagTests.testHTMLSpan`.
727    #[test]
728    fn a_span_renders_its_attributes_and_content() {
729        let tag = span().attr("class", "text").text("Hello, World!");
730
731        assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
732    }
733
734    /// Ports `HTMLTagTests.testHTMLButton`.
735    ///
736    /// Swift's `Button` defaults to `type="button"` and appends it after the caller's
737    /// attributes. `button_typed` in Rust puts the type first, so the order here comes from
738    /// the generic builder.
739    #[test]
740    fn a_button_carries_its_type_after_its_class() {
741        let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
742
743        assert_eq!(
744            document.render(),
745            r#"<html><button class="button-class" type="button"></button></html>"#
746        );
747    }
748
749    /// Ports `HTMLTagTests.testHTMLButtonChildren`.
750    #[test]
751    fn a_button_renders_its_children() {
752        let document = html_tag().child(
753            button()
754                .add_class("button-class")
755                .attr("type", "button")
756                .child(span().add_class("icon-bar")),
757        );
758
759        assert_eq!(
760            document.render(),
761            concat!(
762                r#"<html><button class="button-class" type="button">"#,
763                r#"<span class="icon-bar"></span></button></html>"#,
764            )
765        );
766    }
767
768    // Ports the rest of `CSSHelpersTests` and `AttributeHelpersTests`.
769
770    /// Ports `CSSHelpersTests.testChainingPreservesTheConcreteType`.
771    ///
772    /// Swift needs the test because its helpers are declared on a protocol and could erase
773    /// the tag type. Rust's take `self` and return `Self`, so the type survives by
774    /// construction — what is worth pinning is the attribute order the chain produces.
775    #[test]
776    fn chaining_helpers_keeps_the_element_usable() {
777        let card: Element = div().add_class("card").set_id("hero").set_role("region");
778
779        assert_eq!(
780            card.render(),
781            r#"<div class="card" id="hero" role="region"></div>"#
782        );
783    }
784
785    /// Ports `CSSHelpersTests.testSetStyleEscapesQuotes`.
786    #[test]
787    fn set_style_escapes_quotes() {
788        let rendered = div()
789            .set_style(r#"font-family: "Inter", sans-serif"#)
790            .render();
791
792        assert_eq!(
793            rendered,
794            r#"<div style="font-family: &quot;Inter&quot;, sans-serif"></div>"#
795        );
796    }
797
798    /// Ports `CSSHelpersTests.testAddSingleClass`.
799    #[test]
800    fn a_single_class_renders_on_its_own() {
801        assert_eq!(
802            div().add_class("container").render(),
803            r#"<div class="container"></div>"#
804        );
805    }
806
807    /// Ports `CSSHelpersTests.testSetId`.
808    #[test]
809    fn set_id_renders_an_id_attribute() {
810        assert_eq!(
811            div().set_id("main-content").render(),
812            r#"<div id="main-content"></div>"#
813        );
814    }
815
816    /// Ports `CSSHelpersTests.testChainedHelpers`.
817    #[test]
818    fn the_helpers_chain_in_the_order_they_are_called() {
819        let rendered = div()
820            .set_id("content")
821            .add_class("container")
822            .add_class("active")
823            .set_style("padding: 20px;")
824            .render();
825
826        assert_eq!(
827            rendered,
828            r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
829        );
830    }
831
832    /// Ports `AttributeHelpersTests.testMultipleDataAttributes`.
833    ///
834    /// Swift passes a `Dictionary`, whose order is unspecified, so its test can only check
835    /// that both survive. `data_attrs` takes an ordered iterator precisely so the output is
836    /// deterministic, which is what this pins instead.
837    #[test]
838    fn several_data_attributes_keep_the_order_they_are_given() {
839        let rendered = div()
840            .data_attrs([("id", "123"), ("type", "product")])
841            .render();
842
843        assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
844    }
845
846    /// Ports `AttributeHelpersTests.testMultipleAriaAttributes`.
847    #[test]
848    fn several_aria_attributes_keep_the_order_they_are_given() {
849        let rendered = nav()
850            .aria_attrs([("label", "Main navigation"), ("expanded", "true")])
851            .render();
852
853        assert_eq!(
854            rendered,
855            r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
856        );
857    }
858
859    /// Ports `AttributeHelpersTests.testSetAttribute`.
860    ///
861    /// Swift's `setAttribute` appends like every other helper — the name says *set* but it
862    /// does not replace. `attr` is the same operation under an honest name.
863    #[test]
864    fn attr_appends_arbitrary_attributes() {
865        let rendered = input_named("text", "email")
866            .attr("placeholder", "Enter email")
867            .attr("required", "true")
868            .render();
869
870        assert_eq!(
871            rendered,
872            r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
873        );
874    }
875}