DEV Community

Daniel Antonio Rodriguez
Daniel Antonio Rodriguez

Posted on

CoffeeHaml — Write JSX like HAML, with CoffeeScript

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.
Enter fullscreen mode Exit fullscreen mode

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.")
      )
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
import { compile } from 'coffeehaml';

const result = compile(source, {
  wrap: 'component',
  componentName: 'Dashboard',
  sourceMap: true,
  filename: 'dashboard.chaml',
});
Enter fullscreen mode Exit fullscreen mode

Vite:

// vite.config.ts
import coffeeHaml from 'coffeehaml/vite-plugin';

export default {
  plugins: [coffeeHaml({ wrap: 'component' })],
};
Enter fullscreen mode Exit fullscreen mode
// App.tsx — import .chaml directly
import Dashboard from './Dashboard.chaml';
Enter fullscreen mode Exit fullscreen mode

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)