1use crate::core::{Element, Node};
8
9#[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::macros::display_text(&$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 $crate::macros::attr_display($el, $key, &$value), $($rest)*)
166 };
167 ($el:expr, $key:literal = $value:expr) => {
168 $crate::macros::attr_display($el, $key, &$value)
169 };
170
171 ($el:expr, $key:ident = $value:expr, $($rest:tt)*) => {
173 $crate::html_attrs!(
174 $crate::macros::attr_display($el, stringify!($key), &$value), $($rest)*)
175 };
176 ($el:expr, $key:ident = $value:expr) => {
177 $crate::macros::attr_display($el, stringify!($key), &$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)]
197#[must_use]
198pub fn display_text(value: &impl std::fmt::Display) -> Node {
199 Node::text_display(value)
200}
201
202#[doc(hidden)]
205#[must_use]
206pub fn attr_display(
207 element: Element,
208 key: &'static str,
209 value: &impl std::fmt::Display,
210) -> Element {
211 element.add_attribute(crate::core::Attribute::from_display(key, value))
212}
213
214#[doc(hidden)]
220#[must_use]
221pub fn node_buffer() -> Vec<Node> {
222 Vec::new()
223}
224
225#[doc(hidden)]
233#[must_use]
234pub fn element_with_children(element: Element, children: Vec<Node>) -> Element {
235 let all_text = !children.is_empty() && children.iter().all(|c| matches!(c, Node::Text(_)));
236
237 if all_text {
238 let mut texts = children.into_iter().filter_map(|child| match child {
241 Node::Text(text) => Some(text.into_string()),
242 _ => None,
243 });
244 let mut content = texts.next().unwrap_or_default();
245 for text in texts {
246 content.push_str(&text);
247 }
248 return element.raw_text(content);
250 }
251
252 element.children_from(children)
253}
254
255#[doc(hidden)]
257#[macro_export]
258macro_rules! html_if {
259 ($out:ident, [$($cond:tt)+] { $($body:tt)* } @else { $($alt:tt)* } $($rest:tt)*) => {
260 if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
261 else { $crate::html_nodes!($out, $($alt)*); }
262 $crate::html_nodes!($out, $($rest)*);
263 };
264 ($out:ident, [$($cond:tt)+] { $($body:tt)* } $($rest:tt)*) => {
265 if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
266 $crate::html_nodes!($out, $($rest)*);
267 };
268 ($out:ident, [$($cond:tt)*] $next:tt $($rest:tt)*) => {
269 $crate::html_if!($out, [$($cond)* $next] $($rest)*);
270 };
271}
272
273#[doc(hidden)]
275#[macro_export]
276macro_rules! html_for {
277 ($out:ident, [$($head:tt)+] { $($body:tt)* } $($rest:tt)*) => {
278 for $($head)+ { $crate::html_nodes!($out, $($body)*); }
279 $crate::html_nodes!($out, $($rest)*);
280 };
281 ($out:ident, [$($head:tt)*] $next:tt $($rest:tt)*) => {
282 $crate::html_for!($out, [$($head)* $next] $($rest)*);
283 };
284}
285
286#[cfg(test)]
287mod tests {
288 struct Pieces;
291
292 impl std::fmt::Display for Pieces {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 f.write_str("a<")?;
295 f.write_str("b&")?;
296 write!(f, "{}\"c'", 7)
297 }
298 }
299
300 #[test]
301 fn a_display_value_is_escaped_as_it_is_formatted() {
302 let text = crate::html! { p { (Pieces) } };
303 assert_eq!(text.render(), "<p>a<b&7"c'</p>");
304 let attribute = crate::html! { p(title = Pieces) { "x" } };
305 assert_eq!(
306 attribute.render(),
307 "<p title=\"a<b&7"c'\">x</p>"
308 );
309 assert_eq!(
310 text.render(),
311 Node::from(crate::elements::p().text(Pieces.to_string())).render()
312 );
313 }
314
315 #[test]
316 fn numbers_and_keyword_keys_still_work_as_attributes() {
317 let image = crate::html! { img(width = 100, "type" = 'x', hidden) };
318 assert_eq!(image.render(), r#"<img width="100" type="x" hidden>"#);
319 }
320
321 #[test]
324 fn hyphenated_attribute_names_use_the_string_key_form() {
325 let id = 7;
326 let el = crate::html! {
327 button("data-id" = id, "aria-label" = "Save", "hx-post" = "/save", "hx-swap") {
328 "Save"
329 }
330 };
331 let expected =
332 r#"<button data-id="7" aria-label="Save" hx-post="/save" hx-swap>Save</button>"#;
333 assert_eq!(el.render(), expected);
334 let built = crate::elements::button()
335 .data_attr("id", "7")
336 .aria_attr("label", "Save")
337 .attr("hx-post", "/save")
338 .bool_attr("hx-swap")
339 .text("Save");
340 assert_eq!(el.render(), Node::from(built).render());
341 }
342
343 #[test]
344 fn several_text_children_are_joined_into_one_content_string() {
345 let paragraph = crate::html! { p { "a " (1) " <b>" } };
346 assert_eq!(paragraph.render(), "<p>a 1 <b></p>");
347 }
348
349 use crate::prelude::*;
350
351 #[test]
352 fn a_bare_element_renders_empty() {
353 assert_eq!(html! { div }.render(), "<div></div>");
354 }
355
356 #[test]
357 fn text_children_are_escaped() {
358 assert_eq!(html! { p { "a & b" } }.render(), "<p>a & b</p>");
359 }
360
361 #[test]
362 fn raw_children_are_not_escaped() {
363 assert_eq!(html! { p { raw("<b>x</b>") } }.render(), "<p><b>x</b></p>");
364 }
365
366 #[test]
367 fn attributes_and_children_combine() {
368 assert_eq!(
369 html! { div(class = "card", id = "hero") { p { "hi" } } }.render(),
370 r#"<div class="card" id="hero"><p>hi</p></div>"#
371 );
372 }
373
374 #[test]
375 fn a_bare_attribute_name_is_a_boolean_attribute() {
376 assert_eq!(
377 html! { input("type" = "email", name = "email", required) }.render(),
378 r#"<input type="email" name="email" required>"#
379 );
380 }
381
382 #[test]
383 fn expressions_interpolate_escaped() {
384 let name = "Tom & Jerry";
385 assert_eq!(
386 html! { span { (name) } }.render(),
387 "<span>Tom & Jerry</span>"
388 );
389 }
390
391 #[test]
393 fn for_loops_expand_to_siblings() {
394 let names = ["Ana", "Bruno"];
395 assert_eq!(
396 html! { ul { @for n in names { li { (n) } } } }.render(),
397 "<ul><li>Ana</li><li>Bruno</li></ul>"
398 );
399 }
400
401 #[test]
403 fn a_false_condition_contributes_no_node() {
404 let show = false;
405 assert_eq!(
406 html! { div { @if show { p { "x" } } } }.render(),
407 "<div></div>"
408 );
409 }
410
411 #[test]
413 fn if_else_picks_one_branch() {
414 let logged_in = true;
415 let markup = html! {
416 nav { @if logged_in { a { "Sign out" } } @else { a { "Sign in" } } }
417 };
418 assert_eq!(markup.render(), "<nav><a>Sign out</a></nav>");
419 }
420
421 #[test]
422 fn several_roots_become_a_fragment() {
423 assert_eq!(html! { p { "a" } p { "b" } }.render(), "<p>a</p><p>b</p>");
424 }
425
426 #[test]
428 fn macro_output_equals_the_builder_output() {
429 let from_macro = html! { div(class = "card") { h1 { "T" } p { "B" } } };
430 let from_builder = Node::from(
431 div()
432 .add_class("card")
433 .child(h1().text("T"))
434 .child(p().text("B")),
435 );
436 assert_eq!(from_macro.render(), from_builder.render());
437 assert_eq!(from_macro.render_pretty(), from_builder.render_pretty());
438 }
439
440 #[test]
441 fn nesting_survives_pretty_printing() {
442 let markup = html! { div { section { p { "deep" } } } };
443 assert_eq!(
444 markup.render_pretty(),
445 "<div>\n <section>\n <p>deep</p>\n </section>\n</div>"
446 );
447 }
448
449 #[test]
454 fn the_macro_and_the_builder_agree_on_a_plain_container() {
455 let macro_built = html! { div { p { "Hi" } } };
456 let builder_built = div().child(p().text("Hi"));
457
458 assert_eq!(macro_built.render(), builder_built.render());
459 assert_eq!(macro_built.render(), "<div><p>Hi</p></div>");
460 }
461
462 #[test]
463 fn the_macro_and_the_builder_agree_on_a_required_attribute() {
464 let macro_built = html! { a(href = "/docs") { span { "Docs" } } };
465 let builder_built = link_to("/docs").child(span().text("Docs"));
466
467 assert_eq!(macro_built.render(), builder_built.render());
468 assert_eq!(
469 macro_built.render(),
470 r#"<a href="/docs"><span>Docs</span></a>"#
471 );
472 }
473
474 #[test]
475 fn attributes_and_children_render_together() {
476 assert_eq!(
477 html! { div(id = "main") { p { "Hi" } } }.render(),
478 r#"<div id="main"><p>Hi</p></div>"#
479 );
480 }
481
482 #[test]
483 fn a_boolean_flag_survives_the_macro() {
484 assert_eq!(
485 html! { details(open) { summary { "More" } } }.render(),
486 "<details open><summary>More</summary></details>"
487 );
488 }
489
490 #[test]
491 fn the_table_family_nests() {
492 let markup = html! {
493 table {
494 thead { tr { th { "A" } } }
495 tbody { tr { td { "1" } } }
496 }
497 };
498
499 assert_eq!(
500 markup.render(),
501 "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>"
502 );
503 }
504
505 #[test]
508 fn the_form_family_nests() {
509 let markup = html! {
510 form {
511 fieldset {
512 legend { "Account" }
513 label("for" = "email") { "Email" }
514 }
515 }
516 };
517
518 assert_eq!(
519 markup.render(),
520 concat!(
521 "<form><fieldset><legend>Account</legend>",
522 r#"<label for="email">Email</label></fieldset></form>"#,
523 )
524 );
525 }
526
527 #[test]
528 fn the_media_family_nests() {
529 let markup = html! {
530 picture {
531 source(srcset = "a.webp", "type" = "image/webp")
532 img(src = "a.jpg", alt = "A")
533 }
534 };
535
536 assert_eq!(
537 markup.render(),
538 concat!(
539 r#"<picture><source srcset="a.webp" type="image/webp">"#,
540 r#"<img src="a.jpg" alt="A"></picture>"#,
541 )
542 );
543 }
544
545 #[test]
546 fn a_loop_inside_the_macro_repeats_its_body() {
547 let markup = html! { ul { @for name in ["a", "b", "c"] { li { (name) } } } };
548
549 assert_eq!(markup.render(), "<ul><li>a</li><li>b</li><li>c</li></ul>");
550 }
551
552 #[test]
553 fn a_false_condition_inside_the_macro_renders_nothing() {
554 let is_admin = false;
555 let markup = html! {
556 nav {
557 a(href = "/") { "Home" }
558 @if is_admin { a(href = "/admin") { "Admin" } }
559 }
560 };
561
562 assert_eq!(markup.render(), r#"<nav><a href="/">Home</a></nav>"#);
563 }
564
565 #[test]
568 fn a_mapped_sequence_matches_the_macro_loop() {
569 let macro_built = html! { ol { @for item in ["x", "y"] { li { (item) } } } };
570 let builder_built = ol().children_from(["x", "y"].map(|item| li().text(item)));
571
572 assert_eq!(macro_built.render(), builder_built.render());
573 assert_eq!(macro_built.render(), "<ol><li>x</li><li>y</li></ol>");
574 }
575
576 #[test]
577 fn an_empty_body_produces_an_empty_element() {
578 assert_eq!(html! { div {} }.render(), "<div></div>");
579 }
580
581 #[test]
585 fn an_untyped_element_takes_macro_built_children() {
586 let group = Element::new("hgroup")
587 .child(html! { h1 { "Title" } })
588 .child(html! { p { "Subtitle" } });
589
590 assert_eq!(
591 group.render(),
592 "<hgroup><h1>Title</h1><p>Subtitle</p></hgroup>"
593 );
594 }
595
596 #[test]
600 fn chaining_happens_on_the_builder_side() {
601 let chained = div().child(html! { p { "Hi" } }).add_class("card");
602
603 assert_eq!(chained.render(), r#"<div class="card"><p>Hi</p></div>"#);
604 }
605
606 #[test]
607 fn nesting_goes_as_deep_as_it_is_written() {
608 let page = html! {
609 body { main_tag { section { article { h2 { "Title" } } } } }
610 };
611
612 assert_eq!(
613 page.render(),
614 "<body><main><section><article><h2>Title</h2></article></section></main></body>"
615 );
616 }
617
618 #[test]
623 fn a_root_html_element_keeps_its_children_in_order() {
624 let document = html_tag().child(body()).child(head());
625
626 assert_eq!(document.render(), "<html><body></body><head></head></html>");
627 }
628
629 #[test]
632 fn an_optional_child_is_included_when_present() {
633 let present: Option<Element> = Some(Element::new("optional"));
634 let absent: Option<Element> = None;
635
636 assert_eq!(
637 html_tag().children_from(present).render(),
638 "<html><optional></optional></html>"
639 );
640 assert_eq!(html_tag().children_from(absent).render(), "<html></html>");
641 }
642
643 #[test]
644 fn a_single_child_gains_no_wrapper() {
645 let document = html_tag().child(body().child(h1().text("Hi")));
646
647 assert_eq!(document.render(), "<html><body><h1>Hi</h1></body></html>");
648 }
649
650 #[test]
651 fn an_if_else_renders_whichever_branch_holds() {
652 fn page(logged_in: bool) -> Element {
653 html_tag().child(html! {
654 @if logged_in { nav { "Sign out" } } @else { nav { "Sign in" } }
655 })
656 }
657
658 assert_eq!(page(true).render(), "<html><nav>Sign out</nav></html>");
659 assert_eq!(page(false).render(), "<html><nav>Sign in</nav></html>");
660 }
661
662 #[test]
663 fn a_sequence_of_children_flattens_into_siblings() {
664 let document = html_tag().children_from(["a", "b"].map(|text| p().text(text)));
665
666 assert_eq!(document.render(), "<html><p>a</p><p>b</p></html>");
667 }
668
669 #[test]
671 fn the_first_branch_of_a_condition_renders_alone() {
672 let document = html_tag().child(html! {
673 @if true { header } @else { footer }
674 });
675
676 assert_eq!(document.render(), "<html><header></header></html>");
677 }
678
679 #[test]
680 fn the_second_branch_of_a_condition_renders_alone() {
681 let document = html_tag().child(html! {
682 @if false { header } @else { footer }
683 });
684
685 assert_eq!(document.render(), "<html><footer></footer></html>");
686 }
687}