plait 0.7.0

A modern HTML templating library for Rust that embraces composition.
Documentation

plait

A lightweight, type-safe HTML templating library for Rust.

Plait provides a macro-based DSL for writing HTML directly in Rust code with compile-time validation and automatic escaping. It's designed for building server-side rendered HTML with minimal runtime overhead.

Quick Start

The html! macro returns a value that implements Display(core::fmt::Display), so you can render it with .to_string(), write!, or format!.

use plait::html;

let name = "World";
let page = html! {
    div(class: "greeting") {
        h1 { "Hello, " (name) "!" }
    }
};

assert_eq!(page.to_string(), "<div class=\"greeting\"><h1>Hello, World!</h1></div>");

The html! Macro

The html! macro provides a concise syntax for writing HTML:

use plait::html;

let page = html! {
    // Elements with attributes
    div(id: "main", class: "container") {
        // Nested elements
        h1 { "Title" }

        // Self-closing void elements
        br;
        input(type: "text", name: "query");

        // Text content and expressions (escaped by default)
        p { "Static text and " (2 + 2) " dynamic values" }
    }
};

assert_eq!(
    page.to_string(),
    "<div id=\"main\" class=\"container\">\
     <h1>Title</h1>\
     <br>\
     <input type=\"text\" name=\"query\">\
     <p>Static text and 4 dynamic values</p>\
     </div>",
);

DOCTYPE

Use #doctype to emit <!DOCTYPE html>:

use plait::html;

let page = html! {
    #doctype
    html {
        head { title { "My Page" } }
        body { "Hello" }
    }
};

assert!(page.to_string().starts_with("<!DOCTYPE html>"));

Expressions

Use parentheses to embed Rust expressions. Content is automatically HTML-escaped:

use plait::html;

let user_input = "<script>alert('xss')</script>";
let html = html! { p { (user_input) } };

assert_eq!(html.to_string(), "<p>&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;</p>");

For raw (unescaped) content, prefix with #:

use plait::html;

let trusted_html = "<strong>Bold</strong>";
let html = html! { div { #(trusted_html) } };

assert_eq!(html.to_string(), "<div><strong>Bold</strong></div>");

Let Bindings

Use let bindings to compute intermediate values:

use plait::html;

let world = " World";
let html = html! {
    let hello = world.len();
    (hello) (world)
};

assert_eq!(html.to_string(), "6 World");

Attributes

Attributes support several forms:

use plait::html;

let class = Some("active");
let disabled = true;

let html = html! {
    // Boolean attribute (no value)
    button(checked) { "Checked" }

    // Optional attribute with `?:` - included when Some or true, omitted when None or false
    div(class?: class) { "Has class" }
    button(disabled?: disabled) { "Disabled" }

    // Attribute names with underscores are converted to hyphens
    div(hx_target: "body") {}

    // String attribute names for special characters
    div("@click": "handler()") {}

    // Raw (unescaped) attribute value with `#(...)`
    div(class: #("<raw>")) {}
};

assert_eq!(
    html.to_string(),
    "<button checked>Checked</button>\
     <div class=\"active\">Has class</div>\
     <button disabled>Disabled</button>\
     <div hx-target=\"body\"></div>\
     <div @click=\"handler()\"></div>\
     <div class=\"<raw>\"></div>",
);

Control Flow

Standard Rust control flow works naturally:

use plait::html;

let show = true;
let items = vec!["a", "b", "c"];
let variant = "primary";
let user = Some("Alice");

let html = html! {
    // Conditionals
    if show {
        p { "Visible" }
    } else {
        p { "Hidden" }
    }

    // if let
    if let Some(name) = user {
        p { "Welcome, " (name) "!" }
    } else {
        p { "Please log in" }
    }

    // Loops
    ul {
        for item in &items {
            li { (item) }
        }
    }

    // Pattern matching
    match variant {
        "primary" => button(class: "btn-primary") { "Primary" },
        "secondary" => button(class: "btn-secondary") { "Secondary" },
        _ => button { "Default" }
    }
};

assert_eq!(
    html.to_string(),
    "<p>Visible</p>\
     <p>Welcome, Alice!</p>\
     <ul><li>a</li><li>b</li><li>c</li></ul>\
     <button class=\"btn-primary\">Primary</button>",
);

Nesting HTML Fragments

Use @(expr) to embed a value that implements HtmlDisplay (such as another html! fragment) without escaping:

use plait::html;

let inner = html! { p { "Hello World" } };
let outer = html! { div { @(&inner) } };

assert_eq!(outer.to_string(), "<div><p>Hello World</p></div>");

Ownership and Borrowing

html! expands into a move closure that implements Fn - it must be callable more than once (e.g. via Display::fmt(core::fmt::Display::fmt)). Values used in the template are moved into the closure, but because the closure is Fn, its captures live behind a shared reference and cannot be moved out.

For Copy types like &str, i32, or bool, this is invisible - they are copied each time the closure runs. For owned types like String or Vec, you must explicitly borrow with & inside the template:

use plait::html;

let name = String::from("World");

// ERROR: cannot move `name` out of the `Fn` closure
let fragment = html! { p { (name) } };

Use & to borrow the captured value instead:

use plait::html;

let name = String::from("World");

let fragment = html! { p { (&name) } };

assert_eq!(fragment.to_string(), "<p>World</p>");

The same applies anywhere a value is used inside the template - element children, attribute values, loop iterators, etc. When in doubt, borrow with &.

Components

Create reusable components using the component! macro:

use plait::{component, html, classes, ClassPart};

component! {
    fn Button(class: impl ClassPart) {
        button(class: classes!("btn", class), #attrs) {
            #children
        }
    }
}

let html = html! {
    // Component props before `;`, extra HTML attributes after
    @Button(class: "primary"; id: "submit-btn", disabled?: false) {
        "Click me"
    }
};

assert_eq!(html.to_string(), "<button class=\"btn primary\" id=\"submit-btn\">Click me</button>");

Inside components, #attrs spreads additional HTML attributes passed at the call site and #children renders the component's child content.

Passing HTML as Props

Components can accept html! fragments as props using the HtmlDisplay trait:

use plait::{HtmlDisplay, component, html};

component! {
    fn Card(title: impl HtmlDisplay) {
        div(class: "card") {
            h1 { @(title) }
            #children
        }
    }
}

let html = html! {
    @Card(title: html! { span(class: "highlight") { "My Title" } }) {
        p { "Card content" }
    }
};

assert_eq!(
    html.to_string(),
    "<div class=\"card\"><h1><span class=\"highlight\">My Title</span></h1><p>Card content</p></div>",
);

Component Syntax

Components support generics, lifetimes, anonymous lifetimes, impl Trait parameters, and where clauses:

use plait::{HtmlDisplay, ClassPart, component, html, classes};

// Anonymous lifetimes: `&str` is automatically desugared
component! {
    fn NavLink(href: &str, label: &str, class: impl ClassPart, active: bool) {
        a(href: href, class: classes!(
            "nav-link", class,
            if *active { "active" } else { "" },
        )) {
            (label)
        }
    }
}

// Explicit generics with where clauses
component! {
    fn Card<H, F>(header: H, footer: F) where H: HtmlDisplay, F: HtmlDisplay {
        div(class: "card") {
            div(class: "header") { @(header) }
            div(class: "body") { #children }
            div(class: "footer") { @(footer) }
        }
    }
}

Prop Access

Props are received as references inside the component body. Primitive types like bool and u32 should be dereferenced with *:

use plait::{component, html};

component! {
    fn Badge(count: u32, visible: bool) {
        if *visible {
            span(class: "badge") { (count) }
        }
    }
}

let html = html! {
    @Badge(count: 5, visible: true) {}
};

assert_eq!(html.to_string(), "<span class=\"badge\">5</span>");

URL Safety

URL attributes (href, src, action, etc.) are automatically validated. Dangerous schemes like javascript: are stripped:

use plait::html;

let html = html! {
    a(href: "javascript:alert('xss')") { "Click" }
};

assert_eq!(html.to_string(), "<a>Click</a>");  // href removed

Safe schemes (http, https, mailto, tel) and relative paths are allowed. Use #(...) for raw URLs when you trust the source.

Merging CSS Classes

Use classes! to combine multiple class values into a single space-separated string. Any type implementing ClassPart can be used - empty strings and None values are automatically skipped:

use plait::{component, html, classes, ClassPart};

component! {
    fn Button(class: impl ClassPart) {
        button(class: classes!("btn", class), #attrs) {
            #children
        }
    }
}

let html = html! {
    @Button(class: Some("btn-primary")) { "Click me" }
};

assert_eq!(html.to_string(), "<button class=\"btn btn-primary\">Click me</button>");

License

Licensed under either of

at your option.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.