npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mjmx-rs/mjmx

v0.1.1

Published

MJML-to-HTML email compiler for JavaScript — written in Rust, compiled to WebAssembly.

Readme

@mjmx-rs/mjmx

MJML → HTML email compiler for JavaScript. Written in Rust, compiled to WebAssembly.

A faster, drop-in-shaped alternative to mjml / mjml-browser. Works in Node, browsers, Deno and Bun — no native addon, no postinstall, no build step.

npm install @mjmx-rs/mjmx
import { render } from '@mjmx-rs/mjmx';

const html = render(`
  <mjml>
    <mj-body>
      <mj-section background-color="#f4f4f4">
        <mj-column>
          <mj-text font-size="20px">Hello</mj-text>
          <mj-button href="https://example.com">Click me</mj-button>
        </mj-column>
      </mj-section>
    </mj-body>
  </mjml>
`);

Why not just use mjml?

| | @mjmx-rs/mjmx | mjml / mjml-browser | | --- | --- | --- | | Speed | ~7× faster | baseline | | API | render()string | mjml2html(){ html, errors } | | Async | synchronous | async since mjml 5 | | Bundle impact | ~620 KB (one flavour) | ~1.1 MB (mjml-browser) | | mjml versions | 5.4.0 and 4.18.0, selectable | whichever you installed | | Errors | throws, or lint() for a list | errors array |

Speed measured in Node over 40 MJML fixtures, 800 renders each, against [email protected]: ~55 µs vs ~377 µs per render, stable across three runs. Single machine, warm process — directional, not paper-grade. Reproduce it yourself; don't take the number on faith.

Need more? The native Node addon (@mjmx-rs/node) is ~19 µs — about 3× this package and ~20× mjml — at the cost of a per-platform binary. This package trades that for running anywhere.

The package unpacks to ~1.9 MB because it ships three wasm flavours (Node, bundler, browser). Your bundle only ever includes one of them.

Migrating from mjml / mjml-browser

Two things change at the call site.

1. The return shape. mjml2html hands back an object; render hands back the string and throws on failure. A mechanical find-and-replace will silently give you undefined:

// before
const { html, errors } = mjml2html(source);

// after
let html;
try {
  html = render(source);
} catch (err) {
  console.error(err.message);
  // mjmx: error: unclosed element `<mj-section>` (help: …) (+2 more diagnostics)
}

Want the diagnostics as data instead of an exception? lint(source) returns an array and never throws:

const problems = lint(source);          // [{ severity, message, labels, help }]
const fatal = problems.filter((d) => d.severity === 'error');

2. Pin the mjml version. mjmx defaults to reproducing mjml 5.4.0. If you are coming from mjml 4.x and want your existing output back, ask for it:

render(source, { mjmlVersion: 4 });   // reproduce mjml 4.18.0
render(source);                       // reproduce mjml 5.4.0 (default)

Both are kept because a renderer that silently changes its HTML on upgrade is unusable in a pipeline that diffs output. V4 output is frozen and gated by golden fixtures generated from real mjml; every V5 behaviour was verified byte-for-byte against real [email protected].

Diff before you trust it. V4 mode reproduces 4.18.0 specifically — if you are on a different 4.x, render a few real templates both ways and compare.

If you were already awaiting mjml 5's mjml2html, await render(...) still works on a non-promise, so that call site can stay as-is until you clean it up.

Usage

Node (CommonJS)

const { render, lint, version } = require('@mjmx-rs/mjmx');
console.log(version());

Bundlers and browsers (ESM)

import { render } from '@mjmx-rs/mjmx';

Three builds ship behind a conditional exports map, so one import resolves correctly everywhere:

| condition | flavour | for | | --- | --- | --- | | node | pkg/node/ (CommonJS) | Node.js | | browser | pkg/web/ (ESM, self-fetching) | browsers / Deno, no bundler | | import / default | pkg/bundler/ (ESM) | webpack / vite / rollup |

Embedding the wasm yourself

For bundlers that cannot fetch a sibling .wasm, or sandboxed hosts with no network access (a Figma plugin, for instance), import the web flavour directly and boot it from bytes you inline at build time:

import { initSync, render } from '@mjmx-rs/mjmx/pkg/web/mjmx_wasm.js';
import wasmBytes from '@mjmx-rs/mjmx/pkg/web/mjmx_wasm_bg.wasm';  // esbuild: loader { '.wasm': 'binary' }

initSync({ module: wasmBytes });   // once, before the first render

API

render(source: string, options?: RenderOptions): string   // throws on diagnostics
lint(source: string): Diagnostic[]                        // never throws
format(source: string, options?: FormatOptions): string   // pretty-print MJML source
version(): string

RenderOptions mirrors mjml's own mjml2html option names and defaults. Every field is documented in the bundled .d.ts, so your editor will explain them inline.

| option | default | notes | | --- | --- | --- | | mjmlVersion | 5 | 4 or 5 — which mjml release to reproduce | | breakpoint | 480 | mjmx extension; upstream only allows <mj-breakpoint> | | beautify / minify | false | post-processing | | keepComments | true | matches upstream's parser default | | validationLevel | 'soft' | 'soft' | 'strict' | 'skip' | | fonts | built-ins | replaces the built-in table; key order decides <link> order | | minifyOptions | {} | only read when minify | | ignoreIncludes | follows mjmlVersion | mjml 5 disables includes by default | | printerSupport | false | adds an @media only print block | | juiceOptions | juice's | how <mj-style inline="inline"> is inlined | | juicePreserveTags | — | extra templating delimiters the inliner must not enter | | sanitizeStyles | false | protects {{template}} syntax in CSS from the minifier | | templateSyntax | {{ }}, [[ ]] | delimiter pairs for sanitizeStyles | | allowMixedSyntax | false | allow mixed block and value template syntax | | components | — | custom component renderers, see below | | filePath | — | unsupported, throws — see "No filesystem" |

An unsupported juiceOptions key is refused with the reason, never silently ignored. EJS (<% %>) and Handlebars ({{ }}) are always masked before inlining, so templating syntax is never mistaken for markup.

Templating in your MJML

Template placeholders survive rendering, including inside <mj-style> when you ask for it:

render(source, { minify: true, sanitizeStyles: true });

Without sanitizeStyles, a {{brand}} inside CSS would be fed to the CSS minifier, which cannot parse it. mjmx raises an actionable error instead of silently corrupting your stylesheet.

Custom components

Register a renderer for a tag MJML doesn't know:

const html = render(source, {
  components: {
    'mj-badge': (cx) => `<span>${cx.attr('label') ?? ''}</span>`,
    'mj-card': (cx) => ({
      html: `<div class="card">${cx.rawInner}</div>`,
      styles: ['.card{border:1px solid #ccc}'],
      fonts: [['Inter', 'https://fonts.example/inter.css']],
    }),
  },
});

The callback receives a read-only cx (attr(name), attrs, containerWidth, breakpoint, rawInner) and returns a string of HTML, or { html, styles?, fonts? } to also contribute to <head>. An unregistered unknown tag stays a fatal error, as in mjml.

You return the cell content; mjmx wraps it in the standard leaf <td> — reading align, css-class, container-background-color and padding from the component's own attributes — so the table markup stays valid. Components are body leaves; container components that lay out their own children are not available from JavaScript yet.

No filesystem

WebAssembly has no ambient filesystem, so there is no renderFile, and <mj-include path="…"> cannot be resolved — there is no base directory and no loader. Passing filePath throws rather than quietly returning HTML with the includes unresolved. Either inline partials into the source string before calling render, or use the native Node addon (@mjmx-rs/node) where a filesystem exists.

TypeScript

Types ship with the package — no @types/… needed.

import { render, type RenderOptions } from '@mjmx-rs/mjmx';

const opts: RenderOptions = { mjmlVersion: 4, beautify: true };
const html: string = render(source, opts);

Links

  • Repository — architecture, and the rest of the toolchain: CLI, linter, formatter, minifier, sourcemap, language server, dev-server
  • MJML language reference — mjmx implements the same components and attributes

License

MIT