1use crate::core::attribute::Attribute;
9use crate::core::element::Element;
10use crate::core::escape::write_escaped_text;
11use crate::core::render::{Render, RenderOptions};
12use crate::core::tags::{is_raw_text, is_valid_tag_name, is_void, is_whitespace_sensitive};
13
14#[derive(Debug)]
23#[non_exhaustive]
24pub enum Node {
25 Element(Element),
27 Text(Markup),
29 Raw(Markup),
34 Comment(Markup),
38 Fragment(Vec<Node>),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub struct Markup(String);
68
69impl Markup {
70 pub(crate) fn into_string(self) -> String {
72 self.0
73 }
74
75 #[must_use]
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80}
81
82impl std::ops::Deref for Markup {
83 type Target = str;
84
85 fn deref(&self) -> &str {
86 &self.0
87 }
88}
89
90impl AsRef<str> for Markup {
91 fn as_ref(&self) -> &str {
92 &self.0
93 }
94}
95
96impl std::fmt::Display for Markup {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(&self.0)
99 }
100}
101
102impl PartialEq<str> for Markup {
103 fn eq(&self, other: &str) -> bool {
104 self.0 == other
105 }
106}
107
108impl PartialEq<&str> for Markup {
109 fn eq(&self, other: &&str) -> bool {
110 self.0 == *other
111 }
112}
113
114impl Node {
115 #[must_use]
117 pub fn text(content: impl AsRef<str>) -> Self {
118 let raw = content.as_ref();
119 let mut escaped = String::with_capacity(raw.len());
120 write_escaped_text(&mut escaped, raw, false);
121 Self::Text(Markup(escaped))
122 }
123
124 pub(crate) fn text_display(value: &impl std::fmt::Display) -> Self {
127 use std::fmt::Write as _;
128 let mut escaped = String::new();
129 let _ = write!(crate::core::escape::TextWriter(&mut escaped), "{value}");
131 Self::Text(Markup(escaped))
132 }
133
134 #[must_use]
155 pub fn raw(content: impl Into<String>) -> Self {
156 Self::Raw(Markup(content.into()))
157 }
158
159 #[must_use]
171 pub fn comment(content: impl AsRef<str>) -> Self {
172 let mut neutralised = String::with_capacity(content.as_ref().len());
173 push_neutralised_comment(&mut neutralised, content.as_ref());
174 Self::Comment(Markup(neutralised))
175 }
176
177 #[must_use]
179 pub fn fragment(children: impl IntoIterator<Item = Node>) -> Self {
180 Self::Fragment(children.into_iter().collect())
181 }
182
183 #[must_use]
190 pub fn is_empty(&self) -> bool {
191 match self {
192 Self::Element(_) | Self::Comment(_) => false,
193 Self::Text(s) | Self::Raw(s) => s.is_empty(),
194 Self::Fragment(children) => {
195 let mut pending: Vec<&Node> = children.iter().collect();
198 while let Some(node) = pending.pop() {
199 match node {
200 Self::Element(_) | Self::Comment(_) => return false,
201 Self::Text(s) | Self::Raw(s) => {
202 if !s.is_empty() {
203 return false;
204 }
205 }
206 Self::Fragment(children) => pending.extend(children),
207 }
208 }
209 true
210 }
211 }
212 }
213}
214
215impl Clone for Node {
221 fn clone(&self) -> Self {
222 crate::core::element::clone_tree(Source::Node(self))
223 }
224}
225
226impl PartialEq for Node {
227 fn eq(&self, other: &Self) -> bool {
228 crate::core::element::trees_equal(Source::Node(self), Source::Node(other))
229 }
230}
231
232impl Eq for Node {}
233
234#[derive(Clone, Copy)]
236pub(crate) enum Source<'a> {
237 Node(&'a Node),
238 Element(&'a Element),
239}
240
241impl std::fmt::Display for Node {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.write_str(&if f.alternate() {
245 self.render_pretty()
246 } else {
247 self.render()
248 })
249 }
250}
251
252impl std::hash::Hash for Node {
253 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
254 crate::core::element::hash_tree(Source::Node(self), state);
255 }
256}
257
258impl<N: Into<Node>> FromIterator<N> for Node {
261 fn from_iter<I: IntoIterator<Item = N>>(iter: I) -> Self {
262 Self::Fragment(iter.into_iter().map(Into::into).collect())
263 }
264}
265
266impl From<Vec<Node>> for Node {
267 fn from(children: Vec<Node>) -> Self {
268 Self::Fragment(children)
269 }
270}
271
272impl<T: Into<Node>> From<Option<T>> for Node {
274 fn from(value: Option<T>) -> Self {
275 value.map_or_else(|| Self::Fragment(Vec::new()), Into::into)
276 }
277}
278
279impl From<char> for Node {
280 fn from(c: char) -> Self {
281 Self::text(c.to_string())
282 }
283}
284
285impl From<Element> for Node {
286 fn from(element: Element) -> Self {
287 Self::Element(element)
288 }
289}
290
291impl From<&str> for Node {
292 fn from(text: &str) -> Self {
293 Self::text(text)
294 }
295}
296
297impl From<String> for Node {
298 fn from(text: String) -> Self {
299 Self::text(text)
300 }
301}
302
303impl Render for Node {
304 fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
305 write_tree(
306 if options.pretty {
307 Step::Pretty(self, depth)
308 } else {
309 Step::Compact(self)
310 },
311 out,
312 options,
313 );
314 }
315}
316
317pub(crate) fn write_element_tree(
323 element: &Element,
324 out: &mut String,
325 options: &RenderOptions,
326 depth: usize,
327) {
328 write_tree(
329 if options.pretty {
330 Step::PrettyElement(element, depth)
331 } else {
332 Step::CompactElement(element)
333 },
334 out,
335 options,
336 );
337}
338
339pub(crate) fn write_document_root(
346 lang: Option<&str>,
347 head: &Element,
348 body: &Element,
349 out: &mut String,
350 options: &RenderOptions,
351) {
352 if options.pretty {
353 options.write_indent(out, 0);
354 }
355 out.push_str("<html");
356 if let Some(lang) = lang {
357 Attribute::new_static("lang", lang).write_into(out);
358 }
359 out.push('>');
360
361 let steps = if options.pretty {
363 vec![
364 Step::ClosePretty("html", 0),
365 Step::PrettyChildElement {
366 element: body,
367 depth: 1,
368 },
369 Step::PrettyChildElement {
370 element: head,
371 depth: 1,
372 },
373 ]
374 } else {
375 vec![
376 Step::Close("html"),
377 Step::CompactElement(body),
378 Step::CompactElement(head),
379 ]
380 };
381 run_steps(steps, out, options, 1);
383}
384
385enum Step<'a> {
393 Compact(&'a Node),
395 Pretty(&'a Node, usize),
397 CompactElement(&'a Element),
399 PrettyElement(&'a Element, usize),
401 Close(&'a str),
403 ClosePretty(&'a str, usize),
405 PrettyChild {
412 node: &'a Node,
413 depth: usize,
414 group_start: Option<usize>,
415 },
416 PrettyChildElement { element: &'a Element, depth: usize },
419 DropIfEmpty { mark: usize, after: usize },
426}
427
428fn write_tree(start: Step<'_>, out: &mut String, options: &RenderOptions) {
430 let mut stack = Vec::with_capacity(16);
431 stack.push(start);
432 run_steps(stack, out, options, 0);
433}
434
435fn run_steps(stack: Vec<Step<'_>>, out: &mut String, options: &RenderOptions, already_open: usize) {
437 if options.max_depth.is_some() {
441 run_steps_with::<true>(stack, out, options, already_open);
442 } else {
443 run_steps_with::<false>(stack, out, options, already_open);
444 }
445}
446
447#[allow(clippy::too_many_lines)]
450fn run_steps_with<const LIMITED: bool>(
451 mut stack: Vec<Step<'_>>,
452 out: &mut String,
453 options: &RenderOptions,
454 already_open: usize,
455) {
456 let mut raw_text_depth = 0usize;
460 let mut open_depth = already_open;
463 let max_depth = options.max_depth;
464 while let Some(step) = stack.pop() {
465 match step {
466 Step::Compact(Node::Text(text) | Node::Raw(text)) => out.push_str(text),
469 Step::Compact(Node::Comment(content)) => write_comment(out, content),
470 Step::Compact(Node::Fragment(children)) => {
471 stack.extend(children.iter().rev().map(Step::Compact));
472 }
473 Step::Compact(Node::Element(element)) => stack.push(Step::CompactElement(element)),
474
475 Step::Pretty(Node::Text(text) | Node::Raw(text), depth) => {
476 options.write_indent(out, depth);
477 out.push_str(text);
478 }
479 Step::Pretty(Node::Comment(content), depth) => {
480 options.write_indent(out, depth);
481 write_comment(out, content);
482 }
483 Step::Pretty(Node::Fragment(children), depth) => {
484 let group_start = out.len();
487 stack.extend(children.iter().rev().map(|child| Step::PrettyChild {
488 node: child,
489 depth,
490 group_start: Some(group_start),
491 }));
492 }
493 Step::Pretty(Node::Element(element), depth) => {
494 stack.push(Step::PrettyElement(element, depth));
495 }
496
497 Step::CompactElement(element) => {
498 if LIMITED && max_depth.is_some_and(|max| open_depth >= max) {
501 continue;
502 }
503 let in_raw_text = raw_text_depth > 0;
504 if push_compact_element(element, out, options, &mut stack, in_raw_text) {
505 if LIMITED {
506 open_depth += 1;
507 }
508 if is_raw_text(element.tag()) {
509 raw_text_depth += 1;
510 }
511 }
512 }
513 Step::PrettyElement(element, depth) => {
514 if LIMITED && max_depth.is_some_and(|max| open_depth >= max) {
515 continue;
516 }
517 let in_raw_text = raw_text_depth > 0;
518 if push_pretty_element(element, depth, out, options, &mut stack, in_raw_text) {
519 if LIMITED {
520 open_depth += 1;
521 }
522 if is_raw_text(element.tag()) {
523 raw_text_depth += 1;
524 }
525 }
526 }
527
528 Step::Close(tag) => {
529 if LIMITED {
530 open_depth = open_depth.saturating_sub(1);
531 }
532 if is_raw_text(tag) {
533 raw_text_depth = raw_text_depth.saturating_sub(1);
534 }
535 out.push_str("</");
536 out.push_str(tag);
537 out.push('>');
538 }
539 Step::ClosePretty(tag, depth) => {
540 if LIMITED {
541 open_depth = open_depth.saturating_sub(1);
542 }
543 if is_raw_text(tag) {
544 raw_text_depth = raw_text_depth.saturating_sub(1);
545 }
546 out.push('\n');
547 options.write_indent(out, depth);
548 out.push_str("</");
549 out.push_str(tag);
550 out.push('>');
551 }
552
553 Step::PrettyChild {
554 node,
555 depth,
556 group_start,
557 } => {
558 let mark = out.len();
559 let needs_separator = group_start.is_none_or(|start| out.len() > start);
560 if needs_separator {
561 out.push('\n');
562 }
563 let after = out.len();
564 stack.push(Step::DropIfEmpty { mark, after });
566 stack.push(Step::Pretty(node, depth));
567 }
568 Step::PrettyChildElement { element, depth } => {
569 let mark = out.len();
570 out.push('\n');
571 let after = out.len();
572 stack.push(Step::DropIfEmpty { mark, after });
573 stack.push(Step::PrettyElement(element, depth));
574 }
575 Step::DropIfEmpty { mark, after } => {
576 if out.len() == after {
577 out.truncate(mark);
578 }
579 }
580 }
581 }
582}
583
584fn push_compact_element<'a>(
588 element: &'a Element,
589 out: &mut String,
590 options: &RenderOptions,
591 stack: &mut Vec<Step<'a>>,
592 in_raw_text: bool,
593) -> bool {
594 if !is_valid_tag_name(element.tag()) {
597 return false;
598 }
599 out.push('<');
600 out.push_str(element.tag());
601 write_attributes(element.attributes(), out, in_raw_text, element.tag());
602
603 if is_void(element.tag()) || element.is_self_closing() {
608 write_void_suffix(out, options);
609 return false;
610 }
611
612 out.push('>');
613 if let Some(content) = element.content() {
614 out.push_str(content);
615 }
616 stack.push(Step::Close(element.tag()));
617 stack.extend(element.children().iter().rev().map(Step::Compact));
618 true
619}
620
621fn push_pretty_element<'a>(
623 element: &'a Element,
624 depth: usize,
625 out: &mut String,
626 options: &RenderOptions,
627 stack: &mut Vec<Step<'a>>,
628 in_raw_text: bool,
629) -> bool {
630 if !is_valid_tag_name(element.tag()) {
633 return false;
634 }
635 if is_whitespace_sensitive(element.tag()) {
638 options.write_indent(out, depth);
639 stack.push(Step::CompactElement(element));
640 return false;
641 }
642
643 options.write_indent(out, depth);
644 out.push('<');
645 out.push_str(element.tag());
646 write_attributes(element.attributes(), out, in_raw_text, element.tag());
647
648 if is_void(element.tag()) || element.is_self_closing() {
649 write_void_suffix(out, options);
650 return false;
651 }
652
653 out.push('>');
654
655 if element.children().is_empty() {
656 if let Some(content) = element.content() {
657 out.push_str(content);
658 }
659 out.push_str("</");
660 out.push_str(element.tag());
661 out.push('>');
662 return false;
663 }
664
665 if let Some(content) = element.content() {
666 out.push('\n');
667 options.write_indent(out, depth + 1);
668 out.push_str(content);
669 }
670
671 stack.push(Step::ClosePretty(element.tag(), depth));
672 stack.extend(
673 element
674 .children()
675 .iter()
676 .rev()
677 .map(|child| Step::PrettyChild {
678 node: child,
679 depth: depth + 1,
680 group_start: None,
681 }),
682 );
683 true
684}
685
686fn write_comment(out: &mut String, content: &str) {
687 out.push_str("<!-- ");
690 out.push_str(content);
691 out.push_str(" -->");
692}
693
694fn push_neutralised_comment(out: &mut String, content: &str) {
699 let mut previous_hyphen = false;
700 for c in content.chars() {
701 if c == '-' && previous_hyphen {
702 out.push(' ');
703 }
704 out.push(c);
705 previous_hyphen = c == '-';
706 }
707 if previous_hyphen {
708 out.push(' ');
709 }
710}
711
712fn write_attributes(attributes: &[Attribute], out: &mut String, in_raw_text: bool, tag: &str) {
713 for attribute in attributes {
714 attribute.write_into_in(out, in_raw_text, tag);
715 }
716}
717
718fn write_void_suffix(out: &mut String, options: &RenderOptions) {
720 out.push_str(if options.xhtml_self_closing {
721 " />"
722 } else {
723 ">"
724 });
725}
726
727#[cfg(test)]
728mod tests {
729 use std::collections::hash_map::DefaultHasher;
730 use std::hash::{Hash, Hasher};
731
732 fn hash_of(value: &impl Hash) -> u64 {
733 let mut hasher = DefaultHasher::new();
734 value.hash(&mut hasher);
735 hasher.finish()
736 }
737
738 #[test]
739 fn equal_trees_hash_equal_and_different_trees_do_not() {
740 let a = div()
741 .attr("id", "x")
742 .child(p().text("t"))
743 .child(Node::comment("c"));
744 let b = div()
745 .attr("id", "x")
746 .child(p().text("t"))
747 .child(Node::comment("c"));
748 assert_eq!(a, b);
749 assert_eq!(hash_of(&a), hash_of(&b));
750 assert_eq!(hash_of(&Node::from(a.clone())), hash_of(&Node::from(b)));
751 assert_ne!(hash_of(&a), hash_of(&a.clone().add_class("y")));
752 assert_ne!(hash_of(&Node::text("a")), hash_of(&Node::raw("a")));
753 assert_ne!(
754 hash_of(&Node::fragment([Node::text("a"), Node::text("b")])),
755 hash_of(&Node::fragment([
756 Node::fragment([Node::text("a")]),
757 Node::text("b")
758 ]))
759 );
760 }
761
762 #[test]
763 fn display_is_compact_and_alternate_is_pretty() {
764 let el = div().child(p().text("hi"));
765 assert_eq!(el.to_string(), "<div><p>hi</p></div>");
766 assert_eq!(format!("{el:#}"), "<div>\n <p>hi</p>\n</div>");
767 let node = Node::from(el);
768 assert_eq!(node.to_string(), "<div><p>hi</p></div>");
769 assert_eq!(format!("{node:#}"), "<div>\n <p>hi</p>\n</div>");
770 }
771
772 #[test]
773 fn nodes_collect_and_convert_from_the_common_shapes() {
774 let collected: Node = ["a", "b"].into_iter().map(Node::text).collect();
775 assert_eq!(collected.render(), "ab");
776 assert_eq!(
777 Node::from(vec![Node::text("a"), Node::text("b")]).render(),
778 "ab"
779 );
780 assert_eq!(Node::from(Some(p().text("x"))).render(), "<p>x</p>");
781 assert!(Node::from(None::<Element>).is_empty());
782 assert_eq!(Node::from('<').render(), "<");
783
784 let mut list = ul();
785 list.extend([li().text("1"), li().text("2")]);
786 list.extend(["tail"]);
787 assert_eq!(list.render(), "<ul><li>1</li><li>2</li>tail</ul>");
788 }
789
790 #[test]
791 fn references_boxes_and_options_render_like_what_they_hold() {
792 fn through<T: Render>(value: T) -> String {
796 value.render()
797 }
798 let el = p().text("x");
799 assert_eq!(through(&el), "<p>x</p>");
800 assert_eq!(through(Box::new(el.clone())), "<p>x</p>");
801 assert_eq!(through(Some(el.clone())), "<p>x</p>");
802 assert_eq!(through(None::<Element>), "");
803 let dynamic: Box<dyn Render> = Box::new(el);
804 assert_eq!(dynamic.render(), "<p>x</p>");
805 }
806
807 #[test]
808 fn a_document_has_a_default_and_a_display() {
809 let page = crate::document::Document::default();
810 assert_eq!(page.to_string(), page.render());
811 assert!(page.to_string().starts_with("<!DOCTYPE html>\n<html>"));
812 }
813
814 use super::*;
815 use crate::elements::{
816 body, code, div, h1, head, html_tag, i, img, li, p, pre, span, textarea, title, ul,
817 };
818 use crate::html;
821
822 #[test]
823 fn the_tree_is_send_and_sync() {
824 fn assert_send_sync<T: Send + Sync>() {}
825 assert_send_sync::<Node>();
826 assert_send_sync::<Element>();
827 }
828
829 #[test]
830 fn text_nodes_are_escaped() {
831 assert_eq!(Node::text("a & b").render(), "a & b");
832 }
833
834 #[test]
836 fn raw_nodes_are_never_escaped() {
837 assert_eq!(Node::raw("<b>bold</b>").render(), "<b>bold</b>");
838 assert_eq!(Node::raw("<b>bold</b>").render_pretty(), "<b>bold</b>");
839 }
840
841 #[test]
842 fn a_comment_cannot_close_itself_early() {
843 let rendered = Node::comment("a --> b").render();
844 assert_eq!(rendered.matches("-->").count(), 1);
845 assert!(rendered.ends_with("-->"));
846 }
847
848 #[test]
851 fn an_unsafe_tag_name_renders_nothing() {
852 for hostile in [
853 "img src=x onerror=alert(1)",
854 "div><script>alert(1)</script><div",
855 "a\"b",
856 "",
857 "1div",
858 "p\n",
859 ] {
860 let tree = div()
861 .child(Element::new(hostile).text("kept?"))
862 .child(p().text("after"));
863 assert_eq!(tree.render(), "<div><p>after</p></div>", "{hostile:?}");
864 assert_eq!(
865 tree.render_pretty(),
866 "<div>\n <p>after</p>\n</div>",
867 "{hostile:?} must leave no blank line in pretty mode"
868 );
869 assert_eq!(Element::new(hostile).render(), "", "{hostile:?}");
870 }
871 }
872
873 #[test]
874 fn custom_and_namespaced_tags_still_render() {
875 assert_eq!(
876 Element::new("my-card").text("x").render(),
877 "<my-card>x</my-card>"
878 );
879 assert_eq!(Element::new("svg:rect").render(), "<svg:rect></svg:rect>");
880 }
881
882 #[test]
886 fn angle_brackets_in_attributes_are_escaped_only_under_raw_text_parents() {
887 let hostile = "</noscript><img src=x onerror=alert(1)>";
888 let escaped = "</noscript><img src=x onerror=alert(1)>";
889
890 for parent in [
891 "noscript", "iframe", "xmp", "noembed", "noframes", "style", "script",
892 ] {
893 let tree = Element::new(parent).child(img().attr("alt", hostile));
894 assert_eq!(
895 tree.render(),
896 format!(r#"<{parent}><img alt="{escaped}"></{parent}>"#),
897 "{parent}"
898 );
899 }
900
901 assert_eq!(
902 div().child(img().attr("alt", hostile)).render(),
903 format!(r#"<div><img alt="{hostile}"></div>"#),
904 "ordinary parents leave `<` and `>` in an attribute value alone"
905 );
906 }
907
908 #[test]
909 fn the_raw_text_escape_covers_deep_descendants_and_ends_with_the_parent() {
910 let hostile = "a<b>c";
911 let tree = div()
912 .child(
913 Element::new("noscript")
914 .attr("title", hostile)
915 .child(div().child(span().child(img().attr("alt", hostile)))),
916 )
917 .child(img().attr("alt", hostile));
918 assert_eq!(
921 tree.render(),
922 concat!(
923 r#"<div><noscript title="a<b>c"><div><span>"#,
924 r#"<img alt="a<b>c"></span></div></noscript>"#,
925 r#"<img alt="a<b>c"></div>"#
926 )
927 );
928 assert_eq!(tree.render_pretty().matches("a<b>c").count(), 1);
929 assert_eq!(tree.render_pretty().matches("a<b>c").count(), 2);
930 }
931
932 #[test]
933 fn a_comment_survives_runs_of_hyphens_and_bang_closers() {
934 for hostile in [
935 "x---><img src=x onerror=alert(1)>",
936 "--!><b>",
937 "a-",
938 "---",
939 "-->",
940 "--",
941 ] {
942 let rendered = Node::comment(hostile).render();
943 let inner = rendered
944 .strip_prefix("<!-- ")
945 .and_then(|r| r.strip_suffix(" -->"));
946 let inner = inner.expect("comment keeps its delimiters");
947 assert!(!inner.contains("--"), "{hostile:?} -> {rendered:?}");
948 assert!(!inner.ends_with('-'), "{hostile:?} -> {rendered:?}");
949 }
950 }
951
952 #[test]
953 fn an_empty_fragment_leaves_no_blank_line() {
954 let tree = div()
955 .child(p().text("a"))
956 .child(Node::fragment([]))
957 .child(p().text("b"));
958 assert_eq!(
959 tree.render_pretty(),
960 "<div>\n <p>a</p>\n <p>b</p>\n</div>"
961 );
962 }
963
964 #[test]
965 fn a_fragment_renders_its_children_without_a_wrapper() {
966 let tree = Node::fragment([p().text("a").into(), p().text("b").into()]);
967 assert_eq!(tree.render(), "<p>a</p><p>b</p>");
968 assert_eq!(tree.render_pretty(), "<p>a</p>\n<p>b</p>");
969 }
970
971 #[test]
972 fn void_elements_take_no_children() {
973 let tree = img().attr("src", "/a.png").child(span().text("dropped"));
974 assert_eq!(tree.render(), r#"<img src="/a.png">"#);
975 }
976
977 #[test]
978 fn xhtml_mode_closes_void_elements_with_a_slash() {
979 let options = RenderOptions::compact().with_xhtml_self_closing(true);
980 assert_eq!(
981 img().attr("src", "/a.png").render_with(&options),
982 r#"<img src="/a.png" />"#
983 );
984 }
985
986 #[test]
988 fn whitespace_sensitive_tags_are_not_indented_inside() {
989 let tree = div().child(pre().child(code().text("let page = html { }")));
990 assert_eq!(
991 tree.render_pretty(),
992 "<div>\n <pre><code>let page = html { }</code></pre>\n</div>"
993 );
994 }
995
996 #[test]
998 fn content_goes_on_its_own_line_before_children() {
999 let tree = div().text("lead").child(p().text("body"));
1000 assert_eq!(tree.render_pretty(), "<div>\n lead\n <p>body</p>\n</div>");
1001 }
1002
1003 #[test]
1004 fn an_element_with_content_and_no_children_stays_on_one_line() {
1005 assert_eq!(p().text("hi").render_pretty(), "<p>hi</p>");
1006 }
1007
1008 #[test]
1009 fn compact_is_the_default_for_an_element() {
1010 let tree = div().child(p().text("a"));
1011 assert_eq!(tree.render(), "<div><p>a</p></div>");
1012 }
1013
1014 #[test]
1015 fn raw_markup_renders_with_no_wrapper_around_it() {
1016 let raw = Node::raw(r#"<span class="x">hi</span>"#);
1017
1018 assert_eq!(raw.render(), r#"<span class="x">hi</span>"#);
1019 assert!(!raw.render().contains("<div"));
1020 }
1021
1022 #[test]
1023 fn raw_markup_as_a_child_adds_no_wrapper() {
1024 let markup = div()
1025 .child(Node::raw(r#"<i class="fa fa-home"></i>"#))
1026 .child(span().text("Home"));
1027
1028 assert_eq!(
1029 markup.render(),
1030 r#"<div><i class="fa fa-home"></i><span>Home</span></div>"#
1031 );
1032 }
1033
1034 #[test]
1035 fn a_fragment_renders_its_children_with_no_wrapper() {
1036 let fragment = Node::fragment([
1037 i().add_class("fa fa-star").into(),
1038 span().text(" Featured").into(),
1039 ]);
1040
1041 assert_eq!(
1042 fragment.render(),
1043 r#"<i class="fa fa-star"></i><span> Featured</span>"#
1044 );
1045 }
1046
1047 #[test]
1048 fn a_fragment_takes_a_mapped_sequence() {
1049 let fragment = Node::fragment(
1050 ["One", "Two", "Three"].map(|title| div().add_class("card").text(title).into()),
1051 );
1052
1053 assert_eq!(
1054 fragment.render(),
1055 concat!(
1056 r#"<div class="card">One</div><div class="card">Two</div>"#,
1057 r#"<div class="card">Three</div>"#,
1058 )
1059 );
1060 }
1061
1062 #[test]
1066 fn an_empty_fragment_renders_nothing_in_either_mode() {
1067 assert_eq!(Node::fragment([]).render(), "");
1068 assert_eq!(Node::fragment([]).render_pretty(), "");
1069 }
1070
1071 #[test]
1072 fn a_fragments_children_are_indented_as_the_parents_own() {
1073 let list = ul().child(Node::fragment([
1074 li().text("a").into(),
1075 li().text("b").into(),
1076 ]));
1077
1078 assert_eq!(
1079 list.render_pretty(),
1080 "<ul>\n <li>a</li>\n <li>b</li>\n</ul>"
1081 );
1082 }
1083
1084 #[test]
1085 fn a_fragment_takes_a_filtered_and_mapped_sequence() {
1086 let names = ["Ana", "Bruno", "Carla"];
1087 let group = Node::fragment(
1088 names
1089 .iter()
1090 .filter(|name| name.len() > 3)
1091 .map(|name| li().text(name).into()),
1092 );
1093
1094 assert_eq!(group.render(), "<li>Bruno</li><li>Carla</li>");
1095 }
1096
1097 #[test]
1098 fn a_false_condition_emits_no_stray_node() {
1099 let show_banner = false;
1100 let page = html_tag()
1101 .child(head().child(title().text("Home")))
1102 .child(html! { @if show_banner { div { "banner" } } })
1103 .child(body().child(h1().text("Hi")));
1104
1105 assert_eq!(
1106 page.render(),
1107 "<html><head><title>Home</title></head><body><h1>Hi</h1></body></html>"
1108 );
1109 }
1110
1111 #[test]
1112 fn a_true_condition_emits_its_branch() {
1113 let show_banner = true;
1114 let page = html_tag().child(html! { @if show_banner { div { "banner" } } });
1115
1116 assert_eq!(page.render(), "<html><div>banner</div></html>");
1117 }
1118
1119 #[test]
1120 fn a_loop_emits_one_node_per_iteration() {
1121 let page = html_tag().child(html! {
1122 @for index in 1..=3 { p { "line " (index) } }
1123 });
1124
1125 assert_eq!(
1126 page.render(),
1127 "<html><p>line 1</p><p>line 2</p><p>line 3</p></html>"
1128 );
1129 }
1130
1131 #[test]
1132 fn raw_markup_is_indented_as_one_blob() {
1133 let container = div().child(Node::raw("<custom-element></custom-element>"));
1134
1135 assert_eq!(
1136 container.render_pretty(),
1137 "<div>\n <custom-element></custom-element>\n</div>"
1138 );
1139 }
1140
1141 #[test]
1142 fn a_condition_can_pick_either_branch() {
1143 fn badge(is_beta: bool) -> Node {
1144 html! { @if is_beta { span { "beta" } } @else { span { "stable" } } }
1145 }
1146
1147 assert_eq!(badge(true).render(), "<span>beta</span>");
1148 assert_eq!(badge(false).render(), "<span>stable</span>");
1149 }
1150
1151 #[test]
1154 fn nested_fragments_flatten_without_leaving_gaps() {
1155 let list = ul().child(Node::fragment([
1156 Node::fragment([li().text("a").into()]),
1157 Node::fragment([]),
1158 li().text("b").into(),
1159 ]));
1160
1161 assert_eq!(
1162 list.render_pretty(),
1163 "<ul>\n <li>a</li>\n <li>b</li>\n</ul>"
1164 );
1165 }
1166
1167 #[test]
1168 fn raw_markup_is_emitted_verbatim() {
1169 let raw = Node::raw(r#"<custom-element data-x="1"></custom-element>"#);
1170
1171 assert_eq!(
1172 raw.render(),
1173 r#"<custom-element data-x="1"></custom-element>"#
1174 );
1175 }
1176
1177 #[test]
1178 fn a_tag_with_only_content_stays_on_one_line_when_pretty() {
1179 assert_eq!(
1180 div().text("Hello World").render_pretty(),
1181 "<div>Hello World</div>"
1182 );
1183 }
1184
1185 #[test]
1186 fn children_each_get_their_own_line_when_pretty() {
1187 let tree = div()
1188 .child(p().text("Paragraph 1"))
1189 .child(p().text("Paragraph 2"));
1190
1191 assert_eq!(
1192 tree.render_pretty(),
1193 "<div>\n <p>Paragraph 1</p>\n <p>Paragraph 2</p>\n</div>"
1194 );
1195 }
1196
1197 #[test]
1198 fn compact_keeps_everything_on_one_line() {
1199 assert_eq!(
1200 div().child(p().text("Test")).render(),
1201 "<div><p>Test</p></div>"
1202 );
1203 }
1204
1205 #[test]
1206 fn a_void_element_does_not_self_close_when_pretty() {
1207 let rendered = img()
1208 .attr("src", "test.jpg")
1209 .attr("alt", "Test")
1210 .render_pretty();
1211
1212 assert!(rendered.starts_with("<img"));
1213 assert!(!rendered.contains("/>"));
1214 assert!(rendered.ends_with('>'));
1215 }
1216
1217 #[test]
1218 fn a_textarea_keeps_its_newlines() {
1219 let field = textarea().attr("name", "bio").text("line 1\nline 2");
1220
1221 assert_eq!(
1222 field.render_pretty(),
1223 "<textarea name=\"bio\">line 1\nline 2</textarea>"
1224 );
1225 }
1226
1227 #[test]
1228 fn a_pre_block_is_indented_from_the_outside_but_not_within() {
1229 let container = div().child(pre().child(code().text("cargo build")));
1230
1231 assert_eq!(
1232 container.render_pretty(),
1233 "<div>\n <pre><code>cargo build</code></pre>\n</div>"
1234 );
1235 }
1236}