maud · diff

v1.5 to v1.5

1 added, 2 removed. Audit B to B.

---
name: maud
description: "Maud Rust `html!` macro reference (0.27)."
metadata:
author: uwuclxdy
version: "1.5"
---
# Maud (Rust HTML Macro)
> _Captured 2026-07-10 (maud 0.27.0). To update: re-verify against maud.lambda.xyz + docs.rs/maud, then diff for changes._
Maud is a compile-time HTML macro (`html!`); markup lives inline in Rust, type-checked at compile time. This skill targets **maud 0.27.x** (0.27.0, released 2025-02-02).
Maud runs on **stable Rust and nightly** (better error messages on nightly). Stable-Rust-capable since 0.22.1 (2020-11-02); earlier it needed nightly-only compiler features. MSRV is not documented.
---
## 1. Setup
### Cargo.toml
```toml
[dependencies]
maud = "0.27"
```
---
## 2. Rendering API
`html! { ... }` returns a `Markup` value. `Markup` is a type alias for `PreEscaped<String>`, a wrapper around an already-escaped HTML string.
| Operation | Result | Notes |
|---|---|---|
| `html! { ... }` | `Markup` | The macro expression's value. |
| `markup.into_string()` | `String` | Consumes the `Markup`, hands back the built string. |
| `(DOCTYPE)` inside `html!` | emits `<!DOCTYPE html>` | `maud::DOCTYPE` is a constant equal to the literal string `<!DOCTYPE html>`. |
```rust
use maud::{html, DOCTYPE};
let page = html! {
(DOCTYPE)
html {
head { title { "My site" } }
body { p { "Hello" } }
}
};
```
Rendering a `Display` value: `maud::display(x)` is a free function that renders any value through its `Display` impl inside a template. It replaced the old blanket `Render for Display` impl removed in 0.24.0.
```rust
use maud::{html, display};
let n = 42;
html! { p { (display(n)) } };
```
Note: `Markup`/`PreEscaped` has no `Display` impl (checked against docs.rs 0.27; it implements `Render`, not `Display`). Convert to `String` with `.into_string()` when you need the raw string. There is no `html_to!` / write-into-buffer macro (`html_debug!` was removed in 0.25.0). To write into an existing buffer, use the `Render` trait's `render_to` (see §9).
---
## 3. Web Framework Integration
Turn on the matching feature flag from the table below and `Markup` implements the corresponding trait. A handler can then just return `Markup` directly.
| Framework | Feature flag | Trait `Markup` implements | Dep pins |
|---|---|---|---|
| default | — | — | nothing enabled |
| Actix-web | `actix-web` | `actix_web::Responder` | `actix-web-dep` + `futures-util` |
| Axum | `axum` | `IntoResponse` | `axum-core` `^0.5` + `http` `^1` |
| Rocket | `rocket` | Rocket's `Responder` | `rocket` `^0.5` |
| Warp | `warp` | `warp::Reply` | `warp` `^0.3.6` |
| Poem | `poem` | `poem::IntoResponse` | `poem` `^3` |
| Tide | `tide` | `From<PreEscaped<String>>` for `tide::Response` | `tide` `^0.16.0` |
| Submillisecond | `submillisecond` (new in 0.27.0) | `IntoResponse` | `submillisecond` `^0.4.1` |
| Rouille | manual, see below | none | — |
Dependency version pins came from the docs.rs feature graph, single source. Verify against `Cargo.toml` if exact ranges matter.
```toml
maud = { version = "0.27", features = ["axum"] }
```
### Handler Example
Rocket:
```rust
#[get("/<name>")]
fn hello(name: &str) -> Markup {
html! { h1 { "Hello, " (name) "!" } }
}
```
Actix-web and Tide handlers wrap the return in a `Result`: Actix `async fn index() -> AwResult<Markup> { Ok(html! { ... }) }`; Tide returns `Ok(html! { ... })` from a closure inferred as `tide::Result<impl Into<Response>>` (its feature adds `From<PreEscaped<String>>`, not `IntoResponse`, so a bare `-> Markup` handler won't compile). Axum, Poem, Submillisecond return `Markup` directly from a sync or async handler. The trait impl converts it to the response type each one expects.
### Manual Path (No Feature Flag)
Call `.into_string()` and wrap the string yourself.
```rust
// Rouille has no trait impl:
Response::html(html! { h1 { "Hello, " (name) "!" } })
```
---
## 4. Elements and Nesting
Elements with content use braces; text and nested elements go inside.
Void elements end with `;` and take no body. Maud emits HTML5 syntax (`<br>`), not XHTML (`<br />`).
```rust
html! {
link rel="stylesheet" href="poetry.css";
p {
"Rock, you are a rock."
br;
"Gray, you are gray,"
}
}
```
Elements and attributes with hyphens are supported (custom elements, `data-*`, ARIA).
"Type-checked at compile time" means Rust splice-expression types only. Maud does not validate element or attribute names against the HTML5 spec. It does not check content-model nesting either (a `<div>` inside a `<p>` compiles fine). Any valid Rust ident compiles as a tag, typos included.
### Text
Plain double-quoted Rust string literals are bare expressions inside `html!`. Raw string literals handle long or special-character text.
```rust
html! {
pre {
r#"
Rocks, these are my rocks.
Smooth and round,
Asleep in the ground.
"#
}
}
```
---
## 5. Attributes
Value attribute: `name="value"`.
```rust
html! {
a href="about:blank" { "Apple Bloom" }
li class="lower-middle" { "Sweetie Belle" }
}
```
Boolean / empty attribute: bare name, no `=`.
```rust
html! {
input type="checkbox" name="cupcakes" checked;
label for="cupcakes" { "Do you like cupcakes?" }
}
```
### `#id` / `.class` Shorthand
```rust
html! {
input #cannon .big.scary.bright-red type="button" value="Launch";
div."col-sm-2" { "Bootstrap column!" }
}
```
- Multiple classes chain: `.big.scary.bright-red`.
- Quoted names allow characters not valid as bare idents: `."col-sm-2"`.
- Rust 2021 whitespace gotcha: `#` must be preceded by a space (`input #pinkie;`). Older editions allowed `input#pinkie;`.
### Implicit `div`
Omitting the tag name when a `#id` / `.class` shorthand is present defaults the element to `div`.
```rust
html! {
#main {
"Main content!"
.tip { "Storing food in a refrigerator can make it 20% cooler." }
}
}
```
### Toggles `[condition]`
A bracketed bool expression toggles a boolean attribute or a class on or off.
```rust
let allow_editing = true;
html! {
p contenteditable[allow_editing] { "Edit me." }
}
let cuteness = 95;
html! {
p.cute[cuteness > 50] { "Squee!" }
}
```
### Optional Attribute Values `attr=[Option<T>]`
With `=`, a bracketed `Option` sets the attribute's value only when `Some`. `None` omits the attribute entirely. This differs from bare `[cond]`, which toggles presence.
```rust
html! {
p title=[Some("Good password")] { "Correct horse" }
@let value = Some(42);
input value=[value];
@let title: Option<&str> = None;
p title=[title] { "Battery staple" } // title omitted
}
```
### Dynamic Names
Splice a computed id with `#(...)`. Build a class from a literal plus splice inside `.{ }`.
```rust
let name = "rarity";
let severity = "critical";
html! {
aside #(name) {
p.{ "color-" (severity) } { "This is the worst! Possible! Thing!" }
}
}
```
---
## 6. Splices and Interpolation
`(expr)` inserts a runtime value into markup. HTML special characters in the value are escaped by default.
```rust
let best_pony = "Pinkie Pie";
let numbers = [1, 2, 3, 4];
html! {
p { "Hi, " (best_pony) "!" }
p {
"I have " (numbers.len()) " numbers, "
"and the first one is " (numbers[0])
}
}
```
Splice any type that has a `maud::Render` impl. Most primitives (`str`, `i32`, ...) already do.
Block-expression splice for arbitrary Rust logic:
```rust
html! {
p {
({
let f: Foo = something_convertible_to_foo()?;
f.time().format("%H%Mh")
})
}
}
```
### Splices in Attributes
Single value with `=`:
```rust
let secret_message = "Surprise!";
html! {
p title=(secret_message) { "Nothing to see here." }
}
```
Concatenating literal plus splice needs `{ }` wrapping:
```rust
const GITHUB: &'static str = "https://github.com";
html! {
a href={ (GITHUB) "/lambda-fairy/maud" } { "Fork me on GitHub" }
}
```
---
## 7. Control Structures
Every control keyword takes an `@` prefix. Bare `if` / `for` / `match` / `let` are not recognized as control flow inside `html!`. Braces are mandatory on every branch.
### `@if` / `@else if` / `@else`
```rust
#[derive(PartialEq)]
enum Princess { Celestia, Luna, Cadance, TwilightSparkle }
let user = Princess::Celestia;
html! {
@if user == Princess::Luna {
h1 { "Super secret woona to-do list" }
ul { li { "Evil laugh" } }
} @else if user == Princess::Celestia {
p { "Sister, please stop reading my diary." }
} @else {
p { "Nothing to see here; move along." }
}
}
```
`@if let` works (the condition is a plain Rust expression, so `let PAT = EXPR` parses natively):
```rust
let user = Some("Pinkie Pie");
html! {
p {
"Hello, "
@if let Some(name) = user { (name) } @else { "stranger" }
"!"
}
}
```
### `@for`
```rust
let names = ["Applejack", "Rarity", "Fluttershy"];
html! {
ol {
@for name in &names {
li { (name) }
}
}
}
```
### `@while`
Present in the parser AST (`WhileExpr`), absent from the published book. No documented example exists. Structure matches `@if`, so `@while cond { ... }` and by analogy `@while let PAT = EXPR { ... }` are expected to work. Treat `@while let` as unverified until you compile it.
### `@match`
```rust
html! {
@match user {
Princess::Luna => h1 { "Woona's here" },
Princess::Celestia | Princess::Cadance => p { "A princess." },
_ => p { "Nothing to see here; move along." }
}
}
```
Reuses the `Princess` enum and `user` binding from `@if` above. Match arms accept full Rust patterns per the parser source: or-patterns (`A | B`, shown above), bindings, ranges, destructuring, guards (`pat if cond =>`). The book shows only basic arms, so verify guard / or-pattern arms by compiling.
### `@let`
Declares a variable inside `html!`, most useful inside loops. Accepts any Rust `let` pattern including type ascription.
```rust
let names = ["Applejack", "Rarity", "Fluttershy"];
html! {
@for name in &names {
@let first_letter = name.chars().next().unwrap();
p {
"The first letter of " b { (name) } " is " b { (first_letter) } "."
}
}
}
```
---
## 8. Escaping and Raw HTML
Splices and text are escaped by default. Maud escapes exactly four characters: `&`, `<`, `>`, `"`. Single quotes pass through unescaped.
Bypass escaping with `maud::PreEscaped`, which renders its inner value verbatim.
```rust
use maud::PreEscaped;
html! {
"<script>alert(\"XSS\")</script>" // &lt;script&gt;...
(PreEscaped("<script>alert(\"XSS\")</script>")) // <script>...
}
```
Only wrap content you have already sanitized. `PreEscaped` on untrusted input is an XSS hole. The `AsRef<str>` bound restriction on `PreEscaped` was removed in 0.26.0, so it wraps a wider range of inner types now.
---
## 9. Custom Rendering and Composition
### The `Render` Trait
Write a `maud::Render` impl to teach maud how to splice your own type. The trait has two methods, both with default impls that call each other:
```rust
pub trait Render {
fn render(&self) -> Markup {
let mut buffer = String::new();
self.render_to(&mut buffer);
PreEscaped(buffer)
}
fn render_to(&self, buffer: &mut String) {
buffer.push_str(&self.render().into_string());
}
}
```
Override at least one. Overriding neither causes infinite recursion (each default calls the other). Override `render` for the simple case. Override `render_to` when you want to write straight into the output buffer, for example to append multiple pieces without an intermediate `Markup`.
A `render_to` override is responsible for its own escaping. Raw writes into `buffer` are not escaped for you. Need that inside one? Reach for `maud::Escaper`, it escapes as it writes.
`render`/`render_to` are sync-only. There is no async `Render` trait or streaming variant. Resolve async data (a DB or HTTP call) to a value before entering `html!` and splice the resolved value.
### Components Are Just Functions
A partial or component is a plain function returning `Markup`. Compose by splicing the call.
```rust
use maud::{html, Markup, DOCTYPE};
fn header(title: &str) -> Markup {
html! {
head { title { (title) } }
}
}
fn page(title: &str, body: Markup) -> Markup {
html! {
(DOCTYPE)
html {
(header(title))
body { (body) }
}
}
}
// usage
let markup = page("Home", html! { h1 { "Hello" } });
```
Splicing a `Markup` value never re-escapes it, so nesting components is safe. Maud does not currently export a `RenderOnce` trait (it had one from 0.8.0 through 0.15.0, removed 2017-01-26); don't confuse the name with horrorshow's still-existing `RenderOnce` trait.
---
## 10. Gotchas and Footguns
Each is flagged inline at its section; §11 consolidates. Index:
- Void `;` not self-close; control flow needs `@` (every branch braced); §4, §7
- `[cond]` (toggle) vs `attr=[Option<T>]` (value only when `Some`); attr concat needs `{ }`; §5, §6
- Splices escape by default; `PreEscaped` is an XSS hole on untrusted input; only `&` `<` `>` `"` escaped (single quote not); §6, §8
- Rust 2021 `#` spacing (`input #pinkie;`); §5
- `Render` with neither method overridden recurses forever; a `render_to` override does its own escaping (`maud::Escaper`); §9
- `render`/`render_to` are sync-only; no async `Render` trait, no streaming variant; resolve async values before `html!` (§9)
- `Markup` has no `Display` impl (`.into_string()` / `maud::display(x)`); no template files, no `html_to!`; §2
- "Type-checked" covers Rust splice types only; no HTML5 tag/attribute validation, no content-model nesting checks (§4)
---
## 11. Quick Reference Card
```
Value: html! { ... } -> Markup Get String: .into_string()
Doctype: (DOCTYPE) -> <!DOCTYPE html>
Element: p { "text" nested { } }
Void element: br; link rel="x" href="y"; (trailing ; , renders <br>)
Text: "literal" r#"raw literal"#
Attr: name="value"
Bool attr: checked (bare name, no =)
Shorthand: input #id .class1.class2; .class -> implies <div> if no tag
Toggle: p contenteditable[cond] { } p.cute[n > 50] { }
Optional attr: input value=[Some(42)]; title=[opt] (None omits attr)
Splice: (expr) escapes by default
Attr splice: title=(x) href={ (a) "/" (b) }
Dyn name: #(id_expr) .{ "color-" (sev) }
Block splice: ({ let x = f()?; x })
If: @if c { } @else if d { } @else { }
If let: @if let Some(x) = opt { (x) } @else { }
For: @for x in &xs { li { (x) } }
Match: @match v { A => { }, _ => p { } } (guards / or-pats via Rust patterns)
While: @while cond { } (@while let unverified)
Let: @let x = expr; @let y: Option<&str> = None;
Raw HTML: (PreEscaped(safe_html)) disables escaping
Display value: (display(x))
Component: fn c(...) -> Markup { html! { } } splice with (c(...))
Render trait: fn render(&self) -> Markup fn render_to(&self, buf: &mut String) (both have default impls, both sync-only)
Escapes: & < > " (single quote NOT escaped)
```
- Book: https://maud.lambda.xyz
- API docs: https://docs.rs/maud
+ Book: https://maud.lambda.xyz API docs: https://docs.rs/maud