π Fast and small markdown parser and renderer
See the codeFast and Small markdown parser and renderer based on mity/md4c.
==mark==), footnotes, inline attributes# Local files
npx md4x README.md # ANSI output
npx md4x README.md -t html # HTML output
npx md4x README.md -t text # Plain text output (strip markdown)
npx md4x README.md -t ast # JSON AST output (comark)
npx md4x README.md -t meta # Metadata JSON output
npx md4x README.md -t markdown # Clean markdown (strip MDC/frontmatter/HTML)
npx md4x README.md -t heal # Heal incomplete markdown
npx md4x README.md --heal # Heal before rendering (any format)
npx md4x README.md --heal -t ast # Heal + JSON AST output
# Remote sources
npx md4x https://nitro.build/guide # Fetch and render any URL
npx md4x gh:nitrojs/nitro # GitHub repo β README.md
npx md4x npm:vue@3 # npm package at specific version
# Stdin
echo "# Hello" | npx md4x -t text
cat README.md | npx md4x -t html
# Output to file
npx md4x README.md -t meta -o README.json
# Full HTML document
npx md4x README.md -t html -f --html-title="My Docs" # Wrap in full HTML with <head>
npx md4x README.md -t html -f --html-css=style.css # Add CSS link
yay -S md4x # md4x-git
The examples above are the npm CLI (npx md4x). The native binary β the one zig build
produces and md4x(1) documents β is a smaller tool: it reads a local file or stdin, with
no URL, gh: or npm: shorthands, and its formats are html (the default), text,
json, ansi, markdown and heal, where the npm CLI spells the AST format ast and
adds meta.
Available as a native Node.js addon (NAPI) for maximum performance, or as a portable WASM module that works in any JavaScript runtime (Node.js, Deno, Bun, browsers, edge workers, etc.).
The bare md4x import auto-selects NAPI on Node.js and WASM elsewhere.
import {
init,
renderToHtml,
renderToAST,
parseAST,
renderToAnsi,
renderToText,
renderToMarkdown,
renderToMeta,
parseMeta,
parseYAML,
heal,
} from "md4x";
// await init(); // required for WASM, optional for NAPI
const html = renderToHtml("# Hello, **world**!");
const json = renderToAST("# Hello, **world**!"); // raw JSON string
const ast = parseAST("# Hello, **world**!"); // parsed ComarkTree object
const ansi = renderToAnsi("# Hello, **world**!");
const text = renderToText("# Hello, **world**!"); // plain text (stripped)
const md = renderToMarkdown("# Hello, **world**!"); // clean standard markdown
const metaJson = renderToMeta("# Hello, **world**!"); // raw JSON string
const meta = parseMeta("# Hello, **world**!"); // parsed meta
const yaml = parseYAML("title: Hello"); // standalone YAML -> JS value
const healed = heal("**incomplete streaming mark"); // "**incomplete streaming mark**"
Both NAPI and WASM export a unified API with init(). For WASM, init() must be called before rendering. For NAPI, it is optional (the native binding loads lazily on first render call).
Synchronous, zero-overhead native addon. Best performance for server-side use.
import { renderToHtml } from "md4x/napi";
Works anywhere with WebAssembly support. Requires a one-time async initialization.
import { init, renderToHtml } from "md4x/wasm";
await init(); // call once before rendering
const html = renderToHtml("# Hello");
init() accepts an optional options object with a wasm property (ArrayBuffer, Response, WebAssembly.Module, or Promise<Response>). When called with no arguments, it loads the bundled .wasm file automatically.
A single, minified, dependency-free ES module (~140 KB) with the same API as md4x/wasm, the WASM binary embedded into the same chunk.
import { init, renderToHtml } from "md4x/standalone";
await init(); // inflates and instantiates the inlined binary
const html = renderToHtml("# Hello");
This is also what md4x and md4x/wasm resolve to under the browser export condition, so browser bundlers get the self-contained module automatically (the explicit unwasm condition still wins where it is set).
(source: packages/md4x/bench)
bun packages/md4x/bench/index.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.4.2 (x64-linux)
benchmark avg (min β¦ max) p75 / p99 (min β¦ top 1%)
md4x.napi (renderToHtml) 7.38 Β΅s/iter 7.43 Β΅s 7.47 Β΅s βββββββββββ
md4x.wasm (renderToHtml) 15.08 Β΅s/iter 15.41 Β΅s 30.75 Β΅s ββ
βββββββββ
md4w (renderToHtml) 17.40 Β΅s/iter 17.71 Β΅s 37.11 Β΅s βββ
ββββββββ
markdown-it (renderToHtml) 72.68 Β΅s/iter 73.10 Β΅s 191.23 Β΅s βββββββββββ
markdown-exit (renderToHtml) 53.66 Β΅s/iter 55.23 Β΅s 106.47 Β΅s βββββββββββ
satteri (renderToHtml) 14.84 Β΅s/iter 15.52 Β΅s 27.61 Β΅s βββ
ββββββββ
ox-content (renderToHtml) 12.72 Β΅s/iter 13.52 Β΅s 22.70 Β΅s βββ
ββββββββ
qip.wasm (renderToHtml) 20.17 Β΅s/iter 20.45 Β΅s 34.41 Β΅s ββ
βββββββββ
summary
md4x.napi (renderToHtml)
1.72x faster than ox-content (renderToHtml)
2.01x faster than satteri (renderToHtml)
2.05x faster than md4x.wasm (renderToHtml)
2.36x faster than md4w (renderToHtml)
2.73x faster than qip.wasm (renderToHtml)
7.28x faster than markdown-exit (renderToHtml)
9.85x faster than markdown-it (renderToHtml)
md4x.napi (parseAST) (medium) 21.16 Β΅s/iter 21.80 Β΅s 34.83 Β΅s βββββββββββ
md4x.wasm (parseAST) (medium) 28.12 Β΅s/iter 28.56 Β΅s 45.51 Β΅s βββ
ββββββββ
md4w (parseAST) (medium) 23.07 Β΅s/iter 23.53 Β΅s 24.73 Β΅s βββββββββββ
markdown-it (parseAST) (medium) 50.44 Β΅s/iter 51.52 Β΅s 53.98 Β΅s βββββββββββ
markdown-exit (parseAST) (medium) 30.15 Β΅s/iter 30.70 Β΅s 31.38 Β΅s βββββββββββ
satteri (parseAST) (medium) 32.27 Β΅s/iter 32.38 Β΅s 67.77 Β΅s βββββββββββ
ox-content (parseAST) (medium) 17.54 Β΅s/iter 18.19 Β΅s 18.72 Β΅s βββββββββββ
summary
ox-content (parseAST) (medium)
1.21x faster than md4x.napi (parseAST) (medium)
1.32x faster than md4w (parseAST) (medium)
1.6x faster than md4x.wasm (parseAST) (medium)
1.72x faster than markdown-exit (parseAST) (medium)
1.84x faster than satteri (parseAST) (medium)
2.88x faster than markdown-it (parseAST) (medium)
Notes:
parseAST group at the top (ox-content, md4x.napi, md4w) sits within ~20% of each other, which is close to run-to-run noise on this machine β repeat runs reorder them. Treat them as roughly tied; the clear gaps are further down the list.position data on every node, and ox-content hands back the tree as a JSON string (the bench JSON.parses it so every entry ends at a materialized tree).{ gfm: true } to put it on the same fixture as the rest.gfm-commonmark.0.31.2 WASM component, not an npm package β the bench fetches it once into bench/.cache/ (gitignored) and skips the entry if the download fails. It renders through fixed 2 MiB in/out buffers with no imports, and exposes HTML only, so it does not appear in the parseAST group.renderToHtml and renderToAnsi support a highlighter option for custom syntax highlighting of fenced code blocks. It receives the raw code (HTML-unescaped) and the block's metadata (language, filename, highlighted lines), and returns a replacement string or undefined to keep the default rendering.
import { renderToHtml } from "md4x";
import { codeToHtml } from "rangi";
import { githubDark } from "rangi/themes";
const html = renderToHtml("```js\nconst x = 1;\n```", {
highlighter: (code, block) => {
if (!block.lang) return; // keep default for fences with no language
return codeToHtml(code, { lang: block.lang, theme: githubDark });
},
});
Any synchronous highlighter works. These examples use rangi (a separate install: npm i rangi) because it needs no async setup and inlines its theme colors, so the markup is self-contained.
Code block metadata from the info string is parsed automatically:
```ts [app.ts] {1,3-5}
// block.lang = "ts"
// block.filename = "app.ts"
// block.highlights = [1, 3, 4, 5]
```
renderToAnsi renders a document straight to ANSI escape sequences β headings, emphasis, tables, lists, blockquotes, alerts and OSC 8 clickable links β ready to console.log in a CLI or TUI.
import { renderToAnsi } from "md4x";
import { codeToAnsi } from "rangi";
console.log(
renderToAnsi(doc, {
highlighter: (code, block) =>
block.lang ? codeToAnsi(code, { lang: block.lang }) : undefined,
}),
);
The highlighter is the same hook as for HTML, returning terminal escapes instead of markup. Code arrives with the block's indentation stripped, and md4x re-applies it to every line that comes back β so a block nested in a blockquote or list keeps its bars and indent without the highlighter knowing anything about the surrounding document. Control bytes in the source are neutralized before the code is handed over, so a fenced block cannot smuggle escape sequences into the terminal.
Options: showUrls prints link targets after the text (for terminals without OSC 8 support), showFrontmatter renders frontmatter as dim text instead of hiding it, and heal: true closes unterminated markup β the combination that makes streaming LLM output render cleanly frame by frame.
renderToAnsi(chunk, { heal: true, showUrls: true });
The CLI is this renderer with a file argument: npx md4x README.md previews any document in the terminal, since it defaults to ansi when stdout is a TTY (and text when piped β pass --format=ansi to force escapes into a pipe).
heal() fixes incomplete markdown from streaming LLM output β closing unclosed bold, italic, strikethrough, inline code, code blocks, links, and more. Useful for rendering partial markdown in real-time as tokens arrive (inspired by streamdown/remend).
import { heal } from "md4x";
heal("**bold"); // "**bold**"
heal("*ita"); // "*ita*"
heal("~~strike"); // "~~strike~~"
heal("`code"); // "`code`"
heal("```js\ncode"); // "```js\ncode\n```"
heal("[text](http:"); // "" (strips broken links)
All render functions also accept a { heal: true } option to heal input before rendering in a single pass:
import { renderToHtml, parseAST, renderToAnsi, renderToText } from "md4x";
// Heal + render in one call β ideal for streaming LLM output
renderToHtml("# Hello **world", { heal: true });
// "<h1>Hello <strong>world</strong></h1>\n"
parseAST("# Hello **world", { heal: true });
// { nodes: [["h1", {}, "Hello ", ["strong", {}, "world"]]], ... }
renderToAnsi("# Hello **world", { heal: true });
renderToText("# Hello **world", { heal: true });
// Combines with other options
renderToHtml("# Hello **world", { heal: true, full: true });
bun packages/md4x/bench/heal.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.4.2 (x64-linux)
benchmark avg (min β¦ max) p75 / p99 (min β¦ top 1%)
md4x-napi heal (small) 1.23 Β΅s/iter 1.22 Β΅s 1.79 Β΅s βββββββββββ
md4x-wasm heal (small) 1.80 Β΅s/iter 1.80 Β΅s 2.16 Β΅s βββββββββββ
remend heal (small) 3.16 Β΅s/iter 3.17 Β΅s 9.27 Β΅s βββββββββββ
summary
md4x-napi heal (small)
1.47x faster than md4x-wasm heal (small)
2.57x faster than remend heal (small)
md4x-napi heal (medium) 3.14 Β΅s/iter 3.16 Β΅s 3.44 Β΅s βββ
ββββββββ
md4x-wasm heal (medium) 4.17 Β΅s/iter 4.26 Β΅s 4.38 Β΅s β
β
βββββββ
ββ
remend heal (medium) 19.24 Β΅s/iter 18.97 Β΅s 32.81 Β΅s βββββββββββ
summary
md4x-napi heal (medium)
1.33x faster than md4x-wasm heal (medium)
6.12x faster than remend heal (medium)
md4x-napi heal (large) 148.63 Β΅s/iter 147.74 Β΅s 230.79 Β΅s βββββββββββ
md4x-wasm heal (large) 184.50 Β΅s/iter 186.32 Β΅s 259.67 Β΅s β
ββββββββββ
remend heal (large) 603.85 Β΅s/iter 604.93 Β΅s 762.44 Β΅s βββββββββββ
summary
md4x-napi heal (large)
1.24x faster than md4x-wasm heal (large)
4.06x faster than remend heal (large)
MD4X ships its own YAML parser β a Zig port of libyaml, with no C dependency in the shipped artifacts. It backs frontmatter parsing, and is exposed directly for standalone YAML documents. Any root node is accepted (mapping, sequence, or bare scalar); an empty document yields null.
import { parseYAML, yamlToJson } from "md4x";
parseYAML("title: Hello\ncount: 42\ndraft: true");
// { title: "Hello", count: 42, draft: true }
yamlToJson("title: Hello"); // '{"title":"Hello"}' (raw JSON string)
yamlToJson is parseYAML without the JSON.parse β use it when the value is headed straight back out as JSON.
bun packages/md4x/bench/yaml.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.4.2 (x64-linux)
benchmark avg (min β¦ max) p75 / p99 (min β¦ top 1%)
md4x.napi (parseYAML) (medium) 21.87 Β΅s/iter 21.80 Β΅s 22.98 Β΅s β
ββββββββββ
md4x.wasm (parseYAML) (medium) 31.16 Β΅s/iter 33.67 Β΅s 34.15 Β΅s β
ββ
βββ
β
ββββ
js-yaml (parseYAML) (medium) 46.59 Β΅s/iter 46.55 Β΅s 83.79 Β΅s βββββββββββ
yaml (parseYAML) (medium) 292.90 Β΅s/iter 301.71 Β΅s 527.77 Β΅s βββββββββββ
confbox (parseYAML) (medium) 44.69 Β΅s/iter 45.34 Β΅s 76.02 Β΅s βββ
ββββββββ
summary
md4x.napi (parseYAML) (medium)
1.42x faster than md4x.wasm (parseYAML) (medium)
2.04x faster than confbox (parseYAML) (medium)
2.13x faster than js-yaml (parseYAML) (medium)
13.39x faster than yaml (parseYAML) (medium)
md4x.napi (yamlToJson) (medium) 17.24 Β΅s/iter 17.36 Β΅s 17.45 Β΅s β
ββββββ
β
β
ββ
md4x.wasm (yamlToJson) (medium) 24.42 Β΅s/iter 24.81 Β΅s 25.12 Β΅s βββββββββββ
js-yaml (yamlToJson) (medium) 43.13 Β΅s/iter 43.01 Β΅s 45.38 Β΅s βββββββββββ
yaml (yamlToJson) (medium) 253.26 Β΅s/iter 253.56 Β΅s 412.91 Β΅s βββββββββββ
summary
md4x.napi (yamlToJson) (medium)
1.42x faster than md4x.wasm (yamlToJson) (medium)
2.5x faster than js-yaml (yamlToJson) (medium)
14.69x faster than yaml (yamlToJson) (medium)
Notes:
parseYAML group ends at a materialized JS value for every entry. The yamlToJson group compares md4x's native JSON-string output against the JS libs' parse-then-JSON.stringify; confbox has no string-output path, so it only appears in the first group.confbox bundles js-yaml 4, which is why it tracks js-yaml closely.yaml builds a full CST and Document on every parse, which accounts for its much larger gap.MD4X is written in Zig. src/lib.zig is the library root: it pulls the parser, the entity
table and every renderer into a single module and re-exports their entry points, so they
call each other directly.
| Entry point | Output |
|---|---|
md_html / md_html_ex | HTML (_ex adds full-document output and the syntax-highlight hook) |
md_ast | Comark AST as JSON |
md_ansi / md_ansi_ex | ANSI terminal output (_ex adds the syntax-highlight hook) |
md_text | Plain text (markdown stripped) |
md_meta | Frontmatter + headings as JSON |
md_markdown | Clean, normalized markdown |
md_heal | Healed markdown (a text transform β it does not parse) |
md_yaml | Standalone YAML document as JSON |
md_parse | The SAX parser itself, for custom rendering |
Every renderer takes the same shape β input bytes, an output callback, userdata, and a
word of renderer flags β and streams its result through the callback without building an
AST:
const md4x = @import("md4x"); // src/lib.zig
fn sink(text: [*c]const u8, size: c_uint, userdata: ?*anyopaque) void {
const out: *std.ArrayListUnmanaged(u8) = @ptrCast(@alignCast(userdata.?));
out.appendSlice(gpa, text[0..size]) catch {};
}
var out: std.ArrayListUnmanaged(u8) = .empty;
_ = md4x.md_html(input.ptr, @intCast(input.len), sink, &out, 0);
md_parse is the five-callback SAX interface the renderers themselves are written
against (enter_block / leave_block / enter_span / leave_span / text), for when
none of the bundled renderers fit. The markdown dialect is fixed: no entry point takes
parser flags, and there is nothing to select. See docs/parser-api.md
for the callback table and docs/renderers.md for each renderer's
flags and behavior.
Requires Zig. No other external dependencies (libyaml is fetched
by build.zig.zon).
zig build # CLI at zig-out/bin/md4x (ReleaseFast, the default)
zig build -Doptimize=Debug # Debug build
zig build wasm # WASM β packages/md4x/build/md4x.wasm
zig build napi-all # NAPI addon for all 9 platforms
bun run build:js # wasm + host NAPI + standalone bundle (what the JS package loads)
(top 24 of 30)
5,089 followers Β· starred Mar 2026
5,517 followers Β· starred Mar 2026
727 followers Β· starred Mar 2026
2,533 followers Β· starred Mar 2026
π Fast and small markdown parser and renderer
See the codeFast and Small markdown parser and renderer based on mity/md4c.
==mark==), footnotes, inline attributes# Local files
npx md4x README.md # ANSI output
npx md4x README.md -t html # HTML output
npx md4x README.md -t text # Plain text output (strip markdown)
npx md4x README.md -t ast # JSON AST output (comark)
npx md4x README.md -t meta # Metadata JSON output
npx md4x README.md -t markdown # Clean markdown (strip MDC/frontmatter/HTML)
npx md4x README.md -t heal # Heal incomplete markdown
npx md4x README.md --heal # Heal before rendering (any format)
npx md4x README.md --heal -t ast # Heal + JSON AST output
# Remote sources
npx md4x https://nitro.build/guide # Fetch and render any URL
npx md4x gh:nitrojs/nitro # GitHub repo β README.md
npx md4x npm:vue@3 # npm package at specific version
# Stdin
echo "# Hello" | npx md4x -t text
cat README.md | npx md4x -t html
# Output to file
npx md4x README.md -t meta -o README.json
# Full HTML document
npx md4x README.md -t html -f --html-title="My Docs" # Wrap in full HTML with <head>
npx md4x README.md -t html -f --html-css=style.css # Add CSS link
yay -S md4x # md4x-git
The examples above are the npm CLI (npx md4x). The native binary β the one zig build
produces and md4x(1) documents β is a smaller tool: it reads a local file or stdin, with
no URL, gh: or npm: shorthands, and its formats are html (the default), text,
json, ansi, markdown and heal, where the npm CLI spells the AST format ast and
adds meta.
Available as a native Node.js addon (NAPI) for maximum performance, or as a portable WASM module that works in any JavaScript runtime (Node.js, Deno, Bun, browsers, edge workers, etc.).
The bare md4x import auto-selects NAPI on Node.js and WASM elsewhere.
import {
init,
renderToHtml,
renderToAST,
parseAST,
renderToAnsi,
renderToText,
renderToMarkdown,
renderToMeta,
parseMeta,
parseYAML,
heal,
} from "md4x";
// await init(); // required for WASM, optional for NAPI
const html = renderToHtml("# Hello, **world**!");
const json = renderToAST("# Hello, **world**!"); // raw JSON string
const ast = parseAST("# Hello, **world**!"); // parsed ComarkTree object
const ansi = renderToAnsi("# Hello, **world**!");
const text = renderToText("# Hello, **world**!"); // plain text (stripped)
const md = renderToMarkdown("# Hello, **world**!"); // clean standard markdown
const metaJson = renderToMeta("# Hello, **world**!"); // raw JSON string
const meta = parseMeta("# Hello, **world**!"); // parsed meta
const yaml = parseYAML("title: Hello"); // standalone YAML -> JS value
const healed = heal("**incomplete streaming mark"); // "**incomplete streaming mark**"
Both NAPI and WASM export a unified API with init(). For WASM, init() must be called before rendering. For NAPI, it is optional (the native binding loads lazily on first render call).
Synchronous, zero-overhead native addon. Best performance for server-side use.
import { renderToHtml } from "md4x/napi";
Works anywhere with WebAssembly support. Requires a one-time async initialization.
import { init, renderToHtml } from "md4x/wasm";
await init(); // call once before rendering
const html = renderToHtml("# Hello");
init() accepts an optional options object with a wasm property (ArrayBuffer, Response, WebAssembly.Module, or Promise<Response>). When called with no arguments, it loads the bundled .wasm file automatically.
A single, minified, dependency-free ES module (~140 KB) with the same API as md4x/wasm, the WASM binary embedded into the same chunk.
import { init, renderToHtml } from "md4x/standalone";
await init(); // inflates and instantiates the inlined binary
const html = renderToHtml("# Hello");
This is also what md4x and md4x/wasm resolve to under the browser export condition, so browser bundlers get the self-contained module automatically (the explicit unwasm condition still wins where it is set).
(source: packages/md4x/bench)
bun packages/md4x/bench/index.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.4.2 (x64-linux)
benchmark avg (min β¦ max) p75 / p99 (min β¦ top 1%)
md4x.napi (renderToHtml) 7.38 Β΅s/iter 7.43 Β΅s 7.47 Β΅s βββββββββββ
md4x.wasm (renderToHtml) 15.08 Β΅s/iter 15.41 Β΅s 30.75 Β΅s ββ
βββββββββ
md4w (renderToHtml) 17.40 Β΅s/iter 17.71 Β΅s 37.11 Β΅s βββ
ββββββββ
markdown-it (renderToHtml) 72.68 Β΅s/iter 73.10 Β΅s 191.23 Β΅s βββββββββββ
markdown-exit (renderToHtml) 53.66 Β΅s/iter 55.23 Β΅s 106.47 Β΅s βββββββββββ
satteri (renderToHtml) 14.84 Β΅s/iter 15.52 Β΅s 27.61 Β΅s βββ
ββββββββ
ox-content (renderToHtml) 12.72 Β΅s/iter 13.52 Β΅s 22.70 Β΅s βββ
ββββββββ
qip.wasm (renderToHtml) 20.17 Β΅s/iter 20.45 Β΅s 34.41 Β΅s ββ
βββββββββ
summary
md4x.napi (renderToHtml)
1.72x faster than ox-content (renderToHtml)
2.01x faster than satteri (renderToHtml)
2.05x faster than md4x.wasm (renderToHtml)
2.36x faster than md4w (renderToHtml)
2.73x faster than qip.wasm (renderToHtml)
7.28x faster than markdown-exit (renderToHtml)
9.85x faster than markdown-it (renderToHtml)
md4x.napi (parseAST) (medium) 21.16 Β΅s/iter 21.80 Β΅s 34.83 Β΅s βββββββββββ
md4x.wasm (parseAST) (medium) 28.12 Β΅s/iter 28.56 Β΅s 45.51 Β΅s βββ
ββββββββ
md4w (parseAST) (medium) 23.07 Β΅s/iter 23.53 Β΅s 24.73 Β΅s βββββββββββ
markdown-it (parseAST) (medium) 50.44 Β΅s/iter 51.52 Β΅s 53.98 Β΅s βββββββββββ
markdown-exit (parseAST) (medium) 30.15 Β΅s/iter 30.70 Β΅s 31.38 Β΅s βββββββββββ
satteri (parseAST) (medium) 32.27 Β΅s/iter 32.38 Β΅s 67.77 Β΅s βββββββββββ
ox-content (parseAST) (medium) 17.54 Β΅s/iter 18.19 Β΅s 18.72 Β΅s βββββββββββ
summary
ox-content (parseAST) (medium)
1.21x faster than md4x.napi (parseAST) (medium)
1.32x faster than md4w (parseAST) (medium)
1.6x faster than md4x.wasm (parseAST) (medium)
1.72x faster than markdown-exit (parseAST) (medium)
1.84x faster than satteri (parseAST) (medium)
2.88x faster than markdown-it (parseAST) (medium)
Notes:
parseAST group at the top (ox-content, md4x.napi, md4w) sits within ~20% of each other, which is close to run-to-run noise on this machine β repeat runs reorder them. Treat them as roughly tied; the clear gaps are further down the list.position data on every node, and ox-content hands back the tree as a JSON string (the bench JSON.parses it so every entry ends at a materialized tree).{ gfm: true } to put it on the same fixture as the rest.gfm-commonmark.0.31.2 WASM component, not an npm package β the bench fetches it once into bench/.cache/ (gitignored) and skips the entry if the download fails. It renders through fixed 2 MiB in/out buffers with no imports, and exposes HTML only, so it does not appear in the parseAST group.renderToHtml and renderToAnsi support a highlighter option for custom syntax highlighting of fenced code blocks. It receives the raw code (HTML-unescaped) and the block's metadata (language, filename, highlighted lines), and returns a replacement string or undefined to keep the default rendering.
import { renderToHtml } from "md4x";
import { codeToHtml } from "rangi";
import { githubDark } from "rangi/themes";
const html = renderToHtml("```js\nconst x = 1;\n```", {
highlighter: (code, block) => {
if (!block.lang) return; // keep default for fences with no language
return codeToHtml(code, { lang: block.lang, theme: githubDark });
},
});
Any synchronous highlighter works. These examples use rangi (a separate install: npm i rangi) because it needs no async setup and inlines its theme colors, so the markup is self-contained.
Code block metadata from the info string is parsed automatically:
```ts [app.ts] {1,3-5}
// block.lang = "ts"
// block.filename = "app.ts"
// block.highlights = [1, 3, 4, 5]
```
renderToAnsi renders a document straight to ANSI escape sequences β headings, emphasis, tables, lists, blockquotes, alerts and OSC 8 clickable links β ready to console.log in a CLI or TUI.
import { renderToAnsi } from "md4x";
import { codeToAnsi } from "rangi";
console.log(
renderToAnsi(doc, {
highlighter: (code, block) =>
block.lang ? codeToAnsi(code, { lang: block.lang }) : undefined,
}),
);
The highlighter is the same hook as for HTML, returning terminal escapes instead of markup. Code arrives with the block's indentation stripped, and md4x re-applies it to every line that comes back β so a block nested in a blockquote or list keeps its bars and indent without the highlighter knowing anything about the surrounding document. Control bytes in the source are neutralized before the code is handed over, so a fenced block cannot smuggle escape sequences into the terminal.
Options: showUrls prints link targets after the text (for terminals without OSC 8 support), showFrontmatter renders frontmatter as dim text instead of hiding it, and heal: true closes unterminated markup β the combination that makes streaming LLM output render cleanly frame by frame.
renderToAnsi(chunk, { heal: true, showUrls: true });
The CLI is this renderer with a file argument: npx md4x README.md previews any document in the terminal, since it defaults to ansi when stdout is a TTY (and text when piped β pass --format=ansi to force escapes into a pipe).
heal() fixes incomplete markdown from streaming LLM output β closing unclosed bold, italic, strikethrough, inline code, code blocks, links, and more. Useful for rendering partial markdown in real-time as tokens arrive (inspired by streamdown/remend).
import { heal } from "md4x";
heal("**bold"); // "**bold**"
heal("*ita"); // "*ita*"
heal("~~strike"); // "~~strike~~"
heal("`code"); // "`code`"
heal("```js\ncode"); // "```js\ncode\n```"
heal("[text](http:"); // "" (strips broken links)
All render functions also accept a { heal: true } option to heal input before rendering in a single pass:
import { renderToHtml, parseAST, renderToAnsi, renderToText } from "md4x";
// Heal + render in one call β ideal for streaming LLM output
renderToHtml("# Hello **world", { heal: true });
// "<h1>Hello <strong>world</strong></h1>\n"
parseAST("# Hello **world", { heal: true });
// { nodes: [["h1", {}, "Hello ", ["strong", {}, "world"]]], ... }
renderToAnsi("# Hello **world", { heal: true });
renderToText("# Hello **world", { heal: true });
// Combines with other options
renderToHtml("# Hello **world", { heal: true, full: true });
bun packages/md4x/bench/heal.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.4.2 (x64-linux)
benchmark avg (min β¦ max) p75 / p99 (min β¦ top 1%)
md4x-napi heal (small) 1.23 Β΅s/iter 1.22 Β΅s 1.79 Β΅s βββββββββββ
md4x-wasm heal (small) 1.80 Β΅s/iter 1.80 Β΅s 2.16 Β΅s βββββββββββ
remend heal (small) 3.16 Β΅s/iter 3.17 Β΅s 9.27 Β΅s βββββββββββ
summary
md4x-napi heal (small)
1.47x faster than md4x-wasm heal (small)
2.57x faster than remend heal (small)
md4x-napi heal (medium) 3.14 Β΅s/iter 3.16 Β΅s 3.44 Β΅s βββ
ββββββββ
md4x-wasm heal (medium) 4.17 Β΅s/iter 4.26 Β΅s 4.38 Β΅s β
β
βββββββ
ββ
remend heal (medium) 19.24 Β΅s/iter 18.97 Β΅s 32.81 Β΅s βββββββββββ
summary
md4x-napi heal (medium)
1.33x faster than md4x-wasm heal (medium)
6.12x faster than remend heal (medium)
md4x-napi heal (large) 148.63 Β΅s/iter 147.74 Β΅s 230.79 Β΅s βββββββββββ
md4x-wasm heal (large) 184.50 Β΅s/iter 186.32 Β΅s 259.67 Β΅s β
ββββββββββ
remend heal (large) 603.85 Β΅s/iter 604.93 Β΅s 762.44 Β΅s βββββββββββ
summary
md4x-napi heal (large)
1.24x faster than md4x-wasm heal (large)
4.06x faster than remend heal (large)
MD4X ships its own YAML parser β a Zig port of libyaml, with no C dependency in the shipped artifacts. It backs frontmatter parsing, and is exposed directly for standalone YAML documents. Any root node is accepted (mapping, sequence, or bare scalar); an empty document yields null.
import { parseYAML, yamlToJson } from "md4x";
parseYAML("title: Hello\ncount: 42\ndraft: true");
// { title: "Hello", count: 42, draft: true }
yamlToJson("title: Hello"); // '{"title":"Hello"}' (raw JSON string)
yamlToJson is parseYAML without the JSON.parse β use it when the value is headed straight back out as JSON.
bun packages/md4x/bench/yaml.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.4.2 (x64-linux)
benchmark avg (min β¦ max) p75 / p99 (min β¦ top 1%)
md4x.napi (parseYAML) (medium) 21.87 Β΅s/iter 21.80 Β΅s 22.98 Β΅s β
ββββββββββ
md4x.wasm (parseYAML) (medium) 31.16 Β΅s/iter 33.67 Β΅s 34.15 Β΅s β
ββ
βββ
β
ββββ
js-yaml (parseYAML) (medium) 46.59 Β΅s/iter 46.55 Β΅s 83.79 Β΅s βββββββββββ
yaml (parseYAML) (medium) 292.90 Β΅s/iter 301.71 Β΅s 527.77 Β΅s βββββββββββ
confbox (parseYAML) (medium) 44.69 Β΅s/iter 45.34 Β΅s 76.02 Β΅s βββ
ββββββββ
summary
md4x.napi (parseYAML) (medium)
1.42x faster than md4x.wasm (parseYAML) (medium)
2.04x faster than confbox (parseYAML) (medium)
2.13x faster than js-yaml (parseYAML) (medium)
13.39x faster than yaml (parseYAML) (medium)
md4x.napi (yamlToJson) (medium) 17.24 Β΅s/iter 17.36 Β΅s 17.45 Β΅s β
ββββββ
β
β
ββ
md4x.wasm (yamlToJson) (medium) 24.42 Β΅s/iter 24.81 Β΅s 25.12 Β΅s βββββββββββ
js-yaml (yamlToJson) (medium) 43.13 Β΅s/iter 43.01 Β΅s 45.38 Β΅s βββββββββββ
yaml (yamlToJson) (medium) 253.26 Β΅s/iter 253.56 Β΅s 412.91 Β΅s βββββββββββ
summary
md4x.napi (yamlToJson) (medium)
1.42x faster than md4x.wasm (yamlToJson) (medium)
2.5x faster than js-yaml (yamlToJson) (medium)
14.69x faster than yaml (yamlToJson) (medium)
Notes:
parseYAML group ends at a materialized JS value for every entry. The yamlToJson group compares md4x's native JSON-string output against the JS libs' parse-then-JSON.stringify; confbox has no string-output path, so it only appears in the first group.confbox bundles js-yaml 4, which is why it tracks js-yaml closely.yaml builds a full CST and Document on every parse, which accounts for its much larger gap.MD4X is written in Zig. src/lib.zig is the library root: it pulls the parser, the entity
table and every renderer into a single module and re-exports their entry points, so they
call each other directly.
| Entry point | Output |
|---|---|
md_html / md_html_ex | HTML (_ex adds full-document output and the syntax-highlight hook) |
md_ast | Comark AST as JSON |
md_ansi / md_ansi_ex | ANSI terminal output (_ex adds the syntax-highlight hook) |
md_text | Plain text (markdown stripped) |
md_meta | Frontmatter + headings as JSON |
md_markdown | Clean, normalized markdown |
md_heal | Healed markdown (a text transform β it does not parse) |
md_yaml | Standalone YAML document as JSON |
md_parse | The SAX parser itself, for custom rendering |
Every renderer takes the same shape β input bytes, an output callback, userdata, and a
word of renderer flags β and streams its result through the callback without building an
AST:
const md4x = @import("md4x"); // src/lib.zig
fn sink(text: [*c]const u8, size: c_uint, userdata: ?*anyopaque) void {
const out: *std.ArrayListUnmanaged(u8) = @ptrCast(@alignCast(userdata.?));
out.appendSlice(gpa, text[0..size]) catch {};
}
var out: std.ArrayListUnmanaged(u8) = .empty;
_ = md4x.md_html(input.ptr, @intCast(input.len), sink, &out, 0);
md_parse is the five-callback SAX interface the renderers themselves are written
against (enter_block / leave_block / enter_span / leave_span / text), for when
none of the bundled renderers fit. The markdown dialect is fixed: no entry point takes
parser flags, and there is nothing to select. See docs/parser-api.md
for the callback table and docs/renderers.md for each renderer's
flags and behavior.
Requires Zig. No other external dependencies (libyaml is fetched
by build.zig.zon).
zig build # CLI at zig-out/bin/md4x (ReleaseFast, the default)
zig build -Doptimize=Debug # Debug build
zig build wasm # WASM β packages/md4x/build/md4x.wasm
zig build napi-all # NAPI addon for all 9 platforms
bun run build:js # wasm + host NAPI + standalone bundle (what the JS package loads)
(top 24 of 30)
5,089 followers Β· starred Mar 2026
5,517 followers Β· starred Mar 2026
727 followers Β· starred Mar 2026
2,533 followers Β· starred Mar 2026