maud-extensions 0.4.0

Component, inline CSS/JS, and font helper macros for Maud views.
Documentation

maud-extensions

crates.io docs.rs license

Proc macros for Maud that make inline CSS/JS and component-style authoring simpler.

This crate includes bundled copies of gnat/surreal and gnat/css-scope-inline. Check those repos to see what these two tiny JS files can do and how to use them.

Why use it?

  • Define component-local js() and css() helpers with js! / css!.
  • Wrap markup with component! and auto-inject JS/CSS helpers.
  • Emit direct <script> / <style> blocks when needed.
  • Bundle surreal.js and css-scope-inline.js with zero path setup.
  • Embed fonts as base64 @font-face CSS.

Table of Contents

Install

cargo add maud-extensions
cargo add maud-extensions-runtime # needed for slots + `.in_slot("name")`

What's New in 0.4.x

  • New component! macro for auto-injecting JS/CSS helpers into one root element.
  • Swapped JS/CSS macro naming so js!/css! define local helpers and inline_js!/inline_css! emit direct tags.
  • Bundled runtime helper surreal_scope_inline!() with no path setup.
  • Explicit compile-time shape checks for component! input.
  • Slot flow simplified to runtime APIs: slot(), named_slot("..."), and .with_children(...) + .in_slot("...").

Quick Start

This example shows the single-file component pattern: js! at the top, component! markup in Render, and css! at the bottom.

use maud::{html, Markup, Render};
use maud_extensions::{component, css, js, surreal_scope_inline};

// Component behavior at file scope.
js! {
    me().class_add("ready");
}

struct StatusCard<'a> {
    message: &'a str,
}

impl<'a> Render for StatusCard<'a> {
    fn render(&self) -> Markup {
        component! {
            article class="status-card" {
                h2 { "System status" }
                p class="message" { (self.message) }
            }
        }
    }
}

// Component styles at file scope.
css! {
    me {
        border: 1px solid #ddd;
        border-radius: 10px;
        padding: 12px;
        transition: border-color 160ms ease-in;
    }
    me.ready {
        border-color: #16a34a;
    }
    me .message {
        margin: 0;
        opacity: 0.85;
    }
}

struct Page;

impl Render for Page {
    fn render(&self) -> Markup {
        html! {
            head {
                // Inject bundled `surreal.js` + `css-scope-inline.js`.
                (surreal_scope_inline!())
            }
            body {
                (StatusCard { message: "All systems operational" })
            }
        }
    }
}

component!

component! wraps one top-level Maud element and appends the JS/CSS helpers generated by js! and css! inside that root element automatically.

use maud::{Markup, Render};
use maud_extensions::{component, css, js};

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

struct Card;

impl Render for Card {
    fn render(&self) -> Markup {
        component! {
            section class="card" {
                p { "Hello" }
            }
        }
    }
}

css! {
    me { border: 1px solid #ddd; }
}

Equivalent output shape:

  • root element content
  • then (js())
  • then (css())

Rules:

  • input must be exactly one top-level element with a { ... } body
  • js! { ... } and css! { ... } must be present in scope (empty is valid: js! {} / css! {})
  • a clean pattern is one component per module/file with js! above and css! below the Render impl
  • trailing ; is allowed
  • invalid root shapes fail at compile time with guidance
  • if a helper is missing, the compiler error points at a required internal helper symbol; add the corresponding js! { ... } or css! { ... } call

Slots

Use runtime slot functions inside your component template, then pass children through .with_children(...). Unannotated children go to the default slot, and named content is tagged with .in_slot("name").

use maud::{Markup, Render, html};
use maud_extensions_runtime::{InSlotExt, WithChildrenExt, named_slot, slot};

struct Card;

impl Render for Card {
    fn render(&self) -> Markup {
        html! {
            article class="card" {
                header class="card-header" { (named_slot("header")) }
                section class="card-body" { (slot()) }
            }
        }
    }
}

struct CardHeader<'a> {
    title: &'a str,
}

impl<'a> Render for CardHeader<'a> {
    fn render(&self) -> Markup {
        html! { h2 { (self.title) } }
    }
}

let view = html! {
    (Card.with_children(html! {
        (CardHeader { title: "Status" }.in_slot("header"))
        p { "All systems operational" }
    }))
};

Rules:

  • slot() renders the default slot.
  • named_slot("name") renders a named slot.
  • .in_slot("name") assigns a child component to that named slot.
  • .with_children(html! { ... }) provides child content for slot resolution.
  • missing named slots render empty content.
  • extra provided named slots are ignored.

Runtime Injection

Use bundled runtime scripts with no filesystem setup:

use maud_extensions::surreal_scope_inline;

maud::html! {
    (surreal_scope_inline!())
}

Need custom files instead? Use js_file! / css_file! (include_str! behavior):

use maud_extensions::js_file;

maud::html! {
    (js_file!(concat!(env!("CARGO_MANIFEST_DIR"), "/static/vendor/custom-runtime.js")))
}

Macro Reference

  • js! { ... } / js!("...")
    • Generate local fn js() -> maud::Markup and the hidden helper used by component!.
  • css! { ... } / css!("...")
    • Generate local fn css() -> maud::Markup and the hidden helper used by component!.
  • component! { ... }
    • Wrap one root element and inject helpers emitted by js! / css! at the end of its body.
  • inline_js! { ... } / inline_js!("...")
    • Emit <script> markup directly.
    • Validate JS via swc_ecma_parser.
  • inline_css! { ... } / inline_css!("...")
    • Emit <style> markup directly.
    • Validate CSS via cssparser.
  • js_file!("path") / css_file!("path")
    • Emit <script> / <style> tags from file contents.
  • surreal_scope_inline!()
    • Emit bundled surreal.js and css-scope-inline.js.
  • font_face!(...) / font_faces!(...)
    • Embed font files as base64 @font-face CSS.

Runtime Slot API

From maud-extensions-runtime:

  • slot()
    • Render default slot content for the current slotted component context.
  • named_slot("name")
    • Render named slot content.
  • WithChildrenExt::with_children(html! { ... })
    • Attach child content to a component value before rendering.
  • InSlotExt::in_slot("name")
    • Mark child content for a named slot.

Font Helpers

font_face! and font_faces! embed font files as base64 data URLs. Because this macro expands at the call site, the consuming crate must include base64 if you use these macros.

use maud_extensions::font_face;

maud::html! {
    (font_face!(
        "../static/fonts/JetBrainsMono.woff2",
        "JetBrains Mono"
    ))
}

Migration Guide (0.3 -> 0.4)

1. Replace slot macros with runtime functions

Old:

use maud_extensions::slot;

html! {
    (slot!())
    (slot!("header"))
}

New:

use maud_extensions_runtime::{named_slot, slot};

html! {
    (slot())
    (named_slot("header"))
}

2. Replace use_component! with .with_children(...)

Old:

use maud_extensions::use_component;
use maud_extensions_runtime::InSlotExt;

html! {
    (use_component!(
        Card,
        {
            (Title.in_slot("header"))
            p { "Body" }
        }
    ))
}

New:

use maud_extensions_runtime::{InSlotExt, WithChildrenExt};

html! {
    (Card.with_children(html! {
        (Title.in_slot("header"))
        p { "Body" }
    }))
}

Migration Guide (0.2 -> 0.3)

1. Rename JS/CSS macro usage

The JS/CSS macro names were intentionally swapped:

  • old js! -> new inline_js!
  • old css! -> new inline_css!
  • old inline_js! -> new js!
  • old inline_css! -> new css!

2. Move to component! for root injection

Old pattern:

inline_js! { me().class_add("ready"); }
let view = maud::html! {
    article {
        "Hello"
        (js())
        (css())
    }
};
inline_css! { me { color: red; } }

New pattern:

js! { me().class_add("ready"); }
let view = component! {
    article {
        "Hello"
    }
};
css! { me { color: red; } }

3. Keep runtime scripts explicit in layout/page shell

maud::html! {
    head {
        (surreal_scope_inline!())
    }
}

4. Update assumptions in your codebase

  • component! requires exactly one top-level element with a body block.
  • component! expects js! and css! calls in scope (empty blocks are allowed).
  • defining fn js() / fn css() manually is not enough; use js! / css! so component! sees required helpers.
  • font_face!/font_faces! still require base64 in the consuming crate.
  • js_file!/css_file! paths are resolved from the calling source file context.

License

MIT OR Apache-2.0