mjmlgo

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: MIT Imports: 19 Imported by: 0

README

mjmlgo

A pure-Go renderer for MJML, the email markup language. It targets MJML 4 (currently 4.18.0): an .mjml document becomes the HTML an email client can display, the same document the reference JavaScript implementation produces — without Node.js, a browser, or CGO at runtime.

var out bytes.Buffer
if err := mjmlgo.Render(&out, src); err != nil {
	return err
}

There is also a command line compiler, covered below.

What it is

  • Parity, not approximation. Every fixture in testdata/parity (87 documents, including ones derived from the upstream MJML test suite) is rendered by this package and by the pinned mjml CLI, then compared element by element: attributes, inline styles, rule order, and comments (byte for byte). No allowlist, no recorded baselines. See testdata/README.md.
  • No runtime dependencies. A Go program that renders email needs no Node.js toolchain, no node_modules, and no subprocess.
  • Fail-fast validation. A document with an unknown element, a bad attribute value, or a child in the wrong place is an error with a structured value attached, never partially rendered HTML.
  • Writer-based output. The caller supplies the io.Writer, so a bytes.Buffer reused across renders keeps its capacity and a server writes straight to its response body or a file.
  • Concurrency-safe renderers. Renderer holds configuration only; all per-render state lives in the call.

Pinning one release is deliberate: package.json fixes the reference version and the tests assert it, so the suite never compares against a different one by accident. Pre-1.0: the public surface may still change.

Install

go get github.com/julez-dev/mjmlgo
go install github.com/julez-dev/mjmlgo/cmd/mjmlgo@latest

Requires Go 1.27 or newer.

Library

Render a document from memory:

import (
	"bytes"

	"github.com/julez-dev/mjmlgo"
)

func render(src []byte) ([]byte, error) {
	var out bytes.Buffer

	if err := mjmlgo.Render(&out, src); err != nil {
		return nil, err
	}

	return out.Bytes(), nil
}

Configure a reusable renderer with the reference implementation's knobs (WithComments, WithFonts, WithIgnoreIncludes, WithPrinterSupport), or with WithIDGenerator for deterministic output and WithComponents for custom components:

renderer := mjmlgo.NewRenderer(
	mjmlgo.WithComments(false),
	mjmlgo.WithFonts(map[string]string{
		"Inter": "https://example.test/inter.css",
	}),
)

var out bytes.Buffer
err := renderer.RenderFile(ctx, &out, "templates/welcome.mjml")

RenderFile reads the document from disk and scopes mj-include to the file's own directory. RenderFS takes any fs.FS plus a name inside it, so documents can come from an embedded filesystem, a zip, or a test fixture:

embedFS, _ := fs.Sub(assets, "templates")

err := renderer.RenderFS(ctx, &out, embedFS, "welcome.mjml")

A document that uses mj-include needs one of those two: Renderer.Render returns ErrIncludeNoFS rather than silently reading the process's working directory.

Errors

Validation and parse failures are *mjmlgo.Error. Inspect the fields instead of matching message text:

var invalid *mjmlgo.Error

if errors.As(err, &invalid) {
	log.Printf("%s at %s (attribute %s=%q)",
		invalid.Kind, strings.Join(invalid.Path, " > "), invalid.Attribute, invalid.Value)
}

errors.Is still finds the package sentinels (ErrValidation, ErrParsingFailed, ErrIncludeCycle, …). I/O problems that are not about the document — an unreadable file, a cancelled context, a nil writer — keep their plain errors.

Custom components

Implement component.Component (tag, allowed attributes with validators, defaults, render), optionally component.ChildComponent for its children, and register it on a renderer with WithComponents. A registered tag then behaves like a built-in one: same attribute validation, same default cascade, same mj-attributes/mj-class handling. Registration is per renderer, never global. example/customcomponent is the worked example.

Command line

mjmlgo newsletter.mjml                  # compact HTML on stdout
mjmlgo 'mail/*.mjml' -o dist            # one .html per input
mjmlgo -i -c filePath=templates < page.mjml
mjmlgo -v 'mail/**/*.mjml'              # validate only, no output
Option Meaning
-r, --read <file...> Compile file(s); glob patterns, ** included
-i, --stdin Compile a document from standard input
-v, --validate <file...> Validate without writing output
-s, --stdout Write HTML to stdout (the default)
-o, --output <path> Output file, or an existing directory
--noStdoutFileComment Omit the <!-- FILE: … --> header on stdout
-c, --config <key=value> fonts, keepComments, filePath; also --config.<key>=<value>
--json One JSON record per input, for tooling
-h, --help, -V, --version

Exit codes match the reference CLI: 0 success, 1 command-line or validation error, 2 when some input failed to render. A run in which every input failed counts as a command-line error, which is also what the reference CLI does. Failures are reported per file and the remaining inputs are still written.

Supported MJML

The full 4.18.0 component set:

  • Layout — mj-body, mj-section, mj-wrapper, mj-group, mj-column, mj-hero
  • Content — mj-text, mj-button, mj-image, mj-divider, mj-spacer, mj-table, mj-raw, mj-social, mj-social-element, mj-navbar, mj-navbar-link, mj-accordion (+ -element, -title, -text), mj-carousel (+ -image)
  • Head — mj-head, mj-attributes (mj-all, mj-class), mj-html-attributes (mj-selector), mj-breakpoint, mj-font, mj-preview, mj-style (including inline="inline" for juice-style CSS inlining), mj-title
  • Composition — mj-include for type="mjml", "html" and "css", author comments, Outlook conditional comments, media queries, web fonts, printer styles

Everything is emitted compact. There is no pretty-printer: formatting belongs to whatever displays or transports the HTML.

Differences from the reference implementation

reference mjml mjmlgo
Output beautified by default compact only; no beautify/minify
Invalid documents warnings on stderr, HTML still produced error, no output
-v/--validate lists every warning reports the first failure per file
mj-include root may climb with ../ confined to the fs.FS root
Custom components JavaScript packages, .mjmlconfig Go types registered per renderer
Watch mode, MJML 3 migration supported not implemented
Malformed XML (a comment containing --, say) tolerated by the lenient parser rejected

The mjml CLI's --config.beautify, --config.minify and unknown config keys are rejected with an explanation rather than silently ignored.

Tests

The reference implementation is a test dependency, pinned in package.json. Its version is read back from the CLI at test time, so a mismatch fails instead of comparing against the wrong oracle.

npm install          # once, Node.js >= 18; fetches the pinned mjml CLI
go test ./...        # includes the parity suite
Variable Effect
MJML_VERSION Use another MJML version for this run (resolved through npx)
MJML_BIN Use an existing CLI executable instead
MJML_SKIP Skip the reference comparison entirely (no Node.js needed)

go test -short ./... skips the parity suite too. Fuzz targets FuzzParse and FuzzRenderMJML live in the same package. testdata/README.md documents both corpora, the per-fixture reference flags, and how to add fixtures.

Performance

Rendering testdata/parity/simple-hero.mjml (603 bytes in, 4.3 KB out) through the library costs 36 µs, 66 KB of allocation and 491 allocations on a 32-core Linux machine with Go 1.27.

End to end, one CLI process rendering the largest public fixture (button-complex.mjml, 5.3 KB), median of ten invocations:

wall clock max RSS
mjmlgo button-complex.mjml -o out.html 1.9 ms 15 MB
mjml button-complex.mjml -o out.html 152 ms 170 MB

Reproduce the library numbers with go test ./ -run XXX -bench BenchmarkRenderMJML -benchmem, which runs the full pipeline over every parity fixture. The CLI figures are wall clock around the process and its peak RSS, so they include startup; a long-lived library caller sees only the rendering cost.

License

MIT — see LICENSE.

The fixtures in testdata/parity/upstream were extracted from MJML's test suite and remain under MJML's MIT license (Copyright (c) 2016 Mailjet SAS, https://mjml.io); see the manifest there.

Documentation

Overview

Package mjmlgo renders MJML documents to HTML.

The implementation targets MJML 4.18.0 exactly: for the supported feature set it produces the same document as the reference JavaScript implementation. Output is compact (never pretty-printed) and validation is fail-fast: unknown tags, invalid attributes or nesting, and missing required children are reported as errors instead of being warned about and ignored. Invalid input never produces partial output.

Every validation and MJML parse rejection is a structured *Error: inspect its Kind, Path, Attribute and Value instead of matching the message text. I/O failures that are not about the document's content - an unreadable top-level file, a nil argument, a cancelled context - keep their own plain errors. errors.Is still finds the package's sentinels (ErrValidation, ErrParsingFailed, ErrIncludeCycle, ...) and errors.As unwraps through wrapping, so both inspection styles keep working. The kinds are a closed set; see the ErrorKind constants.

Render writes the HTML for an in-memory document to a writer, and NewRenderer configures a renderer first, for example to drop author comments:

var out bytes.Buffer
err := mjmlgo.Render(&out, src)

renderer := mjmlgo.NewRenderer(mjmlgo.WithComments(false))
err = renderer.Render(ctx, &out, src)

The output goes to the writer the caller supplies, so a bytes.Buffer that is Reset between renders keeps its capacity and a server allocates the output buffer once.

A document that uses mj-include needs a filesystem: Renderer.RenderFS reads the top-level document through an fs.FS and resolves includes against it, so callers decide where documents come from and the renderer performs no implicit filesystem or network access. A Renderer is safe for concurrent use.

WithComponents adds custom components to a renderer. A host implements component.Component - tag, allowed attributes with validators, defaults and render - and registers it; a registered tag is then a first-class element, with the same validation and default cascade as a built-in one. Registration is per renderer, never global. Implement component.ChildComponent to declare child elements. example/customcomponent is the worked example, and package component documents the contract.

mj-include is resolved through the same fs.FS: paths are relative to the directory of the file that contains the include, and type="html", type="css" and the default type="mjml" are supported. Include cycles, paths that would leave the filesystem and unknown include types are errors.

The tree the renderer walks and the components it dispatches to live in the public node and component packages, so that custom components can receive the same values. Their exported shape is frozen; their package documentation lists the exact set, and changing any of it is a breaking change.

Renderer.RenderFile is the shorthand for a document on disk: the file's own directory becomes the filesystem root, so relative includes stay scoped to it.

Index

Constants

View Source
const (
	KindUnknownElement     = component.KindUnknownElement
	KindUnknownAttribute   = component.KindUnknownAttribute
	KindInvalidValue       = component.KindInvalidValue
	KindDisallowedChild    = component.KindDisallowedChild
	KindUnknownIncludeType = component.KindUnknownIncludeType
	KindUndefinedMJClass   = component.KindUndefinedMJClass
	KindParseFailure       = component.KindParseFailure
)

The closed set of structured-error kinds, re-exported from the component package. Each kind's meaning is documented on the component constant of the same name; the block is the whole set: KindUnknownElement, KindUnknownAttribute, KindInvalidValue, KindDisallowedChild, KindUnknownIncludeType, KindUndefinedMJClass and KindParseFailure.

Variables

View Source
var (
	ErrParsingFailed = errors.New("parsing MJML structure failed")

	// ErrIncludeNoFS is returned when a document contains mj-include but was
	// rendered through an entry point without a filesystem (Renderer.Render).
	ErrIncludeNoFS = errors.New("mjml: mj-include requires an fs.FS")
	// ErrIncludePath is returned for include paths that are absolute, escape the
	// filesystem root or are otherwise not valid fs.FS names.
	ErrIncludePath = errors.New("mjml: invalid mj-include path")
	// ErrIncludeType is returned for mj-include type values other than mjml,
	// html and css. MJML silently treats unknown types as mjml includes.
	ErrIncludeType = errors.New("mjml: unknown mj-include type")
	// ErrIncludeCycle is returned when a file includes itself, directly or
	// through other files.
	ErrIncludeCycle = errors.New("mjml: mj-include cycle")
	// ErrIncludeDepth is returned when includes nest deeper than maxIncludeDepth.
	ErrIncludeDepth = errors.New("mjml: mj-include depth limit exceeded")
)
View Source
var ErrUnknownStartingTag = errors.New("mjml: unknown starting tag")

ErrUnknownStartingTag reports a document whose root element is not <mjml>.

View Source
var ErrValidation = component.ErrValidation

ErrValidation reports a document that failed validation. A structured error of a validation kind matches it with errors.Is.

Functions

func Render

func Render(w io.Writer, src []byte) error

Render renders the MJML document in src with default options and writes the resulting HTML to w. It is the zero-configuration entry point; use NewRenderer for any option and Renderer.RenderFS for documents that use mj-include.

Types

type Error

type Error = component.Error

Error is the structured failure every validation and parse site returns. It is the same type as component.Error, aliased here so callers of this package can use errors.As to a *mjmlgo.Error without importing the component package.

Inspect Kind, Path, Attribute and Value instead of matching the message text: the text is for people and may change. errors.Is works for the package's error sentinels (ErrValidation, ErrParsingFailed, ErrIncludeCycle, ...) and errors.As unwraps through wrapping.

type ErrorKind

type ErrorKind = component.ErrorKind

ErrorKind classifies a structured Error. See the component package constants for the closed set of kinds.

type Font

type Font struct {
	// Family is the name documents refer to, e.g. "Inter". It is matched
	// case-sensitively against the families a document mentions. An entry with
	// an empty Family is ignored.
	Family string
	// Href is the stylesheet URL, imported the way MJML imports its defaults.
	Href string
}

Font is one entry of the web-font registry: the family a document names in a font-family attribute, and the URL of the stylesheet that provides it.

func FontsFromMap

func FontsFromMap(fonts map[string]string) []Font

FontsFromMap converts a family-to-URL map into a registry ordered by family name. It is the bridge for callers holding a map: a Go map has no order of its own, so this states the order instead of leaving it to chance. FontsFromMap(nil) keeps the default registry, like WithFonts(nil).

type Option

type Option func(*rendererConfig)

Option configures a Renderer.

func WithComments

func WithComments(keep bool) Option

WithComments controls whether author comments in the document are preserved. The default is true, matching MJML: comments are emitted where MJML emits them, byte for byte. WithComments(false) drops them while parsing, exactly like MJML's keepComments option; comments inside raw content elements (mj-text, mj-raw, mj-table, ...) are part of that content and are never affected, and neither are the Outlook conditional comments the renderer generates itself.

func WithComponents

func WithComponents(components ...component.Component) Option

WithComponents registers custom components on the Renderer. A registered tag becomes a first-class MJML element: it is validated like a built-in one, receives the defaults declared in <mj-attributes> and <mj-class>, and renders at its document position. Registration is per renderer - there is no global registry - so two renderers in one process may know different tags.

A custom component is accepted where content goes - as a direct child of mj-body, mj-column or mj-hero - and is a leaf unless it implements component.ChildComponent. A leaf rejects every child under its own tag. A container declares the children it accepts and renders them with component.RenderChildren, so it controls the markup around them; its declared children must be built-in content components or other components registered on the same renderer.

A component's tag must not be empty, must not be registered twice and must not shadow a built-in element; such a renderer is rejected with a *component.Error of kind component.KindInvalidComponent, whose Path names the offending tag (and whose Value names the declared child, for a child nothing can render), when it renders. Calling WithComponents more than once registers the union, so a duplicate tag across calls is rejected like a duplicate within one call.

func WithFonts

func WithFonts(fonts []Font) Option

WithFonts replaces the web-font registry MJML imports only for the families a document actually uses. WithFonts(nil) keeps the five defaults (Open Sans, Droid Sans, Lato, Roboto, Ubuntu); a non-nil slice replaces them completely, so a default family is no longer imported unless the slice lists it. An <mj-font> element in the document still wins for its family, like it does in MJML.

Fonts are emitted in the order given, which is the order the reference implementation emits a registry it received as a JavaScript object. A family listed twice keeps its first position and its last URL, like assigning to an object property does. FontsFromMap builds the slice from a map for callers whose registry has no order worth preserving.

func WithIDGenerator

func WithIDGenerator(gen func() string) Option

WithIDGenerator overrides the source of the opaque ids MJML assigns to navbar hamburger labels and carousels. The default is 16 lowercase hex characters from crypto/rand, like the reference implementation.

A constant generator makes rendering deterministic. The generator is called while the renderer holds its lock, so it does not need to be safe for concurrent use itself.

func WithIgnoreIncludes

func WithIgnoreIncludes(ignore bool) Option

WithIgnoreIncludes leaves mj-include elements unexpanded, like MJML's ignoreIncludes option: the element is dropped from the output and the files it names are never read.

func WithPrinterSupport

func WithPrinterSupport(enable bool) Option

WithPrinterSupport adds MJML's printer style block to the head: the same media queries the document already generates, repeated inside an `@media only print` rule. It has no effect on a document without media queries, matching the reference implementation.

type Renderer

type Renderer struct {
	// contains filtered or unexported fields
}

Renderer renders MJML documents with the options it was created with. It is safe for concurrent use by multiple goroutines. All per-render state lives in the individual call.

func NewRenderer

func NewRenderer(opts ...Option) *Renderer

NewRenderer returns a Renderer configured with the given options.

func (*Renderer) Render

func (r *Renderer) Render(ctx context.Context, w io.Writer, src []byte) error

Render renders the MJML document in src and writes the resulting HTML to w. Cancellation is honoured before any work starts and between stages.

src is the whole document, so the renderer never touches the filesystem on its own and documents with mj-include are rejected with ErrIncludeNoFS: use RenderFS to resolve includes against a filesystem.

A bytes.Buffer can be reused across renders: its Reset keeps the capacity, so a server can grow the output buffer once and hand the same one to every call. strings.Builder cannot be reused for that - its Reset drops the buffer.

func (*Renderer) RenderFS

func (r *Renderer) RenderFS(ctx context.Context, w io.Writer, fsys fs.FS, name string) error

RenderFS reads the MJML document name from fsys, renders it and writes the resulting HTML to w. Cancellation is honoured before any work starts.

The document is read through fsys, so rendering never touches the filesystem on its own. mj-include is resolved through the same fsys: include paths are relative to the directory of the file that contains them, and include cycles, paths escaping the filesystem root and unknown include types are hard errors.

func (*Renderer) RenderFile

func (r *Renderer) RenderFile(ctx context.Context, w io.Writer, path string) error

RenderFile renders the MJML document at path and writes the resulting HTML to w. The document's directory is the root passed to the renderer, so relative references stay scoped to it.

Directories

Path Synopsis
cmd
mjmlgo command
Command mjmlgo compiles MJML documents to HTML.
Command mjmlgo compiles MJML documents to HTML.
Package component renders the MJML component tree: every body and head component of MJML 4.18, the per-render state the renderer accumulates from the document head, the validation applied to a parsed document before it is rendered, and the contract a host implements to add a component of its own.
Package component renders the MJML component tree: every body and head component of MJML 4.18, the per-render state the renderer accumulates from the document head, the validation applied to a parsed document before it is rendered, and the contract a host implements to add a component of its own.
example
customcomponent
Package customcomponent is the in-repo example of the custom-component contract in package component.
Package customcomponent is the in-repo example of the custom-component contract in package component.
internal
mjmltest
Package mjmltest contains the test harness that renders MJML input with the reference JavaScript implementation (the `mjml` npm package) so that the Go implementation in this repository can be diffed against it.
Package mjmltest contains the test harness that renders MJML input with the reference JavaScript implementation (the `mjml` npm package) so that the Go implementation in this repository can be diffed against it.
Package node holds the element tree the parser produces and the renderer walks: one Node per MJML element, with its attributes, its children and - for a raw-content element such as mj-text or mj-raw - its unparsed content.
Package node holds the element tree the parser produces and the renderer walks: one Node per MJML element, with its attributes, its children and - for a raw-content element such as mj-text or mj-raw - its unparsed content.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址