Skip to main content

molt/
macros.rs

1//! The [`html!`](crate::html) declarative macro.
2//!
3//! Nested, markup-like syntax for building a tree. It is **sugar over the builder API, never a
4//! second code path** — every form here expands to the same `Element` and `Node` calls you
5//! would write by hand, so there is exactly one renderer to keep correct.
6
7use crate::core::{Element, Node};
8
9/// Builds a node tree from nested markup-like syntax.
10///
11/// # Syntax
12///
13/// | form | meaning |
14/// | --- | --- |
15/// | `div { … }` | an element with children |
16/// | `div(class = "card", id = "x") { … }` | attributes, then children |
17/// | `input(type = "email", required)` | a bare name is a boolean attribute |
18/// | `"text"` | escaped text |
19/// | `(expr)` | an escaped `Display` expression |
20/// | `raw(expr)` | unescaped markup |
21/// | `@if cond { … } @else { … }` | conditional, empty branch renders to nothing |
22/// | `@for pat in iter { … }` | repetition |
23///
24/// # Examples
25///
26/// ```
27/// use molt::{html, prelude::*};
28///
29/// let page = html! {
30///     div(class = "card", id = "hero") {
31///         h1 { "Welcome" }
32///         p { "Fuel, tyres & chain." }
33///     }
34/// };
35///
36/// assert_eq!(
37///     page.render(),
38///     r#"<div class="card" id="hero"><h1>Welcome</h1><p>Fuel, tyres &amp; chain.</p></div>"#
39/// );
40/// ```
41///
42/// Conditionals and loops, matching `buildOptional` / `buildEither` / `buildArray`:
43///
44/// ```
45/// use molt::{html, prelude::*};
46///
47/// let names = ["Ana", "Bruno"];
48/// let logged_in = false;
49///
50/// let list = html! {
51///     ul {
52///         @for name in names { li { (name) } }
53///         @if logged_in { li { "Sign out" } }
54///     }
55/// };
56///
57/// assert_eq!(list.render(), "<ul><li>Ana</li><li>Bruno</li></ul>");
58/// ```
59///
60/// The expansion is the builder API, so the two are interchangeable:
61///
62/// ```
63/// use molt::{html, prelude::*};
64/// assert_eq!(
65///     html! { p(class = "lead") { "hi" } }.render(),
66///     Node::from(p().add_class("lead").child(Node::text("hi"))).render()
67/// );
68/// ```
69#[macro_export]
70macro_rules! html {
71    // A single root node.
72    ($($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/// Accumulates sibling nodes into a `Vec`. Not part of the public API.
84#[doc(hidden)]
85#[macro_export]
86macro_rules! html_nodes {
87    // Terminal.
88    ($out:ident,) => {};
89
90    // @if and @for hand off to a token muncher. A `$cond:expr` fragment cannot be
91    // used before a brace: the expression parser would read `show { … }` as a struct
92    // literal and macro_rules cannot backtrack once a fragment has been consumed.
93    ($out:ident, @if $($tail:tt)*) => { $crate::html_if!($out, [] $($tail)*); };
94    ($out:ident, @for $($tail:tt)*) => { $crate::html_for!($out, [] $($tail)*); };
95
96    // raw(expr) — unescaped markup.
97    ($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    // Element with attributes and children.
103    ($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    // Element with attributes only.
116    ($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    // Element with children only.
125    ($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    // Interpolated expression, escaped.
136    ($out:ident, ($value:expr) $($rest:tt)*) => {
137        $out.push($crate::macros::display_text(&$value));
138        $crate::html_nodes!($out, $($rest)*);
139    };
140
141    // Literal text, escaped.
142    ($out:ident, $text:literal $($rest:tt)*) => {
143        $out.push($crate::Node::text($text));
144        $crate::html_nodes!($out, $($rest)*);
145    };
146
147    // A bare element with no attributes and no children.
148    ($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/// Applies an attribute list to an element. Not part of the public API.
155#[doc(hidden)]
156#[macro_export]
157macro_rules! html_attrs {
158    ($el:expr,) => { $el };
159    ($el:expr) => { $el };
160
161    // "key" = value — a string key, for names that are Rust keywords such as `type`.
162    ($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    // key = value
171    ($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    // A bare name is a boolean attribute.
180    ($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/// A text node from a `Display` value, escaped as it is formatted. Not public API.
195#[doc(hidden)]
196#[must_use]
197pub fn display_text(value: &impl std::fmt::Display) -> Node {
198    Node::text_display(value)
199}
200
201/// Adds `key = value` to an element, formatting the value straight into escaped form. The
202/// key is always a literal in the macro, so it is borrowed, not allocated. Not public API.
203#[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/// An empty sibling buffer for the macro to push into.
214///
215/// A function rather than an inline `Vec::new()` so that `clippy::vec_init_then_push` does
216/// not fire inside every `html!` expansion in every downstream crate. The lint is about
217/// hand-written style; this code is generated.
218#[doc(hidden)]
219#[must_use]
220pub fn node_buffer() -> Vec<Node> {
221    Vec::new()
222}
223
224/// Attaches macro-collected children to an element.
225///
226/// When every child is text, it becomes the element's **content** rather than a list of
227/// child nodes. That is what the builder API produces for `p().text("x")`, and the two
228/// render differently once pretty printing is on: content stays on the element's line,
229/// while a child text node gets its own indented line. Collapsing here is what keeps the
230/// macro true sugar over the builder rather than a second, subtly different code path.
231#[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        // The first text node's string is reused as the buffer, so the common single-text
238        // child (`li { (name) }`) is moved, not copied.
239        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        // The text was escaped when each node was built, so it goes in verbatim.
248        return element.raw_text(content);
249    }
250
251    element.children_from(children)
252}
253
254/// Token muncher for `@if`. Collects condition tokens until the body block. Not public.
255#[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/// Token muncher for `@for`. Collects `pat in iter` until the body block. Not public.
273#[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    /// A `Display` impl that writes in several pieces, each holding a special character.
288    /// Escaping per piece must equal escaping the whole.
289    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&lt;b&amp;7&quot;c&#x27;</p>");
303        let attribute = crate::html! { p(title = Pieces) { "x" } };
304        assert_eq!(
305            attribute.render(),
306            "<p title=\"a<b&amp;7&quot;c&#x27;\">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 &lt;b&gt;</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 &amp; 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 &amp; Jerry</span>"
365        );
366    }
367
368    /// A `@for` produces one sibling per item.
369    #[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    /// Matches `buildOptional`: a false branch renders to nothing at all.
379    #[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    /// Matches `buildEither`.
389    #[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    /// The macro is sugar: it must produce exactly what the builder produces.
404    #[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    // `html!` promises to be exactly equivalent to the builder — rule 9 of AGENTS.md: sugar over
427    // the builder, never a second code path — so each of these asserts the macro and the
428    // builder agree, and then pins the markup.
429
430    #[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    /// `for` is a Rust keyword, so the attribute name is quoted — the one place the macro asks
483    /// for punctuation.
484    #[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    /// The macro takes an iterator through `@for`, and `children_from` is the builder's
543    /// spelling of the same thing — the test is that both land on identical markup.
544    #[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    /// The macro resolves a tag name to its generated constructor, and there is no
559    /// `hgroup()` among the 93. `Element::new` is the escape hatch for a tag without a
560    /// constructor, and it composes with macro-built children.
561    #[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    /// `html!` returns a [`Node`], which is the union of every shape a child can take
574    /// and has no builder methods — so chaining happens on the builder, and the macro
575    /// supplies the children. Both spellings render the same.
576    #[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    // `html!` lowers `@if`/`@else`/`@for` straight onto the builder. These tests assert markup
596    // rather than reaching into the tree, which is rule 11 of AGENTS.md and what lets the
597    // internals be rewritten without touching them.
598
599    #[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    /// `Option` is an iterator of zero or one item, so `children_from` is all the plumbing
607    /// an optional child needs.
608    #[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    /// The macro resolves a tag name to its generated constructor, so real tags stand in.
647    #[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}