If you miss the terseness of HAML and the elegance of CoffeeScript but work in React, this is for you.
-# dashboard.chaml
import { observer } from 'mobx-react-lite'
@observer
%div.page
%header
%h1= "Welcome, #{user.name}"
%nav{ role: 'primary' }
- for link in navLinks
%a{ href: link.url }= link.label
%main
.dashboard-grid
- for widget in widgets
%Widget{ widget, onDelete: -> handleDelete(widget.id) }
%aside
- if alerts.length > 0
.alert-banner{ role: 'alert' }
- for alert in alerts
%p= alert.message
- else
%p.empty-state No alerts — all clear.
Compiles to a clean React component:
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import { observer } from 'mobx-react-lite'
@observer
export default function Dashboard(props) {
return jsxs("div", { className: "page" },
jsxs("header", null,
jsxs("h1", null, "Welcome, ", user.name),
jsxs("nav", { role: "primary" },
navLinks.map(link => jsx("a", { href: link.url }, link.label))
)
),
jsxs("main", null,
jsxs("div", { className: "dashboard-grid" },
widgets.map(widget => jsx(Widget, { widget, onDelete: () => handleDelete(widget.id) }))
),
jsxs("aside", null,
alerts.length > 0 ?
jsx("div", { className: "alert-banner", role: "alert" },
alerts.map(alert => jsx("p", null, alert.message))
)
: jsx("p", { className: "empty-state" }, "No alerts — all clear.")
)
)
);
}
No JSX closing tags. No {} noise. No return boilerplate. Just the structure.
What's in the box — v0.4.1
| Feature | What it does |
|---|---|
| HAML syntax |
%tag, .class, #id, attributes via {} / ()
|
| CoffeeScript expressions |
=, !=, ->, for...in, if/else, assignments |
| Filter plugins |
:markdown, :asciidoc, custom |
| Component wrapping |
wrap: 'component' → export default function Name
|
| HOC chains | wrap: ['observer', 'memo', 'forwardRef'] |
| Spread attributes |
{ props..., className: 'extra' } → { ...props, className }
|
| Prologue passthrough |
import, @decorator, const — verbatim above component |
| Optional chaining |
props.gyro?.roll — auto-IIFE wrapping |
| Source maps | Full support via source-map
|
| Vite plugin |
@coffeehaml/vite-plugin — import .chaml directly |
| TextMate grammar | Syntax highlighting in VS Code & TextMate |
| Zero runtime deps | Just the compiler — 52 kB |
Quick start
npm install coffeehaml
import { compile } from 'coffeehaml';
const result = compile(source, {
wrap: 'component',
componentName: 'Dashboard',
sourceMap: true,
filename: 'dashboard.chaml',
});
Vite:
// vite.config.ts
import coffeeHaml from 'coffeehaml/vite-plugin';
export default {
plugins: [coffeeHaml({ wrap: 'component' })],
};
// App.tsx — import .chaml directly
import Dashboard from './Dashboard.chaml';
The philosophy
Frameworks like JSX treat markup as an extension of JavaScript. CoffeeHaml treats your component as a document — structure first, logic woven in where it belongs. The compiler handles the mechanical transformation; you stay in flow.
CoffeeScript's expression-oriented nature means there's no awkward "template language inside a template language" — it's the same language throughout. for widget in widgets works the same in a - block as it does in your .coffee files.
GitHub
https://dantiel.github.io/CoffeeHaml/ — ⭐ welcome.
Top comments (0)