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// MARK: - Form helpers
322//
323// Low-level shapes for form-heavy pages (#94). Options arrive as `(value, label)` pairs from
324// any iterator, so a literal array and the rows of a query feed the same function. The CSRF
325// field is `hidden_input` with whatever name the application's framework expects; Molt fixes
326// none. Higher-level form builders stay out of core.
327
328/// A `<select name="…">`.
329///
330/// Fill it with [`options_from`] through [`Element::children_from`], or with
331/// [`optgroup_labeled`] groups. [`select_from`] does the ungrouped case in one call.
332///
333/// # Examples
334/// ```
335/// use molt::prelude::*;
336/// let options = options_from([("poa", "Porto Alegre")], None);
337/// let south = optgroup_labeled("Sul").children_from(options);
338/// let select = select_named("city").child(south);
339/// assert_eq!(
340///     select.render(),
341///     concat!(
342///         r#"<select name="city"><optgroup label="Sul">"#,
343///         r#"<option value="poa">Porto Alegre</option></optgroup></select>"#,
344///     )
345/// );
346/// ```
347#[must_use]
348pub fn select_named(name: impl AsRef<str>) -> Element {
349    select().attr("name", name)
350}
351
352/// A `<select name="…">` holding one `<option>` per `(value, label)` pair.
353///
354/// The option whose value equals `selected` is marked `selected`; pass `None` when none is.
355///
356/// # Examples
357/// ```
358/// use molt::prelude::*;
359/// let countries = [("br", "Brasil"), ("ar", "Argentina")];
360/// assert_eq!(
361///     select_from("country", countries, Some("ar")).render(),
362///     concat!(
363///         r#"<select name="country"><option value="br">Brasil</option>"#,
364///         r#"<option value="ar" selected>Argentina</option></select>"#,
365///     )
366/// );
367/// ```
368#[must_use]
369pub fn select_from<K: AsRef<str>, V: AsRef<str>>(
370    name: impl AsRef<str>,
371    options: impl IntoIterator<Item = (K, V)>,
372    selected: Option<&str>,
373) -> Element {
374    select_named(name).children_from(options_from(options, selected))
375}
376
377/// One `<option value="…">label</option>` per `(value, label)` pair, for
378/// [`Element::children_from`].
379///
380/// The option whose value equals `selected` is marked `selected`; pass `None` when none is.
381/// Any iterator of pairs works, so a literal array and the rows of a query feed the same
382/// call. Values and labels are escaped on entry like every other attribute and text.
383///
384/// # Examples
385/// ```
386/// use molt::prelude::*;
387/// let rows = vec![("sp".to_string(), "São Paulo".to_string())];
388/// let select = select_named("city")
389///     .children_from(options_from(rows.iter().map(|r| (&r.0, &r.1)), Some("sp")));
390/// assert_eq!(
391///     select.render(),
392///     r#"<select name="city"><option value="sp" selected>São Paulo</option></select>"#
393/// );
394/// ```
395#[must_use]
396pub fn options_from<K: AsRef<str>, V: AsRef<str>>(
397    options: impl IntoIterator<Item = (K, V)>,
398    selected: Option<&str>,
399) -> Vec<Element> {
400    options_with(options, |value| selected == Some(value))
401}
402
403/// An `<option value="…">`. The label is its text.
404///
405/// # Examples
406/// ```
407/// use molt::prelude::*;
408/// assert_eq!(
409///     option_valued("br").text("Brasil").render(),
410///     r#"<option value="br">Brasil</option>"#
411/// );
412/// ```
413#[must_use]
414pub fn option_valued(value: impl AsRef<str>) -> Element {
415    option().attr("value", value)
416}
417
418/// An `<optgroup label="…">`. Fill it with [`options_from`] through
419/// [`Element::children_from`].
420///
421/// # Examples
422/// ```
423/// use molt::prelude::*;
424/// let options = options_from([("poa", "Porto Alegre")], None);
425/// let south = optgroup_labeled("Sul").children_from(options);
426/// assert_eq!(
427///     south.render(),
428///     r#"<optgroup label="Sul"><option value="poa">Porto Alegre</option></optgroup>"#
429/// );
430/// ```
431#[must_use]
432pub fn optgroup_labeled(label_text: impl AsRef<str>) -> Element {
433    optgroup().attr("label", label_text)
434}
435
436/// A `<textarea name="…">`. Its value is its text.
437///
438/// The value is escaped on entry, so a `</textarea>` inside it cannot close the element,
439/// and the whitespace inside is rendered exactly as given.
440///
441/// # Examples
442/// ```
443/// use molt::prelude::*;
444/// assert_eq!(
445///     textarea_named("bio").text("Hi <b>").render(),
446///     r#"<textarea name="bio">Hi &lt;b&gt;</textarea>"#
447/// );
448/// ```
449#[must_use]
450pub fn textarea_named(name: impl AsRef<str>) -> Element {
451    textarea().attr("name", name)
452}
453
454/// An `<input type="hidden" name="…" value="…">`: the shape of a CSRF token field.
455///
456/// The field name is the application's (`_csrf`, `authenticity_token`,
457/// `csrfmiddlewaretoken`, …); Molt fixes none. The value is escaped like any attribute.
458///
459/// # Examples
460/// ```
461/// use molt::prelude::*;
462/// assert_eq!(
463///     hidden_input("_csrf", "t0ken").render(),
464///     r#"<input type="hidden" name="_csrf" value="t0ken">"#
465/// );
466/// ```
467#[must_use]
468pub fn hidden_input(name: impl AsRef<str>, value: impl AsRef<str>) -> Element {
469    input_named("hidden", name).attr("value", value)
470}
471
472/// A `<fieldset>` whose first child is `<legend>…</legend>`, where HTML requires the legend
473/// to be. The fields added afterwards follow it.
474///
475/// # Examples
476/// ```
477/// use molt::prelude::*;
478/// assert_eq!(
479///     fieldset_legend("Account").child(input_named("email", "email")).render(),
480///     r#"<fieldset><legend>Account</legend><input type="email" name="email"></fieldset>"#
481/// );
482/// ```
483#[must_use]
484pub fn fieldset_legend(legend_text: impl AsRef<str>) -> Element {
485    fieldset().child(legend().text(legend_text))
486}
487
488/// A `<select name="…" multiple>` holding one `<option>` per `(value, label)` pair, with
489/// every option whose value is in `selected` marked `selected`.
490///
491/// [`select_from`] is the single-choice form. This one exists because a multiple select
492/// needs a set of selected values, not an `Option`; pass `&[]` when none is.
493///
494/// # Examples
495/// ```
496/// use molt::prelude::*;
497/// let tags = [("rust", "Rust"), ("wasm", "WebAssembly")];
498/// assert_eq!(
499///     select_multiple_from("tags", tags, &["rust"]).render(),
500///     concat!(
501///         r#"<select name="tags" multiple><option value="rust" selected>Rust</option>"#,
502///         r#"<option value="wasm">WebAssembly</option></select>"#,
503///     )
504/// );
505/// ```
506#[must_use]
507pub fn select_multiple_from<K: AsRef<str>, V: AsRef<str>>(
508    name: impl AsRef<str>,
509    options: impl IntoIterator<Item = (K, V)>,
510    selected: &[&str],
511) -> Element {
512    select_named(name)
513        .bool_attr("multiple")
514        .children_from(options_with(options, |value| selected.contains(&value)))
515}
516
517/// An `<input type="checkbox" name="…" value="…">`, with `checked` when asked.
518///
519/// # Examples
520/// ```
521/// use molt::prelude::*;
522/// assert_eq!(
523///     checkbox_named("terms", "yes", true).render(),
524///     r#"<input type="checkbox" name="terms" value="yes" checked>"#
525/// );
526/// ```
527#[must_use]
528pub fn checkbox_named(name: impl AsRef<str>, value: impl AsRef<str>, checked: bool) -> Element {
529    checkable("checkbox", name, value, checked)
530}
531
532/// An `<input type="radio" name="…" value="…">`, with `checked` when asked.
533///
534/// # Examples
535/// ```
536/// use molt::prelude::*;
537/// assert_eq!(
538///     radio_named("plan", "pro", false).render(),
539///     r#"<input type="radio" name="plan" value="pro">"#
540/// );
541/// ```
542#[must_use]
543pub fn radio_named(name: impl AsRef<str>, value: impl AsRef<str>, checked: bool) -> Element {
544    checkable("radio", name, value, checked)
545}
546
547/// Checkbox and radio differ only in `type`; `checked` is a boolean attribute, so it is either
548/// present as a bare key or absent, never `checked="false"`.
549fn checkable(kind: &str, name: impl AsRef<str>, value: impl AsRef<str>, checked: bool) -> Element {
550    let input = input_named(kind, name).attr("value", value);
551    if checked {
552        input.bool_attr("checked")
553    } else {
554        input
555    }
556}
557
558/// The options of a select, with `is_selected` deciding which carry `selected`. One
559/// `<option>` per pair, in the iterator's order, values and labels escaped on entry.
560fn options_with<K: AsRef<str>, V: AsRef<str>>(
561    options: impl IntoIterator<Item = (K, V)>,
562    is_selected: impl Fn(&str) -> bool,
563) -> Vec<Element> {
564    options
565        .into_iter()
566        .map(|(value, label)| {
567            let option = option_valued(value.as_ref()).text(label);
568            if is_selected(value.as_ref()) {
569                option.bool_attr("selected")
570            } else {
571                option
572            }
573        })
574        .collect()
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::core::{Render, RenderOptions};
581
582    #[test]
583    fn the_catalog_covers_every_generated_tag() {
584        assert!(
585            ALL_TAGS.len() >= 90,
586            "expected ~93 tags, found {}",
587            ALL_TAGS.len()
588        );
589    }
590
591    /// A catalog pattern that rejects digits would silently drop `h1`–`h6`. Guard against that
592    /// gap.
593    #[test]
594    fn the_catalog_includes_the_headings() {
595        for heading in ["h1", "h2", "h3", "h4", "h5", "h6"] {
596            assert!(
597                ALL_TAGS.iter().any(|(_, tag)| *tag == heading),
598                "{heading} missing from ALL_TAGS"
599            );
600        }
601    }
602
603    #[test]
604    fn no_tag_is_listed_twice() {
605        let mut tags: Vec<&str> = ALL_TAGS.iter().map(|(_, tag)| *tag).collect();
606        tags.sort_unstable();
607        let before = tags.len();
608        tags.dedup();
609        assert_eq!(before, tags.len(), "duplicate tag in ALL_TAGS");
610    }
611
612    /// `var` needs no suffix in Rust; `<main>` does, because `fn main` shadows it.
613    #[test]
614    fn the_renamed_tags_still_emit_their_html_names() {
615        assert_eq!(main_tag().render(), "<main></main>");
616        assert_eq!(var().render(), "<var></var>");
617    }
618
619    #[test]
620    fn typed_constructors_set_their_attributes() {
621        assert_eq!(link_to("/x").render(), r#"<a href="/x"></a>"#);
622        assert_eq!(
623            image("/a.png", "A cat").render(),
624            r#"<img src="/a.png" alt="A cat">"#
625        );
626        assert_eq!(
627            label_for("email").text("E-mail").render(),
628            r#"<label for="email">E-mail</label>"#
629        );
630        assert_eq!(
631            stylesheet("/s.css").render(),
632            r#"<link href="/s.css" rel="stylesheet">"#
633        );
634    }
635
636    #[test]
637    fn an_iframe_built_through_the_typed_constructor_always_has_a_title() {
638        let rendered = iframe_titled("/embed", "A map").render();
639        assert!(rendered.contains(r#"title="A map""#));
640        assert!(rendered.contains(r#"loading="lazy""#));
641    }
642
643    // Media, progress and column shapes go through the generic builder, so each test asserts
644    // the rendered markup rather than a typed initialiser.
645
646    #[test]
647    fn a_table_renders_every_section_in_order() {
648        let table = table()
649            .child(caption().text("Sales"))
650            .child(colgroup().child(col().attr("span", "2")))
651            .child(thead().child(tr().child(th().text("Month")).child(th().text("Total"))))
652            .child(tbody().child(tr().child(td().text("Jan")).child(td().text("10"))))
653            .child(tfoot().child(tr().child(td().text("Sum")).child(td().text("10"))));
654
655        assert_eq!(
656            table.render(),
657            concat!(
658                "<table><caption>Sales</caption>",
659                r#"<colgroup><col span="2"></colgroup>"#,
660                "<thead><tr><th>Month</th><th>Total</th></tr></thead>",
661                "<tbody><tr><td>Jan</td><td>10</td></tr></tbody>",
662                "<tfoot><tr><td>Sum</td><td>10</td></tr></tfoot></table>",
663            )
664        );
665    }
666
667    #[test]
668    fn void_elements_render_without_a_closing_tag() {
669        assert_eq!(col().render(), "<col>");
670        assert_eq!(wbr().render(), "<wbr>");
671        assert_eq!(
672            base().attr("href", "https://example.com/").render(),
673            r#"<base href="https://example.com/">"#
674        );
675        assert_eq!(
676            source()
677                .attr("srcset", "a.webp")
678                .attr("type", "image/webp")
679                .render(),
680            r#"<source srcset="a.webp" type="image/webp">"#
681        );
682        assert_eq!(
683            track()
684                .attr("src", "cc.vtt")
685                .attr("kind", "subtitles")
686                .attr("srclang", "en")
687                .bool_attr("default")
688                .render(),
689            r#"<track src="cc.vtt" kind="subtitles" srclang="en" default>"#
690        );
691    }
692
693    #[test]
694    fn void_elements_honour_xhtml_mode() {
695        let options = RenderOptions::compact().with_xhtml_self_closing(true);
696
697        assert_eq!(col().render_with(&options), "<col />");
698        assert_eq!(wbr().render_with(&options), "<wbr />");
699    }
700
701    #[test]
702    fn a_picture_wraps_its_sources_and_fallback_image() {
703        let picture = picture()
704            .child(
705                source()
706                    .attr("srcset", "/img/hero.avif")
707                    .attr("type", "image/avif"),
708            )
709            .child(image("/img/hero.jpg", "Hero"));
710
711        assert_eq!(
712            picture.render(),
713            concat!(
714                r#"<picture><source srcset="/img/hero.avif" type="image/avif">"#,
715                r#"<img src="/img/hero.jpg" alt="Hero"></picture>"#,
716            )
717        );
718    }
719
720    #[test]
721    fn video_boolean_attributes_render_bare() {
722        let rendered = video()
723            .attr("src", "/demo.mp4")
724            .attr("poster", "/poster.jpg")
725            .bool_attr("controls")
726            .bool_attr("muted")
727            .render();
728
729        assert_eq!(
730            rendered,
731            r#"<video src="/demo.mp4" poster="/poster.jpg" controls muted></video>"#
732        );
733        assert!(!rendered.contains("autoplay"));
734    }
735
736    #[test]
737    fn audio_without_controls_has_no_boolean_attribute() {
738        assert_eq!(
739            audio().attr("src", "/song.mp3").render(),
740            r#"<audio src="/song.mp3"></audio>"#
741        );
742    }
743
744    #[test]
745    fn an_iframe_carries_its_title_and_lazy_loading() {
746        assert_eq!(
747            iframe_titled("https://example.com", "Example").render(),
748            r#"<iframe src="https://example.com" title="Example" loading="lazy"></iframe>"#
749        );
750    }
751
752    #[test]
753    fn details_renders_open_with_a_summary() {
754        let details = details()
755            .bool_attr("open")
756            .child(summary().text("More"))
757            .child(p().text("Hidden text"));
758
759        assert_eq!(
760            details.render(),
761            "<details open><summary>More</summary><p>Hidden text</p></details>"
762        );
763    }
764
765    #[test]
766    fn details_is_closed_unless_open_is_set() {
767        assert_eq!(
768            details().child(summary().text("More")).render(),
769            "<details><summary>More</summary></details>"
770        );
771    }
772
773    #[test]
774    fn a_definition_list_pairs_terms_with_descriptions() {
775        let list = dl()
776            .child(dt().text("Molt"))
777            .child(dd().text("An HTML library for Rust"));
778
779        assert_eq!(
780            list.render(),
781            "<dl><dt>Molt</dt><dd>An HTML library for Rust</dd></dl>"
782        );
783    }
784
785    #[test]
786    fn the_text_semantic_tags_render_their_own_names() {
787        assert_eq!(
788            blockquote().text("Quoted").render(),
789            "<blockquote>Quoted</blockquote>"
790        );
791        assert_eq!(
792            q().attr("cite", "https://example.com")
793                .text("Short")
794                .render(),
795            r#"<q cite="https://example.com">Short</q>"#
796        );
797        assert_eq!(cite().text("Moby Dick").render(), "<cite>Moby Dick</cite>");
798        assert_eq!(
799            abbr()
800                .attr("title", "HyperText Markup Language")
801                .text("HTML")
802                .render(),
803            r#"<abbr title="HyperText Markup Language">HTML</abbr>"#
804        );
805        assert_eq!(address().text("Rua 1").render(), "<address>Rua 1</address>");
806        assert_eq!(sub().text("2").render(), "<sub>2</sub>");
807        assert_eq!(sup().text("2").render(), "<sup>2</sup>");
808        assert_eq!(del().text("old").render(), "<del>old</del>");
809        assert_eq!(ins().text("new").render(), "<ins>new</ins>");
810        assert_eq!(kbd().text("\u{2318}S").render(), "<kbd>\u{2318}S</kbd>");
811        assert_eq!(samp().text("ok").render(), "<samp>ok</samp>");
812        assert_eq!(var().text("x").render(), "<var>x</var>");
813        assert_eq!(dialog().text("Hi").render(), "<dialog>Hi</dialog>");
814        assert_eq!(
815            noscript().text("Enable JS").render(),
816            "<noscript>Enable JS</noscript>"
817        );
818        assert_eq!(
819            canvas().attr("width", "300").render(),
820            r#"<canvas width="300"></canvas>"#
821        );
822    }
823
824    /// `text()` escapes everything, `<style>` included, so CSS goes in through `raw_text`,
825    /// which is the documented escape hatch and greppable on purpose. Escaping here would turn a child
826    /// selector into `a &gt; b` and break the sheet.
827    #[test]
828    fn a_stylesheet_is_not_escaped() {
829        assert_eq!(
830            style()
831                .attr("media", "screen")
832                .raw_text("a > b { color: red; }")
833                .render(),
834            r#"<style media="screen">a > b { color: red; }</style>"#
835        );
836    }
837
838    /// There is nothing to omit: an attribute exists when you call `attr` for it.
839    #[test]
840    fn media_elements_render_only_the_attributes_they_are_given() {
841        assert_eq!(
842            source()
843                .attr("src", "a.mp4")
844                .attr("media", "(min-width: 40em)")
845                .render(),
846            r#"<source src="a.mp4" media="(min-width: 40em)">"#
847        );
848        assert_eq!(
849            track()
850                .attr("src", "t.vtt")
851                .attr("kind", "captions")
852                .attr("label", "PT")
853                .render(),
854            r#"<track src="t.vtt" kind="captions" label="PT">"#
855        );
856        assert_eq!(
857            video().bool_attr("controls").render(),
858            "<video controls></video>"
859        );
860        assert_eq!(
861            audio().bool_attr("autoplay").bool_attr("loop").render(),
862            "<audio autoplay loop></audio>"
863        );
864        assert_eq!(
865            iframe()
866                .attr("src", "/e")
867                .attr("title", "E")
868                .bool_attr("allowfullscreen")
869                .render(),
870            r#"<iframe src="/e" title="E" allowfullscreen></iframe>"#
871        );
872        assert_eq!(
873            base().attr("href", "/").attr("target", "_blank").render(),
874            r#"<base href="/" target="_blank">"#
875        );
876        assert_eq!(col().render(), "<col>");
877        assert_eq!(q().text("quoted").render(), "<q>quoted</q>");
878    }
879
880    #[test]
881    fn meta_renders_whichever_attributes_it_is_given() {
882        assert_eq!(
883            meta()
884                .attr("http-equiv", "refresh")
885                .attr("content", "5")
886                .render(),
887            r#"<meta http-equiv="refresh" content="5">"#
888        );
889        assert_eq!(
890            meta().attr("itemprop", "name").render(),
891            r#"<meta itemprop="name">"#
892        );
893    }
894
895    #[test]
896    fn flow_containers_accept_text_as_well_as_children() {
897        assert_eq!(aside().text("Note").render(), "<aside>Note</aside>");
898        assert_eq!(nav().text("Menu").render(), "<nav>Menu</nav>");
899        assert_eq!(header().text("Top").render(), "<header>Top</header>");
900        assert_eq!(footer().text("Bottom").render(), "<footer>Bottom</footer>");
901        assert_eq!(main_tag().text("Body").render(), "<main>Body</main>");
902        assert_eq!(article().text("Post").render(), "<article>Post</article>");
903        assert_eq!(figure().text("Fig").render(), "<figure>Fig</figure>");
904        assert_eq!(form().text("F").render(), "<form>F</form>");
905        assert_eq!(fieldset().text("Set").render(), "<fieldset>Set</fieldset>");
906    }
907
908    #[test]
909    fn flow_container_content_is_escaped() {
910        assert_eq!(
911            aside().text("<b>x</b>").render(),
912            "<aside>&lt;b&gt;x&lt;/b&gt;</aside>"
913        );
914    }
915
916    #[test]
917    fn a_fieldset_carries_a_legend_and_its_fields() {
918        let fieldset = fieldset()
919            .child(legend().text("Account"))
920            .child(input_named("text", "email"));
921
922        assert_eq!(
923            fieldset.render(),
924            r#"<fieldset><legend>Account</legend><input type="text" name="email"></fieldset>"#
925        );
926    }
927
928    #[test]
929    fn a_select_groups_its_options() {
930        let select = select().attr("name", "city").child(
931            optgroup()
932                .attr("label", "Brazil")
933                .child(option().attr("value", "sp").text("S\u{e3}o Paulo")),
934        );
935
936        assert_eq!(
937            select.render(),
938            concat!(
939                r#"<select name="city"><optgroup label="Brazil">"#,
940                "<option value=\"sp\">S\u{e3}o Paulo</option></optgroup></select>",
941            )
942        );
943    }
944
945    #[test]
946    fn the_form_display_elements_render() {
947        assert_eq!(
948            datalist().child(option().attr("value", "rust")).render(),
949            r#"<datalist><option value="rust"></option></datalist>"#
950        );
951        assert_eq!(
952            progress().attr("value", "0.7").attr("max", "1.0").render(),
953            r#"<progress value="0.7" max="1.0"></progress>"#
954        );
955        assert_eq!(
956            progress().attr("max", "1.0").render(),
957            r#"<progress max="1.0"></progress>"#
958        );
959        assert_eq!(
960            meter()
961                .attr("value", "6.0")
962                .attr("min", "0.0")
963                .attr("max", "10.0")
964                .render(),
965            r#"<meter value="6.0" min="0.0" max="10.0"></meter>"#
966        );
967        assert_eq!(output().text("42").render(), "<output>42</output>");
968    }
969
970    #[test]
971    fn boolean_attributes_carry_no_value() {
972        let rendered = input_named("checkbox", "agree")
973            .bool_attr("checked")
974            .bool_attr("required")
975            .render();
976
977        assert_eq!(
978            rendered,
979            r#"<input type="checkbox" name="agree" checked required>"#
980        );
981        assert!(!rendered.contains("checked="));
982        assert!(!rendered.contains("required="));
983    }
984
985    #[test]
986    fn void_elements_do_not_self_close_by_default() {
987        let rendered = image("a.png", "A").render();
988
989        assert_eq!(rendered, r#"<img src="a.png" alt="A">"#);
990        assert!(!rendered.contains("/>"));
991    }
992
993    #[test]
994    fn xhtml_mode_self_closes_void_elements() {
995        let options = RenderOptions::compact().with_xhtml_self_closing(true);
996
997        assert_eq!(
998            image("a.png", "A").render_with(&options),
999            r#"<img src="a.png" alt="A" />"#
1000        );
1001    }
1002
1003    #[test]
1004    fn anchors_and_headings_take_element_children() {
1005        assert_eq!(
1006            link_to("/news").child(image("thumb.jpg", "Thumb")).render(),
1007            r#"<a href="/news"><img src="thumb.jpg" alt="Thumb"></a>"#
1008        );
1009        assert_eq!(
1010            h3().child(link_to("/news").text("Headline")).render(),
1011            r#"<h3><a href="/news">Headline</a></h3>"#
1012        );
1013        assert_eq!(
1014            i().add_class("fas fa-search").render(),
1015            r#"<i class="fas fa-search"></i>"#
1016        );
1017    }
1018
1019    #[test]
1020    fn a_submit_button_carries_its_type() {
1021        assert_eq!(
1022            button_typed("submit").text("Send").render(),
1023            r#"<button type="submit">Send</button>"#
1024        );
1025    }
1026
1027    #[test]
1028    fn a_label_without_a_target_has_no_for_attribute() {
1029        let rendered = label().text("Accept cookies").render();
1030
1031        assert_eq!(rendered, "<label>Accept cookies</label>");
1032        assert!(!rendered.contains("for="));
1033    }
1034
1035    #[test]
1036    fn an_input_without_a_name_has_no_name_attribute() {
1037        let rendered = input()
1038            .attr("type", "search")
1039            .attr("placeholder", "Search")
1040            .render();
1041
1042        assert_eq!(rendered, r#"<input type="search" placeholder="Search">"#);
1043        assert!(!rendered.contains("name="));
1044    }
1045
1046    #[test]
1047    fn a_section_renders_its_class_and_text() {
1048        assert_eq!(
1049            section().add_class("hero").text("Hello").render(),
1050            r#"<section class="hero">Hello</section>"#
1051        );
1052    }
1053
1054    #[test]
1055    fn the_inline_semantic_tags_render() {
1056        assert_eq!(strong().text("bold").render(), "<strong>bold</strong>");
1057        assert_eq!(em().text("emph").render(), "<em>emph</em>");
1058        assert_eq!(small().text("fine").render(), "<small>fine</small>");
1059        assert_eq!(br().render(), "<br>");
1060        assert_eq!(hr().render(), "<hr>");
1061    }
1062
1063    #[test]
1064    fn a_pre_block_keeps_its_line_breaks() {
1065        let block =
1066            pre().text("This is preformatted text.\nIt preserves whitespace and line breaks.");
1067
1068        assert_eq!(
1069            block.render(),
1070            "<pre>This is preformatted text.\nIt preserves whitespace and line breaks.</pre>"
1071        );
1072    }
1073
1074    #[test]
1075    fn a_code_block_keeps_its_line_breaks() {
1076        let block = code().text("let x = 10\nprint(x)");
1077
1078        assert_eq!(block.render(), "<code>let x = 10\nprint(x)</code>");
1079    }
1080
1081    #[test]
1082    fn an_embed_is_a_void_element() {
1083        assert_eq!(
1084            embed()
1085                .attr("src", "video.mp4")
1086                .attr("type", "video/mp4")
1087                .render(),
1088            r#"<embed src="video.mp4" type="video/mp4">"#
1089        );
1090    }
1091
1092    #[test]
1093    fn an_article_wraps_its_children() {
1094        assert_eq!(
1095            article().child(h1().text("Title")).render(),
1096            "<article><h1>Title</h1></article>"
1097        );
1098    }
1099
1100    #[test]
1101    fn an_aside_wraps_its_children() {
1102        assert_eq!(
1103            aside().child(p().text("Sidebar")).render(),
1104            "<aside><p>Sidebar</p></aside>"
1105        );
1106    }
1107
1108    #[test]
1109    fn a_figure_pairs_an_image_with_its_caption() {
1110        let block = figure()
1111            .child(image("image.jpg", "Test"))
1112            .child(figcaption().text("Image caption"));
1113
1114        assert_eq!(
1115            block.render(),
1116            concat!(
1117                r#"<figure><img src="image.jpg" alt="Test">"#,
1118                "<figcaption>Image caption</figcaption></figure>",
1119            )
1120        );
1121    }
1122
1123    #[test]
1124    fn a_time_element_carries_its_datetime() {
1125        assert_eq!(
1126            time()
1127                .attr("datetime", "2024-01-15")
1128                .text("January 15, 2024")
1129                .render(),
1130            r#"<time datetime="2024-01-15">January 15, 2024</time>"#
1131        );
1132    }
1133
1134    #[test]
1135    fn a_mark_element_renders_its_text() {
1136        assert_eq!(
1137            mark().text("highlighted").render(),
1138            "<mark>highlighted</mark>"
1139        );
1140    }
1141
1142    #[test]
1143    fn every_heading_level_renders_its_own_tag() {
1144        let headings = [
1145            h1().text("H1"),
1146            h2().text("H2"),
1147            h3().text("H3"),
1148            h4().text("H4"),
1149            h5().text("H5"),
1150            h6().text("H6"),
1151        ];
1152
1153        for (index, heading) in headings.into_iter().enumerate() {
1154            let level = index + 1;
1155            assert_eq!(heading.render(), format!("<h{level}>H{level}</h{level}>"));
1156        }
1157    }
1158
1159    #[test]
1160    fn a_form_renders_its_fieldsets_and_submit() {
1161        let form = form()
1162            .attr("action", "/submit")
1163            .child(
1164                fieldset()
1165                    .child(label_for("name").text("Name"))
1166                    .child(input_named("text", "name")),
1167            )
1168            .child(
1169                fieldset()
1170                    .child(label_for("message").text("Message"))
1171                    .child(textarea().attr("name", "message")),
1172            )
1173            .child(input_named("submit", "submit").attr("value", "Send"));
1174
1175        assert_eq!(
1176            form.render(),
1177            concat!(
1178                r#"<form action="/submit"><fieldset><label for="name">Name</label>"#,
1179                r#"<input type="text" name="name"></fieldset><fieldset>"#,
1180                r#"<label for="message">Message</label>"#,
1181                r#"<textarea name="message"></textarea></fieldset>"#,
1182                r#"<input type="submit" name="submit" value="Send"></form>"#,
1183            )
1184        );
1185    }
1186
1187    #[test]
1188    fn a_select_renders_each_option() {
1189        let select = select()
1190            .attr("name", "options")
1191            .children_from((1..=3).map(|i| {
1192                option()
1193                    .attr("value", i.to_string())
1194                    .text(format!("Option {i}"))
1195            }));
1196
1197        assert_eq!(
1198            select.render(),
1199            concat!(
1200                r#"<select name="options"><option value="1">Option 1</option>"#,
1201                r#"<option value="2">Option 2</option>"#,
1202                r#"<option value="3">Option 3</option></select>"#,
1203            )
1204        );
1205    }
1206
1207    #[test]
1208    fn a_label_points_at_its_field() {
1209        assert_eq!(
1210            label_for("username").text("Username").render(),
1211            r#"<label for="username">Username</label>"#
1212        );
1213    }
1214
1215    #[test]
1216    fn an_input_renders_its_type_name_and_value() {
1217        assert_eq!(
1218            input_named("text", "username")
1219                .attr("value", "JohnDoe")
1220                .render(),
1221            r#"<input type="text" name="username" value="JohnDoe">"#
1222        );
1223    }
1224
1225    #[test]
1226    fn a_textarea_renders_its_content_between_the_tags() {
1227        assert_eq!(
1228            textarea()
1229                .attr("name", "message")
1230                .text("Hello, World!")
1231                .render(),
1232            r#"<textarea name="message">Hello, World!</textarea>"#
1233        );
1234    }
1235
1236    #[test]
1237    fn a_fieldset_groups_a_label_and_its_field() {
1238        let block = fieldset()
1239            .child(label_for("name").text("Name"))
1240            .child(input_named("text", "name"));
1241
1242        assert_eq!(
1243            block.render(),
1244            concat!(
1245                r#"<fieldset><label for="name">Name</label>"#,
1246                r#"<input type="text" name="name"></fieldset>"#,
1247            )
1248        );
1249    }
1250
1251    #[test]
1252    fn a_section_wraps_its_children() {
1253        assert_eq!(
1254            section().child(p().text("This is a section.")).render(),
1255            "<section><p>This is a section.</p></section>"
1256        );
1257    }
1258
1259    // MARK: - Form helpers (#94)
1260
1261    #[test]
1262    fn select_named_renders_its_name() {
1263        assert_eq!(
1264            select_named("country").render(),
1265            r#"<select name="country"></select>"#
1266        );
1267    }
1268
1269    #[test]
1270    fn options_from_renders_each_value_and_label_pair() {
1271        let select = select_named("country")
1272            .children_from(options_from([("br", "Brasil"), ("ar", "Argentina")], None));
1273
1274        assert_eq!(
1275            select.render(),
1276            concat!(
1277                r#"<select name="country"><option value="br">Brasil</option>"#,
1278                r#"<option value="ar">Argentina</option></select>"#,
1279            )
1280        );
1281    }
1282
1283    #[test]
1284    fn options_from_marks_the_selected_value() {
1285        let select = select_named("country").children_from(options_from(
1286            [("br", "Brasil"), ("ar", "Argentina")],
1287            Some("ar"),
1288        ));
1289
1290        assert_eq!(
1291            select.render(),
1292            concat!(
1293                r#"<select name="country"><option value="br">Brasil</option>"#,
1294                r#"<option value="ar" selected>Argentina</option></select>"#,
1295            )
1296        );
1297    }
1298
1299    #[test]
1300    fn options_from_accepts_owned_pairs_such_as_database_rows() {
1301        let rows: Vec<(String, String)> = vec![
1302            ("sp".to_string(), "S\u{e3}o Paulo".to_string()),
1303            ("rj".to_string(), "Rio".to_string()),
1304        ];
1305        let select = select_named("city")
1306            .children_from(options_from(rows.iter().map(|r| (&r.0, &r.1)), None));
1307
1308        assert_eq!(
1309            select.render(),
1310            concat!(
1311                "<select name=\"city\"><option value=\"sp\">S\u{e3}o Paulo</option>",
1312                r#"<option value="rj">Rio</option></select>"#,
1313            )
1314        );
1315    }
1316
1317    #[test]
1318    fn options_from_escapes_values_and_labels() {
1319        let select = select_named("q").children_from(options_from([("a\"b", "a&b")], None));
1320
1321        assert_eq!(
1322            select.render(),
1323            r#"<select name="q"><option value="a&quot;b">a&amp;b</option></select>"#
1324        );
1325    }
1326
1327    #[test]
1328    fn select_from_builds_the_select_with_its_options() {
1329        assert_eq!(
1330            select_from(
1331                "country",
1332                [("br", "Brasil"), ("ar", "Argentina")],
1333                Some("br")
1334            )
1335            .render(),
1336            concat!(
1337                r#"<select name="country"><option value="br" selected>Brasil</option>"#,
1338                r#"<option value="ar">Argentina</option></select>"#,
1339            )
1340        );
1341    }
1342
1343    #[test]
1344    fn option_valued_renders_its_value() {
1345        assert_eq!(
1346            option_valued("br").text("Brasil").render(),
1347            r#"<option value="br">Brasil</option>"#
1348        );
1349    }
1350
1351    #[test]
1352    fn optgroup_labeled_groups_its_options() {
1353        let select = select_named("city").child(
1354            optgroup_labeled("Sul").children_from(options_from([("poa", "Porto Alegre")], None)),
1355        );
1356
1357        assert_eq!(
1358            select.render(),
1359            concat!(
1360                r#"<select name="city"><optgroup label="Sul">"#,
1361                r#"<option value="poa">Porto Alegre</option></optgroup></select>"#,
1362            )
1363        );
1364    }
1365
1366    #[test]
1367    fn textarea_named_escapes_its_value() {
1368        assert_eq!(
1369            textarea_named("bio").text("</textarea><b>").render(),
1370            r#"<textarea name="bio">&lt;/textarea&gt;&lt;b&gt;</textarea>"#
1371        );
1372    }
1373
1374    #[test]
1375    fn hidden_input_renders_its_name_and_escaped_value() {
1376        assert_eq!(
1377            hidden_input("_csrf", "t0k\"en&").render(),
1378            r#"<input type="hidden" name="_csrf" value="t0k&quot;en&amp;">"#
1379        );
1380    }
1381
1382    #[test]
1383    fn fieldset_legend_puts_the_legend_before_the_fields() {
1384        let fieldset = fieldset_legend("Endere\u{e7}o").child(input_named("text", "street"));
1385
1386        assert_eq!(
1387            fieldset.render(),
1388            concat!(
1389                "<fieldset><legend>Endere\u{e7}o</legend>",
1390                r#"<input type="text" name="street"></fieldset>"#,
1391            )
1392        );
1393    }
1394
1395    #[test]
1396    fn checkbox_named_renders_checked_when_asked() {
1397        assert_eq!(
1398            checkbox_named("terms", "yes", true).render(),
1399            r#"<input type="checkbox" name="terms" value="yes" checked>"#
1400        );
1401    }
1402
1403    #[test]
1404    fn checkbox_named_omits_checked_when_not_asked() {
1405        assert_eq!(
1406            checkbox_named("terms", "yes", false).render(),
1407            r#"<input type="checkbox" name="terms" value="yes">"#
1408        );
1409    }
1410
1411    #[test]
1412    fn radio_named_renders_checked_when_asked() {
1413        assert_eq!(
1414            radio_named("plan", "pro", true).render(),
1415            r#"<input type="radio" name="plan" value="pro" checked>"#
1416        );
1417    }
1418
1419    #[test]
1420    fn radio_named_omits_checked_when_not_asked() {
1421        assert_eq!(
1422            radio_named("plan", "free", false).render(),
1423            r#"<input type="radio" name="plan" value="free">"#
1424        );
1425    }
1426
1427    #[test]
1428    fn select_multiple_from_marks_every_selected_value() {
1429        let tags = [("a", "A"), ("b", "B"), ("c", "C")];
1430
1431        assert_eq!(
1432            select_multiple_from("tags", tags, &["a", "c"]).render(),
1433            concat!(
1434                r#"<select name="tags" multiple><option value="a" selected>A</option>"#,
1435                r#"<option value="b">B</option><option value="c" selected>C</option></select>"#,
1436            )
1437        );
1438    }
1439
1440    #[test]
1441    fn select_multiple_from_with_nothing_selected_marks_no_option() {
1442        assert_eq!(
1443            select_multiple_from("tags", [("a", "A")], &[]).render(),
1444            r#"<select name="tags" multiple><option value="a">A</option></select>"#
1445        );
1446    }
1447}