use std::borrow::Cow;
use crate::core::attribute::Attribute;
use crate::core::escape::write_escaped_text;
use crate::core::node::{Node, Source};
use crate::core::render::{Render, RenderOptions};
#[derive(Debug)]
pub struct Element {
tag: Cow<'static, str>,
attributes: Vec<Attribute>,
content: Option<String>,
children: Vec<Node>,
self_closing: bool,
}
impl Clone for Element {
fn clone(&self) -> Self {
match clone_tree(Source::Element(self)) {
Node::Element(element) => element,
_ => unreachable!("cloning an element yields an element"),
}
}
}
impl PartialEq for Element {
fn eq(&self, other: &Self) -> bool {
trees_equal(Source::Element(self), Source::Element(other))
}
}
impl Eq for Element {}
impl std::fmt::Display for Element {
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 Element {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
hash_tree(Source::Element(self), state);
}
}
impl<N: Into<Node>> Extend<N> for Element {
fn extend<I: IntoIterator<Item = N>>(&mut self, iter: I) {
self.children.extend(iter.into_iter().map(Into::into));
}
}
pub(crate) fn hash_tree<H: std::hash::Hasher>(root: Source<'_>, state: &mut H) {
use std::hash::Hash;
let mut pending = vec![root];
while let Some(source) = pending.pop() {
match source {
Source::Element(element) | Source::Node(Node::Element(element)) => {
0_u8.hash(state);
element.tag.hash(state);
element.attributes.hash(state);
element.content.hash(state);
element.self_closing.hash(state);
element.children.len().hash(state);
pending.extend(element.children.iter().rev().map(Source::Node));
}
Source::Node(Node::Fragment(children)) => {
1_u8.hash(state);
children.len().hash(state);
pending.extend(children.iter().rev().map(Source::Node));
}
Source::Node(Node::Text(text)) => {
2_u8.hash(state);
text.hash(state);
}
Source::Node(Node::Raw(text)) => {
3_u8.hash(state);
text.hash(state);
}
Source::Node(Node::Comment(text)) => {
4_u8.hash(state);
text.hash(state);
}
}
}
}
fn shell(element: &Element) -> Element {
Element {
tag: element.tag.clone(),
attributes: element.attributes.clone(),
content: element.content.clone(),
children: Vec::with_capacity(element.children.len()),
self_closing: element.self_closing,
}
}
enum CloneFrame<'a> {
Element {
source: &'a [Node],
built: Element,
},
Fragment {
source: &'a [Node],
built: Vec<Node>,
},
}
pub(crate) fn clone_tree(root: Source<'_>) -> Node {
let mut stack: Vec<CloneFrame<'_>> = Vec::new();
let mut descend = Some(root);
loop {
let mut finished = None;
if let Some(source) = descend.take() {
match source {
Source::Element(element) | Source::Node(Node::Element(element)) => {
stack.push(CloneFrame::Element {
source: &element.children,
built: shell(element),
});
}
Source::Node(Node::Fragment(children)) => stack.push(CloneFrame::Fragment {
source: children,
built: Vec::with_capacity(children.len()),
}),
Source::Node(Node::Text(text)) => finished = Some(Node::Text(text.clone())),
Source::Node(Node::Raw(text)) => finished = Some(Node::Raw(text.clone())),
Source::Node(Node::Comment(text)) => finished = Some(Node::Comment(text.clone())),
}
}
if finished.is_none() {
let Some(frame) = stack.last_mut() else {
return Node::Fragment(Vec::new());
};
let (source, built_len) = match frame {
CloneFrame::Element { source, built } => (*source, built.children.len()),
CloneFrame::Fragment { source, built } => (*source, built.len()),
};
if let Some(next) = source.get(built_len) {
descend = Some(Source::Node(next));
continue;
}
finished = stack.pop().map(|frame| match frame {
CloneFrame::Element { built, .. } => Node::Element(built),
CloneFrame::Fragment { built, .. } => Node::Fragment(built),
});
}
let Some(done) = finished else { continue };
match stack.last_mut() {
None => return done,
Some(CloneFrame::Element { built, .. }) => built.children.push(done),
Some(CloneFrame::Fragment { built, .. }) => built.push(done),
}
}
}
pub(crate) fn trees_equal(left: Source<'_>, right: Source<'_>) -> bool {
let mut pending = vec![(left, right)];
while let Some((left, right)) = pending.pop() {
match (left, right) {
(
Source::Element(a) | Source::Node(Node::Element(a)),
Source::Element(b) | Source::Node(Node::Element(b)),
) => {
if a.tag != b.tag
|| a.attributes != b.attributes
|| a.content != b.content
|| a.self_closing != b.self_closing
|| a.children.len() != b.children.len()
{
return false;
}
pending.extend(
a.children
.iter()
.zip(&b.children)
.map(|(a, b)| (Source::Node(a), Source::Node(b))),
);
}
(Source::Node(Node::Fragment(a)), Source::Node(Node::Fragment(b))) => {
if a.len() != b.len() {
return false;
}
pending.extend(
a.iter()
.zip(b)
.map(|(a, b)| (Source::Node(a), Source::Node(b))),
);
}
(Source::Node(Node::Text(a)), Source::Node(Node::Text(b)))
| (Source::Node(Node::Raw(a)), Source::Node(Node::Raw(b)))
| (Source::Node(Node::Comment(a)), Source::Node(Node::Comment(b))) => {
if a != b {
return false;
}
}
_ => return false,
}
}
true
}
impl Element {
pub fn new(tag: impl Into<String>) -> Self {
Self::with_tag(Cow::Owned(tag.into()))
}
#[must_use]
pub fn from_static(tag: &'static str) -> Self {
Self::with_tag(Cow::Borrowed(tag))
}
fn with_tag(tag: Cow<'static, str>) -> Self {
Self {
tag,
attributes: Vec::new(),
content: None,
children: Vec::new(),
self_closing: false,
}
}
#[must_use]
pub fn tag(&self) -> &str {
&self.tag
}
#[must_use]
pub fn attributes(&self) -> &[Attribute] {
&self.attributes
}
#[must_use]
pub fn content(&self) -> Option<&str> {
self.content.as_deref()
}
#[must_use]
pub fn children(&self) -> &[Node] {
&self.children
}
#[must_use]
pub fn is_self_closing(&self) -> bool {
self.self_closing
}
#[must_use]
pub fn text(mut self, 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.content = Some(escaped);
self
}
#[must_use]
pub fn raw_text(mut self, content: impl Into<String>) -> Self {
self.content = Some(content.into());
self
}
#[must_use]
pub fn child(mut self, child: impl Into<Node>) -> Self {
self.children.push(child.into());
self
}
#[must_use]
pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
#[must_use]
pub fn add_attribute(mut self, attribute: Attribute) -> Self {
self.attributes.push(attribute);
self
}
#[must_use]
pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
self.add_attribute(Attribute::new(key, value))
}
#[must_use]
pub fn bool_attr(self, key: impl Into<String>) -> Self {
self.add_attribute(Attribute::boolean(key))
}
#[must_use]
pub fn trusted_url(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
let attribute = Attribute::trusted_url(key, value);
self.attributes
.retain(|existing| existing.key() != attribute.key());
self.attributes.push(attribute);
self
}
#[must_use]
pub fn self_closing(mut self, yes: bool) -> Self {
self.self_closing = yes;
self
}
#[must_use]
pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
self.attr(format!("data-{}", key.as_ref()), value)
}
#[must_use]
pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
mut self,
data: impl IntoIterator<Item = (K, V)>,
) -> Self {
for (key, value) in data {
self = self.data_attr(key, value);
}
self
}
#[must_use]
pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
self.attr(format!("aria-{}", key.as_ref()), value)
}
#[must_use]
pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
mut self,
aria: impl IntoIterator<Item = (K, V)>,
) -> Self {
for (key, value) in aria {
self = self.aria_attr(key, value);
}
self
}
#[must_use]
pub fn set_id(self, id: impl AsRef<str>) -> Self {
self.replace_attribute("id", id.as_ref())
}
#[must_use]
pub fn set_style(self, style: impl AsRef<str>) -> Self {
self.replace_attribute("style", style.as_ref())
}
#[must_use]
pub fn set_role(self, role: impl AsRef<str>) -> Self {
self.replace_attribute("role", role.as_ref())
}
fn replace_attribute(mut self, key: &'static str, value: &str) -> Self {
self.attributes.retain(|a| a.key() != key);
self.attributes.push(Attribute::new_static(key, value));
self
}
#[must_use]
pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
let raw = class_name.as_ref();
if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
existing.push_escaped_word(raw);
} else {
self.attributes.push(Attribute::new_static("class", raw));
}
self
}
#[must_use]
pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
for name in class_names {
self = self.add_class(name);
}
self
}
}
impl Render for Element {
fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
crate::core::node::write_element_tree(self, out, options, depth);
}
}
impl Drop for Element {
fn drop(&mut self) {
let mut pending = core::mem::take(&mut self.children);
while let Some(node) = pending.pop() {
match node {
Node::Element(mut element) => pending.append(&mut element.children),
Node::Fragment(mut children) => pending.append(&mut children),
Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
}
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_clone_equals_its_source_and_is_independent() {
let source = div()
.attr("id", "a")
.add_class("x y")
.child(p().text("t").child(Node::comment("c")))
.child(Node::fragment([
Node::text("a"),
Node::raw("<b>"),
Node::fragment([]),
span().into(),
]))
.child(Node::text("tail"));
let copy = source.clone();
assert_eq!(source, copy);
assert_eq!(source.render(), copy.render());
let changed = copy.clone().child(Node::text("more"));
assert_ne!(source, changed);
assert_eq!(source.render(), copy.render(), "the original is untouched");
}
#[test]
fn equality_sees_every_field() {
let base = div().attr("id", "a").text("t");
assert_eq!(base, div().attr("id", "a").text("t"));
assert_ne!(base, div().attr("id", "b").text("t"));
assert_ne!(base, div().attr("id", "a").text("u"));
assert_ne!(base, span().attr("id", "a").text("t"));
assert_ne!(base, div().attr("id", "a").text("t").self_closing(true));
assert_ne!(Node::text("a"), Node::raw("a"));
assert_ne!(Node::text("a"), Node::comment("a"));
assert_ne!(Node::fragment([]), Node::fragment([Node::text("")]));
assert_eq!(Node::from(base.clone()), Node::from(base));
}
use super::*;
use crate::core::RenderOptions;
use crate::elements::{
a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
};
#[test]
fn trusted_url_replaces_a_blocked_value() {
assert_eq!(
a().attr("href", "javascript:x")
.trusted_url("href", "javascript:print()")
.render(),
r#"<a href="javascript:print()"></a>"#
);
}
#[test]
fn a_non_void_tag_marked_self_closing_drops_its_children() {
let tag = div()
.attr("id", "x")
.self_closing(true)
.child(span().text("no"));
assert_eq!(tag.render(), r#"<div id="x">"#);
assert_eq!(
tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
r#"<div id="x" />"#
);
assert_eq!(tag.render_pretty(), r#"<div id="x">"#);
assert!(!div().is_self_closing());
}
#[test]
fn add_class_appends_to_one_attribute() {
assert_eq!(
div().add_class("a").add_class("b").render(),
r#"<div class="a b"></div>"#
);
}
#[test]
fn chaining_add_class_does_not_double_escape() {
let rendered = div().add_class("a&b").add_class("c").render();
assert_eq!(rendered, r#"<div class="a&b c"></div>"#);
assert!(!rendered.contains("&amp;"));
}
#[test]
fn a_quote_in_a_class_name_cannot_break_out() {
let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
assert!(!rendered.contains("onload=\""));
assert!(rendered.contains("""));
}
#[test]
fn add_classes_appends_all_of_them_in_order() {
assert_eq!(
div().add_classes(["card", "p-4", "shadow"]).render(),
r#"<div class="card p-4 shadow"></div>"#
);
}
#[test]
fn set_id_replaces_rather_than_appending() {
let rendered = div().set_id("first").set_id("second").render();
assert_eq!(rendered, r#"<div id="second"></div>"#);
}
#[test]
fn set_style_and_set_role_also_replace() {
let rendered = div()
.set_style("color:red")
.set_style("color:blue")
.set_role("main")
.render();
assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
}
#[test]
fn data_and_aria_attributes_get_their_prefixes() {
let rendered = span()
.data_attr("id", "7")
.aria_attr("label", "Close")
.render();
assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
}
#[test]
fn bulk_attribute_order_is_stable() {
let build = || {
div()
.data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
.render()
};
let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
for _ in 0..16 {
assert_eq!(build(), expected);
}
}
#[test]
fn plain_attributes_are_appended_without_deduplication() {
assert_eq!(
div().attr("data-x", "1").attr("data-x", "2").render(),
r#"<div data-x="1" data-x="2"></div>"#
);
}
#[test]
fn text_is_escaped_and_raw_text_is_not() {
assert_eq!(p().text("<b>").render(), "<p><b></p>");
assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
}
#[test]
fn boolean_attributes_render_bare() {
assert_eq!(
button().bool_attr("disabled").render(),
"<button disabled></button>"
);
}
#[test]
fn children_from_appends_a_sequence() {
let list = div().children_from([p().text("a"), p().text("b")]);
assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
}
#[test]
fn a_tag_renders_its_attributes_then_its_content() {
let tag = Element::new("p")
.attr("class", "text")
.text("Hello, World!");
assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
}
#[test]
fn extra_attributes_can_precede_the_typed_ones() {
let tag = img()
.attr("width", "100")
.attr("height", "100")
.attr("src", "image.png")
.attr("alt", "An image");
assert_eq!(
tag.render(),
r#"<img width="100" height="100" src="image.png" alt="An image">"#
);
}
#[test]
fn nested_children_render_in_order() {
let document = html_tag().child(
div()
.child(p().text("This is a paragraph."))
.child(img().attr("src", "image.png").attr("alt", "An image")),
);
assert_eq!(
document.render(),
concat!(
"<html><div><p>This is a paragraph.</p>",
r#"<img src="image.png" alt="An image"></div></html>"#,
)
);
}
#[test]
fn a_container_renders_its_class_before_its_children() {
let document = html_tag().child(
div()
.add_class("main-body")
.child(p().text("Title"))
.child(p().text("This is a paragraph.")),
);
assert_eq!(
document.render(),
r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
);
}
#[test]
fn a_table_renders_its_rows_and_cells() {
let document = html_tag().child(
table()
.add_class("table")
.child(
tr().child(th().text("Header 1"))
.child(th().text("Header 2")),
)
.child(
tr().child(td().text("Row 1, Cell 1"))
.child(td().text("Row 1, Cell 2")),
)
.child(
tr().child(td().text("Row 2, Cell 1"))
.child(td().text("Row 2, Cell 2")),
),
);
assert_eq!(
document.render(),
concat!(
r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
"<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
"<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
)
);
}
#[test]
fn an_unordered_list_renders_its_items() {
let document = html_tag().child(
ul().add_class("unordered-list")
.child(li().text("Item 1"))
.child(li().text("Item 2"))
.child(li().text("Item 3")),
);
assert_eq!(
document.render(),
concat!(
r#"<html><ul class="unordered-list">"#,
"<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
)
);
}
#[test]
fn an_ordered_list_renders_its_items() {
let document = html_tag().child(
ol().add_class("ordered-list")
.child(li().text("First"))
.child(li().text("Second"))
.child(li().text("Third")),
);
assert_eq!(
document.render(),
concat!(
r#"<html><ol class="ordered-list">"#,
"<li>First</li><li>Second</li><li>Third</li></ol></html>",
)
);
}
#[test]
fn a_description_list_renders_terms_and_descriptions() {
let document = html_tag().child(
Element::new("dl")
.add_class("description-list")
.child(Element::new("dt").text("Term 1"))
.child(Element::new("dd").text("Description 1"))
.child(Element::new("dt").text("Term 2"))
.child(Element::new("dd").text("Description 2")),
);
assert_eq!(
document.render(),
concat!(
r#"<html><dl class="description-list">"#,
"<dt>Term 1</dt><dd>Description 1</dd>",
"<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
)
);
}
#[test]
fn the_structural_tags_nest_into_a_page() {
let document = html_tag()
.child(
head()
.child(
meta()
.attr("name", "description")
.attr("content", "A description of the page"),
)
.child(stylesheet("styles.css")),
)
.child(
body()
.child(
header().child(
nav()
.child(a().attr("href", "#home").text("Home"))
.child(a().attr("href", "#about").text("About"))
.child(a().attr("href", "#contact").text("Contact")),
),
)
.child(main_tag().child(p().text("Welcome to our website!")))
.child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
);
assert_eq!(
document.render(),
concat!(
r#"<html><head><meta name="description" content="A description of the page">"#,
r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
r##"<a href="#home">Home</a><a href="#about">About</a>"##,
r##"<a href="#contact">Contact</a></nav></header>"##,
"<main><p>Welcome to our website!</p></main>",
"<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
)
);
}
#[test]
fn a_script_body_is_not_escaped() {
let document = html_tag().child(
script()
.attr("type", "text/javascript")
.raw_text("alert('Hello World');"),
);
assert_eq!(
document.render(),
r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
);
}
#[test]
fn meta_renders_both_the_named_and_the_charset_form() {
let document = html_tag()
.child(
meta()
.attr("name", "description")
.attr("content", "A description of the page"),
)
.child(meta().attr("charset", "utf-8"));
assert_eq!(
document.render(),
concat!(
r#"<html><meta name="description" content="A description of the page">"#,
r#"<meta charset="utf-8"></html>"#,
)
);
}
#[test]
fn a_title_renders_its_text() {
let document = html_tag().child(title().text("Title my site"));
assert_eq!(
document.render(),
"<html><title>Title my site</title></html>"
);
}
#[test]
fn a_span_renders_its_attributes_and_content() {
let tag = span().attr("class", "text").text("Hello, World!");
assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
}
#[test]
fn a_button_carries_its_type_after_its_class() {
let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
assert_eq!(
document.render(),
r#"<html><button class="button-class" type="button"></button></html>"#
);
}
#[test]
fn a_button_renders_its_children() {
let document = html_tag().child(
button()
.add_class("button-class")
.attr("type", "button")
.child(span().add_class("icon-bar")),
);
assert_eq!(
document.render(),
concat!(
r#"<html><button class="button-class" type="button">"#,
r#"<span class="icon-bar"></span></button></html>"#,
)
);
}
#[test]
fn chaining_helpers_keeps_the_element_usable() {
let card: Element = div().add_class("card").set_id("hero").set_role("region");
assert_eq!(
card.render(),
r#"<div class="card" id="hero" role="region"></div>"#
);
}
#[test]
fn set_style_escapes_quotes() {
let rendered = div()
.set_style(r#"font-family: "Inter", sans-serif"#)
.render();
assert_eq!(
rendered,
r#"<div style="font-family: "Inter", sans-serif"></div>"#
);
}
#[test]
fn a_single_class_renders_on_its_own() {
assert_eq!(
div().add_class("container").render(),
r#"<div class="container"></div>"#
);
}
#[test]
fn set_id_renders_an_id_attribute() {
assert_eq!(
div().set_id("main-content").render(),
r#"<div id="main-content"></div>"#
);
}
#[test]
fn the_helpers_chain_in_the_order_they_are_called() {
let rendered = div()
.set_id("content")
.add_class("container")
.add_class("active")
.set_style("padding: 20px;")
.render();
assert_eq!(
rendered,
r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
);
}
#[test]
fn several_data_attributes_keep_the_order_they_are_given() {
let rendered = div()
.data_attrs([("id", "123"), ("type", "product")])
.render();
assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
}
#[test]
fn several_aria_attributes_keep_the_order_they_are_given() {
let rendered = nav()
.aria_attrs([("label", "Main navigation"), ("expanded", "true")])
.render();
assert_eq!(
rendered,
r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
);
}
#[test]
fn attr_appends_arbitrary_attributes() {
let rendered = input_named("text", "email")
.attr("placeholder", "Enter email")
.attr("required", "true")
.render();
assert_eq!(
rendered,
r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
);
}
}