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.
- Define component-local
js()andcss()helpers withjs!/css!. - Wrap markup with
component!and auto-inject JS/CSS helpers. - Add component lifecycle behavior with
@js-once/@js-always. - Emit direct
<script>/<style>blocks when needed. - Bundle
surreal.jsandcss-scope-inline.jswith zero path setup. - Embed fonts as base64
@font-faceCSS.
- Install
- What's New in 0.4.x
- Quick Start
- component!
- Lifecycle Cleanup
- Slots
- Runtime Injection
- Macro Reference
- Runtime Slot API
- Font Helpers
- Migration Guide (0.3 -> 0.4)
- Migration Guide (0.2 -> 0.3)
- License
cargo add maud-extensions
cargo add maud-extensions-runtime # needed for slots + `.in_slot("name")`- New
component!macro for auto-injecting JS/CSS helpers into one root element. - Swapped JS/CSS macro naming so
js!/css!define local helpers andinline_js!/inline_css!emit direct tags. - Bundled runtime helper
surreal_scope_inline!()with no path setup. - Explicit compile-time shape checks for
component!input. - Optional
component!JS mode directives:@js-onceand@js-always. - Component-scoped JS cleanup helpers in bundled
surreal.js. - Slot flow simplified to runtime APIs:
slot(),named_slot("..."), and.with_children(...)+.in_slot("...").
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! {
@js-once
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! 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:
- optional directives are supported before the root element:
@js-onceor@js-always - input must be exactly one top-level element with a
{ ... }body js! { ... }andcss! { ... }must be present in scope (empty is valid:js! {}/css! {})- a clean pattern is one component per module/file with
js!above andcss!below theRenderimpl - 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! { ... }orcss! { ... }call component!roots includedata-mx-componentanddata-mx-js-modeattributes for runtime lifecycle behavior
When surreal_scope_inline!() is present, component roots can register cleanup
work and auto-track common side effects.
use maud::{Markup, Render};
use maud_extensions::{component, css, js};
js! {
const onResize = () => me().class_add("resized");
onWindow("resize", onResize);
const tick = interval(() => me().class_add("ping"), 1000);
me().cleanup(() => clearInterval(tick));
const observer = observeMutations(me(), () => {});
me().cleanup(() => observer && observer.disconnect());
// Auto-tracked: removed when the component root unmounts.
me("button").on("click", () => me().class_add("clicked"));
}
struct LifecycleDemo;
impl Render for LifecycleDemo {
fn render(&self) -> Markup {
component! {
@js-once
section class="lifecycle-demo" {
button { "Click me" }
}
}
}
}
css! {}Notes:
@js-onceruns component JS once per root element.@js-always(default) runs JS each time the script executes.- cleanup ownership is scoped to
component!roots. - helpers available from bundled
surreal.jsinclude:cleanup,onWindow,onDocument,timeout,interval, andobserveMutations.
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::prelude::*;
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.
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")))
}js! { ... }/js!("...")- Generate local
fn js() -> maud::Markupand the hidden helper used bycomponent!.
- Generate local
css! { ... }/css!("...")- Generate local
fn css() -> maud::Markupand the hidden helper used bycomponent!.
- Generate local
component! { ... }- Wrap one root element and inject helpers emitted by
js!/css!at the end of its body. - Supports optional top directives:
@js-once,@js-always.
- Wrap one root element and inject helpers emitted by
inline_js! { ... }/inline_js!("...")- Emit
<script>markup directly. - Validate JS via
swc_ecma_parser.
- Emit
inline_css! { ... }/inline_css!("...")- Emit
<style>markup directly. - Validate CSS via
cssparser.
- Emit
js_file!("path")/css_file!("path")- Emit
<script>/<style>tags from file contents.
- Emit
surreal_scope_inline!()- Emit bundled
surreal.jsandcss-scope-inline.js.
- Emit bundled
font_face!(...)/font_faces!(...)- Embed font files as base64
@font-faceCSS.
- Embed font files as base64
From maud-extensions-runtime:
-
prelude::*- Re-exports
slot,named_slot,WithChildrenExt, andInSlotExt.
- Re-exports
-
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_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"
))
}Old:
use maud_extensions::slot;
html! {
(slot!())
(slot!("header"))
}New:
use maud_extensions_runtime::prelude::*;
html! {
(slot())
(named_slot("header"))
}Old:
use maud_extensions::use_component;
use maud_extensions_runtime::prelude::*;
html! {
(use_component!(
Card,
{
(Title.in_slot("header"))
p { "Body" }
}
))
}New:
use maud_extensions_runtime::prelude::*;
html! {
(Card.with_children(html! {
(Title.in_slot("header"))
p { "Body" }
}))
}The JS/CSS macro names were intentionally swapped:
- old
js!-> newinline_js! - old
css!-> newinline_css! - old
inline_js!-> newjs! - old
inline_css!-> newcss!
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; } }maud::html! {
head {
(surreal_scope_inline!())
}
}component!requires exactly one top-level element with a body block.component!expectsjs!andcss!calls in scope (empty blocks are allowed).- defining
fn js()/fn css()manually is not enough; usejs!/css!socomponent!sees required helpers. font_face!/font_faces!still requirebase64in the consuming crate.js_file!/css_file!paths are resolved from the calling source file context.
MIT OR Apache-2.0