Skip to content
devashishdxtPublic

About

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

Resources

Stars

15 stars

Watchers

0 watching

Forks

Repository files navigation

plait

A modern, type-safe HTML templating library for Rust that embraces composition.

Plait lets you write HTML directly in Rust using the html! macro, with compile-time validation, automatic escaping, and a natural syntax that mirrors standard HTML and Rust control flow. Reusable components are defined with the component! macro.

Quick start

use plait::{html, ToHtml};

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

assert_eq!(page.to_html(), r#"<div class="greeting"><h1>Hello, World!</h1></div>"#);

The html! macro returns an HtmlFragment that implements ToHtml. Call .to_html() to get an Html value (a String wrapper that implements Display).

Syntax reference

Elements

Write element names directly. Children go inside braces. Void elements (like br, img, input) use a semicolon instead.

let frag = html! {
    div {
        p { "Hello" }
        br;
        img(src: "/logo.png");
    }
};

Snake-case identifiers are automatically converted to kebab-case:

// Renders as <my-element>...</my-element>
let frag = html! { my_element { "content" } };
assert_eq!(frag.to_html(), "<my-element>content</my-element>");

DOCTYPE

Use #doctype to emit <!DOCTYPE html>:

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

assert_eq!(page.to_html(), "<!DOCTYPE html><html><head><title>My Page</title></head><body>Hello</body></html>");

Text and expressions

String literals are rendered as static text (HTML-escaped). Rust expressions inside parentheses are also HTML-escaped by default. Use #(expr) for raw (unescaped) output.

let user = "<script>alert('xss')</script>";
let frag = html! {
    "Static text "
    (user)              // escaped: &lt;script&gt;...
    #("<b>bold</b>")    // raw: <b>bold</b>
};

Expressions in () must implement RenderEscaped. Expressions in #() must implement RenderRaw.

Attributes

Attributes go in parentheses after the element name.

let frag = html! {
    // String value
    div(class: "container", id: "main") { "content" }

    // Boolean attribute (no value) - always rendered
    button(disabled) { "Can't click" }

    // Expression value (escaped)
    input(type: "text", value: ("hello"));

    // Raw expression value (unescaped)
    div(class: #("raw-class")) {}
};

Underscore-to-hyphen conversion applies to attribute names too:

// Renders as hx-target="body"
let frag = html! { div(hx_target: "body") {} };

assert_eq!(frag.to_html(), "<div hx-target=\"body\"></div>");

Use string literals for attribute names that need special characters:

let frag = html! { div("@click": "handler()") {} };

assert_eq!(frag.to_html(), r#"<div @click="handler()"></div>"#);

Optional attributes

Append ? to the attribute name (before the :) to make it conditional. The attribute is only rendered when the value is Some(_) (for Option) or true (for bool).

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

let frag = html! {
    button(class?: class, disabled?: disabled) { "Click" }
};
assert_eq!(frag.to_html(), r#"<button class="active">Click</button>"#);

Values for ? attributes must implement RenderMaybeAttributeEscaped (or RenderMaybeAttributeRaw when used with #()).

Control flow

Standard Rust if/else, if let, for, and match work inside templates:

let items = vec!["one", "two", "three"];
let show_header = true;

let frag = html! {
    if show_header {
        h1 { "List" }
    }

    ul {
        for item in items.iter() {
            li { (item) }
        }
    }
};
let value = Some("hello");

let frag = html! {
    if let Some(v) = value {
        span { (v) }
    } else {
        span { "nothing" }
    }
};
let tag = "div";

let frag = html! {
    match tag {
        "div" => div { "a div" },
        "span" => span { "a span" },
        _ => "unknown"
    }
};

Let bindings

Compute intermediate values within templates:

let world = "World";

let frag = html! {
    let len = world.len();
    "Length: " (len)
};
assert_eq!(frag.to_html(), "Length: 5");

Nesting fragments

HtmlFragment implements RenderEscaped, so fragments can be embedded in other fragments:

let inner = html! { p { "inner content" } };
let outer = html! { div { (inner) } };
assert_eq!(outer.to_html(), "<div><p>inner content</p></div>");

Components

Components are reusable template functions defined with the component! macro:

use plait::{component, classes, Class};

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

Call components with @ syntax inside html!:

let page = html! {
    @Button(class: "primary"; id: "submit-btn", disabled?: false) {
        "Submit"
    }
};

assert_eq!(
    page.to_html(),
    r#"<button class="btn primary" id="submit-btn">Submit</button>"#
);

In the component call, props appear before the ;, and extra HTML attributes appear after. The component body uses #attrs to spread those extra attributes and #children to render the child content.

Reserved attributes

Use #[reserve_attrs(...)] to reject selected caller attributes, while still allowing same-named props and internal element attributes:

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

component! {
    #[reserve_attrs(type, class, aria_disabled, "x-on:click")]
    fn Button(class: &str = "primary") {
        button(type: "button", class: class, #attrs) { #children }
    }
}

// class is a prop; id is a forwarded attribute.
let page = html! { @Button(class: "danger"; id: "save") { "Save" } };
assert_eq!(page.to_html(), "<button type=\"button\" class=\"danger\" id=\"save\">Save</button>");

// Wrappers inherit the restriction when they forward #attrs.
component! {
    fn Toolbar() { @Button(; #attrs) { #children } }
}
let page = html! { @Toolbar(; id: "save") { "Save" } };
assert_eq!(page.to_html(), "<button type=\"button\" class=\"primary\" id=\"save\">Save</button>");

Names use underscore-to-hyphen conversion and ASCII-case-insensitive matching. The spelling of rendered attributes is unchanged.

These calls fail a full build (using the components above):

// Case and identifier/literal aliases cannot bypass reservations.
html! { @Button(; "TYPE": "submit") {} }.to_html();
html! { @Button(; "aria-disabled": "true") {} }.to_html();

// The restriction follows #attrs through wrappers and nested html! calls.
html! { @Toolbar(; type: "submit") {} }.to_html();

// Absent values still count as supplied attributes.
html! { @Button(; class?: false) {} }.to_html();
html! { @Button(; class?: None::<&str>) {} }.to_html();

Only names explicitly reserved by the receiving component are checked, even if it ignores #attrs. Attributes placed on a wrapper's own element do not reach its child. Empty lists and repeated declarations are allowed.

Build-time checks: use cargo build or cargo build --release. cargo check may miss conflicts, as may code that is never instantiated.

Prop defaults

Use = expression to provide a default value for a prop when it is omitted at call site. All other props are required, including Option<T> props.

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

component! {
    fn SaveButton(label: impl AsRef<str> = "Save", tooltip: Option<&str> = Some("Save changes")) {
        button(title?: tooltip) { (label.as_ref()) }
    }
}
assert_eq!(html! { @SaveButton() {} }.to_html(), "<button title=\"Save changes\">Save</button>");

// A String can override an &str default; None removes the tooltip.
assert_eq!(html! {
    @SaveButton(label: String::from("Save draft"), tooltip: None) {}
}.to_html(), "<button>Save draft</button>");

Creating a fragment does not evaluate its props. Each time the fragment is rendered, supplied prop expressions run in the order written in the call. Defaults for omitted props then run once each, in declaration order. Default expressions can use items visible where the component is defined, but cannot refer to sibling props or variables from the call site. See component! for fragment defaults and typed None.

Shorthand props

When a variable has the same name as a component prop, you can use shorthand syntax - just like Rust struct initialization:

let class = "primary";

// These are equivalent:
let a = html! { @Button(class: class) { "Click" } };
let b = html! { @Button(class) { "Click" } };

assert_eq!(a.to_html(), b.to_html());

Shorthand and explicit props can be mixed freely:

let name = "Alice";
let html = html! { @UserCard(name, role: "Admin") {} };

assert_eq!(html.to_html(), "<div><span>Alice</span> - <span>Admin</span></div>");

Passing fragments as props

Use PartialHtml as a prop bound to accept html! output as a component prop:

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

let page = html! {
    @Card(title: html! { span { "My Card" } }) {
        p { "Card body" }
    }
};

Primitive props

Component props are received as references. For primitive types like bool or u32, dereference with * in the component body:

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

CSS classes

The classes! macro combines multiple class values, automatically skipping empty strings and None values:

let extra: Option<&str> = None;

let frag = html! {
    div(class: classes!("base", "primary", extra)) {}
};
assert_eq!(frag.to_html(), r#"<div class="base primary"></div>"#);

Values passed to classes! must implement the Class trait. This is implemented for &str, Option<T> where T: Class, and Classes<T>.

Web framework integrations

Plait provides optional integrations with popular Rust web frameworks. Both Html and HtmlFragment can be returned directly from request handlers when the corresponding feature is enabled.

Enable integrations by adding the feature flag to your Cargo.toml:

[dependencies]
plait = { version = "0.10", features = ["axum"] }

Available features: actix-web, axum, rocket.

axum

Html and HtmlFragment implement IntoResponse:

use axum::{Router, routing::get};
use plait::{html, ToHtml};

async fn index() -> plait::Html {
    html! {
        h1 { "Hello from plait!" }
    }.to_html()
}

let app = Router::new().route("/", get(index));

You can also return an HtmlFragment directly without calling .to_html():

async fn index() -> impl axum::response::IntoResponse {
    plait::html! {
        h1 { "Hello from plait!" }
    }
}

actix-web

Html and HtmlFragment implement Responder:

use actix_web::{App, HttpServer, get};
use plait::{html, ToHtml};

#[get("/")]
async fn index() -> plait::Html {
    html! {
        h1 { "Hello from plait!" }
    }.to_html()
}

rocket

Html and HtmlFragment implement Responder:

use rocket::get;
use plait::{html, ToHtml};

#[get("/")]
fn index() -> plait::Html {
    html! {
        h1 { "Hello from plait!" }
    }.to_html()
}

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.

About

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

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages