Skip to content

feat(md): Zig markdown parser with Bun.markdown API - #26440

Merged
dylan-conway merged 56 commits into
mainfrom
jarred/mdx
Jan 29, 2026
Merged

dylan-conway merged 56 commits into
mainfrom
jarred/mdx

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jan 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Port md4c (CommonMark-compliant markdown parser) from C to Zig under src/md/
  • Three output modes:
    • Bun.markdown.html(input, options?) — render to HTML string
    • Bun.markdown.render(input, callbacks?) — render with custom callbacks for each element
    • Bun.markdown.react(input, options?) — render to a React Fragment element, directly usable as a component return value
  • React element creation uses a cached JSC Structure with putDirectOffset for fast allocation
  • Component overrides in react(): pass tag names as options keys to replace default HTML elements with custom components
  • GFM extensions: tables, strikethrough, task lists, permissive autolinks, disallowed raw HTML tag filter
  • Wire up .md as a bundler loader (via explicit { type: "md" })

JavaScript API

Bun.markdown.html(input, options?)

Renders markdown to an HTML string:

const html = Bun.markdown.html("# Hello **world**");
// "<h1>Hello <strong>world</strong></h1>\n"

Bun.markdown.html("## Hello", { headingIds: true });
// '<h2 id="hello">Hello</h2>\n'

Bun.markdown.render(input, callbacks?)

Renders markdown with custom JavaScript callbacks for each element. Each callback receives children as a string and optional metadata, and returns a string:

// Custom HTML with classes
const html = Bun.markdown.render("# Title\n\nHello **world**", {
  heading: (children, { level }) => `<h${level} class="title">${children}</h${level}>`,
  paragraph: (children) => `<p>${children}</p>`,
  strong: (children) => `<b>${children}</b>`,
});

// ANSI terminal output
const ansi = Bun.markdown.render("# Hello\n\n**bold**", {
  heading: (children) => `\x1b[1;4m${children}\x1b[0m\n`,
  paragraph: (children) => children + "\n",
  strong: (children) => `\x1b[1m${children}\x1b[22m`,
});

// Strip all formatting
const text = Bun.markdown.render("# Hello **world**", {
  heading: (children) => children,
  paragraph: (children) => children,
  strong: (children) => children,
});
// "Hello world"

// Return null to omit elements
const result = Bun.markdown.render("# Title\n\n![logo](img.png)\n\nHello", {
  image: () => null,
  heading: (children) => children,
  paragraph: (children) => children + "\n",
});
// "Title\nHello\n"

Parser options can be included alongside callbacks:

Bun.markdown.render("Visit www.example.com", {
  link: (children, { href }) => `[${children}](${href})`,
  paragraph: (children) => children,
  permissiveAutolinks: true,
});

Bun.markdown.react(input, options?)

Returns a React Fragment element — use it directly as a component return value:

// Use as a component
function Markdown({ text }: { text: string }) {
  return Bun.markdown.react(text);
}

// With custom components
function Heading({ children }: { children: React.ReactNode }) {
  return <h1 className="title">{children}</h1>;
}
const element = Bun.markdown.react("# Hello", { h1: Heading });

// Server-side rendering
import { renderToString } from "react-dom/server";
const html = renderToString(Bun.markdown.react("# Hello **world**"));
// "<h1>Hello <strong>world</strong></h1>"

React 18 and older

By default, react() uses Symbol.for('react.transitional.element') as the $$typeof symbol, which is what React 19 expects. For React 18 and older, pass reactVersion: 18:

const el = Bun.markdown.react("# Hello", { reactVersion: 18 });

Component Overrides

Tag names can be overridden in react():

Bun.markdown.react(input, {
  h1: MyHeading,      // block elements
  p: CustomParagraph,
  a: CustomLink,      // inline elements
  img: CustomImage,
  pre: CodeBlock,
  // ... h1-h6, p, blockquote, ul, ol, li, pre, hr, html,
  //     table, thead, tbody, tr, th, td,
  //     em, strong, a, img, code, del, math, u, br
});

Boolean values are ignored (not treated as overrides), so parser options like { strikethrough: true } don't conflict with component overrides.

Options

Bun.markdown.html(input, {
  tables: true,              // GFM tables (default: true)
  strikethrough: true,       // ~~deleted~~ (default: true)
  tasklists: true,           // - [x] items (default: true)
  headingIds: true,          // Generate id attributes on headings
  autolinkHeadings: true,    // Wrap heading content in <a> tags
  tagFilter: false,          // GFM disallowed HTML tags
  wikiLinks: false,          // [[wiki]] links
  latexMath: false,          // $inline$ and $$display$$
  underline: false,          // __underline__ (instead of <strong>)
  // ... and more
});

Architecture

Parser (src/md/)

The parser is split into focused modules using Zig's delegation pattern:

Module Purpose
parser.zig Core Parser struct, state, and re-exported method delegation
blocks.zig Block-level parsing: document processing, line analysis, block start/end
containers.zig Container management: blockquotes, lists, list items
inlines.zig Inline parsing: emphasis, code spans, HTML tags, entities
links.zig Link/image resolution, reference links, autolink rendering
autolinks.zig Permissive autolink detection (www, url, email)
line_analysis.zig Line classification: headings, fences, HTML blocks, tables
ref_defs.zig Reference definition parsing and lookup
render_blocks.zig Block rendering dispatch (code, HTML, table blocks)
html_renderer.zig HTML renderer implementing Renderer VTable
types.zig Shared types: Renderer VTable, BlockType, SpanType, TextType, etc.

Renderer Abstraction

Parsing is decoupled from output via a Renderer VTable interface:

pub const Renderer = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        enterBlock: *const fn (...) void,
        leaveBlock: *const fn (...) void,
        enterSpan:  *const fn (...) void,
        leaveSpan:  *const fn (...) void,
        text:       *const fn (...) void,
    };
};

Four renderers are implemented:

  • HtmlRenderer (src/md/html_renderer.zig) — produces HTML string output
  • JsCallbackRenderer (src/bun.js/api/MarkdownObject.zig) — calls JS callbacks for each element, accumulates string output
  • ParseRenderer (src/bun.js/api/MarkdownObject.zig) — builds React element AST with MarkedArgumentBuffer for GC safety
  • JSReactElement (src/bun.js/bindings/JSReactElement.cpp) — C++ fast path for React element creation using cached JSC Structure + putDirectOffset

Test plan

  • 792 spec tests pass (CommonMark, GFM tables, strikethrough, tasklists, permissive autolinks, GFM tag filter, wiki links, coverage, regressions)
  • 114 API tests pass (html(), render(), react(), renderToString integration, component overrides)
  • 58 GFM compatibility tests pass
bun bd test test/js/bun/md/md-spec.test.ts       # 792 pass
bun bd test test/js/bun/md/md-render-api.test.ts  # 114 pass
bun bd test test/js/bun/md/gfm-compat.test.ts     # 58 pass

🤖 Generated with Claude Code

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants