# 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](https://rsx.dev/).
## Installation
Add to your `Cargo.toml`:
```toml
[dependencies]
workers-rsx = "0.1"
```
## Quick Start
```rust
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.
```rust
#[component]
fn Button<'label>(label: &'label str) {
rsx! { <button class="btn">{label}</button> }
}
```
Use components with an uppercase name:
```rust
rsx! { <Button label="Click me" /> }
```
### Elements & Attributes
HTML elements use lowercase names. Attribute values can be string literals or expressions in braces:
```rust
rsx! {
<div class="container" id="main">
<a href="/about">About</a>
</div>
}
```
Use braces for dynamic values:
```rust
let cls = "active";
rsx! { <div class={cls}>x</div> }
```
### Text
Unquoted text renders as static text (HTML-escaped at render time):
```rust
rsx! { <p>Hello, world!</p> }
```
For text containing special characters (emojis, unicode escapes, precise whitespace before siblings), use braced string literals:
```rust
rsx! { <p>{"Hello, "} {name}!</p> }
rsx! { <span>{"⚡ Electric"}</span> }
```
### Expression Blocks
Rust expressions in braces are rendered and HTML-escaped:
```rust
rsx! { <span>{user.name}</span> }
```
### Text Directive `{text expr}`
Forces an expression to render as escaped text:
```rust
rsx! { <p>{text some_value}</p> }
```
### HTML Directive `{html expr}`
Injects raw HTML without escaping — use with trusted content only:
```rust
rsx! { <div>{html "<strong>bold</strong>"}</div> }
```
### Boolean Attributes
Bare attribute names render as boolean HTML attributes:
```rust
rsx! { <input type="text" disabled /> }
// Renders: <input type="text" disabled>
```
### Prop Shorthand
When a variable name matches the prop name, use shorthand syntax:
```rust
let name = "Pikachu";
rsx! { <Heading {name} /> }
// Equivalent to: <Heading name={name} />
```
### Fragments
Group multiple elements without a wrapper tag:
```rust
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:
```rust
rsx! { <br /> }
// Renders: <br>
```
### Conditional Rendering
Use `if`, `else if`, and `else` directly in templates:
```rust
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:
```rust
rsx! {
<ul>
for item in items.iter() {
<li>{text item}</li>
}
</ul>
}
```
### Match Expressions
Pattern matching with optional guards:
```rust
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:
```rust
rsx! {
<div>
let greeting = format!("Hello, {}!", name);
<p>{greeting}</p>
</div>
}
```
### HTML5 Doctype
Use the built-in `HTML5Doctype` component:
```rust
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:
```rust
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`](https://crates.io/crates/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.
```rust
// 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
| `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:
```rust
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](https://rsx.dev/specification):
| 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.
```rust
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