use crate::core::attribute::Attribute;
use crate::core::element::Element;
use crate::core::escape::write_escaped_text;
use crate::core::render::{Render, RenderOptions};
use crate::core::tags::{is_raw_text, is_valid_tag_name, is_void, is_whitespace_sensitive};
#[derive(Debug)]
#[non_exhaustive]
pub enum Node {
Element(Element),
Text(Markup),
Raw(Markup),
Comment(Markup),
Fragment(Vec<Node>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Markup(String);
impl Markup {
pub(crate) fn into_string(self) -> String {
self.0
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for Markup {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Markup {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Markup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl PartialEq<str> for Markup {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for Markup {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl Node {
#[must_use]
pub fn text(content: impl AsRef<str>) -> Self {
let raw = content.as_ref();
let mut escaped = String::with_capacity(raw.len());
write_escaped_text(&mut escaped, raw, false);
Self::Text(Markup(escaped))
}
pub(crate) fn text_display(value: &impl std::fmt::Display) -> Self {
use std::fmt::Write as _;
let mut escaped = String::new();
let _ = write!(crate::core::escape::TextWriter(&mut escaped), "{value}");
Self::Text(Markup(escaped))
}
#[must_use]
pub fn raw(content: impl Into<String>) -> Self {
Self::Raw(Markup(content.into()))
}
#[must_use]
pub fn comment(content: impl AsRef<str>) -> Self {
let mut neutralised = String::with_capacity(content.as_ref().len());
push_neutralised_comment(&mut neutralised, content.as_ref());
Self::Comment(Markup(neutralised))
}
#[must_use]
pub fn fragment(children: impl IntoIterator<Item = Node>) -> Self {
Self::Fragment(children.into_iter().collect())
}
#[must_use]
pub fn is_empty(&self) -> bool {
match self {
Self::Element(_) | Self::Comment(_) => false,
Self::Text(s) | Self::Raw(s) => s.is_empty(),
Self::Fragment(children) => {
let mut pending: Vec<&Node> = children.iter().collect();
while let Some(node) = pending.pop() {
match node {
Self::Element(_) | Self::Comment(_) => return false,
Self::Text(s) | Self::Raw(s) => {
if !s.is_empty() {
return false;
}
}
Self::Fragment(children) => pending.extend(children),
}
}
true
}
}
}
}
impl Clone for Node {
fn clone(&self) -> Self {
crate::core::element::clone_tree(Source::Node(self))
}
}
impl PartialEq for Node {
fn eq(&self, other: &Self) -> bool {
crate::core::element::trees_equal(Source::Node(self), Source::Node(other))
}
}
impl Eq for Node {}
#[derive(Clone, Copy)]
pub(crate) enum Source<'a> {
Node(&'a Node),
Element(&'a Element),
}
impl std::fmt::Display for Node {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&if f.alternate() {
self.render_pretty()
} else {
self.render()
})
}
}
impl std::hash::Hash for Node {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
crate::core::element::hash_tree(Source::Node(self), state);
}
}
impl<N: Into<Node>> FromIterator<N> for Node {
fn from_iter<I: IntoIterator<Item = N>>(iter: I) -> Self {
Self::Fragment(iter.into_iter().map(Into::into).collect())
}
}
impl From<Vec<Node>> for Node {
fn from(children: Vec<Node>) -> Self {
Self::Fragment(children)
}
}
impl<T: Into<Node>> From<Option<T>> for Node {
fn from(value: Option<T>) -> Self {
value.map_or_else(|| Self::Fragment(Vec::new()), Into::into)
}
}
impl From<char> for Node {
fn from(c: char) -> Self {
Self::text(c.to_string())
}
}
impl From<Element> for Node {
fn from(element: Element) -> Self {
Self::Element(element)
}
}
impl From<&str> for Node {
fn from(text: &str) -> Self {
Self::text(text)
}
}
impl From<String> for Node {
fn from(text: String) -> Self {
Self::text(text)
}
}
impl Render for Node {
fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
write_tree(
if options.pretty {
Step::Pretty(self, depth)
} else {
Step::Compact(self)
},
out,
options,
);
}
}
pub(crate) fn write_element_tree(
element: &Element,
out: &mut String,
options: &RenderOptions,
depth: usize,
) {
write_tree(
if options.pretty {
Step::PrettyElement(element, depth)
} else {
Step::CompactElement(element)
},
out,
options,
);
}
pub(crate) fn write_document_root(
lang: Option<&str>,
head: &Element,
body: &Element,
out: &mut String,
options: &RenderOptions,
) {
if options.pretty {
options.write_indent(out, 0);
}
out.push_str("<html");
if let Some(lang) = lang {
Attribute::new_static("lang", lang).write_into(out);
}
out.push('>');
let steps = if options.pretty {
vec![
Step::ClosePretty("html", 0),
Step::PrettyChildElement {
element: body,
depth: 1,
},
Step::PrettyChildElement {
element: head,
depth: 1,
},
]
} else {
vec![
Step::Close("html"),
Step::CompactElement(body),
Step::CompactElement(head),
]
};
run_steps(steps, out, options, 1);
}
enum Step<'a> {
Compact(&'a Node),
Pretty(&'a Node, usize),
CompactElement(&'a Element),
PrettyElement(&'a Element, usize),
Close(&'a str),
ClosePretty(&'a str, usize),
PrettyChild {
node: &'a Node,
depth: usize,
group_start: Option<usize>,
},
PrettyChildElement { element: &'a Element, depth: usize },
DropIfEmpty { mark: usize, after: usize },
}
fn write_tree(start: Step<'_>, out: &mut String, options: &RenderOptions) {
let mut stack = Vec::with_capacity(16);
stack.push(start);
run_steps(stack, out, options, 0);
}
fn run_steps(stack: Vec<Step<'_>>, out: &mut String, options: &RenderOptions, already_open: usize) {
if options.max_depth.is_some() {
run_steps_with::<true>(stack, out, options, already_open);
} else {
run_steps_with::<false>(stack, out, options, already_open);
}
}
#[allow(clippy::too_many_lines)]
fn run_steps_with<const LIMITED: bool>(
mut stack: Vec<Step<'_>>,
out: &mut String,
options: &RenderOptions,
already_open: usize,
) {
let mut raw_text_depth = 0usize;
let mut open_depth = already_open;
let max_depth = options.max_depth;
while let Some(step) = stack.pop() {
match step {
Step::Compact(Node::Text(text) | Node::Raw(text)) => out.push_str(text),
Step::Compact(Node::Comment(content)) => write_comment(out, content),
Step::Compact(Node::Fragment(children)) => {
stack.extend(children.iter().rev().map(Step::Compact));
}
Step::Compact(Node::Element(element)) => stack.push(Step::CompactElement(element)),
Step::Pretty(Node::Text(text) | Node::Raw(text), depth) => {
options.write_indent(out, depth);
out.push_str(text);
}
Step::Pretty(Node::Comment(content), depth) => {
options.write_indent(out, depth);
write_comment(out, content);
}
Step::Pretty(Node::Fragment(children), depth) => {
let group_start = out.len();
stack.extend(children.iter().rev().map(|child| Step::PrettyChild {
node: child,
depth,
group_start: Some(group_start),
}));
}
Step::Pretty(Node::Element(element), depth) => {
stack.push(Step::PrettyElement(element, depth));
}
Step::CompactElement(element) => {
if LIMITED && max_depth.is_some_and(|max| open_depth >= max) {
continue;
}
let in_raw_text = raw_text_depth > 0;
if push_compact_element(element, out, options, &mut stack, in_raw_text) {
if LIMITED {
open_depth += 1;
}
if is_raw_text(element.tag()) {
raw_text_depth += 1;
}
}
}
Step::PrettyElement(element, depth) => {
if LIMITED && max_depth.is_some_and(|max| open_depth >= max) {
continue;
}
let in_raw_text = raw_text_depth > 0;
if push_pretty_element(element, depth, out, options, &mut stack, in_raw_text) {
if LIMITED {
open_depth += 1;
}
if is_raw_text(element.tag()) {
raw_text_depth += 1;
}
}
}
Step::Close(tag) => {
if LIMITED {
open_depth = open_depth.saturating_sub(1);
}
if is_raw_text(tag) {
raw_text_depth = raw_text_depth.saturating_sub(1);
}
out.push_str("</");
out.push_str(tag);
out.push('>');
}
Step::ClosePretty(tag, depth) => {
if LIMITED {
open_depth = open_depth.saturating_sub(1);
}
if is_raw_text(tag) {
raw_text_depth = raw_text_depth.saturating_sub(1);
}
out.push('\n');
options.write_indent(out, depth);
out.push_str("</");
out.push_str(tag);
out.push('>');
}
Step::PrettyChild {
node,
depth,
group_start,
} => {
let mark = out.len();
let needs_separator = group_start.is_none_or(|start| out.len() > start);
if needs_separator {
out.push('\n');
}
let after = out.len();
stack.push(Step::DropIfEmpty { mark, after });
stack.push(Step::Pretty(node, depth));
}
Step::PrettyChildElement { element, depth } => {
let mark = out.len();
out.push('\n');
let after = out.len();
stack.push(Step::DropIfEmpty { mark, after });
stack.push(Step::PrettyElement(element, depth));
}
Step::DropIfEmpty { mark, after } => {
if out.len() == after {
out.truncate(mark);
}
}
}
}
}
fn push_compact_element<'a>(
element: &'a Element,
out: &mut String,
options: &RenderOptions,
stack: &mut Vec<Step<'a>>,
in_raw_text: bool,
) -> bool {
if !is_valid_tag_name(element.tag()) {
return false;
}
out.push('<');
out.push_str(element.tag());
write_attributes(element.attributes(), out, in_raw_text, element.tag());
if is_void(element.tag()) || element.is_self_closing() {
write_void_suffix(out, options);
return false;
}
out.push('>');
if let Some(content) = element.content() {
out.push_str(content);
}
stack.push(Step::Close(element.tag()));
stack.extend(element.children().iter().rev().map(Step::Compact));
true
}
fn push_pretty_element<'a>(
element: &'a Element,
depth: usize,
out: &mut String,
options: &RenderOptions,
stack: &mut Vec<Step<'a>>,
in_raw_text: bool,
) -> bool {
if !is_valid_tag_name(element.tag()) {
return false;
}
if is_whitespace_sensitive(element.tag()) {
options.write_indent(out, depth);
stack.push(Step::CompactElement(element));
return false;
}
options.write_indent(out, depth);
out.push('<');
out.push_str(element.tag());
write_attributes(element.attributes(), out, in_raw_text, element.tag());
if is_void(element.tag()) || element.is_self_closing() {
write_void_suffix(out, options);
return false;
}
out.push('>');
if element.children().is_empty() {
if let Some(content) = element.content() {
out.push_str(content);
}
out.push_str("</");
out.push_str(element.tag());
out.push('>');
return false;
}
if let Some(content) = element.content() {
out.push('\n');
options.write_indent(out, depth + 1);
out.push_str(content);
}
stack.push(Step::ClosePretty(element.tag(), depth));
stack.extend(
element
.children()
.iter()
.rev()
.map(|child| Step::PrettyChild {
node: child,
depth: depth + 1,
group_start: None,
}),
);
true
}
fn write_comment(out: &mut String, content: &str) {
out.push_str("<!-- ");
out.push_str(content);
out.push_str(" -->");
}
fn push_neutralised_comment(out: &mut String, content: &str) {
let mut previous_hyphen = false;
for c in content.chars() {
if c == '-' && previous_hyphen {
out.push(' ');
}
out.push(c);
previous_hyphen = c == '-';
}
if previous_hyphen {
out.push(' ');
}
}
fn write_attributes(attributes: &[Attribute], out: &mut String, in_raw_text: bool, tag: &str) {
for attribute in attributes {
attribute.write_into_in(out, in_raw_text, tag);
}
}
fn write_void_suffix(out: &mut String, options: &RenderOptions) {
out.push_str(if options.xhtml_self_closing {
" />"
} else {
">"
});
}
#[cfg(test)]
mod tests {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn hash_of(value: &impl Hash) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
#[test]
fn equal_trees_hash_equal_and_different_trees_do_not() {
let a = div()
.attr("id", "x")
.child(p().text("t"))
.child(Node::comment("c"));
let b = div()
.attr("id", "x")
.child(p().text("t"))
.child(Node::comment("c"));
assert_eq!(a, b);
assert_eq!(hash_of(&a), hash_of(&b));
assert_eq!(hash_of(&Node::from(a.clone())), hash_of(&Node::from(b)));
assert_ne!(hash_of(&a), hash_of(&a.clone().add_class("y")));
assert_ne!(hash_of(&Node::text("a")), hash_of(&Node::raw("a")));
assert_ne!(
hash_of(&Node::fragment([Node::text("a"), Node::text("b")])),
hash_of(&Node::fragment([
Node::fragment([Node::text("a")]),
Node::text("b")
]))
);
}
#[test]
fn display_is_compact_and_alternate_is_pretty() {
let el = div().child(p().text("hi"));
assert_eq!(el.to_string(), "<div><p>hi</p></div>");
assert_eq!(format!("{el:#}"), "<div>\n <p>hi</p>\n</div>");
let node = Node::from(el);
assert_eq!(node.to_string(), "<div><p>hi</p></div>");
assert_eq!(format!("{node:#}"), "<div>\n <p>hi</p>\n</div>");
}
#[test]
fn nodes_collect_and_convert_from_the_common_shapes() {
let collected: Node = ["a", "b"].into_iter().map(Node::text).collect();
assert_eq!(collected.render(), "ab");
assert_eq!(
Node::from(vec![Node::text("a"), Node::text("b")]).render(),
"ab"
);
assert_eq!(Node::from(Some(p().text("x"))).render(), "<p>x</p>");
assert!(Node::from(None::<Element>).is_empty());
assert_eq!(Node::from('<').render(), "<");
let mut list = ul();
list.extend([li().text("1"), li().text("2")]);
list.extend(["tail"]);
assert_eq!(list.render(), "<ul><li>1</li><li>2</li>tail</ul>");
}
#[test]
fn references_boxes_and_options_render_like_what_they_hold() {
fn through<T: Render>(value: T) -> String {
value.render()
}
let el = p().text("x");
assert_eq!(through(&el), "<p>x</p>");
assert_eq!(through(Box::new(el.clone())), "<p>x</p>");
assert_eq!(through(Some(el.clone())), "<p>x</p>");
assert_eq!(through(None::<Element>), "");
let dynamic: Box<dyn Render> = Box::new(el);
assert_eq!(dynamic.render(), "<p>x</p>");
}
#[test]
fn a_document_has_a_default_and_a_display() {
let page = crate::document::Document::default();
assert_eq!(page.to_string(), page.render());
assert!(page.to_string().starts_with("<!DOCTYPE html>\n<html>"));
}
use super::*;
use crate::elements::{
body, code, div, h1, head, html_tag, i, img, li, p, pre, span, textarea, title, ul,
};
use crate::html;
#[test]
fn the_tree_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Node>();
assert_send_sync::<Element>();
}
#[test]
fn text_nodes_are_escaped() {
assert_eq!(Node::text("a & b").render(), "a & b");
}
#[test]
fn raw_nodes_are_never_escaped() {
assert_eq!(Node::raw("<b>bold</b>").render(), "<b>bold</b>");
assert_eq!(Node::raw("<b>bold</b>").render_pretty(), "<b>bold</b>");
}
#[test]
fn a_comment_cannot_close_itself_early() {
let rendered = Node::comment("a --> b").render();
assert_eq!(rendered.matches("-->").count(), 1);
assert!(rendered.ends_with("-->"));
}
#[test]
fn an_unsafe_tag_name_renders_nothing() {
for hostile in [
"img src=x onerror=alert(1)",
"div><script>alert(1)</script><div",
"a\"b",
"",
"1div",
"p\n",
] {
let tree = div()
.child(Element::new(hostile).text("kept?"))
.child(p().text("after"));
assert_eq!(tree.render(), "<div><p>after</p></div>", "{hostile:?}");
assert_eq!(
tree.render_pretty(),
"<div>\n <p>after</p>\n</div>",
"{hostile:?} must leave no blank line in pretty mode"
);
assert_eq!(Element::new(hostile).render(), "", "{hostile:?}");
}
}
#[test]
fn custom_and_namespaced_tags_still_render() {
assert_eq!(
Element::new("my-card").text("x").render(),
"<my-card>x</my-card>"
);
assert_eq!(Element::new("svg:rect").render(), "<svg:rect></svg:rect>");
}
#[test]
fn angle_brackets_in_attributes_are_escaped_only_under_raw_text_parents() {
let hostile = "</noscript><img src=x onerror=alert(1)>";
let escaped = "</noscript><img src=x onerror=alert(1)>";
for parent in [
"noscript", "iframe", "xmp", "noembed", "noframes", "style", "script",
] {
let tree = Element::new(parent).child(img().attr("alt", hostile));
assert_eq!(
tree.render(),
format!(r#"<{parent}><img alt="{escaped}"></{parent}>"#),
"{parent}"
);
}
assert_eq!(
div().child(img().attr("alt", hostile)).render(),
format!(r#"<div><img alt="{hostile}"></div>"#),
"ordinary parents leave `<` and `>` in an attribute value alone"
);
}
#[test]
fn the_raw_text_escape_covers_deep_descendants_and_ends_with_the_parent() {
let hostile = "a<b>c";
let tree = div()
.child(
Element::new("noscript")
.attr("title", hostile)
.child(div().child(span().child(img().attr("alt", hostile)))),
)
.child(img().attr("alt", hostile));
assert_eq!(
tree.render(),
concat!(
r#"<div><noscript title="a<b>c"><div><span>"#,
r#"<img alt="a<b>c"></span></div></noscript>"#,
r#"<img alt="a<b>c"></div>"#
)
);
assert_eq!(tree.render_pretty().matches("a<b>c").count(), 1);
assert_eq!(tree.render_pretty().matches("a<b>c").count(), 2);
}
#[test]
fn a_comment_survives_runs_of_hyphens_and_bang_closers() {
for hostile in [
"x---><img src=x onerror=alert(1)>",
"--!><b>",
"a-",
"---",
"-->",
"--",
] {
let rendered = Node::comment(hostile).render();
let inner = rendered
.strip_prefix("<!-- ")
.and_then(|r| r.strip_suffix(" -->"));
let inner = inner.expect("comment keeps its delimiters");
assert!(!inner.contains("--"), "{hostile:?} -> {rendered:?}");
assert!(!inner.ends_with('-'), "{hostile:?} -> {rendered:?}");
}
}
#[test]
fn an_empty_fragment_leaves_no_blank_line() {
let tree = div()
.child(p().text("a"))
.child(Node::fragment([]))
.child(p().text("b"));
assert_eq!(
tree.render_pretty(),
"<div>\n <p>a</p>\n <p>b</p>\n</div>"
);
}
#[test]
fn a_fragment_renders_its_children_without_a_wrapper() {
let tree = Node::fragment([p().text("a").into(), p().text("b").into()]);
assert_eq!(tree.render(), "<p>a</p><p>b</p>");
assert_eq!(tree.render_pretty(), "<p>a</p>\n<p>b</p>");
}
#[test]
fn void_elements_take_no_children() {
let tree = img().attr("src", "/a.png").child(span().text("dropped"));
assert_eq!(tree.render(), r#"<img src="/a.png">"#);
}
#[test]
fn xhtml_mode_closes_void_elements_with_a_slash() {
let options = RenderOptions::compact().with_xhtml_self_closing(true);
assert_eq!(
img().attr("src", "/a.png").render_with(&options),
r#"<img src="/a.png" />"#
);
}
#[test]
fn whitespace_sensitive_tags_are_not_indented_inside() {
let tree = div().child(pre().child(code().text("let page = html { }")));
assert_eq!(
tree.render_pretty(),
"<div>\n <pre><code>let page = html { }</code></pre>\n</div>"
);
}
#[test]
fn content_goes_on_its_own_line_before_children() {
let tree = div().text("lead").child(p().text("body"));
assert_eq!(tree.render_pretty(), "<div>\n lead\n <p>body</p>\n</div>");
}
#[test]
fn an_element_with_content_and_no_children_stays_on_one_line() {
assert_eq!(p().text("hi").render_pretty(), "<p>hi</p>");
}
#[test]
fn compact_is_the_default_for_an_element() {
let tree = div().child(p().text("a"));
assert_eq!(tree.render(), "<div><p>a</p></div>");
}
#[test]
fn raw_markup_renders_with_no_wrapper_around_it() {
let raw = Node::raw(r#"<span class="x">hi</span>"#);
assert_eq!(raw.render(), r#"<span class="x">hi</span>"#);
assert!(!raw.render().contains("<div"));
}
#[test]
fn raw_markup_as_a_child_adds_no_wrapper() {
let markup = div()
.child(Node::raw(r#"<i class="fa fa-home"></i>"#))
.child(span().text("Home"));
assert_eq!(
markup.render(),
r#"<div><i class="fa fa-home"></i><span>Home</span></div>"#
);
}
#[test]
fn a_fragment_renders_its_children_with_no_wrapper() {
let fragment = Node::fragment([
i().add_class("fa fa-star").into(),
span().text(" Featured").into(),
]);
assert_eq!(
fragment.render(),
r#"<i class="fa fa-star"></i><span> Featured</span>"#
);
}
#[test]
fn a_fragment_takes_a_mapped_sequence() {
let fragment = Node::fragment(
["One", "Two", "Three"].map(|title| div().add_class("card").text(title).into()),
);
assert_eq!(
fragment.render(),
concat!(
r#"<div class="card">One</div><div class="card">Two</div>"#,
r#"<div class="card">Three</div>"#,
)
);
}
#[test]
fn an_empty_fragment_renders_nothing_in_either_mode() {
assert_eq!(Node::fragment([]).render(), "");
assert_eq!(Node::fragment([]).render_pretty(), "");
}
#[test]
fn a_fragments_children_are_indented_as_the_parents_own() {
let list = ul().child(Node::fragment([
li().text("a").into(),
li().text("b").into(),
]));
assert_eq!(
list.render_pretty(),
"<ul>\n <li>a</li>\n <li>b</li>\n</ul>"
);
}
#[test]
fn a_fragment_takes_a_filtered_and_mapped_sequence() {
let names = ["Ana", "Bruno", "Carla"];
let group = Node::fragment(
names
.iter()
.filter(|name| name.len() > 3)
.map(|name| li().text(name).into()),
);
assert_eq!(group.render(), "<li>Bruno</li><li>Carla</li>");
}
#[test]
fn a_false_condition_emits_no_stray_node() {
let show_banner = false;
let page = html_tag()
.child(head().child(title().text("Home")))
.child(html! { @if show_banner { div { "banner" } } })
.child(body().child(h1().text("Hi")));
assert_eq!(
page.render(),
"<html><head><title>Home</title></head><body><h1>Hi</h1></body></html>"
);
}
#[test]
fn a_true_condition_emits_its_branch() {
let show_banner = true;
let page = html_tag().child(html! { @if show_banner { div { "banner" } } });
assert_eq!(page.render(), "<html><div>banner</div></html>");
}
#[test]
fn a_loop_emits_one_node_per_iteration() {
let page = html_tag().child(html! {
@for index in 1..=3 { p { "line " (index) } }
});
assert_eq!(
page.render(),
"<html><p>line 1</p><p>line 2</p><p>line 3</p></html>"
);
}
#[test]
fn raw_markup_is_indented_as_one_blob() {
let container = div().child(Node::raw("<custom-element></custom-element>"));
assert_eq!(
container.render_pretty(),
"<div>\n <custom-element></custom-element>\n</div>"
);
}
#[test]
fn a_condition_can_pick_either_branch() {
fn badge(is_beta: bool) -> Node {
html! { @if is_beta { span { "beta" } } @else { span { "stable" } } }
}
assert_eq!(badge(true).render(), "<span>beta</span>");
assert_eq!(badge(false).render(), "<span>stable</span>");
}
#[test]
fn nested_fragments_flatten_without_leaving_gaps() {
let list = ul().child(Node::fragment([
Node::fragment([li().text("a").into()]),
Node::fragment([]),
li().text("b").into(),
]));
assert_eq!(
list.render_pretty(),
"<ul>\n <li>a</li>\n <li>b</li>\n</ul>"
);
}
#[test]
fn raw_markup_is_emitted_verbatim() {
let raw = Node::raw(r#"<custom-element data-x="1"></custom-element>"#);
assert_eq!(
raw.render(),
r#"<custom-element data-x="1"></custom-element>"#
);
}
#[test]
fn a_tag_with_only_content_stays_on_one_line_when_pretty() {
assert_eq!(
div().text("Hello World").render_pretty(),
"<div>Hello World</div>"
);
}
#[test]
fn children_each_get_their_own_line_when_pretty() {
let tree = div()
.child(p().text("Paragraph 1"))
.child(p().text("Paragraph 2"));
assert_eq!(
tree.render_pretty(),
"<div>\n <p>Paragraph 1</p>\n <p>Paragraph 2</p>\n</div>"
);
}
#[test]
fn compact_keeps_everything_on_one_line() {
assert_eq!(
div().child(p().text("Test")).render(),
"<div><p>Test</p></div>"
);
}
#[test]
fn a_void_element_does_not_self_close_when_pretty() {
let rendered = img()
.attr("src", "test.jpg")
.attr("alt", "Test")
.render_pretty();
assert!(rendered.starts_with("<img"));
assert!(!rendered.contains("/>"));
assert!(rendered.ends_with('>'));
}
#[test]
fn a_textarea_keeps_its_newlines() {
let field = textarea().attr("name", "bio").text("line 1\nline 2");
assert_eq!(
field.render_pretty(),
"<textarea name=\"bio\">line 1\nline 2</textarea>"
);
}
#[test]
fn a_pre_block_is_indented_from_the_outside_but_not_within() {
let container = div().child(pre().child(code().text("cargo build")));
assert_eq!(
container.render_pretty(),
"<div>\n <pre><code>cargo build</code></pre>\n</div>"
);
}
}