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_void, is_whitespace_sensitive};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Node {
Element(Element),
Text(String),
Raw(String),
Comment(String),
Fragment(Vec<Node>),
}
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(escaped)
}
#[must_use]
pub fn raw(content: impl Into<String>) -> Self {
Self::Raw(content.into())
}
#[must_use]
pub fn comment(content: impl AsRef<str>) -> Self {
Self::Comment(content.as_ref().replace("--", "- -"))
}
#[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) => children.iter().all(Self::is_empty),
}
}
}
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,
);
}
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>,
},
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);
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) => {
push_compact_element(element, out, options, &mut stack);
}
Step::PrettyElement(element, depth) => {
push_pretty_element(element, depth, out, options, &mut stack);
}
Step::Close(tag) => {
out.push_str("</");
out.push_str(tag);
out.push('>');
}
Step::ClosePretty(tag, depth) => {
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::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>>,
) {
out.push('<');
out.push_str(element.tag());
write_attributes(element.attributes(), out);
if is_void(element.tag()) {
write_void_suffix(out, options);
return;
}
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));
}
fn push_pretty_element<'a>(
element: &'a Element,
depth: usize,
out: &mut String,
options: &RenderOptions,
stack: &mut Vec<Step<'a>>,
) {
if is_whitespace_sensitive(element.tag()) {
options.write_indent(out, depth);
stack.push(Step::CompactElement(element));
return;
}
options.write_indent(out, depth);
out.push('<');
out.push_str(element.tag());
write_attributes(element.attributes(), out);
if is_void(element.tag()) {
write_void_suffix(out, options);
return;
}
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;
}
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,
}),
);
}
fn write_comment(out: &mut String, content: &str) {
out.push_str("<!-- ");
out.push_str(content);
out.push_str(" -->");
}
fn write_attributes(attributes: &[Attribute], out: &mut String) {
for attribute in attributes {
attribute.write_into(out);
}
}
fn write_void_suffix(out: &mut String, options: &RenderOptions) {
out.push_str(if options.xhtml_self_closing {
" />"
} else {
">"
});
}
#[cfg(test)]
mod tests {
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_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!(Node::fragment([]).render().is_empty());
assert!(Node::fragment([]).render_pretty().is_empty());
}
#[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("swift build")));
assert_eq!(
container.render_pretty(),
"<div>\n <pre><code>swift build</code></pre>\n</div>"
);
}
}