1use crate::core::{Element, Node};
8
9#[macro_export]
70macro_rules! html {
71 ($($body:tt)*) => {{
73 let mut __nodes = $crate::macros::node_buffer();
74 $crate::html_nodes!(__nodes, $($body)*);
75 if __nodes.len() == 1 {
76 __nodes.pop().unwrap_or_else(|| $crate::Node::Fragment(::std::vec::Vec::new()))
77 } else {
78 $crate::Node::Fragment(__nodes)
79 }
80 }};
81}
82
83#[doc(hidden)]
85#[macro_export]
86macro_rules! html_nodes {
87 ($out:ident,) => {};
89
90 ($out:ident, @if $($tail:tt)*) => { $crate::html_if!($out, [] $($tail)*); };
94 ($out:ident, @for $($tail:tt)*) => { $crate::html_for!($out, [] $($tail)*); };
95
96 ($out:ident, raw($value:expr) $($rest:tt)*) => {
98 $out.push($crate::Node::raw(::std::string::ToString::to_string(&$value)));
99 $crate::html_nodes!($out, $($rest)*);
100 };
101
102 ($out:ident, $tag:ident ( $($attrs:tt)* ) { $($children:tt)* } $($rest:tt)*) => {
104 {
105 let __el = $crate::elements::$tag();
106 let __el = $crate::html_attrs!(__el, $($attrs)*);
107 let mut __kids = $crate::macros::node_buffer();
108 $crate::html_nodes!(__kids, $($children)*);
109 $out.push($crate::Node::Element(
110 $crate::macros::element_with_children(__el, __kids)));
111 }
112 $crate::html_nodes!($out, $($rest)*);
113 };
114
115 ($out:ident, $tag:ident ( $($attrs:tt)* ) $($rest:tt)*) => {
117 {
118 let __el = $crate::elements::$tag();
119 $out.push($crate::Node::Element($crate::html_attrs!(__el, $($attrs)*)));
120 }
121 $crate::html_nodes!($out, $($rest)*);
122 };
123
124 ($out:ident, $tag:ident { $($children:tt)* } $($rest:tt)*) => {
126 {
127 let mut __kids = $crate::macros::node_buffer();
128 $crate::html_nodes!(__kids, $($children)*);
129 $out.push($crate::Node::Element(
130 $crate::macros::element_with_children($crate::elements::$tag(), __kids)));
131 }
132 $crate::html_nodes!($out, $($rest)*);
133 };
134
135 ($out:ident, ($value:expr) $($rest:tt)*) => {
137 $out.push($crate::macros::display_text(&$value));
138 $crate::html_nodes!($out, $($rest)*);
139 };
140
141 ($out:ident, $text:literal $($rest:tt)*) => {
143 $out.push($crate::Node::text($text));
144 $crate::html_nodes!($out, $($rest)*);
145 };
146
147 ($out:ident, $tag:ident $($rest:tt)*) => {
149 $out.push($crate::Node::Element($crate::elements::$tag()));
150 $crate::html_nodes!($out, $($rest)*);
151 };
152}
153
154#[doc(hidden)]
156#[macro_export]
157macro_rules! html_attrs {
158 ($el:expr,) => { $el };
159 ($el:expr) => { $el };
160
161 ($el:expr, $key:literal = $value:expr, $($rest:tt)*) => {
163 $crate::html_attrs!(
164 $crate::macros::attr_display($el, $key, &$value), $($rest)*)
165 };
166 ($el:expr, $key:literal = $value:expr) => {
167 $crate::macros::attr_display($el, $key, &$value)
168 };
169
170 ($el:expr, $key:ident = $value:expr, $($rest:tt)*) => {
172 $crate::html_attrs!(
173 $crate::macros::attr_display($el, stringify!($key), &$value), $($rest)*)
174 };
175 ($el:expr, $key:ident = $value:expr) => {
176 $crate::macros::attr_display($el, stringify!($key), &$value)
177 };
178
179 ($el:expr, $key:literal, $($rest:tt)*) => {
181 $crate::html_attrs!($el.bool_attr($key), $($rest)*)
182 };
183 ($el:expr, $key:literal) => {
184 $el.bool_attr($key)
185 };
186 ($el:expr, $key:ident, $($rest:tt)*) => {
187 $crate::html_attrs!($el.bool_attr(stringify!($key)), $($rest)*)
188 };
189 ($el:expr, $key:ident) => {
190 $el.bool_attr(stringify!($key))
191 };
192}
193
194#[doc(hidden)]
196#[must_use]
197pub fn display_text(value: &impl std::fmt::Display) -> Node {
198 Node::text_display(value)
199}
200
201#[doc(hidden)]
204#[must_use]
205pub fn attr_display(
206 element: Element,
207 key: &'static str,
208 value: &impl std::fmt::Display,
209) -> Element {
210 element.add_attribute(crate::core::Attribute::from_display(key, value))
211}
212
213#[doc(hidden)]
219#[must_use]
220pub fn node_buffer() -> Vec<Node> {
221 Vec::new()
222}
223
224#[doc(hidden)]
232#[must_use]
233pub fn element_with_children(element: Element, children: Vec<Node>) -> Element {
234 let all_text = !children.is_empty() && children.iter().all(|c| matches!(c, Node::Text(_)));
235
236 if all_text {
237 let mut texts = children.into_iter().filter_map(|child| match child {
240 Node::Text(text) => Some(text.into_string()),
241 _ => None,
242 });
243 let mut content = texts.next().unwrap_or_default();
244 for text in texts {
245 content.push_str(&text);
246 }
247 return element.raw_text(content);
249 }
250
251 element.children_from(children)
252}
253
254#[doc(hidden)]
256#[macro_export]
257macro_rules! html_if {
258 ($out:ident, [$($cond:tt)+] { $($body:tt)* } @else { $($alt:tt)* } $($rest:tt)*) => {
259 if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
260 else { $crate::html_nodes!($out, $($alt)*); }
261 $crate::html_nodes!($out, $($rest)*);
262 };
263 ($out:ident, [$($cond:tt)+] { $($body:tt)* } $($rest:tt)*) => {
264 if $($cond)+ { $crate::html_nodes!($out, $($body)*); }
265 $crate::html_nodes!($out, $($rest)*);
266 };
267 ($out:ident, [$($cond:tt)*] $next:tt $($rest:tt)*) => {
268 $crate::html_if!($out, [$($cond)* $next] $($rest)*);
269 };
270}
271
272#[doc(hidden)]
274#[macro_export]
275macro_rules! html_for {
276 ($out:ident, [$($head:tt)+] { $($body:tt)* } $($rest:tt)*) => {
277 for $($head)+ { $crate::html_nodes!($out, $($body)*); }
278 $crate::html_nodes!($out, $($rest)*);
279 };
280 ($out:ident, [$($head:tt)*] $next:tt $($rest:tt)*) => {
281 $crate::html_for!($out, [$($head)* $next] $($rest)*);
282 };
283}
284
285#[cfg(test)]
286mod tests {
287 struct Pieces;
290
291 impl std::fmt::Display for Pieces {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.write_str("a<")?;
294 f.write_str("b&")?;
295 write!(f, "{}\"c'", 7)
296 }
297 }
298
299 #[test]
300 fn a_display_value_is_escaped_as_it_is_formatted() {
301 let text = crate::html! { p { (Pieces) } };
302 assert_eq!(text.render(), "<p>a<b&7"c'</p>");
303 let attribute = crate::html! { p(title = Pieces) { "x" } };
304 assert_eq!(
305 attribute.render(),
306 "<p title=\"a<b&7"c'\">x</p>"
307 );
308 assert_eq!(
309 text.render(),
310 Node::from(crate::elements::p().text(Pieces.to_string())).render()
311 );
312 }
313
314 #[test]
315 fn numbers_and_keyword_keys_still_work_as_attributes() {
316 let image = crate::html! { img(width = 100, "type" = 'x', hidden) };
317 assert_eq!(image.render(), r#"<img width="100" type="x" hidden>"#);
318 }
319
320 #[test]
321 fn several_text_children_are_joined_into_one_content_string() {
322 let paragraph = crate::html! { p { "a " (1) " <b>" } };
323 assert_eq!(paragraph.render(), "<p>a 1 <b></p>");
324 }
325
326 use crate::prelude::*;
327
328 #[test]
329 fn a_bare_element_renders_empty() {
330 assert_eq!(html! { div }.render(), "<div></div>");
331 }
332
333 #[test]
334 fn text_children_are_escaped() {
335 assert_eq!(html! { p { "a & b" } }.render(), "<p>a & b</p>");
336 }
337
338 #[test]
339 fn raw_children_are_not_escaped() {
340 assert_eq!(html! { p { raw("<b>x</b>") } }.render(), "<p><b>x</b></p>");
341 }
342
343 #[test]
344 fn attributes_and_children_combine() {
345 assert_eq!(
346 html! { div(class = "card", id = "hero") { p { "hi" } } }.render(),
347 r#"<div class="card" id="hero"><p>hi</p></div>"#
348 );
349 }
350
351 #[test]
352 fn a_bare_attribute_name_is_a_boolean_attribute() {
353 assert_eq!(
354 html! { input("type" = "email", name = "email", required) }.render(),
355 r#"<input type="email" name="email" required>"#
356 );
357 }
358
359 #[test]
360 fn expressions_interpolate_escaped() {
361 let name = "Tom & Jerry";
362 assert_eq!(
363 html! { span { (name) } }.render(),
364 "<span>Tom & Jerry</span>"
365 );
366 }
367
368 #[test]
370 fn for_loops_expand_to_siblings() {
371 let names = ["Ana", "Bruno"];
372 assert_eq!(
373 html! { ul { @for n in names { li { (n) } } } }.render(),
374 "<ul><li>Ana</li><li>Bruno</li></ul>"
375 );
376 }
377
378 #[test]
380 fn a_false_condition_contributes_no_node() {
381 let show = false;
382 assert_eq!(
383 html! { div { @if show { p { "x" } } } }.render(),
384 "<div></div>"
385 );
386 }
387
388 #[test]
390 fn if_else_picks_one_branch() {
391 let logged_in = true;
392 let markup = html! {
393 nav { @if logged_in { a { "Sign out" } } @else { a { "Sign in" } } }
394 };
395 assert_eq!(markup.render(), "<nav><a>Sign out</a></nav>");
396 }
397
398 #[test]
399 fn several_roots_become_a_fragment() {
400 assert_eq!(html! { p { "a" } p { "b" } }.render(), "<p>a</p><p>b</p>");
401 }
402
403 #[test]
405 fn macro_output_equals_the_builder_output() {
406 let from_macro = html! { div(class = "card") { h1 { "T" } p { "B" } } };
407 let from_builder = Node::from(
408 div()
409 .add_class("card")
410 .child(h1().text("T"))
411 .child(p().text("B")),
412 );
413 assert_eq!(from_macro.render(), from_builder.render());
414 assert_eq!(from_macro.render_pretty(), from_builder.render_pretty());
415 }
416
417 #[test]
418 fn nesting_survives_pretty_printing() {
419 let markup = html! { div { section { p { "deep" } } } };
420 assert_eq!(
421 markup.render_pretty(),
422 "<div>\n <section>\n <p>deep</p>\n </section>\n</div>"
423 );
424 }
425
426 #[test]
431 fn the_macro_and_the_builder_agree_on_a_plain_container() {
432 let macro_built = html! { div { p { "Hi" } } };
433 let builder_built = div().child(p().text("Hi"));
434
435 assert_eq!(macro_built.render(), builder_built.render());
436 assert_eq!(macro_built.render(), "<div><p>Hi</p></div>");
437 }
438
439 #[test]
440 fn the_macro_and_the_builder_agree_on_a_required_attribute() {
441 let macro_built = html! { a(href = "/docs") { span { "Docs" } } };
442 let builder_built = link_to("/docs").child(span().text("Docs"));
443
444 assert_eq!(macro_built.render(), builder_built.render());
445 assert_eq!(
446 macro_built.render(),
447 r#"<a href="/docs"><span>Docs</span></a>"#
448 );
449 }
450
451 #[test]
452 fn attributes_and_children_render_together() {
453 assert_eq!(
454 html! { div(id = "main") { p { "Hi" } } }.render(),
455 r#"<div id="main"><p>Hi</p></div>"#
456 );
457 }
458
459 #[test]
460 fn a_boolean_flag_survives_the_macro() {
461 assert_eq!(
462 html! { details(open) { summary { "More" } } }.render(),
463 "<details open><summary>More</summary></details>"
464 );
465 }
466
467 #[test]
468 fn the_table_family_nests() {
469 let markup = html! {
470 table {
471 thead { tr { th { "A" } } }
472 tbody { tr { td { "1" } } }
473 }
474 };
475
476 assert_eq!(
477 markup.render(),
478 "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>"
479 );
480 }
481
482 #[test]
485 fn the_form_family_nests() {
486 let markup = html! {
487 form {
488 fieldset {
489 legend { "Account" }
490 label("for" = "email") { "Email" }
491 }
492 }
493 };
494
495 assert_eq!(
496 markup.render(),
497 concat!(
498 "<form><fieldset><legend>Account</legend>",
499 r#"<label for="email">Email</label></fieldset></form>"#,
500 )
501 );
502 }
503
504 #[test]
505 fn the_media_family_nests() {
506 let markup = html! {
507 picture {
508 source(srcset = "a.webp", "type" = "image/webp")
509 img(src = "a.jpg", alt = "A")
510 }
511 };
512
513 assert_eq!(
514 markup.render(),
515 concat!(
516 r#"<picture><source srcset="a.webp" type="image/webp">"#,
517 r#"<img src="a.jpg" alt="A"></picture>"#,
518 )
519 );
520 }
521
522 #[test]
523 fn a_loop_inside_the_macro_repeats_its_body() {
524 let markup = html! { ul { @for name in ["a", "b", "c"] { li { (name) } } } };
525
526 assert_eq!(markup.render(), "<ul><li>a</li><li>b</li><li>c</li></ul>");
527 }
528
529 #[test]
530 fn a_false_condition_inside_the_macro_renders_nothing() {
531 let is_admin = false;
532 let markup = html! {
533 nav {
534 a(href = "/") { "Home" }
535 @if is_admin { a(href = "/admin") { "Admin" } }
536 }
537 };
538
539 assert_eq!(markup.render(), r#"<nav><a href="/">Home</a></nav>"#);
540 }
541
542 #[test]
545 fn a_mapped_sequence_matches_the_macro_loop() {
546 let macro_built = html! { ol { @for item in ["x", "y"] { li { (item) } } } };
547 let builder_built = ol().children_from(["x", "y"].map(|item| li().text(item)));
548
549 assert_eq!(macro_built.render(), builder_built.render());
550 assert_eq!(macro_built.render(), "<ol><li>x</li><li>y</li></ol>");
551 }
552
553 #[test]
554 fn an_empty_body_produces_an_empty_element() {
555 assert_eq!(html! { div {} }.render(), "<div></div>");
556 }
557
558 #[test]
562 fn an_untyped_element_takes_macro_built_children() {
563 let group = Element::new("hgroup")
564 .child(html! { h1 { "Title" } })
565 .child(html! { p { "Subtitle" } });
566
567 assert_eq!(
568 group.render(),
569 "<hgroup><h1>Title</h1><p>Subtitle</p></hgroup>"
570 );
571 }
572
573 #[test]
577 fn chaining_happens_on_the_builder_side() {
578 let chained = div().child(html! { p { "Hi" } }).add_class("card");
579
580 assert_eq!(chained.render(), r#"<div class="card"><p>Hi</p></div>"#);
581 }
582
583 #[test]
584 fn nesting_goes_as_deep_as_it_is_written() {
585 let page = html! {
586 body { main_tag { section { article { h2 { "Title" } } } } }
587 };
588
589 assert_eq!(
590 page.render(),
591 "<body><main><section><article><h2>Title</h2></article></section></main></body>"
592 );
593 }
594
595 #[test]
600 fn a_root_html_element_keeps_its_children_in_order() {
601 let document = html_tag().child(body()).child(head());
602
603 assert_eq!(document.render(), "<html><body></body><head></head></html>");
604 }
605
606 #[test]
609 fn an_optional_child_is_included_when_present() {
610 let present: Option<Element> = Some(Element::new("optional"));
611 let absent: Option<Element> = None;
612
613 assert_eq!(
614 html_tag().children_from(present).render(),
615 "<html><optional></optional></html>"
616 );
617 assert_eq!(html_tag().children_from(absent).render(), "<html></html>");
618 }
619
620 #[test]
621 fn a_single_child_gains_no_wrapper() {
622 let document = html_tag().child(body().child(h1().text("Hi")));
623
624 assert_eq!(document.render(), "<html><body><h1>Hi</h1></body></html>");
625 }
626
627 #[test]
628 fn an_if_else_renders_whichever_branch_holds() {
629 fn page(logged_in: bool) -> Element {
630 html_tag().child(html! {
631 @if logged_in { nav { "Sign out" } } @else { nav { "Sign in" } }
632 })
633 }
634
635 assert_eq!(page(true).render(), "<html><nav>Sign out</nav></html>");
636 assert_eq!(page(false).render(), "<html><nav>Sign in</nav></html>");
637 }
638
639 #[test]
640 fn a_sequence_of_children_flattens_into_siblings() {
641 let document = html_tag().children_from(["a", "b"].map(|text| p().text(text)));
642
643 assert_eq!(document.render(), "<html><p>a</p><p>b</p></html>");
644 }
645
646 #[test]
648 fn the_first_branch_of_a_condition_renders_alone() {
649 let document = html_tag().child(html! {
650 @if true { header } @else { footer }
651 });
652
653 assert_eq!(document.render(), "<html><header></header></html>");
654 }
655
656 #[test]
657 fn the_second_branch_of_a_condition_renders_alone() {
658 let document = html_tag().child(html! {
659 @if false { header } @else { footer }
660 });
661
662 assert_eq!(document.render(), "<html><footer></footer></html>");
663 }
664}