1use crate::core::attribute::Attribute;
9use crate::core::element::{DebugNode, Descendants, 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#[non_exhaustive]
23pub enum Node {
24 Element(Element),
26 Text(Markup),
28 Raw(Markup),
33 Comment(Markup),
37 Fragment(Vec<Node>),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct Markup(String);
67
68impl Markup {
69 pub(crate) fn into_string(self) -> String {
71 self.0
72 }
73
74 #[must_use]
76 pub fn as_str(&self) -> &str {
77 &self.0
78 }
79}
80
81impl std::ops::Deref for Markup {
82 type Target = str;
83
84 fn deref(&self) -> &str {
85 &self.0
86 }
87}
88
89impl AsRef<str> for Markup {
90 fn as_ref(&self) -> &str {
91 &self.0
92 }
93}
94
95impl std::fmt::Display for Markup {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(&self.0)
98 }
99}
100
101impl PartialEq<str> for Markup {
102 fn eq(&self, other: &str) -> bool {
103 self.0 == other
104 }
105}
106
107impl PartialEq<&str> for Markup {
108 fn eq(&self, other: &&str) -> bool {
109 self.0 == *other
110 }
111}
112
113impl Node {
114 #[must_use]
127 pub fn find(&self, tag: &str) -> Option<&Element> {
128 Descendants::new(self.below()).find(|element| element.tag() == tag)
129 }
130
131 pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Element> + 'a {
133 Descendants::new(self.below()).filter(move |element| element.tag() == tag)
134 }
135
136 fn below(&self) -> &[Node] {
138 match self {
139 Self::Element(element) => element.children(),
140 Self::Fragment(children) => children,
141 _ => &[],
142 }
143 }
144
145 #[must_use]
147 pub fn text(content: impl AsRef<str>) -> Self {
148 let raw = content.as_ref();
149 let mut escaped = String::with_capacity(raw.len());
150 write_escaped_text(&mut escaped, raw, false);
151 Self::Text(Markup(escaped))
152 }
153
154 pub(crate) fn text_display(value: &impl std::fmt::Display) -> Self {
157 use std::fmt::Write as _;
158 let mut escaped = String::new();
159 let _ = write!(crate::core::escape::TextWriter(&mut escaped), "{value}");
161 Self::Text(Markup(escaped))
162 }
163
164 #[must_use]
185 pub fn raw(content: impl Into<String>) -> Self {
186 Self::Raw(Markup(content.into()))
187 }
188
189 #[must_use]
201 pub fn comment(content: impl AsRef<str>) -> Self {
202 let mut neutralised = String::with_capacity(content.as_ref().len());
203 push_neutralised_comment(&mut neutralised, content.as_ref());
204 Self::Comment(Markup(neutralised))
205 }
206
207 #[must_use]
209 pub fn fragment(children: impl IntoIterator<Item = Node>) -> Self {
210 Self::Fragment(children.into_iter().collect())
211 }
212
213 #[must_use]
220 pub fn is_empty(&self) -> bool {
221 match self {
222 Self::Element(_) | Self::Comment(_) => false,
223 Self::Text(s) | Self::Raw(s) => s.is_empty(),
224 Self::Fragment(children) => {
225 let mut pending: Vec<&Node> = children.iter().collect();
228 while let Some(node) = pending.pop() {
229 match node {
230 Self::Element(_) | Self::Comment(_) => return false,
231 Self::Text(s) | Self::Raw(s) => {
232 if !s.is_empty() {
233 return false;
234 }
235 }
236 Self::Fragment(children) => pending.extend(children),
237 }
238 }
239 true
240 }
241 }
242 }
243}
244
245impl std::fmt::Debug for Node {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 DebugNode(self, 0).fmt(f)
259 }
260}
261
262impl Clone for Node {
263 fn clone(&self) -> Self {
264 crate::core::element::clone_tree(Source::Node(self))
265 }
266}
267
268impl PartialEq for Node {
269 fn eq(&self, other: &Self) -> bool {
270 crate::core::element::trees_equal(Source::Node(self), Source::Node(other))
271 }
272}
273
274impl Eq for Node {}
275
276#[derive(Clone, Copy)]
278pub(crate) enum Source<'a> {
279 Node(&'a Node),
280 Element(&'a Element),
281}
282
283impl std::fmt::Display for Node {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.write_str(&if f.alternate() {
287 self.render_pretty()
288 } else {
289 self.render()
290 })
291 }
292}
293
294impl std::hash::Hash for Node {
295 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
296 crate::core::element::hash_tree(Source::Node(self), state);
297 }
298}
299
300impl<N: Into<Node>> FromIterator<N> for Node {
303 fn from_iter<I: IntoIterator<Item = N>>(iter: I) -> Self {
304 Self::Fragment(iter.into_iter().map(Into::into).collect())
305 }
306}
307
308impl From<Vec<Node>> for Node {
309 fn from(children: Vec<Node>) -> Self {
310 Self::Fragment(children)
311 }
312}
313
314impl<T: Into<Node>> From<Option<T>> for Node {
316 fn from(value: Option<T>) -> Self {
317 value.map_or_else(|| Self::Fragment(Vec::new()), Into::into)
318 }
319}
320
321impl From<char> for Node {
322 fn from(c: char) -> Self {
323 Self::text(c.to_string())
324 }
325}
326
327impl From<Element> for Node {
328 fn from(element: Element) -> Self {
329 Self::Element(element)
330 }
331}
332
333impl From<&str> for Node {
334 fn from(text: &str) -> Self {
335 Self::text(text)
336 }
337}
338
339impl From<String> for Node {
340 fn from(text: String) -> Self {
341 Self::text(text)
342 }
343}
344
345impl Render for Node {
346 fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
347 write_tree(
348 if options.pretty {
349 Step::Pretty(self, depth)
350 } else {
351 Step::Compact(self)
352 },
353 out,
354 options,
355 );
356 }
357}
358
359pub(crate) fn write_element_tree(
365 element: &Element,
366 out: &mut String,
367 options: &RenderOptions,
368 depth: usize,
369) {
370 write_tree(
371 if options.pretty {
372 Step::PrettyElement(element, depth)
373 } else {
374 Step::CompactElement(element)
375 },
376 out,
377 options,
378 );
379}
380
381pub(crate) fn write_document_root(
388 lang: Option<&str>,
389 attributes: &[Attribute],
390 head: &Element,
391 body: &Element,
392 out: &mut String,
393 options: &RenderOptions,
394) {
395 if options.pretty {
396 options.write_indent(out, 0);
397 }
398 write_html_open_tag(lang, attributes, out);
399
400 let steps = if options.pretty {
402 vec![
403 Step::ClosePretty("html", 0),
404 Step::PrettyChildElement {
405 element: body,
406 depth: 1,
407 },
408 Step::PrettyChildElement {
409 element: head,
410 depth: 1,
411 },
412 ]
413 } else {
414 vec![
415 Step::Close("html"),
416 Step::CompactElement(body),
417 Step::CompactElement(head),
418 ]
419 };
420 run_steps(steps, out, options, 1);
422}
423
424pub(crate) fn write_html_open_tag(lang: Option<&str>, attributes: &[Attribute], out: &mut String) {
428 out.push_str("<html");
429 if let Some(lang) = lang {
430 Attribute::new_static("lang", lang).write_into_in(out, false, "html");
431 }
432 write_attributes(attributes, out, false, "html");
433 out.push('>');
434}
435
436enum Step<'a> {
444 Compact(&'a Node),
446 Pretty(&'a Node, usize),
448 CompactElement(&'a Element),
450 PrettyElement(&'a Element, usize),
452 Close(&'a str),
454 ClosePretty(&'a str, usize),
456 PrettyChild {
463 node: &'a Node,
464 depth: usize,
465 group_start: Option<usize>,
466 },
467 PrettyChildElement { element: &'a Element, depth: usize },
470 DropIfEmpty { mark: usize, after: usize },
477}
478
479fn write_tree(start: Step<'_>, out: &mut String, options: &RenderOptions) {
481 let mut stack = Vec::with_capacity(16);
482 stack.push(start);
483 run_steps(stack, out, options, 0);
484}
485
486fn run_steps(stack: Vec<Step<'_>>, out: &mut String, options: &RenderOptions, already_open: usize) {
488 if options.max_depth.is_some() {
492 run_steps_with::<true>(stack, out, options, already_open);
493 } else {
494 run_steps_with::<false>(stack, out, options, already_open);
495 }
496}
497
498#[allow(clippy::too_many_lines)]
501fn run_steps_with<const LIMITED: bool>(
502 mut stack: Vec<Step<'_>>,
503 out: &mut String,
504 options: &RenderOptions,
505 already_open: usize,
506) {
507 let mut raw_text_depth = 0usize;
511 let mut open_depth = already_open;
514 let max_depth = options.max_depth;
515 while let Some(step) = stack.pop() {
516 match step {
517 Step::Compact(Node::Text(text) | Node::Raw(text)) => out.push_str(text),
520 Step::Compact(Node::Comment(content)) => write_comment(out, content),
521 Step::Compact(Node::Fragment(children)) => {
522 stack.extend(children.iter().rev().map(Step::Compact));
523 }
524 Step::Compact(Node::Element(element)) => stack.push(Step::CompactElement(element)),
525
526 Step::Pretty(Node::Text(text) | Node::Raw(text), depth) => {
527 options.write_indent(out, depth);
528 out.push_str(text);
529 }
530 Step::Pretty(Node::Comment(content), depth) => {
531 options.write_indent(out, depth);
532 write_comment(out, content);
533 }
534 Step::Pretty(Node::Fragment(children), depth) => {
535 let group_start = out.len();
538 stack.extend(children.iter().rev().map(|child| Step::PrettyChild {
539 node: child,
540 depth,
541 group_start: Some(group_start),
542 }));
543 }
544 Step::Pretty(Node::Element(element), depth) => {
545 stack.push(Step::PrettyElement(element, depth));
546 }
547
548 Step::CompactElement(element) => {
549 if LIMITED && max_depth.is_some_and(|max| open_depth >= max) {
552 continue;
553 }
554 let in_raw_text = raw_text_depth > 0;
555 if push_compact_element(element, out, options, &mut stack, in_raw_text) {
556 if LIMITED {
557 open_depth += 1;
558 }
559 if is_raw_text(element.tag()) {
560 raw_text_depth += 1;
561 }
562 }
563 }
564 Step::PrettyElement(element, depth) => {
565 if LIMITED && max_depth.is_some_and(|max| open_depth >= max) {
566 continue;
567 }
568 let in_raw_text = raw_text_depth > 0;
569 if push_pretty_element(element, depth, out, options, &mut stack, in_raw_text) {
570 if LIMITED {
571 open_depth += 1;
572 }
573 if is_raw_text(element.tag()) {
574 raw_text_depth += 1;
575 }
576 }
577 }
578
579 Step::Close(tag) => {
580 if LIMITED {
581 open_depth = open_depth.saturating_sub(1);
582 }
583 if is_raw_text(tag) {
584 raw_text_depth = raw_text_depth.saturating_sub(1);
585 }
586 out.push_str("</");
587 out.push_str(tag);
588 out.push('>');
589 }
590 Step::ClosePretty(tag, depth) => {
591 if LIMITED {
592 open_depth = open_depth.saturating_sub(1);
593 }
594 if is_raw_text(tag) {
595 raw_text_depth = raw_text_depth.saturating_sub(1);
596 }
597 out.push('\n');
598 options.write_indent(out, depth);
599 out.push_str("</");
600 out.push_str(tag);
601 out.push('>');
602 }
603
604 Step::PrettyChild {
605 node,
606 depth,
607 group_start,
608 } => {
609 let mark = out.len();
610 let needs_separator = group_start.is_none_or(|start| out.len() > start);
611 if needs_separator {
612 out.push('\n');
613 }
614 let after = out.len();
615 stack.push(Step::DropIfEmpty { mark, after });
617 stack.push(Step::Pretty(node, depth));
618 }
619 Step::PrettyChildElement { element, depth } => {
620 let mark = out.len();
621 out.push('\n');
622 let after = out.len();
623 stack.push(Step::DropIfEmpty { mark, after });
624 stack.push(Step::PrettyElement(element, depth));
625 }
626 Step::DropIfEmpty { mark, after } => {
627 if out.len() == after {
628 out.truncate(mark);
629 }
630 }
631 }
632 }
633}
634
635fn push_compact_element<'a>(
639 element: &'a Element,
640 out: &mut String,
641 options: &RenderOptions,
642 stack: &mut Vec<Step<'a>>,
643 in_raw_text: bool,
644) -> bool {
645 if !is_valid_tag_name(element.tag()) {
648 return false;
649 }
650 out.push('<');
651 out.push_str(element.tag());
652 write_attributes(element.attributes(), out, in_raw_text, element.tag());
653
654 if is_void(element.tag()) || element.is_self_closing() {
659 write_void_suffix(out, options);
660 return false;
661 }
662
663 out.push('>');
664 if let Some(content) = element.content() {
665 out.push_str(content);
666 }
667 stack.push(Step::Close(element.tag()));
668 stack.extend(element.children().iter().rev().map(Step::Compact));
669 true
670}
671
672fn push_pretty_element<'a>(
674 element: &'a Element,
675 depth: usize,
676 out: &mut String,
677 options: &RenderOptions,
678 stack: &mut Vec<Step<'a>>,
679 in_raw_text: bool,
680) -> bool {
681 if !is_valid_tag_name(element.tag()) {
684 return false;
685 }
686 if is_whitespace_sensitive(element.tag()) {
689 options.write_indent(out, depth);
690 stack.push(Step::CompactElement(element));
691 return false;
692 }
693
694 options.write_indent(out, depth);
695 out.push('<');
696 out.push_str(element.tag());
697 write_attributes(element.attributes(), out, in_raw_text, element.tag());
698
699 if is_void(element.tag()) || element.is_self_closing() {
700 write_void_suffix(out, options);
701 return false;
702 }
703
704 out.push('>');
705
706 if element.children().is_empty() {
707 if let Some(content) = element.content() {
708 out.push_str(content);
709 }
710 out.push_str("</");
711 out.push_str(element.tag());
712 out.push('>');
713 return false;
714 }
715
716 if let Some(content) = element.content() {
717 out.push('\n');
718 options.write_indent(out, depth + 1);
719 out.push_str(content);
720 }
721
722 stack.push(Step::ClosePretty(element.tag(), depth));
723 stack.extend(
724 element
725 .children()
726 .iter()
727 .rev()
728 .map(|child| Step::PrettyChild {
729 node: child,
730 depth: depth + 1,
731 group_start: None,
732 }),
733 );
734 true
735}
736
737fn write_comment(out: &mut String, content: &str) {
738 out.push_str("<!-- ");
741 out.push_str(content);
742 out.push_str(" -->");
743}
744
745fn push_neutralised_comment(out: &mut String, content: &str) {
750 let mut previous_hyphen = false;
751 for c in content.chars() {
752 if c == '-' && previous_hyphen {
753 out.push(' ');
754 }
755 out.push(c);
756 previous_hyphen = c == '-';
757 }
758 if previous_hyphen {
759 out.push(' ');
760 }
761}
762
763fn write_attributes(attributes: &[Attribute], out: &mut String, in_raw_text: bool, tag: &str) {
764 for attribute in attributes {
765 attribute.write_into_in(out, in_raw_text, tag);
766 }
767}
768
769fn write_void_suffix(out: &mut String, options: &RenderOptions) {
771 out.push_str(if options.xhtml_self_closing {
772 " />"
773 } else {
774 ">"
775 });
776}
777
778#[cfg(test)]
779mod tests {
780 use std::collections::hash_map::DefaultHasher;
781 use std::hash::{Hash, Hasher};
782
783 fn hash_of(value: &impl Hash) -> u64 {
784 let mut hasher = DefaultHasher::new();
785 value.hash(&mut hasher);
786 hasher.finish()
787 }
788
789 #[test]
790 fn equal_trees_hash_equal_and_different_trees_do_not() {
791 let a = div()
792 .attr("id", "x")
793 .child(p().text("t"))
794 .child(Node::comment("c"));
795 let b = div()
796 .attr("id", "x")
797 .child(p().text("t"))
798 .child(Node::comment("c"));
799 assert_eq!(a, b);
800 assert_eq!(hash_of(&a), hash_of(&b));
801 assert_eq!(hash_of(&Node::from(a.clone())), hash_of(&Node::from(b)));
802 assert_ne!(hash_of(&a), hash_of(&a.clone().add_class("y")));
803 assert_ne!(hash_of(&Node::text("a")), hash_of(&Node::raw("a")));
804 assert_ne!(
805 hash_of(&Node::fragment([Node::text("a"), Node::text("b")])),
806 hash_of(&Node::fragment([
807 Node::fragment([Node::text("a")]),
808 Node::text("b")
809 ]))
810 );
811 }
812
813 #[test]
814 fn display_is_compact_and_alternate_is_pretty() {
815 let el = div().child(p().text("hi"));
816 assert_eq!(el.to_string(), "<div><p>hi</p></div>");
817 assert_eq!(format!("{el:#}"), "<div>\n <p>hi</p>\n</div>");
818 let node = Node::from(el);
819 assert_eq!(node.to_string(), "<div><p>hi</p></div>");
820 assert_eq!(format!("{node:#}"), "<div>\n <p>hi</p>\n</div>");
821 }
822
823 #[test]
824 fn nodes_collect_and_convert_from_the_common_shapes() {
825 let collected: Node = ["a", "b"].into_iter().map(Node::text).collect();
826 assert_eq!(collected.render(), "ab");
827 assert_eq!(
828 Node::from(vec![Node::text("a"), Node::text("b")]).render(),
829 "ab"
830 );
831 assert_eq!(Node::from(Some(p().text("x"))).render(), "<p>x</p>");
832 assert!(Node::from(None::<Element>).is_empty());
833 assert_eq!(Node::from('<').render(), "<");
834
835 let mut list = ul();
836 list.extend([li().text("1"), li().text("2")]);
837 list.extend(["tail"]);
838 assert_eq!(list.render(), "<ul><li>1</li><li>2</li>tail</ul>");
839 }
840
841 #[test]
842 fn references_boxes_and_options_render_like_what_they_hold() {
843 fn through<T: Render>(value: T) -> String {
847 value.render()
848 }
849 let el = p().text("x");
850 assert_eq!(through(&el), "<p>x</p>");
851 assert_eq!(through(Box::new(el.clone())), "<p>x</p>");
852 assert_eq!(through(Some(el.clone())), "<p>x</p>");
853 assert_eq!(through(None::<Element>), "");
854 let dynamic: Box<dyn Render> = Box::new(el);
855 assert_eq!(dynamic.render(), "<p>x</p>");
856 }
857
858 #[test]
859 fn a_document_has_a_default_and_a_display() {
860 let page = crate::document::Document::default();
861 assert_eq!(page.to_string(), page.render());
862 assert!(page.to_string().starts_with("<!DOCTYPE html>\n<html>"));
863 }
864
865 use super::*;
866 use crate::elements::{
867 body, code, div, h1, head, html_tag, i, img, li, p, pre, span, textarea, title, ul,
868 };
869 use crate::html;
872
873 #[test]
874 fn the_tree_is_send_and_sync() {
875 fn assert_send_sync<T: Send + Sync>() {}
876 assert_send_sync::<Node>();
877 assert_send_sync::<Element>();
878 }
879
880 #[test]
881 fn text_nodes_are_escaped() {
882 assert_eq!(Node::text("a & b").render(), "a & b");
883 }
884
885 #[test]
887 fn raw_nodes_are_never_escaped() {
888 assert_eq!(Node::raw("<b>bold</b>").render(), "<b>bold</b>");
889 assert_eq!(Node::raw("<b>bold</b>").render_pretty(), "<b>bold</b>");
890 }
891
892 #[test]
893 fn a_comment_cannot_close_itself_early() {
894 let rendered = Node::comment("a --> b").render();
895 assert_eq!(rendered.matches("-->").count(), 1);
896 assert!(rendered.ends_with("-->"));
897 }
898
899 #[test]
902 fn an_unsafe_tag_name_renders_nothing() {
903 for hostile in [
904 "img src=x onerror=alert(1)",
905 "div><script>alert(1)</script><div",
906 "a\"b",
907 "",
908 "1div",
909 "p\n",
910 ] {
911 let tree = div()
912 .child(Element::new(hostile).text("kept?"))
913 .child(p().text("after"));
914 assert_eq!(tree.render(), "<div><p>after</p></div>", "{hostile:?}");
915 assert_eq!(
916 tree.render_pretty(),
917 "<div>\n <p>after</p>\n</div>",
918 "{hostile:?} must leave no blank line in pretty mode"
919 );
920 assert_eq!(Element::new(hostile).render(), "", "{hostile:?}");
921 }
922 }
923
924 #[test]
925 fn custom_and_namespaced_tags_still_render() {
926 assert_eq!(
927 Element::new("my-card").text("x").render(),
928 "<my-card>x</my-card>"
929 );
930 assert_eq!(Element::new("svg:rect").render(), "<svg:rect></svg:rect>");
931 }
932
933 #[test]
937 fn angle_brackets_in_attributes_are_escaped_only_under_raw_text_parents() {
938 let hostile = "</noscript><img src=x onerror=alert(1)>";
939 let escaped = "</noscript><img src=x onerror=alert(1)>";
940
941 for parent in [
942 "noscript", "iframe", "xmp", "noembed", "noframes", "style", "script",
943 ] {
944 let tree = Element::new(parent).child(img().attr("alt", hostile));
945 assert_eq!(
946 tree.render(),
947 format!(r#"<{parent}><img alt="{escaped}"></{parent}>"#),
948 "{parent}"
949 );
950 }
951
952 assert_eq!(
953 div().child(img().attr("alt", hostile)).render(),
954 format!(r#"<div><img alt="{hostile}"></div>"#),
955 "ordinary parents leave `<` and `>` in an attribute value alone"
956 );
957 }
958
959 #[test]
960 fn the_raw_text_escape_covers_deep_descendants_and_ends_with_the_parent() {
961 let hostile = "a<b>c";
962 let tree = div()
963 .child(
964 Element::new("noscript")
965 .attr("title", hostile)
966 .child(div().child(span().child(img().attr("alt", hostile)))),
967 )
968 .child(img().attr("alt", hostile));
969 assert_eq!(
972 tree.render(),
973 concat!(
974 r#"<div><noscript title="a<b>c"><div><span>"#,
975 r#"<img alt="a<b>c"></span></div></noscript>"#,
976 r#"<img alt="a<b>c"></div>"#
977 )
978 );
979 assert_eq!(tree.render_pretty().matches("a<b>c").count(), 1);
980 assert_eq!(tree.render_pretty().matches("a<b>c").count(), 2);
981 }
982
983 #[test]
984 fn a_comment_survives_runs_of_hyphens_and_bang_closers() {
985 for hostile in [
986 "x---><img src=x onerror=alert(1)>",
987 "--!><b>",
988 "a-",
989 "---",
990 "-->",
991 "--",
992 ] {
993 let rendered = Node::comment(hostile).render();
994 let inner = rendered
995 .strip_prefix("<!-- ")
996 .and_then(|r| r.strip_suffix(" -->"));
997 let inner = inner.expect("comment keeps its delimiters");
998 assert!(!inner.contains("--"), "{hostile:?} -> {rendered:?}");
999 assert!(!inner.ends_with('-'), "{hostile:?} -> {rendered:?}");
1000 }
1001 }
1002
1003 #[test]
1004 fn an_empty_fragment_leaves_no_blank_line() {
1005 let tree = div()
1006 .child(p().text("a"))
1007 .child(Node::fragment([]))
1008 .child(p().text("b"));
1009 assert_eq!(
1010 tree.render_pretty(),
1011 "<div>\n <p>a</p>\n <p>b</p>\n</div>"
1012 );
1013 }
1014
1015 #[test]
1016 fn a_fragment_renders_its_children_without_a_wrapper() {
1017 let tree = Node::fragment([p().text("a").into(), p().text("b").into()]);
1018 assert_eq!(tree.render(), "<p>a</p><p>b</p>");
1019 assert_eq!(tree.render_pretty(), "<p>a</p>\n<p>b</p>");
1020 }
1021
1022 #[test]
1023 fn void_elements_take_no_children() {
1024 let tree = img().attr("src", "/a.png").child(span().text("dropped"));
1025 assert_eq!(tree.render(), r#"<img src="/a.png">"#);
1026 }
1027
1028 #[test]
1029 fn xhtml_mode_closes_void_elements_with_a_slash() {
1030 let options = RenderOptions::compact().with_xhtml_self_closing(true);
1031 assert_eq!(
1032 img().attr("src", "/a.png").render_with(&options),
1033 r#"<img src="/a.png" />"#
1034 );
1035 }
1036
1037 #[test]
1039 fn whitespace_sensitive_tags_are_not_indented_inside() {
1040 let tree = div().child(pre().child(code().text("let page = html { }")));
1041 assert_eq!(
1042 tree.render_pretty(),
1043 "<div>\n <pre><code>let page = html { }</code></pre>\n</div>"
1044 );
1045 }
1046
1047 #[test]
1049 fn content_goes_on_its_own_line_before_children() {
1050 let tree = div().text("lead").child(p().text("body"));
1051 assert_eq!(tree.render_pretty(), "<div>\n lead\n <p>body</p>\n</div>");
1052 }
1053
1054 #[test]
1057 fn child_then_text_keeps_call_order() {
1058 let tree = p().child(i().text("Owner: ")).text("Ada");
1059 assert_eq!(tree.render(), "<p><i>Owner: </i>Ada</p>");
1060 assert_eq!(tree.render_pretty(), "<p>\n <i>Owner: </i>\n Ada\n</p>");
1061 }
1062
1063 #[test]
1064 fn text_then_child_then_text_keeps_call_order() {
1065 let tree = p().text("a ").child(span().text("b")).text(" c");
1066 assert_eq!(tree.render(), "<p>a <span>b</span> c</p>");
1067 }
1068
1069 #[test]
1070 fn two_text_calls_append() {
1071 assert_eq!(p().text("a").text("b").render(), "<p>ab</p>");
1072 assert_eq!(p().text("a<").text("<b").render(), "<p>a<<b</p>");
1073 }
1074
1075 #[test]
1076 fn raw_text_follows_the_same_order_and_text_after_it_is_still_escaped() {
1077 let tree = p().raw_text("<i>").child(span()).raw_text("</i>").text("<");
1079 assert_eq!(tree.render(), "<p><i><span></span></i><</p>");
1080 }
1081
1082 #[test]
1083 fn a_code_window_line_ends_with_its_newline() {
1084 let line = span()
1085 .child(span().text("1"))
1086 .child(span().text(" let x = 1;"))
1087 .text("\n");
1088 assert_eq!(
1089 line.render(),
1090 "<span><span>1</span><span> let x = 1;</span>\n</span>"
1091 );
1092 }
1093
1094 #[test]
1095 fn find_returns_the_first_match_in_document_order_and_never_the_element_itself() {
1096 let tree = div()
1097 .child(p().child(span().text("inner")))
1098 .child(span().text("outer"));
1099 assert_eq!(
1100 tree.find("span").map(Render::render).as_deref(),
1101 Some("<span>inner</span>")
1102 );
1103 assert_eq!(tree.find_all("span").count(), 2);
1104 assert!(tree.find("div").is_none(), "only what is inside counts");
1105 assert!(tree.find("table").is_none());
1106 }
1107
1108 #[test]
1109 fn find_all_lists_matches_in_document_order() {
1110 let tree = ul().children_from(["a", "b", "c"].map(|x| li().text(x)));
1111 let texts: Vec<String> = tree.find_all("li").map(Render::render).collect();
1112 assert_eq!(texts, ["<li>a</li>", "<li>b</li>", "<li>c</li>"]);
1113 }
1114
1115 #[test]
1116 fn a_fragment_is_transparent_to_a_search() {
1117 let nodes = Node::fragment([
1118 Node::text("x"),
1119 Node::fragment([Node::from(h1().text("T"))]),
1120 Node::from(p().child(h1().text("U"))),
1121 ]);
1122 assert_eq!(nodes.find_all("h1").count(), 2);
1123 assert!(Node::text("x").find("p").is_none());
1124 let wrapper = Node::from(div().child(p()));
1125 assert!(wrapper.find("div").is_none());
1126 assert!(wrapper.find("p").is_some());
1127 }
1128
1129 #[test]
1130 fn attribute_reads_the_value_as_written_and_the_first_of_a_repeated_name() {
1131 let link = a_like();
1132 assert_eq!(link.attribute("href"), Some("/x?a=1&b=2"));
1133 assert_eq!(link.attribute("hidden"), Some(""));
1134 assert_eq!(link.attribute("title"), Some("first"));
1135 assert_eq!(link.attribute("missing"), None);
1136 }
1137
1138 fn a_like() -> Element {
1139 Element::new("a")
1140 .attr("href", "/x?a=1&b=2")
1141 .attr("title", "first")
1142 .attr("title", "second")
1143 .bool_attr("hidden")
1144 }
1145
1146 #[test]
1147 fn an_element_with_content_and_no_children_stays_on_one_line() {
1148 assert_eq!(p().text("hi").render_pretty(), "<p>hi</p>");
1149 }
1150
1151 #[test]
1152 fn compact_is_the_default_for_an_element() {
1153 let tree = div().child(p().text("a"));
1154 assert_eq!(tree.render(), "<div><p>a</p></div>");
1155 }
1156
1157 #[test]
1158 fn raw_markup_renders_with_no_wrapper_around_it() {
1159 let raw = Node::raw(r#"<span class="x">hi</span>"#);
1160
1161 assert_eq!(raw.render(), r#"<span class="x">hi</span>"#);
1162 assert!(!raw.render().contains("<div"));
1163 }
1164
1165 #[test]
1166 fn raw_markup_as_a_child_adds_no_wrapper() {
1167 let markup = div()
1168 .child(Node::raw(r#"<i class="fa fa-home"></i>"#))
1169 .child(span().text("Home"));
1170
1171 assert_eq!(
1172 markup.render(),
1173 r#"<div><i class="fa fa-home"></i><span>Home</span></div>"#
1174 );
1175 }
1176
1177 #[test]
1178 fn a_fragment_renders_its_children_with_no_wrapper() {
1179 let fragment = Node::fragment([
1180 i().add_class("fa fa-star").into(),
1181 span().text(" Featured").into(),
1182 ]);
1183
1184 assert_eq!(
1185 fragment.render(),
1186 r#"<i class="fa fa-star"></i><span> Featured</span>"#
1187 );
1188 }
1189
1190 #[test]
1191 fn a_fragment_takes_a_mapped_sequence() {
1192 let fragment = Node::fragment(
1193 ["One", "Two", "Three"].map(|title| div().add_class("card").text(title).into()),
1194 );
1195
1196 assert_eq!(
1197 fragment.render(),
1198 concat!(
1199 r#"<div class="card">One</div><div class="card">Two</div>"#,
1200 r#"<div class="card">Three</div>"#,
1201 )
1202 );
1203 }
1204
1205 #[test]
1209 fn an_empty_fragment_renders_nothing_in_either_mode() {
1210 assert_eq!(Node::fragment([]).render(), "");
1211 assert_eq!(Node::fragment([]).render_pretty(), "");
1212 }
1213
1214 #[test]
1215 fn a_fragments_children_are_indented_as_the_parents_own() {
1216 let list = ul().child(Node::fragment([
1217 li().text("a").into(),
1218 li().text("b").into(),
1219 ]));
1220
1221 assert_eq!(
1222 list.render_pretty(),
1223 "<ul>\n <li>a</li>\n <li>b</li>\n</ul>"
1224 );
1225 }
1226
1227 #[test]
1228 fn a_fragment_takes_a_filtered_and_mapped_sequence() {
1229 let names = ["Ana", "Bruno", "Carla"];
1230 let group = Node::fragment(
1231 names
1232 .iter()
1233 .filter(|name| name.len() > 3)
1234 .map(|name| li().text(name).into()),
1235 );
1236
1237 assert_eq!(group.render(), "<li>Bruno</li><li>Carla</li>");
1238 }
1239
1240 #[test]
1241 fn a_false_condition_emits_no_stray_node() {
1242 let show_banner = false;
1243 let page = html_tag()
1244 .child(head().child(title().text("Home")))
1245 .child(html! { @if show_banner { div { "banner" } } })
1246 .child(body().child(h1().text("Hi")));
1247
1248 assert_eq!(
1249 page.render(),
1250 "<html><head><title>Home</title></head><body><h1>Hi</h1></body></html>"
1251 );
1252 }
1253
1254 #[test]
1255 fn a_true_condition_emits_its_branch() {
1256 let show_banner = true;
1257 let page = html_tag().child(html! { @if show_banner { div { "banner" } } });
1258
1259 assert_eq!(page.render(), "<html><div>banner</div></html>");
1260 }
1261
1262 #[test]
1263 fn a_loop_emits_one_node_per_iteration() {
1264 let page = html_tag().child(html! {
1265 @for index in 1..=3 { p { "line " (index) } }
1266 });
1267
1268 assert_eq!(
1269 page.render(),
1270 "<html><p>line 1</p><p>line 2</p><p>line 3</p></html>"
1271 );
1272 }
1273
1274 #[test]
1275 fn raw_markup_is_indented_as_one_blob() {
1276 let container = div().child(Node::raw("<custom-element></custom-element>"));
1277
1278 assert_eq!(
1279 container.render_pretty(),
1280 "<div>\n <custom-element></custom-element>\n</div>"
1281 );
1282 }
1283
1284 #[test]
1285 fn a_condition_can_pick_either_branch() {
1286 fn badge(is_beta: bool) -> Node {
1287 html! { @if is_beta { span { "beta" } } @else { span { "stable" } } }
1288 }
1289
1290 assert_eq!(badge(true).render(), "<span>beta</span>");
1291 assert_eq!(badge(false).render(), "<span>stable</span>");
1292 }
1293
1294 #[test]
1297 fn nested_fragments_flatten_without_leaving_gaps() {
1298 let list = ul().child(Node::fragment([
1299 Node::fragment([li().text("a").into()]),
1300 Node::fragment([]),
1301 li().text("b").into(),
1302 ]));
1303
1304 assert_eq!(
1305 list.render_pretty(),
1306 "<ul>\n <li>a</li>\n <li>b</li>\n</ul>"
1307 );
1308 }
1309
1310 #[test]
1311 fn raw_markup_is_emitted_verbatim() {
1312 let raw = Node::raw(r#"<custom-element data-x="1"></custom-element>"#);
1313
1314 assert_eq!(
1315 raw.render(),
1316 r#"<custom-element data-x="1"></custom-element>"#
1317 );
1318 }
1319
1320 #[test]
1321 fn a_tag_with_only_content_stays_on_one_line_when_pretty() {
1322 assert_eq!(
1323 div().text("Hello World").render_pretty(),
1324 "<div>Hello World</div>"
1325 );
1326 }
1327
1328 #[test]
1329 fn children_each_get_their_own_line_when_pretty() {
1330 let tree = div()
1331 .child(p().text("Paragraph 1"))
1332 .child(p().text("Paragraph 2"));
1333
1334 assert_eq!(
1335 tree.render_pretty(),
1336 "<div>\n <p>Paragraph 1</p>\n <p>Paragraph 2</p>\n</div>"
1337 );
1338 }
1339
1340 #[test]
1341 fn compact_keeps_everything_on_one_line() {
1342 assert_eq!(
1343 div().child(p().text("Test")).render(),
1344 "<div><p>Test</p></div>"
1345 );
1346 }
1347
1348 #[test]
1349 fn a_void_element_does_not_self_close_when_pretty() {
1350 let rendered = img()
1351 .attr("src", "test.jpg")
1352 .attr("alt", "Test")
1353 .render_pretty();
1354
1355 assert!(rendered.starts_with("<img"));
1356 assert!(!rendered.contains("/>"));
1357 assert!(rendered.ends_with('>'));
1358 }
1359
1360 #[test]
1361 fn a_textarea_keeps_its_newlines() {
1362 let field = textarea().attr("name", "bio").text("line 1\nline 2");
1363
1364 assert_eq!(
1365 field.render_pretty(),
1366 "<textarea name=\"bio\">line 1\nline 2</textarea>"
1367 );
1368 }
1369
1370 #[test]
1371 fn a_pre_block_is_indented_from_the_outside_but_not_within() {
1372 let container = div().child(pre().child(code().text("cargo build")));
1373
1374 assert_eq!(
1375 container.render_pretty(),
1376 "<div>\n <pre><code>cargo build</code></pre>\n</div>"
1377 );
1378 }
1379}