Fluent HTML builder

I'm 3 days into Rust and decided to write a fluent HTML builder. Lets assume this is a good idea for the sake of the discussion. There are plenty of alternatives out there but they all fall into either the template bucket (e.g. Askama) or the macro bucket (e.g. Maud). I wanted something that's pure Rust for composability.

I'm looking for general feedback but in particular I'm looking for

  • API ergonomics improvements (without becoming a macro package) and
  • performance improvements.

There are some known TODOs:

  • Allow a HtmlBuilder to nest another HtmlBuilder (this is how we get composability).

Usage example towards the end of the post.

Code:

/// A fluent builder for HTML
use std::collections::HashMap;

/// A node with children, such as a `<div>`.
#[derive(Debug)]
pub struct Element {
    attrs: HashMap<&'static str, String>,  // e.g. {"class": "container"}
    children: Vec<Tree>,                   // child elements
    tag: &'static str,                     // e.g. "div"
}

/// A literal value, such as a text string.
#[derive(Debug)]
struct Literal {
    // TODO(tibbe): Can we
    // - borrow the value for the lifetime of the builder and
    // - accept any value that can be converted to a string?
    //
    // TODO(tibbe): Implement escaping and raw values.
    value: String,
}

// A HTML tree:
#[derive(Debug)]
enum Tree {
    Element(Element),
    Literal(Literal),
}

// The macros aren't core to the library. They just help generate some functions
// (e.g. `class()` as a specialization of `attr()`.
macro_rules! generate_attribute_functions {
    ($($tag:ident),*) => {
        $(
            pub fn $tag<V>(self: &mut Self, value: V) -> &mut Element
            where
                V: Into<String>,
            {
                self.attr(stringify!($tag), value)
            }
        )*
    };
}

macro_rules! generate_element_functions {
    ($($tag:ident),*) => {
        $(
            fn $tag(self: &mut Self) -> &mut Element {
                self.element(stringify!($tag))
            }
        )*
    };
}

impl Element {
    pub fn new(tag: &'static str) -> Self {
        Element {
            attrs: HashMap::new(),
            children: Vec::new(),
            tag,
        }
    }

    pub fn attr<V>(self: &mut Self, name: &'static str, value: V) -> &mut Self
    where
        V: Into<String>,
    {
        self.attrs.insert(name, value.into());
        self
    }

    // TODO(tibbe): Generate more of these once the design has stabilized.
    generate_attribute_functions!(class, id);

    pub fn render(self: &Self, buf: &mut String) {
        buf.push('<');
        buf.push_str(self.tag);
        for (name, value) in self.attrs.iter() {
            buf.push(' ');
            buf.push_str(name);
            buf.push_str("=\"");
            buf.push_str(value.as_str());
            buf.push('"');
        }
        buf.push('>');
        for child in self.children.iter() {
            child.render(buf);
        }
        buf.push_str("</");
        buf.push_str(self.tag);
        buf.push('>');
    }
}

/// Trait for all types that can contain `Element`s.
///
/// TODO(tibbe): This trair only exists so that `Element` and `HtmlBuilder` don't need to duplicate
/// the implementation of e.g. `element()`. Is this really neccesary?
pub trait Node {
    fn children(&self) -> &Vec<Tree>;
    fn mut_children(&mut self) -> &mut Vec<Tree>;

    fn element(self: &mut Self, tag: &'static str) -> &mut Element {
        self.mut_children().push(Tree::Element(Element {
            attrs: HashMap::new(),
            tag,
            children: Vec::new(),
        }));
        let Some(Tree::Element(element)) = self.mut_children().last_mut() else {
            unreachable!();
        };
        element
    }

    fn with_children<F>(self: &mut Self, func: F) -> &mut Self
    where
        F: Fn(&mut Self) -> (),
    {
        func(self);
        self
    }

    fn str(self: &mut Self, value: &str) {
        // TODO(tibbe): Can we instead borrow the value for the lifetime of the builder?
        self.mut_children().push(Tree::Literal(Literal {
            value: value.to_string(),
        }));
    }

    // TODO(tibbe): Generate more of these once the design has stabilized.
    generate_element_functions!(div, span, p, h1, td, tr, th, thead, tbody, table, a, button);
}

impl Node for Element {
    fn children(&self) -> &Vec<Tree> {
        &self.children
    }

    fn mut_children(&mut self) -> &mut Vec<Tree> {
        &mut self.children
    }
}

impl Literal {
    pub fn render(self: &Self, buf: &mut String) {
        buf.push_str(self.value.as_str());
    }
}

impl Tree {
    pub fn render(self: &Self, buf: &mut String) {
        match self {
            Tree::Element(el) => el.render(buf),
            Tree::Literal(l) => l.render(buf),
        }
    }
}

/// HTML builder.
pub struct HtmlBuilder {
    children: Vec<Tree>,
}

impl HtmlBuilder {
    pub fn new() -> Self {
        HtmlBuilder {
            children: Vec::new(),
        }
    }

    pub fn render(self: &Self, buf: &mut String) {
        for child in self.children.iter() {
            child.render(buf);
        }
    }
}

impl Node for HtmlBuilder {
    fn children(&self) -> &Vec<Tree> {
        &self.children
    }

    fn mut_children(&mut self) -> &mut Vec<Tree> {
        &mut self.children
    }
}

Example usage:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let mut builder = HtmlBuilder::new();
        builder.div().class("container").with_children(|b| {
            b.p().str("Hello ");
            b.p().str("world!");
        });
        let mut buf = String::new();
        builder.render(&mut buf);
        assert_eq!(
            buf,
            "<div class=\"container\"><p>Hello </p><p>world!</p></div>"
        );
    }
}

Have you seen the html crate? It's about as fluent-HTML-builder as you can get.

My personal opinion is that yes, it's a good idea. And no, it hasn't been perfected, yet.

html has some gotchas like running afoul of the type recursion depth limit and it doesn't support string escaping for text nodes. But if you are aware of those and take proper precautions, it's worlds better than Turing complete templating engines.

I think an API like this would be nice, because closures can become very annoying:

let mut builder = HtmlBuilder::new();
builder.div().class("container").with_children([
    p().str("Hello ");
    p().str("world!");
]);
let mut buf = String::new();
builder.render(&mut buf);
assert_eq!(
    buf,
    "<div class=\"container\"><p>Hello </p><p>world!</p></div>"
);

If you want you can try to implement it.

This has a lot of problems. Namely creating dynamically-sized heterogenous collections at the call site.

Could you give some tips of what to do instead?

The closure avoids almost all of the problems that I alluded to.

And it also has the benefit that you can pass "higher order builders" to builder methods like with_children(). Say you have common children on many elements:

fn with_common(builder: impl Fn(&mut Element)) -> impl Fn(&mut Element) {
    move |b| {
        b.p().str("Common header");
        builder(b);
        b.p().str("Common footer");
    }
}

#[test]
fn it_works() {
    let mut builder = HtmlBuilder::new();
    builder.div().with_children(with_common(|b| {
        b.p().str("Hello world!");
    }));

    let mut buf = String::new();
    builder.render(&mut buf);
    assert_eq!(
        buf,
        "<div><p>Common header</p><p>Hello world!</p><p>Common footer</p></div>",
    );
}