1use crate::core::{Element, Node};
9
10#[macro_export]
71macro_rules! html {
72 ($($body:tt)*) => {{
74 let mut __nodes = $crate::macros::node_buffer();
75 $crate::html_nodes!(__nodes, $($body)*);
76 if __nodes.len() == 1 {
77 __nodes.pop().unwrap_or_else(|| $crate::Node::Fragment(::std::vec::Vec::new()))
78 } else {
79 $crate::Node::Fragment(__nodes)
80 }
81 }};
82}
83
84#[doc(hidden)]
86#[macro_export]
87macro_rules! html_nodes {
88 ($out:ident,) => {};
90
91 ($out:ident, @if $($tail:tt)*) => { $crate::html_if!($out, [] $($tail)*); };
95 ($out:ident, @for $($tail:tt)*) => { $crate::html_for!($out, [] $($tail)*); };
96
97 ($out:ident, raw($value:expr) $($rest:tt)*) => {
99 $out.push($crate::Node::raw(::std::string::ToString::to_string(&$value)));
100 $crate::html_nodes!($out, $($rest)*);
101 };
102
103 ($out:ident, $tag:ident ( $($attrs:tt)* ) { $($children:tt)* } $($rest:tt)*) => {
105 {
106 let __el = $crate::elements::$tag();
107 let __el = $crate::html_attrs!(__el, $($attrs)*);
108 let mut __kids = $crate::macros::node_buffer();
109 $crate::html_nodes!(__kids, $($children)*);
110 $out.push($crate::Node::Element(
111 $crate::macros::element_with_children(__el, __kids)));
112 }
113 $crate::html_nodes!($out, $($rest)*);
114 };
115
116 ($out:ident, $tag:ident ( $($attrs:tt)* ) $($rest:tt)*) => {
118 {
119 let __el = $crate::elements::$tag();
120 $out.push($crate::Node::Element($crate::html_attrs!(__el, $($attrs)*)));
121 }
122 $crate::html_nodes!($out, $($rest)*);
123 };
124
125 ($out:ident, $tag:ident { $($children:tt)* } $($rest:tt)*) => {
127 {
128 let mut __kids = $crate::macros::node_buffer();
129 $crate::html_nodes!(__kids, $($children)*);
130 $out.push($crate::Node::Element(
131 $crate::macros::element_with_children($crate::elements::$tag(), __kids)));
132 }
133 $crate::html_nodes!($out, $($rest)*);
134 };
135
136 ($out:ident, ($value:expr) $($rest:tt)*) => {
138 $out.push($crate::Node::text(::std::string::ToString::to_string(&$value)));
139 $crate::html_nodes!($out, $($rest)*);
140 };
141
142 ($out:ident, $text:literal $($rest:tt)*) => {
144 $out.push($crate::Node::text($text));
145 $crate::html_nodes!($out, $($rest)*);
146 };
147
148 ($out:ident, $tag:ident $($rest:tt)*) => {
150 $out.push($crate::Node::Element($crate::elements::$tag()));
151 $crate::html_nodes!($out, $($rest)*);
152 };
153}
154
155#[doc(hidden)]
157#[macro_export]
158macro_rules! html_attrs {
159 ($el:expr,) => { $el };
160 ($el:expr) => { $el };
161
162 ($el:expr, $key:literal = $value:expr, $($rest:tt)*) => {
164 $crate::html_attrs!(
165 $el.attr($key, ::std::string::ToString::to_string(&$value)), $($rest)*)
166 };
167 ($el:expr, $key:literal = $value:expr) => {
168 $el.attr($key, ::std::string::ToString::to_string(&$value))
169 };
170
171 ($el:expr, $key:ident = $value:expr, $($rest:tt)*) => {
173 $crate::html_attrs!(
174 $el.attr(stringify!($key), ::std::string::ToString::to_string(&$value)), $($rest)*)
175 };
176 ($el:expr, $key:ident = $value:expr) => {
177 $el.attr(stringify!($key), ::std::string::ToString::to_string(&$value))
178 };
179
180 ($el:expr, $key:literal, $($rest:tt)*) => {
182 $crate::html_attrs!($el.bool_attr($key), $($rest)*)
183 };
184 ($el:expr, $key:literal) => {
185 $el.bool_attr($key)
186 };
187 ($el:expr, $key:ident, $($rest:tt)*) => {
188 $crate::html_attrs!($el.bool_attr(stringify!($key)), $($rest)*)
189 };
190 ($el:expr, $key:ident) => {
191 $el.bool_attr(stringify!($key))
192 };
193}
194
195#[doc(hidden)]
201#[must_use]
202pub fn node_buffer() -> Vec<Node> {
203 Vec::new()
204}
205
206#[doc(hidden)]
214#[must_use]
215pub fn element_with_children(element: Element, children: Vec<Node>) -> Element {
216 let all_text = !children.is_empty() && children.iter().all(|c| matches!(c, Node::Text(_)));
217
218 if all_text {
219 let mut content = String::new();
220 for child in children {
221 if let Node::Text(text) = child {
222 content.push_str(&text);
223 }
224 }
225 return element.raw_text(content);
227 }
228
229 element.children_from(children)
230}
231
232#[doc(hidden)]
234#[macro_export]
235macro_rules! html_if {
236 ($out:ident, [$($cond:tt)+] { $($body:tt)* } @else { $($alt:tt)* } $($rest:tt)*) => {
237 if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
238 else { $crate::html_nodes!($out, $($alt)*); }
239 $crate::html_nodes!($out, $($rest)*);
240 };
241 ($out:ident, [$($cond:tt)+] { $($body:tt)* } $($rest:tt)*) => {
242 if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
243 $crate::html_nodes!($out, $($rest)*);
244 };
245 ($out:ident, [$($cond:tt)*] $next:tt $($rest:tt)*) => {
246 $crate::html_if!($out, [$($cond)* $next] $($rest)*);
247 };
248}
249
250#[doc(hidden)]
252#[macro_export]
253macro_rules! html_for {
254 ($out:ident, [$($head:tt)+] { $($body:tt)* } $($rest:tt)*) => {
255 for $($head)+ { $crate::html_nodes!($out, $($body)*); }
256 $crate::html_nodes!($out, $($rest)*);
257 };
258 ($out:ident, [$($head:tt)*] $next:tt $($rest:tt)*) => {
259 $crate::html_for!($out, [$($head)* $next] $($rest)*);
260 };
261}
262
263#[cfg(test)]
264mod tests {
265 use crate::prelude::*;
266
267 #[test]
268 fn a_bare_element_renders_empty() {
269 assert_eq!(html! { div }.render(), "<div></div>");
270 }
271
272 #[test]
273 fn text_children_are_escaped() {
274 assert_eq!(html! { p { "a & b" } }.render(), "<p>a & b</p>");
275 }
276
277 #[test]
278 fn raw_children_are_not_escaped() {
279 assert_eq!(html! { p { raw("<b>x</b>") } }.render(), "<p><b>x</b></p>");
280 }
281
282 #[test]
283 fn attributes_and_children_combine() {
284 assert_eq!(
285 html! { div(class = "card", id = "hero") { p { "hi" } } }.render(),
286 r#"<div class="card" id="hero"><p>hi</p></div>"#
287 );
288 }
289
290 #[test]
291 fn a_bare_attribute_name_is_a_boolean_attribute() {
292 assert_eq!(
293 html! { input("type" = "email", name = "email", required) }.render(),
294 r#"<input type="email" name="email" required>"#
295 );
296 }
297
298 #[test]
299 fn expressions_interpolate_escaped() {
300 let name = "Tom & Jerry";
301 assert_eq!(
302 html! { span { (name) } }.render(),
303 "<span>Tom & Jerry</span>"
304 );
305 }
306
307 #[test]
309 fn for_loops_expand_to_siblings() {
310 let names = ["Ana", "Bruno"];
311 assert_eq!(
312 html! { ul { @for n in names { li { (n) } } } }.render(),
313 "<ul><li>Ana</li><li>Bruno</li></ul>"
314 );
315 }
316
317 #[test]
319 fn a_false_condition_contributes_no_node() {
320 let show = false;
321 assert_eq!(
322 html! { div { @if show { p { "x" } } } }.render(),
323 "<div></div>"
324 );
325 }
326
327 #[test]
329 fn if_else_picks_one_branch() {
330 let logged_in = true;
331 let markup = html! {
332 nav { @if logged_in { a { "Sign out" } } @else { a { "Sign in" } } }
333 };
334 assert_eq!(markup.render(), "<nav><a>Sign out</a></nav>");
335 }
336
337 #[test]
338 fn several_roots_become_a_fragment() {
339 assert_eq!(html! { p { "a" } p { "b" } }.render(), "<p>a</p><p>b</p>");
340 }
341
342 #[test]
344 fn macro_output_equals_the_builder_output() {
345 let from_macro = html! { div(class = "card") { h1 { "T" } p { "B" } } };
346 let from_builder = Node::from(
347 div()
348 .add_class("card")
349 .child(h1().text("T"))
350 .child(p().text("B")),
351 );
352 assert_eq!(from_macro.render(), from_builder.render());
353 assert_eq!(from_macro.render_pretty(), from_builder.render_pretty());
354 }
355
356 #[test]
357 fn nesting_survives_pretty_printing() {
358 let markup = html! { div { section { p { "deep" } } } };
359 assert_eq!(
360 markup.render_pretty(),
361 "<div>\n <section>\n <p>deep</p>\n </section>\n</div>"
362 );
363 }
364
365 #[test]
372 fn the_macro_and_the_builder_agree_on_a_plain_container() {
373 let macro_built = html! { div { p { "Hi" } } };
374 let builder_built = div().child(p().text("Hi"));
375
376 assert_eq!(macro_built.render(), builder_built.render());
377 assert_eq!(macro_built.render(), "<div><p>Hi</p></div>");
378 }
379
380 #[test]
382 fn the_macro_and_the_builder_agree_on_a_required_attribute() {
383 let macro_built = html! { a(href = "/docs") { span { "Docs" } } };
384 let builder_built = link_to("/docs").child(span().text("Docs"));
385
386 assert_eq!(macro_built.render(), builder_built.render());
387 assert_eq!(
388 macro_built.render(),
389 r#"<a href="/docs"><span>Docs</span></a>"#
390 );
391 }
392
393 #[test]
395 fn attributes_and_children_render_together() {
396 assert_eq!(
397 html! { div(id = "main") { p { "Hi" } } }.render(),
398 r#"<div id="main"><p>Hi</p></div>"#
399 );
400 }
401
402 #[test]
404 fn a_boolean_flag_survives_the_macro() {
405 assert_eq!(
406 html! { details(open) { summary { "More" } } }.render(),
407 "<details open><summary>More</summary></details>"
408 );
409 }
410
411 #[test]
413 fn the_table_family_nests() {
414 let markup = html! {
415 table {
416 thead { tr { th { "A" } } }
417 tbody { tr { td { "1" } } }
418 }
419 };
420
421 assert_eq!(
422 markup.render(),
423 "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>"
424 );
425 }
426
427 #[test]
432 fn the_form_family_nests() {
433 let markup = html! {
434 form {
435 fieldset {
436 legend { "Account" }
437 label("for" = "email") { "Email" }
438 }
439 }
440 };
441
442 assert_eq!(
443 markup.render(),
444 concat!(
445 "<form><fieldset><legend>Account</legend>",
446 r#"<label for="email">Email</label></fieldset></form>"#,
447 )
448 );
449 }
450
451 #[test]
453 fn the_media_family_nests() {
454 let markup = html! {
455 picture {
456 source(srcset = "a.webp", "type" = "image/webp")
457 img(src = "a.jpg", alt = "A")
458 }
459 };
460
461 assert_eq!(
462 markup.render(),
463 concat!(
464 r#"<picture><source srcset="a.webp" type="image/webp">"#,
465 r#"<img src="a.jpg" alt="A"></picture>"#,
466 )
467 );
468 }
469
470 #[test]
472 fn a_loop_inside_the_macro_repeats_its_body() {
473 let markup = html! { ul { @for name in ["a", "b", "c"] { li { (name) } } } };
474
475 assert_eq!(markup.render(), "<ul><li>a</li><li>b</li><li>c</li></ul>");
476 }
477
478 #[test]
480 fn a_false_condition_inside_the_macro_renders_nothing() {
481 let is_admin = false;
482 let markup = html! {
483 nav {
484 a(href = "/") { "Home" }
485 @if is_admin { a(href = "/admin") { "Admin" } }
486 }
487 };
488
489 assert_eq!(markup.render(), r#"<nav><a href="/">Home</a></nav>"#);
490 }
491
492 #[test]
498 fn a_mapped_sequence_matches_the_macro_loop() {
499 let macro_built = html! { ol { @for item in ["x", "y"] { li { (item) } } } };
500 let builder_built = ol().children_from(["x", "y"].map(|item| li().text(item)));
501
502 assert_eq!(macro_built.render(), builder_built.render());
503 assert_eq!(macro_built.render(), "<ol><li>x</li><li>y</li></ol>");
504 }
505
506 #[test]
508 fn an_empty_body_produces_an_empty_element() {
509 assert_eq!(html! { div {} }.render(), "<div></div>");
510 }
511
512 #[test]
519 fn an_untyped_element_takes_macro_built_children() {
520 let group = Element::new("hgroup")
521 .child(html! { h1 { "Title" } })
522 .child(html! { p { "Subtitle" } });
523
524 assert_eq!(
525 group.render(),
526 "<hgroup><h1>Title</h1><p>Subtitle</p></hgroup>"
527 );
528 }
529
530 #[test]
537 fn chaining_happens_on_the_builder_side() {
538 let chained = div().child(html! { p { "Hi" } }).add_class("card");
539
540 assert_eq!(chained.render(), r#"<div class="card"><p>Hi</p></div>"#);
541 }
542
543 #[test]
545 fn nesting_goes_as_deep_as_it_is_written() {
546 let page = html! {
547 body { main_tag { section { article { h2 { "Title" } } } } }
548 };
549
550 assert_eq!(
551 page.render(),
552 "<body><main><section><article><h2>Title</h2></article></section></main></body>"
553 );
554 }
555
556 #[test]
565 fn a_root_html_element_keeps_its_children_in_order() {
566 let document = html_tag().child(body()).child(head());
567
568 assert_eq!(document.render(), "<html><body></body><head></head></html>");
569 }
570
571 #[test]
576 fn an_optional_child_is_included_when_present() {
577 let present: Option<Element> = Some(Element::new("optional"));
578 let absent: Option<Element> = None;
579
580 assert_eq!(
581 html_tag().children_from(present).render(),
582 "<html><optional></optional></html>"
583 );
584 assert_eq!(html_tag().children_from(absent).render(), "<html></html>");
585 }
586
587 #[test]
589 fn a_single_child_gains_no_wrapper() {
590 let document = html_tag().child(body().child(h1().text("Hi")));
591
592 assert_eq!(document.render(), "<html><body><h1>Hi</h1></body></html>");
593 }
594
595 #[test]
597 fn an_if_else_renders_whichever_branch_holds() {
598 fn page(logged_in: bool) -> Element {
599 html_tag().child(html! {
600 @if logged_in { nav { "Sign out" } } @else { nav { "Sign in" } }
601 })
602 }
603
604 assert_eq!(page(true).render(), "<html><nav>Sign out</nav></html>");
605 assert_eq!(page(false).render(), "<html><nav>Sign in</nav></html>");
606 }
607
608 #[test]
610 fn a_sequence_of_children_flattens_into_siblings() {
611 let document = html_tag().children_from(["a", "b"].map(|text| p().text(text)));
612
613 assert_eq!(document.render(), "<html><p>a</p><p>b</p></html>");
614 }
615
616 #[test]
621 fn the_first_branch_of_a_condition_renders_alone() {
622 let document = html_tag().child(html! {
623 @if true { header } @else { footer }
624 });
625
626 assert_eq!(document.render(), "<html><header></header></html>");
627 }
628
629 #[test]
631 fn the_second_branch_of_a_condition_renders_alone() {
632 let document = html_tag().child(html! {
633 @if false { header } @else { footer }
634 });
635
636 assert_eq!(document.render(), "<html><footer></footer></html>");
637 }
638}