Skip to content
Β 
Β 

Repository files navigation

πŸ“„ MD4X

npm version

Fast and Small markdown parser and renderer based on mity/md4c.

Online Playground

Features

  • Fast β€” ~8x faster than markdown-it
  • CLI β€” Render local files, remote URLs, GitHub repos, npm packages
  • Small β€” ~100KB gzip WASM binary works in Node.js and Browser
  • Multi-format output β€” HTML, JSON AST, ANSI terminal, plain text, markdown, metadata
  • Streaming heal β€” Fix incomplete markdown from LLM output in real-time
  • Full CommonMark β€” Passes the CommonMark spec
  • GitHub Flavored Markdown β€” Tables, task lists, strikethrough, autolinks, alerts
  • Built-in YAML parser β€” Frontmatter and standalone YAML, no external dependency
  • Extra extensions β€” LaTeX math, wiki links, underline, highlight, footnotes, inline attributes
  • Comark (MDC) support β€” Block and inline components with props, slots
  • Universal JS β€” Native Node.js addon (NAPI) + portable WASM for browsers, Deno, Bun, edge workers
  • C library β€” SAX-like streaming parser, zero-copy, no AST allocation overhead
  • Zig package β€” Consumable as a Zig dependency

Showcase

  • pi0/mdshot β€” Render beautiful screenshots from Markdown.
  • pi0/mdzilla β€” Markdown browser for humans and agents.

CLI

# 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 json           # 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

Install from AUR

yay -S md4x # md4x-git

JavaScript

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).

NAPI (Node.js native)

Synchronous, zero-overhead native addon. Best performance for server-side use.

import { renderToHtml } from "md4x/napi";

WASM (universal)

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.

Standalone (inlined WASM)

A single, minified, dependency-free ES module (~126 KB) with the same API as md4x/wasm fully embeded into single 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).

Benchmarks

(source: packages/md4x/bench)

bun packages/md4x/bench/index.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.3.14 (x64-linux)

benchmark                        avg (min … max) p75 / p99    (min … top 1%)
md4x.napi (renderToHtml)            6.78 Β΅s/iter   6.82 Β΅s   6.88 Β΅s β–ƒβ–ƒβ–ˆβ–ˆβ–†β–†β–†β–†β–ˆβ–ƒβ–ƒ
md4x.wasm (renderToHtml)           15.15 Β΅s/iter  15.70 Β΅s  31.72 Β΅s β–ˆβ–„β–ƒβ–‚β–‚β–β–β–β–β–β–
md4w (renderToHtml)                17.56 Β΅s/iter  18.23 Β΅s  38.41 Β΅s β–†β–ˆβ–†β–‚β–‚β–β–β–β–β–β–
markdown-it (renderToHtml)         59.77 Β΅s/iter  69.44 Β΅s 143.41 Β΅s β–ˆβ–ˆβ–‚β–ƒβ–ƒβ–‚β–β–β–β–β–
markdown-exit (renderToHtml)       56.02 Β΅s/iter  54.56 Β΅s 125.47 Β΅s β–ƒβ–ˆβ–‚β–β–β–β–β–β–β–β–
satteri (renderToHtml)             26.31 Β΅s/iter  27.21 Β΅s  47.39 Β΅s β–„β–ˆβ–…β–ƒβ–‚β–β–β–β–β–β–
ox-content (renderToHtml)          11.11 Β΅s/iter  11.30 Β΅s  11.56 Β΅s β–ƒβ–ˆβ–†β–†β–β–β–β–ƒβ–β–ƒβ–ƒ
qip.wasm (renderToHtml)            21.57 Β΅s/iter  21.71 Β΅s  37.39 Β΅s β–ˆβ–†β–ƒβ–‚β–β–β–β–β–β–β–

summary
  md4x.napi (renderToHtml)
   1.64x faster than ox-content (renderToHtml)
   2.23x faster than md4x.wasm (renderToHtml)
   2.59x faster than md4w (renderToHtml)
   3.18x faster than qip.wasm (renderToHtml)
   3.88x faster than satteri (renderToHtml)
   8.26x faster than markdown-exit (renderToHtml)
   8.81x faster than markdown-it (renderToHtml)

md4x.napi (parseAST) (medium)      23.24 Β΅s/iter  25.51 Β΅s  27.39 Β΅s β–ˆβ–†β–β–ƒβ–†β–β–β–β–ƒβ–β–†
md4x.wasm (parseAST) (medium)      31.88 Β΅s/iter  33.05 Β΅s  62.20 Β΅s β–†β–ˆβ–†β–ƒβ–‚β–β–β–β–β–β–
md4w (parseAST) (medium)           24.94 Β΅s/iter  26.10 Β΅s  26.99 Β΅s β–ƒβ–ƒβ–ˆβ–ƒβ–ƒβ–ƒβ–β–β–†β–β–ƒ
markdown-it (parseAST) (medium)    41.26 Β΅s/iter  43.17 Β΅s  72.22 Β΅s β–ƒβ–ˆβ–…β–‚β–‚β–‚β–‚β–β–β–β–
markdown-exit (parseAST) (medium)  35.42 Β΅s/iter  36.24 Β΅s  36.58 Β΅s β–…β–…β–β–ˆβ–…β–ˆβ–β–β–…β–ˆβ–…
satteri (parseAST) (medium)        22.31 Β΅s/iter  22.85 Β΅s  39.60 Β΅s β–‚β–ˆβ–ƒβ–‚β–‚β–β–β–β–β–β–
ox-content (parseAST) (medium)     24.21 Β΅s/iter  26.36 Β΅s  26.69 Β΅s β–ˆβ–ˆβ–β–…β–β–…β–…β–β–…β–ˆβ–…

summary
  satteri (parseAST) (medium)
   1.04x faster than md4x.napi (parseAST) (medium)
   1.09x faster than ox-content (parseAST) (medium)
   1.12x faster than md4w (parseAST) (medium)
   1.43x faster than md4x.wasm (parseAST) (medium)
   1.59x faster than markdown-exit (parseAST) (medium)
   1.85x faster than markdown-it (parseAST) (medium)

Notes:

  • The parseAST group at the top (satteri, md4x.napi, ox-content, md4w) sits within ~12% of each other, which is inside run-to-run noise on this machine β€” repeat runs reorder them. Treat them as tied; the clear gaps are further down the list.
  • The parsers do not all return the same thing: markdown-it yields a flat array of tokens where md4x returns a nested comark AST, satteri's mdast carries full 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).
  • ox-content ships with GFM off, so the bench passes { gfm: true } to put it on the same fixture as the rest.
  • qip is the 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.

Code Highlighting

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]
```

Terminal Output (TUI)

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).

Markdown Healing

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 });
Benchmarks
bun packages/md4x/bench/heal.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.3.14 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
md4x-napi heal (small)         1.28 Β΅s/iter   1.32 Β΅s   1.81 Β΅s β–ƒβ–ˆβ–‚β–‚β–β–‚β–β–β–β–‚β–
md4x-wasm heal (small)         3.34 Β΅s/iter   3.08 Β΅s   9.29 Β΅s β–ˆβ–ƒβ–β–‚β–β–β–β–β–β–β–
remend heal (small)            9.09 Β΅s/iter   9.77 Β΅s  22.17 Β΅s β–ˆβ–ˆβ–…β–ƒβ–‚β–‚β–β–β–β–β–

summary
  md4x-napi heal (small)
   2.61x faster than md4x-wasm heal (small)
   7.12x faster than remend heal (small)

md4x-napi heal (medium)        3.21 Β΅s/iter   3.24 Β΅s   3.39 Β΅s β–„β–ˆβ–†β–„β–ƒβ–‚β–‚β–‚β–„β–‚β–‚
md4x-wasm heal (medium)        4.40 Β΅s/iter   4.45 Β΅s   4.93 Β΅s β–ˆβ–…β–ˆβ–…β–…β–ƒβ–β–‚β–β–β–‚
remend heal (medium)          53.67 Β΅s/iter  57.84 Β΅s  78.92 Β΅s β–ˆβ–…β–‚β–ƒβ–ƒβ–‚β–β–β–β–β–

summary
  md4x-napi heal (medium)
   1.37x faster than md4x-wasm heal (medium)
   16.71x faster than remend heal (medium)

md4x-napi heal (large)       147.94 Β΅s/iter 148.21 Β΅s 229.58 Β΅s β–…β–ˆβ–ƒβ–β–β–β–β–β–β–β–
md4x-wasm heal (large)       175.43 Β΅s/iter 177.00 Β΅s 292.66 Β΅s β–„β–ˆβ–‚β–β–β–β–β–β–β–β–
remend heal (large)           18.63 ms/iter  18.79 ms  19.46 ms β–…β–‚β–…β–ˆβ–‚β–„β–‚β–‚β–β–β–‚

summary
  md4x-napi heal (large)
   1.19x faster than md4x-wasm heal (large)
   125.91x faster than remend heal (large)

YAML

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.

Benchmarks
bun packages/md4x/bench/yaml.mjs
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
runtime: bun 1.3.14 (x64-linux)

benchmark                      avg (min … max) p75 / p99    (min … top 1%)
md4x.napi (parseYAML) (medium)   22.34 Β΅s/iter  22.32 Β΅s  22.35 Β΅s β–†β–β–†β–β–β–β–ƒβ–†β–ƒβ–β–ˆ
md4x.wasm (parseYAML) (medium)   41.11 Β΅s/iter  49.34 Β΅s  80.21 Β΅s β–„β–ˆβ–ƒβ–‚β–‚β–‚β–‚β–‚β–β–β–
js-yaml (parseYAML) (medium)     50.69 Β΅s/iter  53.59 Β΅s  98.25 Β΅s β–…β–ˆβ–…β–ƒβ–‚β–‚β–‚β–‚β–β–β–
yaml (parseYAML) (medium)       377.44 Β΅s/iter 392.99 Β΅s 728.75 Β΅s β–ƒβ–ˆβ–ƒβ–‚β–β–β–‚β–‚β–‚β–β–
confbox (parseYAML) (medium)     40.27 Β΅s/iter  43.47 Β΅s  44.08 Β΅s β–…β–…β–ˆβ–…β–…β–…β–…β–β–β–…β–ˆ

summary
  md4x.napi (parseYAML) (medium)
   1.8x faster than confbox (parseYAML) (medium)
   1.84x faster than md4x.wasm (parseYAML) (medium)
   2.27x faster than js-yaml (parseYAML) (medium)
   16.89x faster than yaml (parseYAML) (medium)

md4x.napi (yamlToJson) (medium)  17.78 Β΅s/iter  17.83 Β΅s  19.29 Β΅s β–‚β–‚β–‚β–‚β–ˆβ–‚β–β–β–β–β–‚
md4x.wasm (yamlToJson) (medium)  28.63 Β΅s/iter  29.35 Β΅s  29.61 Β΅s β–ƒβ–β–β–β–†β–β–β–ˆβ–†β–†β–ƒ
js-yaml (yamlToJson) (medium)    44.24 Β΅s/iter  43.28 Β΅s  47.30 Β΅s β–‚β–‚β–ˆβ–‚β–β–β–β–β–β–β–‚
yaml (yamlToJson) (medium)      321.22 Β΅s/iter 310.31 Β΅s 644.56 Β΅s β–…β–ˆβ–‚β–β–β–β–β–β–β–β–

summary
  md4x.napi (yamlToJson) (medium)
   1.61x faster than md4x.wasm (yamlToJson) (medium)
   2.49x faster than js-yaml (yamlToJson) (medium)
   18.07x faster than yaml (yamlToJson) (medium)

Notes:

  • The bench asserts every parser returns the same value as js-yaml on each fixture before timing anything, so the numbers are for identical work.
  • The 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.

Zig Package

MD4X can be consumed as a Zig package dependency via build.zig.zon.

Building

Requires Zig. No other external dependencies.

zig build                      # ReleaseFast (default)
zig build -Doptimize=Debug     # Debug build
zig build wasm                 # WASM target (~163K)
zig build napi                 # Node.js NAPI addon

C Library

SAX-like streaming parser with no AST construction. Link against libmd4x and the renderer you need.

HTML Renderer

#include "md4x.h"
#include "md4x-html.h"

void output(const MD_CHAR* text, MD_SIZE size, void* userdata) {
    fwrite(text, 1, size, (FILE*) userdata);
}

md_html(input, input_size, output, stdout, MD_DIALECT_GITHUB, 0);

JSON Renderer

#include "md4x.h"
#include "md4x-ast.h"

md_ast(input, input_size, output, stdout, MD_DIALECT_GITHUB, 0);

ANSI Renderer

#include "md4x.h"
#include "md4x-ansi.h"

md_ansi(input, input_size, output, stdout, MD_DIALECT_GITHUB, 0);

Text Renderer

Strips markdown formatting and produces plain text:

#include "md4x.h"
#include "md4x-text.h"

md_text(input, input_size, output, stdout, MD_DIALECT_GITHUB, 0);

Markdown Renderer

Converts extended markdown (MDC/Comark) to clean, standard markdown. Strips frontmatter, HTML comments, raw HTML, and inline attributes. Converts block/inline components to HTML tags, wiki links to regular links.

#include "md4x.h"
#include "md4x-markdown.h"

md_markdown(input, input_size, output, stdout, MD_DIALECT_ALL, 0);

Meta Renderer

Extracts frontmatter and headings as a flat JSON object:

#include "md4x.h"
#include "md4x-meta.h"

md_meta(input, input_size, output, stdout, MD_DIALECT_GITHUB, 0);
// {"title":"Hello","headings":[{"level":1,"text":"Hello"}]}

Heal Utility

Fixes incomplete/streaming markdown by closing unclosed delimiters:

#include "md4x-heal.h"

md_heal(input, input_size, output, stdout);

Low-Level Parser

For custom rendering, use the SAX-like parser directly:

#include "md4x.h"

int enter_block(MD_BLOCKTYPE type, void* detail, void* userdata) { return 0; }
int leave_block(MD_BLOCKTYPE type, void* detail, void* userdata) { return 0; }
int enter_span(MD_SPANTYPE type, void* detail, void* userdata) { return 0; }
int leave_span(MD_SPANTYPE type, void* detail, void* userdata) { return 0; }
int text(MD_TEXTTYPE type, const MD_CHAR* text, MD_SIZE size, void* userdata) { return 0; }

MD_PARSER parser = {
    .abi_version = 0,
    .flags = MD_DIALECT_GITHUB,
    .enter_block = enter_block,
    .leave_block = leave_block,
    .enter_span = enter_span,
    .leave_span = leave_span,
    .text = text,
};

md_parse(input, input_size, &parser, NULL);

License

MIT

About

πŸ“„ Fast and small markdown parser and renderer

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages