use crate::core::{Element, Node};
#[macro_export]
macro_rules! html {
($($body:tt)*) => {{
let mut __nodes = $crate::macros::node_buffer();
$crate::html_nodes!(__nodes, $($body)*);
if __nodes.len() == 1 {
__nodes.pop().unwrap_or_else(|| $crate::Node::Fragment(::std::vec::Vec::new()))
} else {
$crate::Node::Fragment(__nodes)
}
}};
}
#[doc(hidden)]
#[macro_export]
macro_rules! html_nodes {
($out:ident,) => {};
($out:ident, @if $($tail:tt)*) => { $crate::html_if!($out, [] $($tail)*); };
($out:ident, @for $($tail:tt)*) => { $crate::html_for!($out, [] $($tail)*); };
($out:ident, raw($value:expr) $($rest:tt)*) => {
$out.push($crate::Node::raw(::std::string::ToString::to_string(&$value)));
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, $tag:ident ( $($attrs:tt)* ) { $($children:tt)* } $($rest:tt)*) => {
{
let __el = $crate::elements::$tag();
let __el = $crate::html_attrs!(__el, $($attrs)*);
let mut __kids = $crate::macros::node_buffer();
$crate::html_nodes!(__kids, $($children)*);
$out.push($crate::Node::Element(
$crate::macros::element_with_children(__el, __kids)));
}
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, $tag:ident ( $($attrs:tt)* ) $($rest:tt)*) => {
{
let __el = $crate::elements::$tag();
$out.push($crate::Node::Element($crate::html_attrs!(__el, $($attrs)*)));
}
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, $tag:ident { $($children:tt)* } $($rest:tt)*) => {
{
let mut __kids = $crate::macros::node_buffer();
$crate::html_nodes!(__kids, $($children)*);
$out.push($crate::Node::Element(
$crate::macros::element_with_children($crate::elements::$tag(), __kids)));
}
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, ($value:expr) $($rest:tt)*) => {
$out.push($crate::macros::display_text(&$value));
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, $text:literal $($rest:tt)*) => {
$out.push($crate::Node::text($text));
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, $tag:ident $($rest:tt)*) => {
$out.push($crate::Node::Element($crate::elements::$tag()));
$crate::html_nodes!($out, $($rest)*);
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! html_attrs {
($el:expr,) => { $el };
($el:expr) => { $el };
($el:expr, $key:literal = $value:expr, $($rest:tt)*) => {
$crate::html_attrs!(
$crate::macros::attr_display($el, $key, &$value), $($rest)*)
};
($el:expr, $key:literal = $value:expr) => {
$crate::macros::attr_display($el, $key, &$value)
};
($el:expr, $key:ident = $value:expr, $($rest:tt)*) => {
$crate::html_attrs!(
$crate::macros::attr_display($el, stringify!($key), &$value), $($rest)*)
};
($el:expr, $key:ident = $value:expr) => {
$crate::macros::attr_display($el, stringify!($key), &$value)
};
($el:expr, $key:literal, $($rest:tt)*) => {
$crate::html_attrs!($el.bool_attr($key), $($rest)*)
};
($el:expr, $key:literal) => {
$el.bool_attr($key)
};
($el:expr, $key:ident, $($rest:tt)*) => {
$crate::html_attrs!($el.bool_attr(stringify!($key)), $($rest)*)
};
($el:expr, $key:ident) => {
$el.bool_attr(stringify!($key))
};
}
#[doc(hidden)]
#[must_use]
pub fn display_text(value: &impl std::fmt::Display) -> Node {
Node::text_display(value)
}
#[doc(hidden)]
#[must_use]
pub fn attr_display(
element: Element,
key: &'static str,
value: &impl std::fmt::Display,
) -> Element {
element.add_attribute(crate::core::Attribute::from_display(key, value))
}
#[doc(hidden)]
#[must_use]
pub fn node_buffer() -> Vec<Node> {
Vec::new()
}
#[doc(hidden)]
#[must_use]
pub fn element_with_children(element: Element, children: Vec<Node>) -> Element {
let all_text = !children.is_empty() && children.iter().all(|c| matches!(c, Node::Text(_)));
if all_text {
let mut texts = children.into_iter().filter_map(|child| match child {
Node::Text(text) => Some(text.into_string()),
_ => None,
});
let mut content = texts.next().unwrap_or_default();
for text in texts {
content.push_str(&text);
}
return element.raw_text(content);
}
element.children_from(children)
}
#[doc(hidden)]
#[macro_export]
macro_rules! html_if {
($out:ident, [$($cond:tt)+] { $($body:tt)* } @else { $($alt:tt)* } $($rest:tt)*) => {
if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
else { $crate::html_nodes!($out, $($alt)*); }
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, [$($cond:tt)+] { $($body:tt)* } $($rest:tt)*) => {
if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, [$($cond:tt)*] $next:tt $($rest:tt)*) => {
$crate::html_if!($out, [$($cond)* $next] $($rest)*);
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! html_for {
($out:ident, [$($head:tt)+] { $($body:tt)* } $($rest:tt)*) => {
for $($head)+ { $crate::html_nodes!($out, $($body)*); }
$crate::html_nodes!($out, $($rest)*);
};
($out:ident, [$($head:tt)*] $next:tt $($rest:tt)*) => {
$crate::html_for!($out, [$($head)* $next] $($rest)*);
};
}
#[cfg(test)]
mod tests {
struct Pieces;
impl std::fmt::Display for Pieces {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a<")?;
f.write_str("b&")?;
write!(f, "{}\"c'", 7)
}
}
#[test]
fn a_display_value_is_escaped_as_it_is_formatted() {
let text = crate::html! { p { (Pieces) } };
assert_eq!(text.render(), "<p>a<b&7"c'</p>");
let attribute = crate::html! { p(title = Pieces) { "x" } };
assert_eq!(
attribute.render(),
"<p title=\"a<b&7"c'\">x</p>"
);
assert_eq!(
text.render(),
Node::from(crate::elements::p().text(Pieces.to_string())).render()
);
}
#[test]
fn numbers_and_keyword_keys_still_work_as_attributes() {
let image = crate::html! { img(width = 100, "type" = 'x', hidden) };
assert_eq!(image.render(), r#"<img width="100" type="x" hidden>"#);
}
#[test]
fn hyphenated_attribute_names_use_the_string_key_form() {
let id = 7;
let el = crate::html! {
button("data-id" = id, "aria-label" = "Save", "hx-post" = "/save", "hx-swap") {
"Save"
}
};
let expected =
r#"<button data-id="7" aria-label="Save" hx-post="/save" hx-swap>Save</button>"#;
assert_eq!(el.render(), expected);
let built = crate::elements::button()
.data_attr("id", "7")
.aria_attr("label", "Save")
.attr("hx-post", "/save")
.bool_attr("hx-swap")
.text("Save");
assert_eq!(el.render(), Node::from(built).render());
}
#[test]
fn several_text_children_are_joined_into_one_content_string() {
let paragraph = crate::html! { p { "a " (1) " <b>" } };
assert_eq!(paragraph.render(), "<p>a 1 <b></p>");
}
use crate::prelude::*;
#[test]
fn a_bare_element_renders_empty() {
assert_eq!(html! { div }.render(), "<div></div>");
}
#[test]
fn text_children_are_escaped() {
assert_eq!(html! { p { "a & b" } }.render(), "<p>a & b</p>");
}
#[test]
fn raw_children_are_not_escaped() {
assert_eq!(html! { p { raw("<b>x</b>") } }.render(), "<p><b>x</b></p>");
}
#[test]
fn attributes_and_children_combine() {
assert_eq!(
html! { div(class = "card", id = "hero") { p { "hi" } } }.render(),
r#"<div class="card" id="hero"><p>hi</p></div>"#
);
}
#[test]
fn a_bare_attribute_name_is_a_boolean_attribute() {
assert_eq!(
html! { input("type" = "email", name = "email", required) }.render(),
r#"<input type="email" name="email" required>"#
);
}
#[test]
fn expressions_interpolate_escaped() {
let name = "Tom & Jerry";
assert_eq!(
html! { span { (name) } }.render(),
"<span>Tom & Jerry</span>"
);
}
#[test]
fn for_loops_expand_to_siblings() {
let names = ["Ana", "Bruno"];
assert_eq!(
html! { ul { @for n in names { li { (n) } } } }.render(),
"<ul><li>Ana</li><li>Bruno</li></ul>"
);
}
#[test]
fn a_false_condition_contributes_no_node() {
let show = false;
assert_eq!(
html! { div { @if show { p { "x" } } } }.render(),
"<div></div>"
);
}
#[test]
fn if_else_picks_one_branch() {
let logged_in = true;
let markup = html! {
nav { @if logged_in { a { "Sign out" } } @else { a { "Sign in" } } }
};
assert_eq!(markup.render(), "<nav><a>Sign out</a></nav>");
}
#[test]
fn several_roots_become_a_fragment() {
assert_eq!(html! { p { "a" } p { "b" } }.render(), "<p>a</p><p>b</p>");
}
#[test]
fn macro_output_equals_the_builder_output() {
let from_macro = html! { div(class = "card") { h1 { "T" } p { "B" } } };
let from_builder = Node::from(
div()
.add_class("card")
.child(h1().text("T"))
.child(p().text("B")),
);
assert_eq!(from_macro.render(), from_builder.render());
assert_eq!(from_macro.render_pretty(), from_builder.render_pretty());
}
#[test]
fn nesting_survives_pretty_printing() {
let markup = html! { div { section { p { "deep" } } } };
assert_eq!(
markup.render_pretty(),
"<div>\n <section>\n <p>deep</p>\n </section>\n</div>"
);
}
#[test]
fn the_macro_and_the_builder_agree_on_a_plain_container() {
let macro_built = html! { div { p { "Hi" } } };
let builder_built = div().child(p().text("Hi"));
assert_eq!(macro_built.render(), builder_built.render());
assert_eq!(macro_built.render(), "<div><p>Hi</p></div>");
}
#[test]
fn the_macro_and_the_builder_agree_on_a_required_attribute() {
let macro_built = html! { a(href = "/docs") { span { "Docs" } } };
let builder_built = link_to("/docs").child(span().text("Docs"));
assert_eq!(macro_built.render(), builder_built.render());
assert_eq!(
macro_built.render(),
r#"<a href="/docs"><span>Docs</span></a>"#
);
}
#[test]
fn attributes_and_children_render_together() {
assert_eq!(
html! { div(id = "main") { p { "Hi" } } }.render(),
r#"<div id="main"><p>Hi</p></div>"#
);
}
#[test]
fn a_boolean_flag_survives_the_macro() {
assert_eq!(
html! { details(open) { summary { "More" } } }.render(),
"<details open><summary>More</summary></details>"
);
}
#[test]
fn the_table_family_nests() {
let markup = html! {
table {
thead { tr { th { "A" } } }
tbody { tr { td { "1" } } }
}
};
assert_eq!(
markup.render(),
"<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>"
);
}
#[test]
fn the_form_family_nests() {
let markup = html! {
form {
fieldset {
legend { "Account" }
label("for" = "email") { "Email" }
}
}
};
assert_eq!(
markup.render(),
concat!(
"<form><fieldset><legend>Account</legend>",
r#"<label for="email">Email</label></fieldset></form>"#,
)
);
}
#[test]
fn the_media_family_nests() {
let markup = html! {
picture {
source(srcset = "a.webp", "type" = "image/webp")
img(src = "a.jpg", alt = "A")
}
};
assert_eq!(
markup.render(),
concat!(
r#"<picture><source srcset="a.webp" type="image/webp">"#,
r#"<img src="a.jpg" alt="A"></picture>"#,
)
);
}
#[test]
fn a_loop_inside_the_macro_repeats_its_body() {
let markup = html! { ul { @for name in ["a", "b", "c"] { li { (name) } } } };
assert_eq!(markup.render(), "<ul><li>a</li><li>b</li><li>c</li></ul>");
}
#[test]
fn a_false_condition_inside_the_macro_renders_nothing() {
let is_admin = false;
let markup = html! {
nav {
a(href = "/") { "Home" }
@if is_admin { a(href = "/admin") { "Admin" } }
}
};
assert_eq!(markup.render(), r#"<nav><a href="/">Home</a></nav>"#);
}
#[test]
fn a_mapped_sequence_matches_the_macro_loop() {
let macro_built = html! { ol { @for item in ["x", "y"] { li { (item) } } } };
let builder_built = ol().children_from(["x", "y"].map(|item| li().text(item)));
assert_eq!(macro_built.render(), builder_built.render());
assert_eq!(macro_built.render(), "<ol><li>x</li><li>y</li></ol>");
}
#[test]
fn an_empty_body_produces_an_empty_element() {
assert_eq!(html! { div {} }.render(), "<div></div>");
}
#[test]
fn an_untyped_element_takes_macro_built_children() {
let group = Element::new("hgroup")
.child(html! { h1 { "Title" } })
.child(html! { p { "Subtitle" } });
assert_eq!(
group.render(),
"<hgroup><h1>Title</h1><p>Subtitle</p></hgroup>"
);
}
#[test]
fn chaining_happens_on_the_builder_side() {
let chained = div().child(html! { p { "Hi" } }).add_class("card");
assert_eq!(chained.render(), r#"<div class="card"><p>Hi</p></div>"#);
}
#[test]
fn nesting_goes_as_deep_as_it_is_written() {
let page = html! {
body { main_tag { section { article { h2 { "Title" } } } } }
};
assert_eq!(
page.render(),
"<body><main><section><article><h2>Title</h2></article></section></main></body>"
);
}
#[test]
fn a_root_html_element_keeps_its_children_in_order() {
let document = html_tag().child(body()).child(head());
assert_eq!(document.render(), "<html><body></body><head></head></html>");
}
#[test]
fn an_optional_child_is_included_when_present() {
let present: Option<Element> = Some(Element::new("optional"));
let absent: Option<Element> = None;
assert_eq!(
html_tag().children_from(present).render(),
"<html><optional></optional></html>"
);
assert_eq!(html_tag().children_from(absent).render(), "<html></html>");
}
#[test]
fn a_single_child_gains_no_wrapper() {
let document = html_tag().child(body().child(h1().text("Hi")));
assert_eq!(document.render(), "<html><body><h1>Hi</h1></body></html>");
}
#[test]
fn an_if_else_renders_whichever_branch_holds() {
fn page(logged_in: bool) -> Element {
html_tag().child(html! {
@if logged_in { nav { "Sign out" } } @else { nav { "Sign in" } }
})
}
assert_eq!(page(true).render(), "<html><nav>Sign out</nav></html>");
assert_eq!(page(false).render(), "<html><nav>Sign in</nav></html>");
}
#[test]
fn a_sequence_of_children_flattens_into_siblings() {
let document = html_tag().children_from(["a", "b"].map(|text| p().text(text)));
assert_eq!(document.render(), "<html><p>a</p><p>b</p></html>");
}
#[test]
fn the_first_branch_of_a_condition_renders_alone() {
let document = html_tag().child(html! {
@if true { header } @else { footer }
});
assert_eq!(document.render(), "<html><header></header></html>");
}
#[test]
fn the_second_branch_of_a_condition_renders_alone() {
let document = html_tag().child(html! {
@if false { header } @else { footer }
});
assert_eq!(document.render(), "<html><footer></footer></html>");
}
}