maud
Skill7 starsChanged 18 days agoMITView on GitHub
Maud Rust `html!` macro reference (0.27).
In this repository15 other files it gives its agents.
- askamaSkill
- cargo-toml-optimizationSkill
- clean-codeSkill
- clean-flutterSkill
- clean-rustSkill
- clean-shellSkill
- docs-syncSkill
- emulator-testingSkill
- mcp-statelessSkill
- name-checkSkill
- ratatui-patternsSkill
- terminal-uxSkill
- todoSkill
- ux-patternsSkill
- webapp-testingSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 101
Call .into_string() and wrap the string yourself.
- Very specific: line 298
Every control keyword takes an @ prefix.
- Very specific: line 395
Only wrap content you have already sanitized.
- Very specific: line 403
Write a maud::Render impl to teach maud how to splice your own type.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
[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>. |
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.
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 | n/a | n/a | 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 | n/a |
Dependency version pins came from the docs.rs feature graph, single source. Verify against Cargo.toml if exact ranges matter.
maud = { version = "0.27", features = ["axum"] }Handler Example
Rocket:
#[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.
// 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 />).
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.
html! {
pre {
r#"
Rocks, these are my rocks.
Smooth and round,
Asleep in the ground.
"#
}
}5. Attributes
Value attribute: name="value".
html! {
a href="about:blank" { "Apple Bloom" }
li class="lower-middle" { "Sweetie Belle" }
}Boolean / empty attribute: bare name, no =.
html! {
input type="checkbox" name="cupcakes" checked;
label for="cupcakes" { "Do you like cupcakes?" }
}#id / .class Shorthand
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 allowedinput#pinkie;.
Implicit div
Omitting the tag name when a #id / .class shorthand is present defaults the element to div.
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.
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.
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 .{ }.
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.
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:
html! {
p {
({
let f: Foo = something_convertible_to_foo()?;
f.time().format("%H%Mh")
})
}
}Splices in Attributes
Single value with =:
let secret_message = "Surprise!";
html! {
p title=(secret_message) { "Nothing to see here." }
}Concatenating literal plus splice needs { } wrapping:
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
#[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):
let user = Some("Pinkie Pie");
html! {
p {
"Hello, "
@if let Some(name) = user { (name) } @else { "stranger" }
"!"
}
}@for
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
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.
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.
use maud::PreEscaped;
html! {
"<script>alert(\"XSS\")</script>" // <script>...
(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:
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.
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) vsattr=[Option<T>](value only whenSome); attr concat needs{ }; §5, §6- Splices escape by default;
PreEscapedis an XSS hole on untrusted input; only&<>"escaped (single quote not); §6, §8 - Rust 2021
#spacing (input #pinkie;); §5 Renderwith neither method overridden recurses forever; arender_tooverride does its own escaping (maud::Escaper); §9render/render_toare sync-only; no asyncRendertrait, no streaming variant; resolve async values beforehtml!(§9)Markuphas noDisplayimpl (.into_string()/maud::display(x)); no template files, nohtml_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
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

