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.
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).
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>");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>");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: <script>...
#("<b>bold</b>") // raw: <b>bold</b>
};Expressions in () must implement RenderEscaped. Expressions in #()
must implement RenderRaw.
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>"#);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 #()).
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"
}
};Compute intermediate values within templates:
let world = "World";
let frag = html! {
let len = world.len();
"Length: " (len)
};
assert_eq!(frag.to_html(), "Length: 5");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 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.
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.
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.
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>");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" }
}
};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) }
}
}
}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>.
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.
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!" }
}
}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()
}Html and HtmlFragment implement
Responder:
use rocket::get;
use plait::{html, ToHtml};
#[get("/")]
fn index() -> plait::Html {
html! {
h1 { "Hello from plait!" }
}.to_html()
}Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
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.