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]
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#[must_use]
336pub fn button_typed(button_type: impl AsRef<str>) -> Element {
337 button().attr("type", button_type)
338}
339
340#[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#[must_use]
350pub fn label_for(for_id: impl AsRef<str>) -> Element {
351 label().attr("for", for_id)
352}
353
354#[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#[must_use]
393pub fn select_named(name: impl AsRef<str>) -> Element {
394 select().attr("name", name)
395}
396
397#[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#[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#[must_use]
459pub fn option_valued(value: impl AsRef<str>) -> Element {
460 option().attr("value", value)
461}
462
463#[must_use]
477pub fn optgroup_labeled(label_text: impl AsRef<str>) -> Element {
478 optgroup().attr("label", label_text)
479}
480
481#[must_use]
495pub fn textarea_named(name: impl AsRef<str>) -> Element {
496 textarea().attr("name", name)
497}
498
499#[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#[must_use]
529pub fn fieldset_legend(legend_text: impl AsRef<str>) -> Element {
530 fieldset().child(legend().text(legend_text))
531}
532
533#[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#[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#[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
592fn 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
603fn 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/"><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 #[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 #[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 #[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 #[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 #[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><b>x</b></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 #[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"b">a&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"></textarea><b></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"en&">"#
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}