Letterpress
Compile MJML and Liquid once. Render safely in pure BEAM.
Installation · Quick start · Packages · Livebooks · Benchmarks · Development
Letterpress is the language boundary for notification templates in Elixir. It compiles restricted MJML and Liquid source into immutable artifacts during authoring, then renders those artifacts in bounded pure-BEAM code during delivery. Its CodeMirror 6 language package and Svelte 5 editor use the generated backend contract for profile metadata, completion vocabulary, and advisory diagnostic codes. Browser feedback never authorizes publication.
Installation
Add Letterpress to your dependencies:
def deps do
[
{:letterpress, "~> 0.1"}
]
end
Node 22 or newer is required on every authoring node because all profiles use the bundled compiler worker for analysis, compilation, formatting, and translation operations. Node 22 is the supported floor; local development and the full CI lane use Node 24 LTS, while the portability matrix covers both releases. Nodes that only render verified artifacts run entirely on the BEAM.
Letterpress is a library application: it does not add processes to your OTP tree. On authoring nodes, add the compiler pool to your own supervisor:
children = [
{Letterpress.Compiler.Supervisor, pool_size: 2}
]
Supervisor.start_link(children, strategy: :one_for_one)
Delivery-only nodes omit this child and do not need Node.
Quick start
Define a typed variable schema, compile the source during authoring, and store the complete artifact:
schema = %{
"version" => 1,
"variables" => %{
"html_name" => %{"type" => "string", "context" => "html_text"},
"subject_name" => %{"type" => "string", "context" => "subject"},
"text_name" => %{"type" => "string", "context" => "text"},
"action_url" => %{"type" => "url", "context" => "url"}
}
}
source = """
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>Hello {{ html_name }}</mj-text>
<mj-button href="{{ action_url }}">Open account</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>
"""
{:ok, artifact, diagnostics} =
Letterpress.compile("email/mjml-liquid@1", source, schema,
subject: "Welcome, {{ subject_name }}",
text: "Hello {{ text_name }}. Open {{ action_url }}"
)
Render every channel atomically from the stored artifact at delivery time:
{:ok, result} =
Letterpress.render(artifact, %{
"html_name" => "Taylor",
"subject_name" => "Taylor",
"text_name" => "Taylor",
"action_url" => "https://example.test/account"
})
Email localization uses the same atomic boundary. Extract channel-aware units
with the original :subject and :text, apply provider results with
Letterpress.localize/5, then compile the returned three sources as one locale
artifact. A missing or structurally invalid unit rejects the complete localized
result rather than mixing locales between HTML, subject, and plain text.
See the quick-start guide for the full lifecycle.
Livebooks
- Compile once, render many times covers the email authoring and delivery boundary.
- Text templates and diagnostics covers the text profile, discovery, and expected failures.
- Artifacts and translations covers translation placeholders and canonical artifact round trips.
The notebooks are included in the HexDocs build. ExUnit evaluates their code cells and checks saved outputs against the current package version.
Packages
| Registry | Package | Responsibility |
|---|---|---|
| Hex | letterpress | Profiles, caller-owned compiler supervision, artifacts, safe Liquid rendering, diagnostics, telemetry, and conformance |
| npm | @letterpress/language | CodeMirror mixed MJML/Liquid/CSS language services |
| npm | @letterpress/svelte | Unstyled Svelte 5 editor and diagnostics integration |
Core lifecycle
source + typed schema + profile
-> analyze
-> compile once with the supervised official MJML worker
-> persist immutable artifact
-> render many times in bounded pure-BEAM Liquid
Consumer applications own templates as business records, publication, tenancy, authorization, localization policy, translation providers, delivery providers, and legacy migration. Letterpress owns only the portable language, compiler, runtime, and editor contract.
The public contract defines this boundary and the serialized formats shared across runtimes.
The platform analysis compares this boundary with hosted notification systems, provider templates, code-first email builders, and Elixir mail libraries.
Benchmarks
The Benchee suite measures pure-BEAM rendering, bounded Liquid loops, artifact encoding/decoding, text compilation, and MJML compilation.
mix bench
mix bench.smoke
See the benchmark guide and recorded results. Smoke output checks that each scenario runs; use a stable run on controlled hardware for comparisons.
Development
The supported floor is Elixir 1.18/OTP 27 and Node 22. Node 24 LTS is the pinned development version, and pnpm manages the npm workspace.
mix setup
mix check
mix check runs the Elixir and browser gates, shared conformance corpus,
package-export validation, release-contract tests, and exact contract checks.
The release harness builds one Hex tarball and both npm tarballs once, records
their hashes, and installs those exact bytes in throwaway consumers before a
publish job can use them.
The compiler/runtime guide explains the authoring-versus-delivery split, and the browser editor guide covers CodeMirror and Svelte integration.
License
MIT. See LICENSE.