1use crate::core::Element;
8
9macro_rules! define_elements {
11 ($($(#[$meta:meta])* $name:ident => $tag:literal),* $(,)?) => {
12 $(
13 $(#[$meta])*
14 #[doc = concat!("let el = ", stringify!($name), "();")]
19 #[doc = concat!("assert_eq!(el.tag(), ", stringify!($tag), ");")]
20 #[must_use]
22 pub fn $name() -> Element {
23 Element::from_static($tag)
24 }
25 )*
26
27 pub const ALL_TAGS: &[(&str, &str)] = &[$((stringify!($name), $tag)),*];
31 };
32}
33
34define_elements! {
35 abbr => "abbr",
38 cite => "cite",
40 del => "del",
42 em => "em",
44 i => "i",
46 ins => "ins",
48 kbd => "kbd",
50 mark => "mark",
52 q => "q",
54 samp => "samp",
56 small => "small",
58 span => "span",
60 strong => "strong",
62 sub => "sub",
64 sup => "sup",
66 time => "time",
68 var => "var",
70 wbr => "wbr",
72
73 br => "br",
76 hr => "hr",
78 img => "img",
80 link => "link",
82 base => "base",
84 meta => "meta",
86 title => "title",
88 script => "script",
91 style => "style",
93 embed => "embed",
95
96 h1 => "h1",
99 h2 => "h2",
101 h3 => "h3",
103 h4 => "h4",
105 h5 => "h5",
107 h6 => "h6",
109
110 address => "address",
113 article => "article",
115 aside => "aside",
117 blockquote => "blockquote",
119 body => "body",
121 canvas => "canvas",
123 dialog => "dialog",
125 div => "div",
127 figcaption => "figcaption",
129 figure => "figure",
131 footer => "footer",
133 head => "head",
135 header => "header",
137 main_tag => "main",
143 nav => "nav",
145 noscript => "noscript",
147 p => "p",
149 picture => "picture",
151 section => "section",
153 html_tag => "html",
155
156 ul => "ul",
159 ol => "ol",
161 li => "li",
163 dl => "dl",
165 dt => "dt",
167 dd => "dd",
169
170 a => "a",
173 button => "button",
175 details => "details",
177 summary => "summary",
179
180 table => "table",
183 tr => "tr",
185 td => "td",
187 th => "th",
189 caption => "caption",
191 colgroup => "colgroup",
193 col => "col",
195 tbody => "tbody",
197 tfoot => "tfoot",
199 thead => "thead",
201
202 form => "form",
205 input => "input",
207 textarea => "textarea",
209 label => "label",
211 fieldset => "fieldset",
213 legend => "legend",
215 select => "select",
217 option => "option",
219 optgroup => "optgroup",
221 datalist => "datalist",
223 output => "output",
225 meter => "meter",
227 progress => "progress",
229
230 audio => "audio",
233 video => "video",
235 source => "source",
237 track => "track",
239 iframe => "iframe",
242
243 code => "code",
246 pre => "pre",
248}
249
250#[must_use]
263pub fn link_to(href: impl AsRef<str>) -> Element {
264 a().attr("href", href)
265}
266
267#[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#[must_use]
279pub fn script_src(src: impl AsRef<str>) -> Element {
280 script().attr("src", src)
281}
282
283#[must_use]
285pub fn stylesheet(href: impl AsRef<str>) -> Element {
286 link().attr("href", href).attr("rel", "stylesheet")
287}
288
289#[must_use]
291pub fn button_typed(button_type: impl AsRef<str>) -> Element {
292 button().attr("type", button_type)
293}
294
295#[must_use]
297pub fn input_named(input_type: impl AsRef<str>, name: impl AsRef<str>) -> Element {
298 input().attr("type", input_type).attr("name", name)
299}
300
301#[must_use]
305pub fn label_for(for_id: impl AsRef<str>) -> Element {
306 label().attr("for", for_id)
307}
308
309#[must_use]
314pub fn iframe_titled(src: impl AsRef<str>, title_text: impl AsRef<str>) -> Element {
315 iframe()
316 .attr("src", src)
317 .attr("title", title_text)
318 .attr("loading", "lazy")
319}
320
321#[must_use]
348pub fn select_named(name: impl AsRef<str>) -> Element {
349 select().attr("name", name)
350}
351
352#[must_use]
369pub fn select_from<K: AsRef<str>, V: AsRef<str>>(
370 name: impl AsRef<str>,
371 options: impl IntoIterator<Item = (K, V)>,
372 selected: Option<&str>,
373) -> Element {
374 select_named(name).children_from(options_from(options, selected))
375}
376
377#[must_use]
396pub fn options_from<K: AsRef<str>, V: AsRef<str>>(
397 options: impl IntoIterator<Item = (K, V)>,
398 selected: Option<&str>,
399) -> Vec<Element> {
400 options_with(options, |value| selected == Some(value))
401}
402
403#[must_use]
414pub fn option_valued(value: impl AsRef<str>) -> Element {
415 option().attr("value", value)
416}
417
418#[must_use]
432pub fn optgroup_labeled(label_text: impl AsRef<str>) -> Element {
433 optgroup().attr("label", label_text)
434}
435
436#[must_use]
450pub fn textarea_named(name: impl AsRef<str>) -> Element {
451 textarea().attr("name", name)
452}
453
454#[must_use]
468pub fn hidden_input(name: impl AsRef<str>, value: impl AsRef<str>) -> Element {
469 input_named("hidden", name).attr("value", value)
470}
471
472#[must_use]
484pub fn fieldset_legend(legend_text: impl AsRef<str>) -> Element {
485 fieldset().child(legend().text(legend_text))
486}
487
488#[must_use]
507pub fn select_multiple_from<K: AsRef<str>, V: AsRef<str>>(
508 name: impl AsRef<str>,
509 options: impl IntoIterator<Item = (K, V)>,
510 selected: &[&str],
511) -> Element {
512 select_named(name)
513 .bool_attr("multiple")
514 .children_from(options_with(options, |value| selected.contains(&value)))
515}
516
517#[must_use]
528pub fn checkbox_named(name: impl AsRef<str>, value: impl AsRef<str>, checked: bool) -> Element {
529 checkable("checkbox", name, value, checked)
530}
531
532#[must_use]
543pub fn radio_named(name: impl AsRef<str>, value: impl AsRef<str>, checked: bool) -> Element {
544 checkable("radio", name, value, checked)
545}
546
547fn checkable(kind: &str, name: impl AsRef<str>, value: impl AsRef<str>, checked: bool) -> Element {
550 let input = input_named(kind, name).attr("value", value);
551 if checked {
552 input.bool_attr("checked")
553 } else {
554 input
555 }
556}
557
558fn options_with<K: AsRef<str>, V: AsRef<str>>(
561 options: impl IntoIterator<Item = (K, V)>,
562 is_selected: impl Fn(&str) -> bool,
563) -> Vec<Element> {
564 options
565 .into_iter()
566 .map(|(value, label)| {
567 let option = option_valued(value.as_ref()).text(label);
568 if is_selected(value.as_ref()) {
569 option.bool_attr("selected")
570 } else {
571 option
572 }
573 })
574 .collect()
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::core::{Render, RenderOptions};
581
582 #[test]
583 fn the_catalog_covers_every_generated_tag() {
584 assert!(
585 ALL_TAGS.len() >= 90,
586 "expected ~93 tags, found {}",
587 ALL_TAGS.len()
588 );
589 }
590
591 #[test]
594 fn the_catalog_includes_the_headings() {
595 for heading in ["h1", "h2", "h3", "h4", "h5", "h6"] {
596 assert!(
597 ALL_TAGS.iter().any(|(_, tag)| *tag == heading),
598 "{heading} missing from ALL_TAGS"
599 );
600 }
601 }
602
603 #[test]
604 fn no_tag_is_listed_twice() {
605 let mut tags: Vec<&str> = ALL_TAGS.iter().map(|(_, tag)| *tag).collect();
606 tags.sort_unstable();
607 let before = tags.len();
608 tags.dedup();
609 assert_eq!(before, tags.len(), "duplicate tag in ALL_TAGS");
610 }
611
612 #[test]
614 fn the_renamed_tags_still_emit_their_html_names() {
615 assert_eq!(main_tag().render(), "<main></main>");
616 assert_eq!(var().render(), "<var></var>");
617 }
618
619 #[test]
620 fn typed_constructors_set_their_attributes() {
621 assert_eq!(link_to("/x").render(), r#"<a href="/x"></a>"#);
622 assert_eq!(
623 image("/a.png", "A cat").render(),
624 r#"<img src="/a.png" alt="A cat">"#
625 );
626 assert_eq!(
627 label_for("email").text("E-mail").render(),
628 r#"<label for="email">E-mail</label>"#
629 );
630 assert_eq!(
631 stylesheet("/s.css").render(),
632 r#"<link href="/s.css" rel="stylesheet">"#
633 );
634 }
635
636 #[test]
637 fn an_iframe_built_through_the_typed_constructor_always_has_a_title() {
638 let rendered = iframe_titled("/embed", "A map").render();
639 assert!(rendered.contains(r#"title="A map""#));
640 assert!(rendered.contains(r#"loading="lazy""#));
641 }
642
643 #[test]
647 fn a_table_renders_every_section_in_order() {
648 let table = table()
649 .child(caption().text("Sales"))
650 .child(colgroup().child(col().attr("span", "2")))
651 .child(thead().child(tr().child(th().text("Month")).child(th().text("Total"))))
652 .child(tbody().child(tr().child(td().text("Jan")).child(td().text("10"))))
653 .child(tfoot().child(tr().child(td().text("Sum")).child(td().text("10"))));
654
655 assert_eq!(
656 table.render(),
657 concat!(
658 "<table><caption>Sales</caption>",
659 r#"<colgroup><col span="2"></colgroup>"#,
660 "<thead><tr><th>Month</th><th>Total</th></tr></thead>",
661 "<tbody><tr><td>Jan</td><td>10</td></tr></tbody>",
662 "<tfoot><tr><td>Sum</td><td>10</td></tr></tfoot></table>",
663 )
664 );
665 }
666
667 #[test]
668 fn void_elements_render_without_a_closing_tag() {
669 assert_eq!(col().render(), "<col>");
670 assert_eq!(wbr().render(), "<wbr>");
671 assert_eq!(
672 base().attr("href", "https://example.com/").render(),
673 r#"<base href="https://example.com/">"#
674 );
675 assert_eq!(
676 source()
677 .attr("srcset", "a.webp")
678 .attr("type", "image/webp")
679 .render(),
680 r#"<source srcset="a.webp" type="image/webp">"#
681 );
682 assert_eq!(
683 track()
684 .attr("src", "cc.vtt")
685 .attr("kind", "subtitles")
686 .attr("srclang", "en")
687 .bool_attr("default")
688 .render(),
689 r#"<track src="cc.vtt" kind="subtitles" srclang="en" default>"#
690 );
691 }
692
693 #[test]
694 fn void_elements_honour_xhtml_mode() {
695 let options = RenderOptions::compact().with_xhtml_self_closing(true);
696
697 assert_eq!(col().render_with(&options), "<col />");
698 assert_eq!(wbr().render_with(&options), "<wbr />");
699 }
700
701 #[test]
702 fn a_picture_wraps_its_sources_and_fallback_image() {
703 let picture = picture()
704 .child(
705 source()
706 .attr("srcset", "/img/hero.avif")
707 .attr("type", "image/avif"),
708 )
709 .child(image("/img/hero.jpg", "Hero"));
710
711 assert_eq!(
712 picture.render(),
713 concat!(
714 r#"<picture><source srcset="/img/hero.avif" type="image/avif">"#,
715 r#"<img src="/img/hero.jpg" alt="Hero"></picture>"#,
716 )
717 );
718 }
719
720 #[test]
721 fn video_boolean_attributes_render_bare() {
722 let rendered = video()
723 .attr("src", "/demo.mp4")
724 .attr("poster", "/poster.jpg")
725 .bool_attr("controls")
726 .bool_attr("muted")
727 .render();
728
729 assert_eq!(
730 rendered,
731 r#"<video src="/demo.mp4" poster="/poster.jpg" controls muted></video>"#
732 );
733 assert!(!rendered.contains("autoplay"));
734 }
735
736 #[test]
737 fn audio_without_controls_has_no_boolean_attribute() {
738 assert_eq!(
739 audio().attr("src", "/song.mp3").render(),
740 r#"<audio src="/song.mp3"></audio>"#
741 );
742 }
743
744 #[test]
745 fn an_iframe_carries_its_title_and_lazy_loading() {
746 assert_eq!(
747 iframe_titled("https://example.com", "Example").render(),
748 r#"<iframe src="https://example.com" title="Example" loading="lazy"></iframe>"#
749 );
750 }
751
752 #[test]
753 fn details_renders_open_with_a_summary() {
754 let details = details()
755 .bool_attr("open")
756 .child(summary().text("More"))
757 .child(p().text("Hidden text"));
758
759 assert_eq!(
760 details.render(),
761 "<details open><summary>More</summary><p>Hidden text</p></details>"
762 );
763 }
764
765 #[test]
766 fn details_is_closed_unless_open_is_set() {
767 assert_eq!(
768 details().child(summary().text("More")).render(),
769 "<details><summary>More</summary></details>"
770 );
771 }
772
773 #[test]
774 fn a_definition_list_pairs_terms_with_descriptions() {
775 let list = dl()
776 .child(dt().text("Molt"))
777 .child(dd().text("An HTML library for Rust"));
778
779 assert_eq!(
780 list.render(),
781 "<dl><dt>Molt</dt><dd>An HTML library for Rust</dd></dl>"
782 );
783 }
784
785 #[test]
786 fn the_text_semantic_tags_render_their_own_names() {
787 assert_eq!(
788 blockquote().text("Quoted").render(),
789 "<blockquote>Quoted</blockquote>"
790 );
791 assert_eq!(
792 q().attr("cite", "https://example.com")
793 .text("Short")
794 .render(),
795 r#"<q cite="https://example.com">Short</q>"#
796 );
797 assert_eq!(cite().text("Moby Dick").render(), "<cite>Moby Dick</cite>");
798 assert_eq!(
799 abbr()
800 .attr("title", "HyperText Markup Language")
801 .text("HTML")
802 .render(),
803 r#"<abbr title="HyperText Markup Language">HTML</abbr>"#
804 );
805 assert_eq!(address().text("Rua 1").render(), "<address>Rua 1</address>");
806 assert_eq!(sub().text("2").render(), "<sub>2</sub>");
807 assert_eq!(sup().text("2").render(), "<sup>2</sup>");
808 assert_eq!(del().text("old").render(), "<del>old</del>");
809 assert_eq!(ins().text("new").render(), "<ins>new</ins>");
810 assert_eq!(kbd().text("\u{2318}S").render(), "<kbd>\u{2318}S</kbd>");
811 assert_eq!(samp().text("ok").render(), "<samp>ok</samp>");
812 assert_eq!(var().text("x").render(), "<var>x</var>");
813 assert_eq!(dialog().text("Hi").render(), "<dialog>Hi</dialog>");
814 assert_eq!(
815 noscript().text("Enable JS").render(),
816 "<noscript>Enable JS</noscript>"
817 );
818 assert_eq!(
819 canvas().attr("width", "300").render(),
820 r#"<canvas width="300"></canvas>"#
821 );
822 }
823
824 #[test]
828 fn a_stylesheet_is_not_escaped() {
829 assert_eq!(
830 style()
831 .attr("media", "screen")
832 .raw_text("a > b { color: red; }")
833 .render(),
834 r#"<style media="screen">a > b { color: red; }</style>"#
835 );
836 }
837
838 #[test]
840 fn media_elements_render_only_the_attributes_they_are_given() {
841 assert_eq!(
842 source()
843 .attr("src", "a.mp4")
844 .attr("media", "(min-width: 40em)")
845 .render(),
846 r#"<source src="a.mp4" media="(min-width: 40em)">"#
847 );
848 assert_eq!(
849 track()
850 .attr("src", "t.vtt")
851 .attr("kind", "captions")
852 .attr("label", "PT")
853 .render(),
854 r#"<track src="t.vtt" kind="captions" label="PT">"#
855 );
856 assert_eq!(
857 video().bool_attr("controls").render(),
858 "<video controls></video>"
859 );
860 assert_eq!(
861 audio().bool_attr("autoplay").bool_attr("loop").render(),
862 "<audio autoplay loop></audio>"
863 );
864 assert_eq!(
865 iframe()
866 .attr("src", "/e")
867 .attr("title", "E")
868 .bool_attr("allowfullscreen")
869 .render(),
870 r#"<iframe src="/e" title="E" allowfullscreen></iframe>"#
871 );
872 assert_eq!(
873 base().attr("href", "/").attr("target", "_blank").render(),
874 r#"<base href="/" target="_blank">"#
875 );
876 assert_eq!(col().render(), "<col>");
877 assert_eq!(q().text("quoted").render(), "<q>quoted</q>");
878 }
879
880 #[test]
881 fn meta_renders_whichever_attributes_it_is_given() {
882 assert_eq!(
883 meta()
884 .attr("http-equiv", "refresh")
885 .attr("content", "5")
886 .render(),
887 r#"<meta http-equiv="refresh" content="5">"#
888 );
889 assert_eq!(
890 meta().attr("itemprop", "name").render(),
891 r#"<meta itemprop="name">"#
892 );
893 }
894
895 #[test]
896 fn flow_containers_accept_text_as_well_as_children() {
897 assert_eq!(aside().text("Note").render(), "<aside>Note</aside>");
898 assert_eq!(nav().text("Menu").render(), "<nav>Menu</nav>");
899 assert_eq!(header().text("Top").render(), "<header>Top</header>");
900 assert_eq!(footer().text("Bottom").render(), "<footer>Bottom</footer>");
901 assert_eq!(main_tag().text("Body").render(), "<main>Body</main>");
902 assert_eq!(article().text("Post").render(), "<article>Post</article>");
903 assert_eq!(figure().text("Fig").render(), "<figure>Fig</figure>");
904 assert_eq!(form().text("F").render(), "<form>F</form>");
905 assert_eq!(fieldset().text("Set").render(), "<fieldset>Set</fieldset>");
906 }
907
908 #[test]
909 fn flow_container_content_is_escaped() {
910 assert_eq!(
911 aside().text("<b>x</b>").render(),
912 "<aside><b>x</b></aside>"
913 );
914 }
915
916 #[test]
917 fn a_fieldset_carries_a_legend_and_its_fields() {
918 let fieldset = fieldset()
919 .child(legend().text("Account"))
920 .child(input_named("text", "email"));
921
922 assert_eq!(
923 fieldset.render(),
924 r#"<fieldset><legend>Account</legend><input type="text" name="email"></fieldset>"#
925 );
926 }
927
928 #[test]
929 fn a_select_groups_its_options() {
930 let select = select().attr("name", "city").child(
931 optgroup()
932 .attr("label", "Brazil")
933 .child(option().attr("value", "sp").text("S\u{e3}o Paulo")),
934 );
935
936 assert_eq!(
937 select.render(),
938 concat!(
939 r#"<select name="city"><optgroup label="Brazil">"#,
940 "<option value=\"sp\">S\u{e3}o Paulo</option></optgroup></select>",
941 )
942 );
943 }
944
945 #[test]
946 fn the_form_display_elements_render() {
947 assert_eq!(
948 datalist().child(option().attr("value", "rust")).render(),
949 r#"<datalist><option value="rust"></option></datalist>"#
950 );
951 assert_eq!(
952 progress().attr("value", "0.7").attr("max", "1.0").render(),
953 r#"<progress value="0.7" max="1.0"></progress>"#
954 );
955 assert_eq!(
956 progress().attr("max", "1.0").render(),
957 r#"<progress max="1.0"></progress>"#
958 );
959 assert_eq!(
960 meter()
961 .attr("value", "6.0")
962 .attr("min", "0.0")
963 .attr("max", "10.0")
964 .render(),
965 r#"<meter value="6.0" min="0.0" max="10.0"></meter>"#
966 );
967 assert_eq!(output().text("42").render(), "<output>42</output>");
968 }
969
970 #[test]
971 fn boolean_attributes_carry_no_value() {
972 let rendered = input_named("checkbox", "agree")
973 .bool_attr("checked")
974 .bool_attr("required")
975 .render();
976
977 assert_eq!(
978 rendered,
979 r#"<input type="checkbox" name="agree" checked required>"#
980 );
981 assert!(!rendered.contains("checked="));
982 assert!(!rendered.contains("required="));
983 }
984
985 #[test]
986 fn void_elements_do_not_self_close_by_default() {
987 let rendered = image("a.png", "A").render();
988
989 assert_eq!(rendered, r#"<img src="a.png" alt="A">"#);
990 assert!(!rendered.contains("/>"));
991 }
992
993 #[test]
994 fn xhtml_mode_self_closes_void_elements() {
995 let options = RenderOptions::compact().with_xhtml_self_closing(true);
996
997 assert_eq!(
998 image("a.png", "A").render_with(&options),
999 r#"<img src="a.png" alt="A" />"#
1000 );
1001 }
1002
1003 #[test]
1004 fn anchors_and_headings_take_element_children() {
1005 assert_eq!(
1006 link_to("/news").child(image("thumb.jpg", "Thumb")).render(),
1007 r#"<a href="/news"><img src="thumb.jpg" alt="Thumb"></a>"#
1008 );
1009 assert_eq!(
1010 h3().child(link_to("/news").text("Headline")).render(),
1011 r#"<h3><a href="/news">Headline</a></h3>"#
1012 );
1013 assert_eq!(
1014 i().add_class("fas fa-search").render(),
1015 r#"<i class="fas fa-search"></i>"#
1016 );
1017 }
1018
1019 #[test]
1020 fn a_submit_button_carries_its_type() {
1021 assert_eq!(
1022 button_typed("submit").text("Send").render(),
1023 r#"<button type="submit">Send</button>"#
1024 );
1025 }
1026
1027 #[test]
1028 fn a_label_without_a_target_has_no_for_attribute() {
1029 let rendered = label().text("Accept cookies").render();
1030
1031 assert_eq!(rendered, "<label>Accept cookies</label>");
1032 assert!(!rendered.contains("for="));
1033 }
1034
1035 #[test]
1036 fn an_input_without_a_name_has_no_name_attribute() {
1037 let rendered = input()
1038 .attr("type", "search")
1039 .attr("placeholder", "Search")
1040 .render();
1041
1042 assert_eq!(rendered, r#"<input type="search" placeholder="Search">"#);
1043 assert!(!rendered.contains("name="));
1044 }
1045
1046 #[test]
1047 fn a_section_renders_its_class_and_text() {
1048 assert_eq!(
1049 section().add_class("hero").text("Hello").render(),
1050 r#"<section class="hero">Hello</section>"#
1051 );
1052 }
1053
1054 #[test]
1055 fn the_inline_semantic_tags_render() {
1056 assert_eq!(strong().text("bold").render(), "<strong>bold</strong>");
1057 assert_eq!(em().text("emph").render(), "<em>emph</em>");
1058 assert_eq!(small().text("fine").render(), "<small>fine</small>");
1059 assert_eq!(br().render(), "<br>");
1060 assert_eq!(hr().render(), "<hr>");
1061 }
1062
1063 #[test]
1064 fn a_pre_block_keeps_its_line_breaks() {
1065 let block =
1066 pre().text("This is preformatted text.\nIt preserves whitespace and line breaks.");
1067
1068 assert_eq!(
1069 block.render(),
1070 "<pre>This is preformatted text.\nIt preserves whitespace and line breaks.</pre>"
1071 );
1072 }
1073
1074 #[test]
1075 fn a_code_block_keeps_its_line_breaks() {
1076 let block = code().text("let x = 10\nprint(x)");
1077
1078 assert_eq!(block.render(), "<code>let x = 10\nprint(x)</code>");
1079 }
1080
1081 #[test]
1082 fn an_embed_is_a_void_element() {
1083 assert_eq!(
1084 embed()
1085 .attr("src", "video.mp4")
1086 .attr("type", "video/mp4")
1087 .render(),
1088 r#"<embed src="video.mp4" type="video/mp4">"#
1089 );
1090 }
1091
1092 #[test]
1093 fn an_article_wraps_its_children() {
1094 assert_eq!(
1095 article().child(h1().text("Title")).render(),
1096 "<article><h1>Title</h1></article>"
1097 );
1098 }
1099
1100 #[test]
1101 fn an_aside_wraps_its_children() {
1102 assert_eq!(
1103 aside().child(p().text("Sidebar")).render(),
1104 "<aside><p>Sidebar</p></aside>"
1105 );
1106 }
1107
1108 #[test]
1109 fn a_figure_pairs_an_image_with_its_caption() {
1110 let block = figure()
1111 .child(image("image.jpg", "Test"))
1112 .child(figcaption().text("Image caption"));
1113
1114 assert_eq!(
1115 block.render(),
1116 concat!(
1117 r#"<figure><img src="image.jpg" alt="Test">"#,
1118 "<figcaption>Image caption</figcaption></figure>",
1119 )
1120 );
1121 }
1122
1123 #[test]
1124 fn a_time_element_carries_its_datetime() {
1125 assert_eq!(
1126 time()
1127 .attr("datetime", "2024-01-15")
1128 .text("January 15, 2024")
1129 .render(),
1130 r#"<time datetime="2024-01-15">January 15, 2024</time>"#
1131 );
1132 }
1133
1134 #[test]
1135 fn a_mark_element_renders_its_text() {
1136 assert_eq!(
1137 mark().text("highlighted").render(),
1138 "<mark>highlighted</mark>"
1139 );
1140 }
1141
1142 #[test]
1143 fn every_heading_level_renders_its_own_tag() {
1144 let headings = [
1145 h1().text("H1"),
1146 h2().text("H2"),
1147 h3().text("H3"),
1148 h4().text("H4"),
1149 h5().text("H5"),
1150 h6().text("H6"),
1151 ];
1152
1153 for (index, heading) in headings.into_iter().enumerate() {
1154 let level = index + 1;
1155 assert_eq!(heading.render(), format!("<h{level}>H{level}</h{level}>"));
1156 }
1157 }
1158
1159 #[test]
1160 fn a_form_renders_its_fieldsets_and_submit() {
1161 let form = form()
1162 .attr("action", "/submit")
1163 .child(
1164 fieldset()
1165 .child(label_for("name").text("Name"))
1166 .child(input_named("text", "name")),
1167 )
1168 .child(
1169 fieldset()
1170 .child(label_for("message").text("Message"))
1171 .child(textarea().attr("name", "message")),
1172 )
1173 .child(input_named("submit", "submit").attr("value", "Send"));
1174
1175 assert_eq!(
1176 form.render(),
1177 concat!(
1178 r#"<form action="/submit"><fieldset><label for="name">Name</label>"#,
1179 r#"<input type="text" name="name"></fieldset><fieldset>"#,
1180 r#"<label for="message">Message</label>"#,
1181 r#"<textarea name="message"></textarea></fieldset>"#,
1182 r#"<input type="submit" name="submit" value="Send"></form>"#,
1183 )
1184 );
1185 }
1186
1187 #[test]
1188 fn a_select_renders_each_option() {
1189 let select = select()
1190 .attr("name", "options")
1191 .children_from((1..=3).map(|i| {
1192 option()
1193 .attr("value", i.to_string())
1194 .text(format!("Option {i}"))
1195 }));
1196
1197 assert_eq!(
1198 select.render(),
1199 concat!(
1200 r#"<select name="options"><option value="1">Option 1</option>"#,
1201 r#"<option value="2">Option 2</option>"#,
1202 r#"<option value="3">Option 3</option></select>"#,
1203 )
1204 );
1205 }
1206
1207 #[test]
1208 fn a_label_points_at_its_field() {
1209 assert_eq!(
1210 label_for("username").text("Username").render(),
1211 r#"<label for="username">Username</label>"#
1212 );
1213 }
1214
1215 #[test]
1216 fn an_input_renders_its_type_name_and_value() {
1217 assert_eq!(
1218 input_named("text", "username")
1219 .attr("value", "JohnDoe")
1220 .render(),
1221 r#"<input type="text" name="username" value="JohnDoe">"#
1222 );
1223 }
1224
1225 #[test]
1226 fn a_textarea_renders_its_content_between_the_tags() {
1227 assert_eq!(
1228 textarea()
1229 .attr("name", "message")
1230 .text("Hello, World!")
1231 .render(),
1232 r#"<textarea name="message">Hello, World!</textarea>"#
1233 );
1234 }
1235
1236 #[test]
1237 fn a_fieldset_groups_a_label_and_its_field() {
1238 let block = fieldset()
1239 .child(label_for("name").text("Name"))
1240 .child(input_named("text", "name"));
1241
1242 assert_eq!(
1243 block.render(),
1244 concat!(
1245 r#"<fieldset><label for="name">Name</label>"#,
1246 r#"<input type="text" name="name"></fieldset>"#,
1247 )
1248 );
1249 }
1250
1251 #[test]
1252 fn a_section_wraps_its_children() {
1253 assert_eq!(
1254 section().child(p().text("This is a section.")).render(),
1255 "<section><p>This is a section.</p></section>"
1256 );
1257 }
1258
1259 #[test]
1262 fn select_named_renders_its_name() {
1263 assert_eq!(
1264 select_named("country").render(),
1265 r#"<select name="country"></select>"#
1266 );
1267 }
1268
1269 #[test]
1270 fn options_from_renders_each_value_and_label_pair() {
1271 let select = select_named("country")
1272 .children_from(options_from([("br", "Brasil"), ("ar", "Argentina")], None));
1273
1274 assert_eq!(
1275 select.render(),
1276 concat!(
1277 r#"<select name="country"><option value="br">Brasil</option>"#,
1278 r#"<option value="ar">Argentina</option></select>"#,
1279 )
1280 );
1281 }
1282
1283 #[test]
1284 fn options_from_marks_the_selected_value() {
1285 let select = select_named("country").children_from(options_from(
1286 [("br", "Brasil"), ("ar", "Argentina")],
1287 Some("ar"),
1288 ));
1289
1290 assert_eq!(
1291 select.render(),
1292 concat!(
1293 r#"<select name="country"><option value="br">Brasil</option>"#,
1294 r#"<option value="ar" selected>Argentina</option></select>"#,
1295 )
1296 );
1297 }
1298
1299 #[test]
1300 fn options_from_accepts_owned_pairs_such_as_database_rows() {
1301 let rows: Vec<(String, String)> = vec![
1302 ("sp".to_string(), "S\u{e3}o Paulo".to_string()),
1303 ("rj".to_string(), "Rio".to_string()),
1304 ];
1305 let select = select_named("city")
1306 .children_from(options_from(rows.iter().map(|r| (&r.0, &r.1)), None));
1307
1308 assert_eq!(
1309 select.render(),
1310 concat!(
1311 "<select name=\"city\"><option value=\"sp\">S\u{e3}o Paulo</option>",
1312 r#"<option value="rj">Rio</option></select>"#,
1313 )
1314 );
1315 }
1316
1317 #[test]
1318 fn options_from_escapes_values_and_labels() {
1319 let select = select_named("q").children_from(options_from([("a\"b", "a&b")], None));
1320
1321 assert_eq!(
1322 select.render(),
1323 r#"<select name="q"><option value="a"b">a&b</option></select>"#
1324 );
1325 }
1326
1327 #[test]
1328 fn select_from_builds_the_select_with_its_options() {
1329 assert_eq!(
1330 select_from(
1331 "country",
1332 [("br", "Brasil"), ("ar", "Argentina")],
1333 Some("br")
1334 )
1335 .render(),
1336 concat!(
1337 r#"<select name="country"><option value="br" selected>Brasil</option>"#,
1338 r#"<option value="ar">Argentina</option></select>"#,
1339 )
1340 );
1341 }
1342
1343 #[test]
1344 fn option_valued_renders_its_value() {
1345 assert_eq!(
1346 option_valued("br").text("Brasil").render(),
1347 r#"<option value="br">Brasil</option>"#
1348 );
1349 }
1350
1351 #[test]
1352 fn optgroup_labeled_groups_its_options() {
1353 let select = select_named("city").child(
1354 optgroup_labeled("Sul").children_from(options_from([("poa", "Porto Alegre")], None)),
1355 );
1356
1357 assert_eq!(
1358 select.render(),
1359 concat!(
1360 r#"<select name="city"><optgroup label="Sul">"#,
1361 r#"<option value="poa">Porto Alegre</option></optgroup></select>"#,
1362 )
1363 );
1364 }
1365
1366 #[test]
1367 fn textarea_named_escapes_its_value() {
1368 assert_eq!(
1369 textarea_named("bio").text("</textarea><b>").render(),
1370 r#"<textarea name="bio"></textarea><b></textarea>"#
1371 );
1372 }
1373
1374 #[test]
1375 fn hidden_input_renders_its_name_and_escaped_value() {
1376 assert_eq!(
1377 hidden_input("_csrf", "t0k\"en&").render(),
1378 r#"<input type="hidden" name="_csrf" value="t0k"en&">"#
1379 );
1380 }
1381
1382 #[test]
1383 fn fieldset_legend_puts_the_legend_before_the_fields() {
1384 let fieldset = fieldset_legend("Endere\u{e7}o").child(input_named("text", "street"));
1385
1386 assert_eq!(
1387 fieldset.render(),
1388 concat!(
1389 "<fieldset><legend>Endere\u{e7}o</legend>",
1390 r#"<input type="text" name="street"></fieldset>"#,
1391 )
1392 );
1393 }
1394
1395 #[test]
1396 fn checkbox_named_renders_checked_when_asked() {
1397 assert_eq!(
1398 checkbox_named("terms", "yes", true).render(),
1399 r#"<input type="checkbox" name="terms" value="yes" checked>"#
1400 );
1401 }
1402
1403 #[test]
1404 fn checkbox_named_omits_checked_when_not_asked() {
1405 assert_eq!(
1406 checkbox_named("terms", "yes", false).render(),
1407 r#"<input type="checkbox" name="terms" value="yes">"#
1408 );
1409 }
1410
1411 #[test]
1412 fn radio_named_renders_checked_when_asked() {
1413 assert_eq!(
1414 radio_named("plan", "pro", true).render(),
1415 r#"<input type="radio" name="plan" value="pro" checked>"#
1416 );
1417 }
1418
1419 #[test]
1420 fn radio_named_omits_checked_when_not_asked() {
1421 assert_eq!(
1422 radio_named("plan", "free", false).render(),
1423 r#"<input type="radio" name="plan" value="free">"#
1424 );
1425 }
1426
1427 #[test]
1428 fn select_multiple_from_marks_every_selected_value() {
1429 let tags = [("a", "A"), ("b", "B"), ("c", "C")];
1430
1431 assert_eq!(
1432 select_multiple_from("tags", tags, &["a", "c"]).render(),
1433 concat!(
1434 r#"<select name="tags" multiple><option value="a" selected>A</option>"#,
1435 r#"<option value="b">B</option><option value="c" selected>C</option></select>"#,
1436 )
1437 );
1438 }
1439
1440 #[test]
1441 fn select_multiple_from_with_nothing_selected_marks_no_option() {
1442 assert_eq!(
1443 select_multiple_from("tags", [("a", "A")], &[]).render(),
1444 r#"<select name="tags" multiple><option value="a">A</option></select>"#
1445 );
1446 }
1447}