Skip to main content

winged_rust/elements/
mod.rs

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