Skip to content

eboody/maud-extensions

Repository files navigation

maud-extensions

crates.io docs.rs license

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!, and component!
  • direct emitters and bundled runtimes such as inline_js!(), surreal_scope_inline!(), and surreal_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.

Install

cargo add maud-extensions
cargo add maud-extensions-runtime # only needed for the lower-level runtime slot transport

Support policy:

  • MSRV: Rust 1.85
  • supported Maud version: 0.27

Choose A Workflow

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

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() and Type::builder()
  • one setter per field
  • maybe_field(option) helpers for Option<T> fields using the exact field type
  • #[builder(each = "...")] item setters for Vec<T> fields
  • .build() once all required fields are present
  • .render() on the complete builder when the component implements Render
  • From<CompleteBuilder> for Type

Field rules:

  • plain fields are required
  • Option<T> fields are optional
  • Option<T> fields also get a maybe_field(Option<T>) helper
  • Vec<T> fields default to empty and can use #[builder(each = "...")]
  • #[builder(default)] makes a non-Option, non-Vec field use Default
  • #[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::Markup accept any impl Render
  • that applies to single-markup fields, optional markup fields, and repeated Vec<Markup> item setters
  • maybe_field(...) helpers for optional markup fields take Option<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 implementing Render

Runtime Slots

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

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.mx and on Surreal-sugared handles such as me(".count")
  • source can 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 uses component!, js!, and Signals binders together

Runtime And Direct Emitters

Bundled runtime macros:

  • surreal_scope_inline!()
    • emits bundled surreal.js and css-scope-inline.js
  • signals_inline!()
    • emits bundled @preact/signals-core and the Maud Signals adapter
  • surreal_scope_signals_inline!()
    • emits surreal.js, css-scope-inline.js, @preact/signals-core, and the Maud Signals adapter in the right order

Direct emitters:

  • inline_js! { ... } / inline_css! { ... }
    • emit direct <script> / <style> tags
  • js_file!(...) / css_file!(...)
    • inline file contents using include_str!-style paths
  • font_face!(...) / font_faces!(...)
    • emit base64 @font-face CSS without adding another dependency

Manual composition rule:

  • if you emit surreal_scope_inline!() and signals_inline!() separately, put surreal_scope_inline!() first so the Signals adapter can extend Surreal before component js! blocks run

Limits And Guarantees

  • component! performs compile-time shape checks over the token stream it sees; it only checks the token shape the macro can observe
  • component! accepts exactly one top-level element with a body block
  • js! and css! must both be in scope for component!, even if one is empty
  • inline_js! validates JavaScript with SWC before generating markup
  • inline_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
  • ComponentBuilder observes lexical type forms, not full type resolution, so type aliases to Markup, Option, or Vec are treated as ordinary fields

Read Next

License

MIT OR Apache-2.0

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