workers-rsx 0.1.0

A JSX-like templating engine for Cloudflare Workers
Documentation

workers-rsx

A RSX-like templating engine for Cloudflare Workers in Rust.

Write HTML templates using a familiar JSX/TSX-like syntax directly in Rust, with compile-time validation and zero runtime overhead. Inspired by RSX.

Installation

Add to your Cargo.toml:

[dependencies]
workers-rsx = "0.1"

Quick Start

use worker::{event, Context, Env, Request, Response, Result};
use workers_rsx::{component, rsx, view};

#[component]
fn Hello<'name>(name: &'name str) {
    rsx! {
        <div>
            <h1>{"Hello, "} {name}!</h1>
        </div>
    }
}

#[event(fetch)]
pub async fn main(_req: Request, _env: Env, _ctx: Context) -> Result<Response> {
    let name = "world";
    view! { <Hello {name} /> }
}

Features

Components

Define reusable components with the #[component] attribute. Components are compiled to structs that implement the Render trait.

#[component]
fn Button<'label>(label: &'label str) {
    rsx! { <button class="btn">{label}</button> }
}

Use components with an uppercase name:

rsx! { <Button label="Click me" /> }

Elements & Attributes

HTML elements use lowercase names. Attribute values can be string literals or expressions in braces:

rsx! {
    <div class="container" id="main">
        <a href="/about">About</a>
    </div>
}

Use braces for dynamic values:

let cls = "active";
rsx! { <div class={cls}>x</div> }

Text

Unquoted text renders as static text (HTML-escaped at render time):

rsx! { <p>Hello, world!</p> }

For text containing special characters (emojis, unicode escapes, precise whitespace before siblings), use braced string literals:

rsx! { <p>{"Hello, "} {name}!</p> }
rsx! { <span>{"⚡ Electric"}</span> }

Expression Blocks

Rust expressions in braces are rendered and HTML-escaped:

rsx! { <span>{user.name}</span> }

Text Directive {text expr}

Forces an expression to render as escaped text:

rsx! { <p>{text some_value}</p> }

HTML Directive {html expr}

Injects raw HTML without escaping — use with trusted content only:

rsx! { <div>{html "<strong>bold</strong>"}</div> }

Boolean Attributes

Bare attribute names render as boolean HTML attributes:

rsx! { <input type="text" disabled /> }
// Renders: <input type="text" disabled>

Prop Shorthand

When a variable name matches the prop name, use shorthand syntax:

let name = "Pikachu";
rsx! { <Heading {name} /> }
// Equivalent to: <Heading name={name} />

Fragments

Group multiple elements without a wrapper tag:

rsx! {
    <>
        <h1>Title</h1>
        <p>Body</p>
    </>
}

Void Elements

Standard HTML void elements (br, hr, img, input, meta, link, etc.) render without a closing tag:

rsx! { <br /> }
// Renders: <br>

Conditional Rendering

Use if, else if, and else directly in templates:

rsx! {
    <div>
        if is_admin {
            <span>Admin</span>
        } else if is_mod {
            <span>Moderator</span>
        } else {
            <span>User</span>
        }
    </div>
}

List Rendering

Use for..in loops to render lists:

rsx! {
    <ul>
        for item in items.iter() {
            <li>{text item}</li>
        }
    </ul>
}

Match Expressions

Pattern matching with optional guards:

rsx! {
    <div>
        match status {
            "active" => {
                <span class="green">Active</span>
            },
            "pending" if count > 0 => {
                <span class="yellow">{"Pending ("} {count})</span>
            },
            _ => {
                <span>Unknown</span>
            },
        }
    </div>
}

Scoped let Bindings

Declare local variables inside templates. They are scoped to the current element's children:

rsx! {
    <div>
        let greeting = format!("Hello, {}!", name);
        <p>{greeting}</p>
    </div>
}

HTML5 Doctype

Use the built-in HTML5Doctype component:

use workers_rsx::html::HTML5Doctype;

rsx! {
    <>
        <HTML5Doctype />
        <html>
            <head><title>My Page</title></head>
            <body><p>Hello</p></body>
        </html>
    </>
}

Raw HTML Macro

For raw HTML outside of templates:

use workers_rsx::raw;

let content = raw!("<p>Unescaped HTML</p>");

Tailwind CSS (Built-in)

Tailwind CSS support is built-in via tailwind-rs-core. The html! and view! macros automatically extract static class attribute values at compile time, generate the corresponding Tailwind CSS at runtime, and prepend a <style> tag to the output. No extra setup or config is needed.

// Tailwind classes are automatically detected and CSS is generated
view! {
    <div class="bg-gray-100 min-h-screen py-8">
        <h1 class="text-4xl font-bold text-center text-gray-800">Hello</h1>
        <p class="text-lg text-gray-600">Styled with Tailwind!</p>
    </div>
}
// Output includes: <style>.bg-gray-100{...}.min-h-screen{...}...</style><div ...>...</div>

Classes inside if, for, and match blocks are also extracted. Dynamic class expressions (class={expr}) are not extracted at compile time — use static string values for automatic Tailwind CSS generation.

If no Tailwind classes are found, no <style> tag is emitted.

Macros

Macro Description
rsx! Returns a renderable component tree (no Tailwind processing)
html! Renders the template to a String with auto-generated Tailwind CSS
view! Renders and returns a worker::Response with auto-generated Tailwind CSS
css! Parses CSS syntax at compile time and returns a &'static str
#[component] Defines a function component struct that implements Render
raw! Creates a raw (unescaped) HTML value

Implementing Render

You can implement Render for your own types:

use workers_rsx::Render;
use std::fmt::{Result, Write};

struct MyWidget {
    label: String,
}

impl Render for MyWidget {
    fn render_into<W: Write>(self, writer: &mut W) -> Result {
        write!(writer, "<div class=\"widget\">{}</div>", self.label)
    }
}

RSX Feature Compatibility

Compatibility with the RSX specification:

RSX Feature workers-rsx Notes
Unquoted text Hello HTML-escaped static text
Braced strings {"text"} For emojis, unicode, precise whitespace
Expression containers {expr} HTML-escaped dynamic values
Text directive {text expr} Explicit escaped text rendering
HTML directive {html expr} Raw unescaped HTML injection
Conditional if / else if / else Full conditional chain support
List rendering for..in for pat in iter { ... }
Pattern matching match With guard clauses
Prop shorthand {name} Variable name matches prop name
Boolean attributes <input disabled />
Void elements br, hr, img, input, meta, etc.
Scoped locals let / const let bindings in template body
Components #[component] attribute macro
Fragments <>...</> Wrapper-free grouping
String attribute values class="foo" without braces
Spread attributes {...props} Not yet supported
Tailwind CSS Auto-generated via tailwind-rs-core

Todo Example

A full CRUD app using htmx with a Redux-style state management pattern. Uses Reducer trait for state/action/view separation, automatic HTML diffing via OOB swaps, and client-side state stored in the DOM.

use serde::{Deserialize, Serialize};
use worker::{event, Context, Env, Request, Response, Result};
use workers_rsx::html::HTML5Doctype;
use workers_rsx::{component, handle, page, rsx, ActionJson};
use workers_rsx::Reducer;

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Todo {
    id: String,
    name: String,
    done: bool,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
enum Filter {
    #[default]
    All,
    Active,
    Completed,
}

#[derive(Clone, Debug, Serialize, Deserialize, Default)]
struct PageState {
    todos: Vec<Todo>,
    next_id: u32,
    editing: Option<String>,
    filter: Filter,
    action: Option<PageAction>,
}

impl PageState {
    fn items_left(&self) -> usize {
        self.todos.iter().filter(|t| !t.done).count()
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, ActionJson)]
#[serde(tag = "type")]
enum PageAction {
    AddTodo { name: String },
    ToggleTodo { id: String },
    DeleteTodo { id: String },
    EditTodo { id: String },
    UpdateTodo { id: String, name: String },
    ClearCompleted,
    SetFilter { filter: Filter },
}

impl Reducer for PageState {
    type Action = PageAction;

    fn initial() -> Self {
        Self {
            todos: vec![
                Todo { id: "1".into(), name: "Taste htmx".into(), done: true },
                Todo { id: "2".into(), name: "Buy a unicorn".into(), done: false },
            ],
            next_id: 3,
            editing: None,
            filter: Filter::All,
            action: None,
        }
    }

    fn reduce(&self, action: PageAction) -> Self {
        let mut next = self.clone();
        match action {
            PageAction::AddTodo { name } => {
                if !name.is_empty() {
                    let id = next.next_id.to_string();
                    next.next_id += 1;
                    next.todos.push(Todo { id, name, done: false });
                }
            }
            PageAction::ToggleTodo { id } => {
                if let Some(todo) = next.todos.iter_mut().find(|t| t.id == id) {
                    todo.done = !todo.done;
                }
            }
            PageAction::DeleteTodo { id } => {
                next.todos.retain(|t| t.id != id);
            }
            PageAction::EditTodo { id } => {
                next.editing = Some(id);
            }
            PageAction::UpdateTodo { id, name } => {
                if let Some(todo) = next.todos.iter_mut().find(|t| t.id == id) {
                    if !name.is_empty() {
                        todo.name = name;
                    }
                }
                next.editing = None;
            }
            PageAction::ClearCompleted => {
                next.todos.retain(|t| !t.done);
            }
            PageAction::SetFilter { filter } => {
                next.filter = filter;
            }
        }
        next.action = None;
        next
    }
}

#[page(PageState)]
fn TodosPage(state: PageState) {
    let left = state.items_left();
    let left_str = left.to_string();
    let label = if left == 1 { "item" } else { "items" };
    let all_class = if state.filter == Filter::All { "selected" } else { "" };
    let active_class = if state.filter == Filter::Active { "selected" } else { "" };
    let completed_class = if state.filter == Filter::Completed { "selected" } else { "" };
    let filtered_todos: Vec<&Todo> = state.todos.iter().filter(|t| match state.filter {
        Filter::Active => !t.done,
        Filter::Completed => t.done,
        Filter::All => true,
    }).collect();

    rsx! {
        <>
            <HTML5Doctype />
            <html lang="en">
                <head>
                    <meta charset="utf-8" />
                    <meta name="viewport" content="width=device-width, initial-scale=1" />
                    <title>{"HTMX • TodoMVC"}</title>
                    <link rel="stylesheet" href="https://unpkg.com/todomvc-common@1.0.5/base.css" />
                    <link rel="stylesheet" href="https://unpkg.com/todomvc-app-css@2.4.2/index.css" />
                    <script src="https://unpkg.com/htmx.org@2.0.4"></script>
                    <script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
                </head>
                <body hx-ext="json-enc">
                    <section class="todoapp">
                        <header class="header">
                            <h1>todos</h1>
                            <form hx-post="/" hx-target="body" hx-swap="none">
                                <input type="hidden" name="type"
                                    value="AddTodo" />
                                <input id="new-todo-input" class="new-todo" name="name"
                                    placeholder="What needs to be done?" />
                            </form>
                        </header>
                        <section class="main">
                            <ul id="todo-list" class="todo-list">
                                for todo in filtered_todos.iter() {
                                    let is_editing = state.editing.as_deref() == Some(todo.id.as_str());
                                    <TodoItem
                                        id={todo.id.clone()}
                                        name={todo.name.clone()}
                                        done={todo.done}
                                        editing={is_editing} />
                                }
                            </ul>
                        </section>
                        <footer class="footer">
                            <span id="todo-count" class="todo-count">
                                <strong>{left_str}</strong>
                                {" "}{label} left
                            </span>
                            <ul id="filters" class="filters">
                                <li><a class={all_class} hx-post="/" hx-target="body" hx-swap="none"
                                    hx-ext="json-enc" hx-vals={PageAction::SetFilter { filter: Filter::All }}>All</a></li>
                                <li><a class={active_class} hx-post="/" hx-target="body" hx-swap="none"
                                    hx-ext="json-enc" hx-vals={PageAction::SetFilter { filter: Filter::Active }}>Active</a></li>
                                <li><a class={completed_class} hx-post="/" hx-target="body" hx-swap="none"
                                    hx-ext="json-enc" hx-vals={PageAction::SetFilter { filter: Filter::Completed }}>Completed</a></li>
                            </ul>
                            <button class="clear-completed"
                                hx-post="/" hx-target="body" hx-swap="none"
                                hx-vals={PageAction::ClearCompleted}>
                                Clear completed
                            </button>
                        </footer>
                    </section>
                    <footer class="info">
                        <p>Double-click to edit a todo</p>
                        <p>Created with <a href="https://github.com/pyrossh/workers-rsx">workers-rsx</a></p>
                        <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
                    </footer>
                </body>
            </html>
        </>
    }
}

#[component]
fn TodoItem(id: String, name: String, done: bool, editing: bool) {
    let li_id = format!("todo-{}", id);
    let li_class = match (done, editing) {
        (_, true) => "editing",
        (true, _) => "completed",
        _ => "",
    };
    rsx! {
        <li id={li_id} class={li_class}>
            if editing {
                <form id="edit-form" hx-post="/" hx-target="body" hx-swap="none" hx-ext="json-enc">
                    <input type="hidden" name="type"
                        value="UpdateTodo" />
                    <input type="hidden" name="id" value={id} />
                    <input class="edit" type="text" name="name" value={name} />
                </form>
            } else {
                <div class="view">
                    if done {
                        <input class="toggle" type="checkbox" checked
                            hx-post="/" hx-target="body" hx-swap="none"
                            hx-ext="json-enc" hx-vals={PageAction::ToggleTodo { id: id.clone() }} />
                    } else {
                        <input class="toggle" type="checkbox"
                            hx-post="/" hx-target="body" hx-swap="none"
                            hx-ext="json-enc" hx-vals={PageAction::ToggleTodo { id: id.clone() }} />
                    }
                    <label hx-post="/" hx-target="body" hx-swap="none"
                        hx-ext="json-enc" hx-vals={PageAction::EditTodo { id: id.clone() }}>
                        {name}
                    </label>
                    <button class="destroy"
                        hx-post="/" hx-target="body" hx-swap="none"
                        hx-ext="json-enc" hx-vals={PageAction::DeleteTodo { id: id.clone() }}>
                    </button>
                </div>
            }
        </li>
    }
}

#[event(fetch)]
pub async fn main(req: Request, _env: Env, _ctx: Context) -> Result<Response> {
    handle::<PageState, TodosPage>(req).await
}

How it works

  1. Reducer trait — Define initial() state and a reduce() function. The view component is passed as a type parameter to handle
  2. ActionJson derive — Generates Display and Into<Cow<str>> for enum variants, so actions serialize to JSON automatically when used as attribute values
  3. handle() — On GET, renders the full page and injects state into the DOM. On POST (htmx), decodes state, dispatches the action through reduce(), diffs the old and new HTML, and returns only changed elements via hx-swap-oob
  4. State transport — State is stored in a <script type="application/json"> tag in the DOM and included in every htmx request automatically

License

MIT