Skip to main content

molt/elements/
mod.rs

1//! Constructors for every HTML element Molt supports.
2//!
3//! All 93 reduce to a handful of shapes, so they are generated from one table here
4//! (`define_elements!`) rather than written one by one. `<main>` keeps a suffix — see
5//! [`main_tag`] for why.
6
7use crate::core::Element;
8
9/// Generates a zero-argument constructor per tag.
10macro_rules! define_elements {
11    ($($(#[$meta:meta])* $name:ident => $tag:literal),* $(,)?) => {
12        $(
13            $(#[$meta])*
14            ///
15            /// # Examples
16            /// ```
17            /// use molt::prelude::*;
18            #[doc = concat!("let el = ", stringify!($name), "();")]
19            #[doc = concat!("assert_eq!(el.tag(), ", stringify!($tag), ");")]
20            /// ```
21            #[must_use]
22            pub fn $name() -> Element {
23                Element::from_static($tag)
24            }
25        )*
26
27        /// Every tag this crate can build, as `(constructor name, tag name)`.
28        ///
29        /// Used by `scripts/generate-tag-catalog.sh` and by the catalog freshness test.
30        pub const ALL_TAGS: &[(&str, &str)] = &[$((stringify!($name), $tag)),*];
31    };
32}
33
34define_elements! {
35    // MARK: - Inline semantics
36    /// An abbreviation or acronym: `<abbr>`.
37    abbr => "abbr",
38    /// A cited creative work: `<cite>`.
39    cite => "cite",
40    /// Deleted text: `<del>`.
41    del => "del",
42    /// Stress emphasis: `<em>`.
43    em => "em",
44    /// Text in an alternate voice: `<i>`.
45    i => "i",
46    /// Inserted text: `<ins>`.
47    ins => "ins",
48    /// Keyboard input: `<kbd>`.
49    kbd => "kbd",
50    /// Marked or highlighted text: `<mark>`.
51    mark => "mark",
52    /// A short inline quotation: `<q>`.
53    q => "q",
54    /// Sample program output: `<samp>`.
55    samp => "samp",
56    /// Side comments and fine print: `<small>`.
57    small => "small",
58    /// A generic inline container: `<span>`.
59    span => "span",
60    /// Strong importance: `<strong>`.
61    strong => "strong",
62    /// Subscript: `<sub>`.
63    sub => "sub",
64    /// Superscript: `<sup>`.
65    sup => "sup",
66    /// A machine-readable date or time: `<time>`.
67    time => "time",
68    /// A variable name: `<var>`.
69    var => "var",
70    /// A line-break opportunity: `<wbr>`.
71    wbr => "wbr",
72
73    // MARK: - Void and leaf elements
74    /// A line break: `<br>`.
75    br => "br",
76    /// A thematic break: `<hr>`.
77    hr => "hr",
78    /// An image: `<img>`.
79    img => "img",
80    /// An external resource link: `<link>`.
81    link => "link",
82    /// The document base URL: `<base>`.
83    base => "base",
84    /// Document metadata: `<meta>`. See [`crate::seo`] for the typed constructors.
85    meta => "meta",
86    /// The document title: `<title>`.
87    title => "title",
88    /// A script: `<script>`. Content is **not** escaped by default — use
89    /// [`Element::raw_text`](crate::core::Element::raw_text).
90    script => "script",
91    /// Embedded CSS: `<style>`. Content is **not** escaped by default.
92    style => "style",
93    /// External content: `<embed>`.
94    embed => "embed",
95
96    // MARK: - Headings
97    /// A top-level heading: `<h1>`.
98    h1 => "h1",
99    /// A second-level heading: `<h2>`.
100    h2 => "h2",
101    /// A third-level heading: `<h3>`.
102    h3 => "h3",
103    /// A fourth-level heading: `<h4>`.
104    h4 => "h4",
105    /// A fifth-level heading: `<h5>`.
106    h5 => "h5",
107    /// A sixth-level heading: `<h6>`.
108    h6 => "h6",
109
110    // MARK: - Sectioning and flow
111    /// Contact information: `<address>`.
112    address => "address",
113    /// A self-contained composition: `<article>`.
114    article => "article",
115    /// Tangential content: `<aside>`.
116    aside => "aside",
117    /// An extended quotation: `<blockquote>`.
118    blockquote => "blockquote",
119    /// The document body: `<body>`.
120    body => "body",
121    /// A drawing surface: `<canvas>`.
122    canvas => "canvas",
123    /// A dialog box: `<dialog>`.
124    dialog => "dialog",
125    /// A generic block container: `<div>`.
126    div => "div",
127    /// A figure caption: `<figcaption>`.
128    figcaption => "figcaption",
129    /// Self-contained content with an optional caption: `<figure>`.
130    figure => "figure",
131    /// A footer: `<footer>`.
132    footer => "footer",
133    /// Document metadata container: `<head>`.
134    head => "head",
135    /// Introductory content: `<header>`.
136    header => "header",
137    /// The dominant content of the body: `<main>`.
138    ///
139    /// Named `main_tag` rather than `main` because a binary crate's own `fn main` shadows a
140    /// glob-imported `main()`, so `main().child(…)` fails to compile in exactly the place
141    /// people write it first.
142    main_tag => "main",
143    /// Navigation links: `<nav>`.
144    nav => "nav",
145    /// Fallback for disabled scripting: `<noscript>`.
146    noscript => "noscript",
147    /// A paragraph: `<p>`.
148    p => "p",
149    /// A responsive image container: `<picture>`.
150    picture => "picture",
151    /// A thematic grouping: `<section>`.
152    section => "section",
153    /// The document root: `<html>`.
154    html_tag => "html",
155
156    // MARK: - Lists
157    /// An unordered list: `<ul>`.
158    ul => "ul",
159    /// An ordered list: `<ol>`.
160    ol => "ol",
161    /// A list item: `<li>`.
162    li => "li",
163    /// A description list: `<dl>`.
164    dl => "dl",
165    /// A description term: `<dt>`.
166    dt => "dt",
167    /// A description detail: `<dd>`.
168    dd => "dd",
169
170    // MARK: - Interactive
171    /// A hyperlink: `<a>`.
172    a => "a",
173    /// A button: `<button>`.
174    button => "button",
175    /// A disclosure widget: `<details>`.
176    details => "details",
177    /// A disclosure summary: `<summary>`.
178    summary => "summary",
179
180    // MARK: - Tables
181    /// A table: `<table>`.
182    table => "table",
183    /// A table row: `<tr>`.
184    tr => "tr",
185    /// A table cell: `<td>`.
186    td => "td",
187    /// A table header cell: `<th>`.
188    th => "th",
189    /// A table caption: `<caption>`.
190    caption => "caption",
191    /// A column group: `<colgroup>`.
192    colgroup => "colgroup",
193    /// A table column: `<col>`.
194    col => "col",
195    /// The table body: `<tbody>`.
196    tbody => "tbody",
197    /// The table footer: `<tfoot>`.
198    tfoot => "tfoot",
199    /// The table header: `<thead>`.
200    thead => "thead",
201
202    // MARK: - Forms
203    /// A form: `<form>`.
204    form => "form",
205    /// A form control: `<input>`.
206    input => "input",
207    /// A multi-line text control: `<textarea>`.
208    textarea => "textarea",
209    /// A caption for a form control: `<label>`.
210    label => "label",
211    /// A group of form controls: `<fieldset>`.
212    fieldset => "fieldset",
213    /// A caption for a fieldset: `<legend>`.
214    legend => "legend",
215    /// A drop-down list: `<select>`.
216    select => "select",
217    /// An option in a select: `<option>`.
218    option => "option",
219    /// A group of options: `<optgroup>`.
220    optgroup => "optgroup",
221    /// A list of predefined options: `<datalist>`.
222    datalist => "datalist",
223    /// The result of a calculation: `<output>`.
224    output => "output",
225    /// A scalar measurement within a range: `<meter>`.
226    meter => "meter",
227    /// Task completion progress: `<progress>`.
228    progress => "progress",
229
230    // MARK: - Media
231    /// Embedded sound: `<audio>`.
232    audio => "audio",
233    /// Embedded video: `<video>`.
234    video => "video",
235    /// A media resource: `<source>`.
236    source => "source",
237    /// A timed text track: `<track>`.
238    track => "track",
239    /// A nested browsing context: `<iframe>`. Prefer [`iframe_titled`], which cannot be
240    /// built without the `title` an assistive technology needs.
241    iframe => "iframe",
242
243    // MARK: - Code
244    /// Inline code: `<code>`. Whitespace-sensitive.
245    code => "code",
246    /// Preformatted text: `<pre>`. Whitespace-sensitive.
247    pre => "pre",
248}
249
250// MARK: - Typed constructors
251//
252// The shapes that carry required or defaulted arguments and do not fit the table above. Rust has no default arguments, so a defaulted parameter becomes either an
253// `Option` or a separate constructor — never a silently different default.
254
255/// A hyperlink with its `href`: `<a href="…">`.
256///
257/// # Examples
258/// ```
259/// use molt::prelude::*;
260/// assert_eq!(link_to("/pricing").text("Pricing").render(), r#"<a href="/pricing">Pricing</a>"#);
261/// ```
262#[must_use]
263pub fn link_to(href: impl AsRef<str>) -> Element {
264    a().attr("href", href)
265}
266
267/// An image with its `src` and `alt`: `<img src="…" alt="…">`.
268///
269/// `alt` is required rather than optional. An image without an alternative text is the
270/// single most common accessibility defect in generated HTML. Requiring it here is cheaper
271/// than linting for it later (though [`crate::accessibility::audit`] does lint for it). Pass `""` deliberately for a decorative image.
272#[must_use]
273pub fn image(src: impl AsRef<str>, alt: impl AsRef<str>) -> Element {
274    img().attr("src", src).attr("alt", alt)
275}
276
277/// A `<script src="…">`.
278#[must_use]
279pub fn script_src(src: impl AsRef<str>) -> Element {
280    script().attr("src", src)
281}
282
283/// A stylesheet link: `<link href="…" rel="stylesheet">`.
284#[must_use]
285pub fn stylesheet(href: impl AsRef<str>) -> Element {
286    link().attr("href", href).attr("rel", "stylesheet")
287}
288
289/// A `<button type="…">`.
290#[must_use]
291pub fn button_typed(button_type: impl AsRef<str>) -> Element {
292    button().attr("type", button_type)
293}
294
295/// An `<input type="…" name="…">`.
296#[must_use]
297pub fn input_named(input_type: impl AsRef<str>, name: impl AsRef<str>) -> Element {
298    input().attr("type", input_type).attr("name", name)
299}
300
301/// A `<label for="…">`.
302///
303/// Named `for_id` because `for` is a Rust keyword; the rendered attribute is still `for`.
304#[must_use]
305pub fn label_for(for_id: impl AsRef<str>) -> Element {
306    label().attr("for", for_id)
307}
308
309/// An `<iframe>` that cannot be built without a `title`.
310///
311/// A frame with no title is unusable with a screen reader, so `title` is a required
312/// parameter. `loading="lazy"` is applied.
313#[must_use]
314pub fn iframe_titled(src: impl AsRef<str>, title_text: impl AsRef<str>) -> Element {
315    iframe()
316        .attr("src", src)
317        .attr("title", title_text)
318        .attr("loading", "lazy")
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::core::{Render, RenderOptions};
325
326    #[test]
327    fn the_catalog_covers_every_generated_tag() {
328        assert!(
329            ALL_TAGS.len() >= 90,
330            "expected ~93 tags, found {}",
331            ALL_TAGS.len()
332        );
333    }
334
335    /// A catalog pattern that rejects digits would silently drop `h1`–`h6`. Guard against that
336    /// gap.
337    #[test]
338    fn the_catalog_includes_the_headings() {
339        for heading in ["h1", "h2", "h3", "h4", "h5", "h6"] {
340            assert!(
341                ALL_TAGS.iter().any(|(_, tag)| *tag == heading),
342                "{heading} missing from ALL_TAGS"
343            );
344        }
345    }
346
347    #[test]
348    fn no_tag_is_listed_twice() {
349        let mut tags: Vec<&str> = ALL_TAGS.iter().map(|(_, tag)| *tag).collect();
350        tags.sort_unstable();
351        let before = tags.len();
352        tags.dedup();
353        assert_eq!(before, tags.len(), "duplicate tag in ALL_TAGS");
354    }
355
356    /// `var` needs no suffix in Rust; `<main>` does, because `fn main` shadows it.
357    #[test]
358    fn the_renamed_tags_still_emit_their_html_names() {
359        assert_eq!(main_tag().render(), "<main></main>");
360        assert_eq!(var().render(), "<var></var>");
361    }
362
363    #[test]
364    fn typed_constructors_set_their_attributes() {
365        assert_eq!(link_to("/x").render(), r#"<a href="/x"></a>"#);
366        assert_eq!(
367            image("/a.png", "A cat").render(),
368            r#"<img src="/a.png" alt="A cat">"#
369        );
370        assert_eq!(
371            label_for("email").text("E-mail").render(),
372            r#"<label for="email">E-mail</label>"#
373        );
374        assert_eq!(
375            stylesheet("/s.css").render(),
376            r#"<link href="/s.css" rel="stylesheet">"#
377        );
378    }
379
380    #[test]
381    fn an_iframe_built_through_the_typed_constructor_always_has_a_title() {
382        let rendered = iframe_titled("/embed", "A map").render();
383        assert!(rendered.contains(r#"title="A map""#));
384        assert!(rendered.contains(r#"loading="lazy""#));
385    }
386
387    // Media, progress and column shapes go through the generic builder, so each test asserts
388    // the rendered markup rather than a typed initialiser.
389
390    #[test]
391    fn a_table_renders_every_section_in_order() {
392        let table = table()
393            .child(caption().text("Sales"))
394            .child(colgroup().child(col().attr("span", "2")))
395            .child(thead().child(tr().child(th().text("Month")).child(th().text("Total"))))
396            .child(tbody().child(tr().child(td().text("Jan")).child(td().text("10"))))
397            .child(tfoot().child(tr().child(td().text("Sum")).child(td().text("10"))));
398
399        assert_eq!(
400            table.render(),
401            concat!(
402                "<table><caption>Sales</caption>",
403                r#"<colgroup><col span="2"></colgroup>"#,
404                "<thead><tr><th>Month</th><th>Total</th></tr></thead>",
405                "<tbody><tr><td>Jan</td><td>10</td></tr></tbody>",
406                "<tfoot><tr><td>Sum</td><td>10</td></tr></tfoot></table>",
407            )
408        );
409    }
410
411    #[test]
412    fn void_elements_render_without_a_closing_tag() {
413        assert_eq!(col().render(), "<col>");
414        assert_eq!(wbr().render(), "<wbr>");
415        assert_eq!(
416            base().attr("href", "https://example.com/").render(),
417            r#"<base href="https://example.com/">"#
418        );
419        assert_eq!(
420            source()
421                .attr("srcset", "a.webp")
422                .attr("type", "image/webp")
423                .render(),
424            r#"<source srcset="a.webp" type="image/webp">"#
425        );
426        assert_eq!(
427            track()
428                .attr("src", "cc.vtt")
429                .attr("kind", "subtitles")
430                .attr("srclang", "en")
431                .bool_attr("default")
432                .render(),
433            r#"<track src="cc.vtt" kind="subtitles" srclang="en" default>"#
434        );
435    }
436
437    #[test]
438    fn void_elements_honour_xhtml_mode() {
439        let options = RenderOptions::compact().with_xhtml_self_closing(true);
440
441        assert_eq!(col().render_with(&options), "<col />");
442        assert_eq!(wbr().render_with(&options), "<wbr />");
443    }
444
445    #[test]
446    fn a_picture_wraps_its_sources_and_fallback_image() {
447        let picture = picture()
448            .child(
449                source()
450                    .attr("srcset", "/img/hero.avif")
451                    .attr("type", "image/avif"),
452            )
453            .child(image("/img/hero.jpg", "Hero"));
454
455        assert_eq!(
456            picture.render(),
457            concat!(
458                r#"<picture><source srcset="/img/hero.avif" type="image/avif">"#,
459                r#"<img src="/img/hero.jpg" alt="Hero"></picture>"#,
460            )
461        );
462    }
463
464    #[test]
465    fn video_boolean_attributes_render_bare() {
466        let rendered = video()
467            .attr("src", "/demo.mp4")
468            .attr("poster", "/poster.jpg")
469            .bool_attr("controls")
470            .bool_attr("muted")
471            .render();
472
473        assert_eq!(
474            rendered,
475            r#"<video src="/demo.mp4" poster="/poster.jpg" controls muted></video>"#
476        );
477        assert!(!rendered.contains("autoplay"));
478    }
479
480    #[test]
481    fn audio_without_controls_has_no_boolean_attribute() {
482        assert_eq!(
483            audio().attr("src", "/song.mp3").render(),
484            r#"<audio src="/song.mp3"></audio>"#
485        );
486    }
487
488    #[test]
489    fn an_iframe_carries_its_title_and_lazy_loading() {
490        assert_eq!(
491            iframe_titled("https://example.com", "Example").render(),
492            r#"<iframe src="https://example.com" title="Example" loading="lazy"></iframe>"#
493        );
494    }
495
496    #[test]
497    fn details_renders_open_with_a_summary() {
498        let details = details()
499            .bool_attr("open")
500            .child(summary().text("More"))
501            .child(p().text("Hidden text"));
502
503        assert_eq!(
504            details.render(),
505            "<details open><summary>More</summary><p>Hidden text</p></details>"
506        );
507    }
508
509    #[test]
510    fn details_is_closed_unless_open_is_set() {
511        assert_eq!(
512            details().child(summary().text("More")).render(),
513            "<details><summary>More</summary></details>"
514        );
515    }
516
517    #[test]
518    fn a_definition_list_pairs_terms_with_descriptions() {
519        let list = dl()
520            .child(dt().text("Molt"))
521            .child(dd().text("An HTML library for Rust"));
522
523        assert_eq!(
524            list.render(),
525            "<dl><dt>Molt</dt><dd>An HTML library for Rust</dd></dl>"
526        );
527    }
528
529    #[test]
530    fn the_text_semantic_tags_render_their_own_names() {
531        assert_eq!(
532            blockquote().text("Quoted").render(),
533            "<blockquote>Quoted</blockquote>"
534        );
535        assert_eq!(
536            q().attr("cite", "https://example.com")
537                .text("Short")
538                .render(),
539            r#"<q cite="https://example.com">Short</q>"#
540        );
541        assert_eq!(cite().text("Moby Dick").render(), "<cite>Moby Dick</cite>");
542        assert_eq!(
543            abbr()
544                .attr("title", "HyperText Markup Language")
545                .text("HTML")
546                .render(),
547            r#"<abbr title="HyperText Markup Language">HTML</abbr>"#
548        );
549        assert_eq!(address().text("Rua 1").render(), "<address>Rua 1</address>");
550        assert_eq!(sub().text("2").render(), "<sub>2</sub>");
551        assert_eq!(sup().text("2").render(), "<sup>2</sup>");
552        assert_eq!(del().text("old").render(), "<del>old</del>");
553        assert_eq!(ins().text("new").render(), "<ins>new</ins>");
554        assert_eq!(kbd().text("\u{2318}S").render(), "<kbd>\u{2318}S</kbd>");
555        assert_eq!(samp().text("ok").render(), "<samp>ok</samp>");
556        assert_eq!(var().text("x").render(), "<var>x</var>");
557        assert_eq!(dialog().text("Hi").render(), "<dialog>Hi</dialog>");
558        assert_eq!(
559            noscript().text("Enable JS").render(),
560            "<noscript>Enable JS</noscript>"
561        );
562        assert_eq!(
563            canvas().attr("width", "300").render(),
564            r#"<canvas width="300"></canvas>"#
565        );
566    }
567
568    /// `text()` escapes everything, `<style>` included, so CSS goes in through `raw_text`,
569    /// which is the documented escape hatch and greppable on purpose. Escaping here would turn a child
570    /// selector into `a &gt; b` and break the sheet.
571    #[test]
572    fn a_stylesheet_is_not_escaped() {
573        assert_eq!(
574            style()
575                .attr("media", "screen")
576                .raw_text("a > b { color: red; }")
577                .render(),
578            r#"<style media="screen">a > b { color: red; }</style>"#
579        );
580    }
581
582    /// There is nothing to omit: an attribute exists when you call `attr` for it.
583    #[test]
584    fn media_elements_render_only_the_attributes_they_are_given() {
585        assert_eq!(
586            source()
587                .attr("src", "a.mp4")
588                .attr("media", "(min-width: 40em)")
589                .render(),
590            r#"<source src="a.mp4" media="(min-width: 40em)">"#
591        );
592        assert_eq!(
593            track()
594                .attr("src", "t.vtt")
595                .attr("kind", "captions")
596                .attr("label", "PT")
597                .render(),
598            r#"<track src="t.vtt" kind="captions" label="PT">"#
599        );
600        assert_eq!(
601            video().bool_attr("controls").render(),
602            "<video controls></video>"
603        );
604        assert_eq!(
605            audio().bool_attr("autoplay").bool_attr("loop").render(),
606            "<audio autoplay loop></audio>"
607        );
608        assert_eq!(
609            iframe()
610                .attr("src", "/e")
611                .attr("title", "E")
612                .bool_attr("allowfullscreen")
613                .render(),
614            r#"<iframe src="/e" title="E" allowfullscreen></iframe>"#
615        );
616        assert_eq!(
617            base().attr("href", "/").attr("target", "_blank").render(),
618            r#"<base href="/" target="_blank">"#
619        );
620        assert_eq!(col().render(), "<col>");
621        assert_eq!(q().text("quoted").render(), "<q>quoted</q>");
622    }
623
624    #[test]
625    fn meta_renders_whichever_attributes_it_is_given() {
626        assert_eq!(
627            meta()
628                .attr("http-equiv", "refresh")
629                .attr("content", "5")
630                .render(),
631            r#"<meta http-equiv="refresh" content="5">"#
632        );
633        assert_eq!(
634            meta().attr("itemprop", "name").render(),
635            r#"<meta itemprop="name">"#
636        );
637    }
638
639    #[test]
640    fn flow_containers_accept_text_as_well_as_children() {
641        assert_eq!(aside().text("Note").render(), "<aside>Note</aside>");
642        assert_eq!(nav().text("Menu").render(), "<nav>Menu</nav>");
643        assert_eq!(header().text("Top").render(), "<header>Top</header>");
644        assert_eq!(footer().text("Bottom").render(), "<footer>Bottom</footer>");
645        assert_eq!(main_tag().text("Body").render(), "<main>Body</main>");
646        assert_eq!(article().text("Post").render(), "<article>Post</article>");
647        assert_eq!(figure().text("Fig").render(), "<figure>Fig</figure>");
648        assert_eq!(form().text("F").render(), "<form>F</form>");
649        assert_eq!(fieldset().text("Set").render(), "<fieldset>Set</fieldset>");
650    }
651
652    #[test]
653    fn flow_container_content_is_escaped() {
654        assert_eq!(
655            aside().text("<b>x</b>").render(),
656            "<aside>&lt;b&gt;x&lt;/b&gt;</aside>"
657        );
658    }
659
660    #[test]
661    fn a_fieldset_carries_a_legend_and_its_fields() {
662        let fieldset = fieldset()
663            .child(legend().text("Account"))
664            .child(input_named("text", "email"));
665
666        assert_eq!(
667            fieldset.render(),
668            r#"<fieldset><legend>Account</legend><input type="text" name="email"></fieldset>"#
669        );
670    }
671
672    #[test]
673    fn a_select_groups_its_options() {
674        let select = select().attr("name", "city").child(
675            optgroup()
676                .attr("label", "Brazil")
677                .child(option().attr("value", "sp").text("S\u{e3}o Paulo")),
678        );
679
680        assert_eq!(
681            select.render(),
682            concat!(
683                r#"<select name="city"><optgroup label="Brazil">"#,
684                "<option value=\"sp\">S\u{e3}o Paulo</option></optgroup></select>",
685            )
686        );
687    }
688
689    #[test]
690    fn the_form_display_elements_render() {
691        assert_eq!(
692            datalist().child(option().attr("value", "rust")).render(),
693            r#"<datalist><option value="rust"></option></datalist>"#
694        );
695        assert_eq!(
696            progress().attr("value", "0.7").attr("max", "1.0").render(),
697            r#"<progress value="0.7" max="1.0"></progress>"#
698        );
699        assert_eq!(
700            progress().attr("max", "1.0").render(),
701            r#"<progress max="1.0"></progress>"#
702        );
703        assert_eq!(
704            meter()
705                .attr("value", "6.0")
706                .attr("min", "0.0")
707                .attr("max", "10.0")
708                .render(),
709            r#"<meter value="6.0" min="0.0" max="10.0"></meter>"#
710        );
711        assert_eq!(output().text("42").render(), "<output>42</output>");
712    }
713
714    #[test]
715    fn boolean_attributes_carry_no_value() {
716        let rendered = input_named("checkbox", "agree")
717            .bool_attr("checked")
718            .bool_attr("required")
719            .render();
720
721        assert_eq!(
722            rendered,
723            r#"<input type="checkbox" name="agree" checked required>"#
724        );
725        assert!(!rendered.contains("checked="));
726        assert!(!rendered.contains("required="));
727    }
728
729    #[test]
730    fn void_elements_do_not_self_close_by_default() {
731        let rendered = image("a.png", "A").render();
732
733        assert_eq!(rendered, r#"<img src="a.png" alt="A">"#);
734        assert!(!rendered.contains("/>"));
735    }
736
737    #[test]
738    fn xhtml_mode_self_closes_void_elements() {
739        let options = RenderOptions::compact().with_xhtml_self_closing(true);
740
741        assert_eq!(
742            image("a.png", "A").render_with(&options),
743            r#"<img src="a.png" alt="A" />"#
744        );
745    }
746
747    #[test]
748    fn anchors_and_headings_take_element_children() {
749        assert_eq!(
750            link_to("/news").child(image("thumb.jpg", "Thumb")).render(),
751            r#"<a href="/news"><img src="thumb.jpg" alt="Thumb"></a>"#
752        );
753        assert_eq!(
754            h3().child(link_to("/news").text("Headline")).render(),
755            r#"<h3><a href="/news">Headline</a></h3>"#
756        );
757        assert_eq!(
758            i().add_class("fas fa-search").render(),
759            r#"<i class="fas fa-search"></i>"#
760        );
761    }
762
763    #[test]
764    fn a_submit_button_carries_its_type() {
765        assert_eq!(
766            button_typed("submit").text("Send").render(),
767            r#"<button type="submit">Send</button>"#
768        );
769    }
770
771    #[test]
772    fn a_label_without_a_target_has_no_for_attribute() {
773        let rendered = label().text("Accept cookies").render();
774
775        assert_eq!(rendered, "<label>Accept cookies</label>");
776        assert!(!rendered.contains("for="));
777    }
778
779    #[test]
780    fn an_input_without_a_name_has_no_name_attribute() {
781        let rendered = input()
782            .attr("type", "search")
783            .attr("placeholder", "Search")
784            .render();
785
786        assert_eq!(rendered, r#"<input type="search" placeholder="Search">"#);
787        assert!(!rendered.contains("name="));
788    }
789
790    #[test]
791    fn a_section_renders_its_class_and_text() {
792        assert_eq!(
793            section().add_class("hero").text("Hello").render(),
794            r#"<section class="hero">Hello</section>"#
795        );
796    }
797
798    #[test]
799    fn the_inline_semantic_tags_render() {
800        assert_eq!(strong().text("bold").render(), "<strong>bold</strong>");
801        assert_eq!(em().text("emph").render(), "<em>emph</em>");
802        assert_eq!(small().text("fine").render(), "<small>fine</small>");
803        assert_eq!(br().render(), "<br>");
804        assert_eq!(hr().render(), "<hr>");
805    }
806
807    #[test]
808    fn a_pre_block_keeps_its_line_breaks() {
809        let block =
810            pre().text("This is preformatted text.\nIt preserves whitespace and line breaks.");
811
812        assert_eq!(
813            block.render(),
814            "<pre>This is preformatted text.\nIt preserves whitespace and line breaks.</pre>"
815        );
816    }
817
818    #[test]
819    fn a_code_block_keeps_its_line_breaks() {
820        let block = code().text("let x = 10\nprint(x)");
821
822        assert_eq!(block.render(), "<code>let x = 10\nprint(x)</code>");
823    }
824
825    #[test]
826    fn an_embed_is_a_void_element() {
827        assert_eq!(
828            embed()
829                .attr("src", "video.mp4")
830                .attr("type", "video/mp4")
831                .render(),
832            r#"<embed src="video.mp4" type="video/mp4">"#
833        );
834    }
835
836    #[test]
837    fn an_article_wraps_its_children() {
838        assert_eq!(
839            article().child(h1().text("Title")).render(),
840            "<article><h1>Title</h1></article>"
841        );
842    }
843
844    #[test]
845    fn an_aside_wraps_its_children() {
846        assert_eq!(
847            aside().child(p().text("Sidebar")).render(),
848            "<aside><p>Sidebar</p></aside>"
849        );
850    }
851
852    #[test]
853    fn a_figure_pairs_an_image_with_its_caption() {
854        let block = figure()
855            .child(image("image.jpg", "Test"))
856            .child(figcaption().text("Image caption"));
857
858        assert_eq!(
859            block.render(),
860            concat!(
861                r#"<figure><img src="image.jpg" alt="Test">"#,
862                "<figcaption>Image caption</figcaption></figure>",
863            )
864        );
865    }
866
867    #[test]
868    fn a_time_element_carries_its_datetime() {
869        assert_eq!(
870            time()
871                .attr("datetime", "2024-01-15")
872                .text("January 15, 2024")
873                .render(),
874            r#"<time datetime="2024-01-15">January 15, 2024</time>"#
875        );
876    }
877
878    #[test]
879    fn a_mark_element_renders_its_text() {
880        assert_eq!(
881            mark().text("highlighted").render(),
882            "<mark>highlighted</mark>"
883        );
884    }
885
886    #[test]
887    fn every_heading_level_renders_its_own_tag() {
888        let headings = [
889            h1().text("H1"),
890            h2().text("H2"),
891            h3().text("H3"),
892            h4().text("H4"),
893            h5().text("H5"),
894            h6().text("H6"),
895        ];
896
897        for (index, heading) in headings.into_iter().enumerate() {
898            let level = index + 1;
899            assert_eq!(heading.render(), format!("<h{level}>H{level}</h{level}>"));
900        }
901    }
902
903    #[test]
904    fn a_form_renders_its_fieldsets_and_submit() {
905        let form = form()
906            .attr("action", "/submit")
907            .child(
908                fieldset()
909                    .child(label_for("name").text("Name"))
910                    .child(input_named("text", "name")),
911            )
912            .child(
913                fieldset()
914                    .child(label_for("message").text("Message"))
915                    .child(textarea().attr("name", "message")),
916            )
917            .child(input_named("submit", "submit").attr("value", "Send"));
918
919        assert_eq!(
920            form.render(),
921            concat!(
922                r#"<form action="/submit"><fieldset><label for="name">Name</label>"#,
923                r#"<input type="text" name="name"></fieldset><fieldset>"#,
924                r#"<label for="message">Message</label>"#,
925                r#"<textarea name="message"></textarea></fieldset>"#,
926                r#"<input type="submit" name="submit" value="Send"></form>"#,
927            )
928        );
929    }
930
931    #[test]
932    fn a_select_renders_each_option() {
933        let select = select()
934            .attr("name", "options")
935            .children_from((1..=3).map(|i| {
936                option()
937                    .attr("value", i.to_string())
938                    .text(format!("Option {i}"))
939            }));
940
941        assert_eq!(
942            select.render(),
943            concat!(
944                r#"<select name="options"><option value="1">Option 1</option>"#,
945                r#"<option value="2">Option 2</option>"#,
946                r#"<option value="3">Option 3</option></select>"#,
947            )
948        );
949    }
950
951    #[test]
952    fn a_label_points_at_its_field() {
953        assert_eq!(
954            label_for("username").text("Username").render(),
955            r#"<label for="username">Username</label>"#
956        );
957    }
958
959    #[test]
960    fn an_input_renders_its_type_name_and_value() {
961        assert_eq!(
962            input_named("text", "username")
963                .attr("value", "JohnDoe")
964                .render(),
965            r#"<input type="text" name="username" value="JohnDoe">"#
966        );
967    }
968
969    #[test]
970    fn a_textarea_renders_its_content_between_the_tags() {
971        assert_eq!(
972            textarea()
973                .attr("name", "message")
974                .text("Hello, World!")
975                .render(),
976            r#"<textarea name="message">Hello, World!</textarea>"#
977        );
978    }
979
980    #[test]
981    fn a_fieldset_groups_a_label_and_its_field() {
982        let block = fieldset()
983            .child(label_for("name").text("Name"))
984            .child(input_named("text", "name"));
985
986        assert_eq!(
987            block.render(),
988            concat!(
989                r#"<fieldset><label for="name">Name</label>"#,
990                r#"<input type="text" name="name"></fieldset>"#,
991            )
992        );
993    }
994
995    #[test]
996    fn a_section_wraps_its_children() {
997        assert_eq!(
998            section().child(p().text("This is a section.")).render(),
999            "<section><p>This is a section.</p></section>"
1000        );
1001    }
1002}