erlmark

CI Hex.pm Docs

A pure Erlang Markdown to HTML converter. It has no NIFs, ports or dependencies, and needs nothing beyond Erlang/OTP.

1> erlmark:to_html(<<"# Hello *world*">>).
<<"<h1>Hello <em>world</em></h1>\n">>

Installation

Erlang (rebar.config):

{deps, [{erlmark, "0.1.0"}]}.

Elixir (mix.exs; Mix builds the rebar3 package for you):

{:erlmark, "~> 0.1"}
:erlmark.to_html("# Hello *world*")
:erlmark.to_html(markdown, parallel: :auto, extensions: [:autolink])

Gleam (gleam add erlmark; rebar3 must be installed):

@external(erlang, "erlmark", "to_html")
pub fn to_html(markdown: String) -> String

API

erlmark:to_html(Markdown :: iodata()) -> binary().
erlmark:to_html(Markdown :: iodata(), [Option]) -> binary().

%% Option = {spawn_above, Bytes | infinity}
%%        | {parallel, false | auto | N}
%%        | {extensions, [autolink | tagfilter]}
Option Default Effect
{spawn_above, Bytes} 256 Inputs larger than Bytes are converted in a short-lived process with a pre-sized heap, which avoids nearly all garbage collection (15-30% faster). Errors are re-raised in the caller. infinity always converts in the caller.
{parallel, auto | N} false Converts large documents on N schedulers (auto is all online). See below.
{extensions, [...]} [] autolink: bare www., http(s)://, ftp:// URLs and e-mail addresses become links. tagfilter: <script>, <style> and similar tags are escaped. Conversion with extensions is sequential and not tuned.

Raw HTML in the input is passed through unchanged, as CommonMark requires. Sanitise the output if the input is untrusted.

Parallel conversion

{parallel, auto} splits the document at top-level block boundaries and converts the pieces concurrently. The output is byte-identical to sequential conversion, checked by a differential fuzz test. Documents under 16 KB are never split, and chunks are at least 2 KB.

A cut is placed at a blank line followed by an unindented letter or #, outside fenced code. The block parser then reports whether a piece ended inside a fenced code or HTML block, or defined link references (which are document-global). Such a piece is merged with its neighbour and redone. If that happens more than once, or any reference is defined, the whole document is converted sequentially.

Typical documents are about 2x faster, and 1.3-2.2x on fence-dense ones. This trades CPU for latency, so it does not raise throughput on a busy node. An indented fence whose content contains an unindented line cannot be modelled from the markers; such documents are converted in one piece. The worst case found (hundreds of such fences) costs about 1.2x the sequential time.

Conformance

Suite Result
CommonMark 0.31.2 spec 652 / 652
GFM spec (tables, strikethrough, task lists, autolinks, tagfilter) 22 / 22 extension examples; 661 / 672 over the full GFM spec
cmark regression tests 35 / 36
500 real Markdown files, compared with MDEx identical output

The 11 full-GFM-spec differences come from the GFM spec being written against CommonMark 0.29 (some emphasis rules and language- class prefixes changed since). The cmark regression failure is a cmark-specific behaviour.

Known deviations:

Performance

Median of 3 runs, Apple Silicon, OTP 29, each implementation in a fresh process. MDEx runs with tables, strikethrough, task lists and unsafe HTML enabled so that its output matches erlmark's.

Document Earmark MDEx (Rust NIF) erlmark erlmark {parallel, auto}
mixed, 4 KB 10.8 ms 0.319 ms 0.064 ms 0.064 ms (not split)
mixed, 84 KB 228 ms 1.76 ms 1.22 ms 0.70 ms
image/link-heavy, 48 KB 42.3 ms 0.656 ms 0.421 ms 0.229 ms
prose, 105 KB 153 ms 1.12 ms 0.798 ms 0.390 ms

Single-threaded, erlmark is 1.4-1.6x faster than MDEx on large documents and about 5x faster on small ones, where the NIF has a fixed per-call cost. With parallel mode it is 2.5-3x faster on large documents.

Over a 52-document survey of single constructs (lists, tables, links, fences, HTML, tabs, CRLF and so on), erlmark is faster on most and behind on a few code-heavy shapes: fenced blocks with CRLF line endings (about 1.14x MDEx's time), a 40-line indented code block (1.11-1.14x) and indentation mixing spaces and tabs (1.18x). Escaping code text costs more in Erlang than in a Rust loop. Over 500 real files (6.1 MB) erlmark takes 41 ms against MDEx's 164 ms.

What makes it fast:

Robustness

Conversion time is linear in the input for about 75 adversarial shapes (nested and unmatched emphasis and brackets, rule-of-3 delimiters, huge tables, lists and quotes, long destinations and labels). Fuzz tests run arbitrary bytes and 300,000 structured inputs without a crash.

Development

rebar3 eunit       # spec examples, API, worker/caller parity, fuzz,
                   # parallel == sequential differential tests
rebar3 dialyzer

src/erlmark_entities.erl is generated from the WHATWG entity list.

License

Copyright 2026 Mouhieddine Sabir. Licensed under the Apache License, Version 2.0; see LICENSE.