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