Proc macros for Maud that make component-style view code, bundled browser runtime helpers, and typed shell components easier to author.
This crate has three main jobs:
- file-scoped component helpers with
js!,css!, andcomponent! - direct emitters and bundled runtimes such as
inline_js!(),surreal_scope_inline!(), andsurreal_scope_signals_inline!() - typed builder generation for layout and shell components with
#[derive(ComponentBuilder)]
Signals support stays JS-first: Maud markup provides anchors, while js!
owns signals, effects, and DOM binding. The companion crate
maud-extensions-runtime
still provides the older string-based slot transport, but for new shell and
layout components the preferred path is ComponentBuilder.
cargo add maud-extensions
cargo add maud-extensions-runtime # only needed for the lower-level runtime slot transportSupport policy:
- MSRV: Rust 1.85
- supported Maud version: 0.27
Use component! when the component owns its own DOM, local CSS, and local JS:
use maud::{html, Markup, Render};
use maud_extensions::{component, css, js, surreal_scope_inline};
js! {
me().class_add("ready");
}
struct StatusCard<'a> {
message: &'a str,
}
impl<'a> Render for StatusCard<'a> {
fn render(&self) -> Markup {
component! {
@js-once
article class="status-card" {
h2 { "System status" }
p class="message" { (self.message) }
}
}
}
}
css! {
me {
border: 1px solid #ddd;
border-radius: 10px;
padding: 12px;
}
me.ready {
border-color: #16a34a;
}
}
let page = html! {
head { (surreal_scope_inline!()) }
body { (StatusCard { message: "All systems operational" }) }
};css! { ... } still defines the default css() helper that component!
injects automatically. If the same scope needs extra stylesheet helpers, use
css! { "card_border", { ... } } to generate a named function such as
card_border().
Use #[derive(ComponentBuilder)] for new shell and layout components with
props and named content regions. This is the preferred path when the regions
can be expressed as typed fields:
use maud::{html, Markup, Render};
use maud_extensions::ComponentBuilder;
#[derive(Clone)]
struct ActionButton {
label: &'static str,
}
impl Render for ActionButton {
fn render(&self) -> Markup {
html! { button type="button" { (self.label) } }
}
}
#[derive(ComponentBuilder)]
struct Card {
tone: &'static str,
#[slot]
header: Option<Markup>,
#[slot(default)]
body: Markup,
#[builder(each = "action")]
actions: Vec<ActionButton>,
}
impl Render for Card {
fn render(&self) -> Markup {
html! {
article class={ "card " (self.tone) } {
@if let Some(header) = &self.header {
header class="card-header" { (header) }
}
main class="card-body" { (self.body) }
@if !self.actions.is_empty() {
footer class="card-actions" {
@for action in &self.actions {
(action)
}
}
}
}
}
}
}
let view = Card::new()
.tone("info")
.header(html! { h2 { "Status" } })
.body(html! { p { "All systems green" } })
.action(ActionButton { label: "Retry" })
.action(ActionButton { label: "Dismiss" })
.render();Use the runtime slot API from maud-extensions-runtime only when you really
need open caller-owned child structure, or when you are keeping an existing
slot-based component. That API is the lower-level string-based transport layer;
see examples/slots.rs and the
maud-extensions-runtime docs
when you actually need it.
ComponentBuilder is the preferred API for new typed shell and layout
components. It doesn't try to invent new Maud syntax. It generates a normal
Rust builder from the component struct you already want to render.
What it generates:
Type::new()andType::builder()- one setter per field
maybe_field(option)helpers forOption<T>fields using the exact field type#[builder(each = "...")]item setters forVec<T>fields.build()once all required fields are present.render()on the complete builder when the component implementsRenderFrom<CompleteBuilder> for Type
Field rules:
- plain fields are required
Option<T>fields are optionalOption<T>fields also get amaybe_field(Option<T>)helperVec<T>fields default to empty and can use#[builder(each = "...")]#[builder(default)]makes a non-Option, non-Vecfield useDefault#[slot]and#[slot(default)]record the component's content-region contract for this builder-core layer and for later syntax sugar
Markup ergonomics:
- regular setters for fields written as
Markup,maud::Markup, or::maud::Markupaccept anyimpl Render - that applies to single-markup fields, optional markup fields, and repeated
Vec<Markup>item setters maybe_field(...)helpers for optional markup fields takeOption<Markup>
Current limits:
- named structs only
- at most one
#[slot(default)]field .build()is still required when you need the concrete component value- there is no
compose!macro or block syntax yet - the builder offers a consuming
.render()convenience instead of implementingRender
The runtime slot API is still supported, but it is no longer the aspirational surface for new shell and layout components.
Why it exists:
- it works for fully open caller-owned child structure
- it keeps existing slot-based components working
- it provides one generic transport path over plain
Render
Why it is lower-level:
- slot names are stringly
- child transport goes through
.with_children(...) - the slot contract stays outside the type system
- missing or extra named slots are runtime behavior, not builder-shape errors
Use it when openness is the point. Otherwise prefer ComponentBuilder.
Signals support is intentionally JS-first. Render stable DOM anchors in Maud,
then create signals and bindings in js!.
use maud::{html, Markup, Render};
use maud_extensions::{component, css, js, surreal_scope_signals_inline};
js! {
const count = mx.signal(0);
const active = mx.computed(() => count.value > 0);
me(".count").bindText(count);
me().bindClass("active", active);
me(".inc").on("click", () => count.value++);
}
struct Counter;
impl Render for Counter {
fn render(&self) -> Markup {
component! {
@js-once
section class="counter" {
p { "Count: " span class="count" {} }
button class="inc" type="button" { "+" }
}
}
}
}
css! {
me.active { border-color: #16a34a; }
}
let page = html! {
head { (surreal_scope_signals_inline!()) }
body { (Counter) }
};Supported v1 binders:
bindText(source)bindAttr(name, source)bindClass(name, source)bindShow(source)
Rules:
- binders live on
window.mxand on Surreal-sugared handles such asme(".count") sourcecan be a Signals object or a function- function sources run inside
mx.effect(...) - binder cleanup is scoped to
component!roots surreal_scope_signals_inline!()is the supported runtime include when a page usescomponent!,js!, and Signals binders together
Bundled runtime macros:
surreal_scope_inline!()- emits bundled
surreal.jsandcss-scope-inline.js
- emits bundled
signals_inline!()- emits bundled
@preact/signals-coreand the Maud Signals adapter
- emits bundled
surreal_scope_signals_inline!()- emits
surreal.js,css-scope-inline.js,@preact/signals-core, and the Maud Signals adapter in the right order
- emits
Direct emitters:
inline_js! { ... }/inline_css! { ... }- emit direct
<script>/<style>tags
- emit direct
js_file!(...)/css_file!(...)- inline file contents using
include_str!-style paths
- inline file contents using
font_face!(...)/font_faces!(...)- emit base64
@font-faceCSS without adding another dependency
- emit base64
Manual composition rule:
- if you emit
surreal_scope_inline!()andsignals_inline!()separately, putsurreal_scope_inline!()first so the Signals adapter can extend Surreal before componentjs!blocks run
component!performs compile-time shape checks over the token stream it sees; it only checks the token shape the macro can observecomponent!accepts exactly one top-level element with a body blockjs!andcss!must both be in scope forcomponent!, even if one is emptyinline_js!validates JavaScript with SWC before generating markupinline_css!runs a lightweight CSS syntax check before generating markup- slot runtime helpers fail closed outside
.with_children(...) - malformed slot transport markers fail closed into default-slot content
- runtime slots are a lower-level transport layer; for new shell/layout
components prefer
ComponentBuilder ComponentBuilderobserves lexical type forms, not full type resolution, so type aliases toMarkup,Option, orVecare treated as ordinary fields
- examples/component_card.rs
- examples/signals_counter.rs
- examples/runtime_injection.rs
- examples/slots.rs
- tests/component_builder.rs
- docs.rs for
maud-extensions - docs.rs for
maud-extensions-runtime
MIT OR Apache-2.0