Skip to content

eboody/maud-extensions

Repository files navigation

maud-extensions

crates.io docs.rs

Small, local superpowers for Maud.

Install

Recommended: install the crate as mx so the macro and component surface reads well at call sites:

cargo add maud-extensions --rename mx

If you want the experimental component system too, enable components on the same dependency:

[dependencies]
mx = { package = "maud-extensions", version = "0.6.7", features = ["components"] }

Core Story

Write plain html! and emit local CSS and JS where they belong:

use maud::html;
use maud_extensions::{css, js};

fn status_card(message: &str) -> maud::Markup {
    html! {
        article class="status-card" {
            h2 { "System status" }
            p class="message" { (message) }

            (css! {
                me {
                    border: 1px solid #ddd;
                    border-radius: 10px;
                    padding: 12px;
                }
                me.ready {
                    border-color: #16a34a;
                }
            })

            (js!(once, {
                me().class_add("ready");
            }))
        }
    }
}

This is still the intended center of gravity:

  • no wrapper component macro
  • no hidden CSS/JS injection
  • no stringly helper names
  • plain Maud remains the main language

Experimental Components

The component system is opt-in behind the components feature.

Preferred authoring pattern:

use maud::Markup;
use mx::{Component, Slot};

#[derive(Component)]
struct Card {
    title: String,
    header: Slot<Markup>,
    #[mx(default)]
    body: Slot<Markup>,
    footer: Slot<Markup>,
    #[mx(each = action)]
    actions: Slot<Vec<Markup>>,
}

impl Card {
    fn css() -> Markup {
        mx::css! {
            me {
                padding: 1rem;
                border: 1px solid #ddd;
            }

            me .actions {
                display: flex;
                gap: 0.5rem;
            }
        }
    }

    fn js() -> Markup {
        mx::js!(once, {
            me().class_add("ready");
        })
    }
}

impl maud::Render for Card {
    fn render(&self) -> Markup {
        maud::html! {
            article.card {
                (Self::css())
                (Self::js())
                header class="header" { (self.header) }
                h2 { (self.title) }
                div.body { (self.body) }
                footer class="footer" { (self.footer) }
                div.actions { (self.actions) }
            }
        }
    }
}

fn view() -> Markup {
    Card::new()
        .title("Profile")
        .header(maud::html! { span { "Welcome" } })
        .child(maud::html! { p { "Body" } })
        .footer(maud::html! { button { "Save" } })
        .action(maud::html! { button { "Edit" } })
        .render()
}

What this gives you:

  • Bon-backed typed builders
  • Slot<Markup> and Slot<Vec<Markup>> as the slot declaration path
  • #[mx(default)] for the single default slot
  • explicit colocated fn css() -> Markup and fn js() -> Markup helpers
  • a normal impl Render where you place (Self::css()) / (Self::js()) exactly where they belong
  • builder .render() just renders the completed component value

Current constraints:

  • use Slot<T> / Slot<Vec<T>> for slot declarations
  • reserve #[mx(default)] for selecting the default slot only
  • use Rust Default or Option<T> for non-slot defaults
  • if there are multiple slot fields, mark exactly one #[mx(default)]
  • repeated slots use Slot<Vec<T>> plus #[mx(each = item_name)]

Mental model:

  • #[derive(Component)] owns fields, slots, and the Bon-backed builder
  • inherent css() / js() helpers own component-local assets
  • the Render impl stays explicit and decides where those helpers are emitted

This keeps the builder story ergonomic while leaving rendering explicit and easy to reason about.

The normal path is just .render() on the complete builder. Use .build() only when you specifically want the concrete component value first.

let markup = Card::new()
    .title("Profile")
    .child(maud::html! { p { "Body" } })
    .render();

Bundled browser-side building blocks

The current CSS/JS/component story is designed to layer on top of a few small browser-side tools:

maud-extensions is not trying to replace those pieces; it is trying to make them feel coherent and component-local from Maud.

To bootstrap the browser-side runtime in a page, use mx::Init in <head>:

use maud::html;
use mx::Init;

fn page() -> maud::Markup {
    html! {
        head {
            (Init::all())
        }
        body {
            // component markup here
        }
    }
}

Recommended bootstrap entrypoints:

  • Init::all()
  • Init::new().surrealjs().scoped_css().signals().build()

If you want a more minimal component style, you can still stop at plain html! + css! + js!. The component system is intentionally a second layer, not the only way to use the crate.

Named Helpers

When reuse helps, define local helper functions with Rust identifiers:

use maud::html;
use maud_extensions::{css, js};

css!(card_css, {
    me { gap: px!(12); }
});

js!(card_js, once, {
    me().class_add("ready");
});

fn card() -> maud::Markup {
    html! {
        article.card {
            (card_css())
            (card_js())
            "Hello"
        }
    }
}

Supported css! forms:

  • css! { ... }
  • css!(name, { ... })

Supported js! forms:

  • js! { ... }
  • js!(once, { ... })
  • js!(name, { ... })
  • js!(name, once, { ... })

CSS Helper Macros

Inside css! token mode you can use:

  • raw!(r#"..."#)
  • media!(prelude, { ... })
  • container!(prelude, { ... })
  • supports!(prelude, { ... })
  • layer!(prelude, { ... })
  • keyframes!(prelude, { ... })
  • unit helpers:
    • rem!(...)
    • em!(...)
    • px!(...)
    • pct!(...)
    • vw!(...)
    • vh!(...)
    • ms!(...)
    • s!(...)

Example:

use maud_extensions::css;

fn responsive_styles() -> maud::Markup {
    css! {
        media!("(min-width: 48rem)", {
            me { padding: rem!(2); }
        })
        supports!("(display: grid)", {
            me { gap: px!(12); }
        })
    }
}

Limits

  • css! and js! are placement-sensitive local emitters
  • js!(once, ...) relies on a data-mx-js-ran marker on the parent element
  • CSS token mode only sees Rust-tokenizable input; use raw!(...) for arbitrary CSS fragments
  • JavaScript is validated with SWC before emission
  • CSS is checked for lightweight syntax and raw-text safety before emission

About

Proc macros for Maud components with inline CSS, JS, slots, and font helpers.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors