mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
## 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:
```js
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:
```js
// 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\n\nHello", {
image: () => null,
heading: (children) => children,
paragraph: (children) => children + "\n",
});
// "Title\nHello\n"
```
Parser options can be included alongside callbacks:
```js
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:
```tsx
// 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`:
```tsx
const el = Bun.markdown.react("# Hello", { reactVersion: 18 });
```
### Component Overrides
Tag names can be overridden in `react()`:
```tsx
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
```js
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:
```zig
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
- [x] 792 spec tests pass (CommonMark, GFM tables, strikethrough,
tasklists, permissive autolinks, GFM tag filter, wiki links, coverage,
regressions)
- [x] 114 API tests pass (`html()`, `render()`, `react()`,
`renderToString` integration, component overrides)
- [x] 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](https://claude.com/claude-code)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
Co-authored-by: SUZUKI Sosuke <sosuke@bun.com>
Co-authored-by: robobun <robobun@oven.sh>
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Kirill Markelov <kerusha.chubko@gmail.com>
Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
Co-authored-by: Alistair Smith <hi@alistair.sh>
554 lines
20 KiB
TypeScript
554 lines
20 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import React from "react";
|
|
import { renderToString } from "react-dom/server";
|
|
|
|
const Markdown = Bun.markdown;
|
|
|
|
/** renderToString the Fragment returned by Markdown.react.
|
|
* Uses reactVersion: 18 since the project has react-dom@18 installed. */
|
|
function reactRender(md: string, components?: any, opts?: any): string {
|
|
return renderToString(Markdown.react(md, components, { reactVersion: 18, ...opts }));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Bun.markdown.react() — React element AST
|
|
// ============================================================================
|
|
|
|
describe("Bun.markdown.react", () => {
|
|
const REACT_ELEMENT_SYMBOL = Symbol.for("react.element");
|
|
const REACT_FRAGMENT_SYMBOL = Symbol.for("react.fragment");
|
|
const REACT_TRANSITIONAL_SYMBOL = Symbol.for("react.transitional.element");
|
|
|
|
/** Helper: get the children array from the Fragment returned by react() */
|
|
function children(md: string, components?: any, opts?: any): any[] {
|
|
return Markdown.react(md, components, opts).props.children;
|
|
}
|
|
|
|
test("returns a Fragment element", () => {
|
|
const result = Markdown.react("# Hello\n");
|
|
expect(result.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(result.type).toBe(REACT_FRAGMENT_SYMBOL);
|
|
expect(result.key).toBeNull();
|
|
expect(result.ref).toBeNull();
|
|
expect(result.props.children).toBeArray();
|
|
});
|
|
|
|
test("fragment children are React elements", () => {
|
|
const els = children("# Hello\n");
|
|
expect(els).toHaveLength(1);
|
|
expect(els[0].$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
});
|
|
|
|
test("element has type, key, ref, props", () => {
|
|
const el = children("# Hello\n")[0];
|
|
expect(el.type).toBe("h1");
|
|
expect(el.key).toBeNull();
|
|
expect(el.ref).toBeNull();
|
|
expect(el.props).toEqual({ children: ["Hello"] });
|
|
});
|
|
|
|
test("heading levels 1-6", () => {
|
|
for (let i = 1; i <= 6; i++) {
|
|
const md = Buffer.alloc(i, "#").toString() + " Level\n";
|
|
const el = children(md)[0];
|
|
expect(el.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(el.type).toBe(`h${i}`);
|
|
expect(el.props.children).toEqual(["Level"]);
|
|
}
|
|
});
|
|
|
|
test("text is plain strings in children", () => {
|
|
expect(children("Hello world\n")[0].props.children).toEqual(["Hello world"]);
|
|
});
|
|
|
|
test("nested inline elements are React elements", () => {
|
|
const p = children("Hello **world**\n")[0];
|
|
expect(p.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(p.props.children[0]).toBe("Hello ");
|
|
const strong = p.props.children[1];
|
|
expect(strong.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(strong.type).toBe("strong");
|
|
expect(strong.props.children).toEqual(["world"]);
|
|
});
|
|
|
|
test("link has href in props", () => {
|
|
const link = children("[click](https://example.com)\n")[0].props.children[0];
|
|
expect(link.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(link.type).toBe("a");
|
|
expect(link.props.href).toBe("https://example.com");
|
|
expect(link.props.children).toEqual(["click"]);
|
|
});
|
|
|
|
test("image has src and alt in props", () => {
|
|
const img = children("\n")[0].props.children[0];
|
|
expect(img.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(img.type).toBe("img");
|
|
expect(img.props.src).toBe("img.png");
|
|
expect(img.props.alt).toBe("alt");
|
|
expect(img.props.children).toBeUndefined();
|
|
});
|
|
|
|
test("code block with language", () => {
|
|
const pre = children("```ts\nconst x = 1;\n```\n")[0];
|
|
expect(pre.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(pre.type).toBe("pre");
|
|
expect(pre.props.language).toBe("ts");
|
|
});
|
|
|
|
test("hr is void element", () => {
|
|
const hr = children("---\n")[0];
|
|
expect(hr.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(hr.type).toBe("hr");
|
|
expect(hr.key).toBeNull();
|
|
expect(hr.ref).toBeNull();
|
|
expect(hr.props).toEqual({});
|
|
});
|
|
|
|
test("br produces React element", () => {
|
|
const pChildren = children("line1 \nline2\n")[0].props.children;
|
|
const br = pChildren.find((c: any) => typeof c === "object" && c?.type === "br");
|
|
expect(br).toBeDefined();
|
|
expect(br.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(br.props).toEqual({});
|
|
});
|
|
|
|
test("ordered list with start", () => {
|
|
const ol = children("3. first\n4. second\n")[0];
|
|
expect(ol.type).toBe("ol");
|
|
expect(ol.props.start).toBe(3);
|
|
expect(ol.props.children).toHaveLength(2);
|
|
expect(ol.props.children[0].type).toBe("li");
|
|
});
|
|
|
|
test("table structure", () => {
|
|
const table = children("| A | B |\n|---|---|\n| 1 | 2 |\n")[0];
|
|
expect(table.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(table.type).toBe("table");
|
|
const thead = table.props.children.find((c: any) => c.type === "thead");
|
|
expect(thead).toBeDefined();
|
|
expect(thead.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
});
|
|
|
|
test("headingIds adds id to props", () => {
|
|
const el = children("## Hello World\n", undefined, { headings: { ids: true } })[0];
|
|
expect(el.type).toBe("h2");
|
|
expect(el.props.id).toBe("hello-world");
|
|
expect(el.props.children).toEqual(["Hello World"]);
|
|
});
|
|
|
|
test("default $$typeof is react.transitional.element", () => {
|
|
const result = Markdown.react("# Hi\n");
|
|
expect(result.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(result.props.children[0].$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
});
|
|
|
|
test("reactVersion 18 uses react.element symbol on all elements", () => {
|
|
const result = Markdown.react("Hello **world**\n", undefined, { reactVersion: 18 });
|
|
expect(result.$$typeof).toBe(REACT_ELEMENT_SYMBOL);
|
|
const p = result.props.children[0];
|
|
expect(p.$$typeof).toBe(REACT_ELEMENT_SYMBOL);
|
|
const strong = p.props.children[1];
|
|
expect(strong.$$typeof).toBe(REACT_ELEMENT_SYMBOL);
|
|
});
|
|
|
|
test("multiple blocks", () => {
|
|
const els = children("# Title\n\nParagraph\n");
|
|
expect(els).toHaveLength(2);
|
|
expect(els[0].$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(els[0].type).toBe("h1");
|
|
expect(els[1].$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(els[1].type).toBe("p");
|
|
});
|
|
|
|
test("complete document", () => {
|
|
const els = children(`# Hello
|
|
|
|
This is **bold** and *italic*.
|
|
|
|
- item one
|
|
- item two
|
|
|
|
---
|
|
`);
|
|
expect(els[0].type).toBe("h1");
|
|
expect(els[1].type).toBe("p");
|
|
expect(els[2].type).toBe("ul");
|
|
expect(els[3].type).toBe("hr");
|
|
for (const el of els) {
|
|
expect(el.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
}
|
|
});
|
|
|
|
test("blockquote contains nested React elements", () => {
|
|
const bq = children("> quoted text\n")[0];
|
|
expect(bq.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(bq.type).toBe("blockquote");
|
|
const p = bq.props.children[0];
|
|
expect(p.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(p.type).toBe("p");
|
|
expect(p.props.children).toEqual(["quoted text"]);
|
|
});
|
|
|
|
test("deeply nested elements are all React elements", () => {
|
|
const bq = children("> **bold *and italic***\n")[0];
|
|
expect(bq.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
const p = bq.props.children[0];
|
|
expect(p.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
const strong = p.props.children[0];
|
|
expect(strong.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(strong.type).toBe("strong");
|
|
const em = strong.props.children[1];
|
|
expect(em.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(em.type).toBe("em");
|
|
expect(em.props.children).toEqual(["and italic"]);
|
|
});
|
|
|
|
test("link with title in React element", () => {
|
|
const link = children('[text](https://example.com "My Title")\n')[0].props.children[0];
|
|
expect(link.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(link.type).toBe("a");
|
|
expect(link.props.href).toBe("https://example.com");
|
|
expect(link.props.title).toBe("My Title");
|
|
expect(link.props.children).toEqual(["text"]);
|
|
});
|
|
|
|
test("image with title in React element", () => {
|
|
const img = children('\n')[0].props.children[0];
|
|
expect(img.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(img.type).toBe("img");
|
|
expect(img.props.src).toBe("pic.jpg");
|
|
expect(img.props.title).toBe("Photo");
|
|
expect(img.props.alt).toBe("alt");
|
|
expect(img.props.children).toBeUndefined();
|
|
});
|
|
|
|
test("inline code is a React element", () => {
|
|
const code = children("`code`\n")[0].props.children[0];
|
|
expect(code.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(code.type).toBe("code");
|
|
expect(code.props.children).toEqual(["code"]);
|
|
});
|
|
|
|
test("strikethrough is a React element", () => {
|
|
const del = children("~~deleted~~\n")[0].props.children[0];
|
|
expect(del.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(del.type).toBe("del");
|
|
expect(del.props.children).toEqual(["deleted"]);
|
|
});
|
|
|
|
test("unordered list children are React elements", () => {
|
|
const ul = children("- a\n- b\n")[0];
|
|
expect(ul.type).toBe("ul");
|
|
for (const li of ul.props.children) {
|
|
expect(li.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(li.type).toBe("li");
|
|
}
|
|
});
|
|
|
|
test("entities are decoded in React output", () => {
|
|
const text = children("& < >\n")[0].props.children.join("");
|
|
expect(text).toContain("&");
|
|
expect(text).toContain("<");
|
|
expect(text).toContain(">");
|
|
});
|
|
|
|
test("softbr becomes newline string", () => {
|
|
const pChildren = children("line1\nline2\n")[0].props.children;
|
|
expect(pChildren).toContain("\n");
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Bun.markdown.react() + React renderToString integration
|
|
// ============================================================================
|
|
|
|
describe("Bun.markdown.react renderToString", () => {
|
|
test("heading", () => {
|
|
expect(reactRender("# Hello\n")).toBe("<h1>Hello</h1>");
|
|
});
|
|
|
|
test("heading levels 1-6", () => {
|
|
for (let i = 1; i <= 6; i++) {
|
|
const md = Buffer.alloc(i, "#").toString() + " Level\n";
|
|
expect(reactRender(md)).toBe(`<h${i}>Level</h${i}>`);
|
|
}
|
|
});
|
|
|
|
test("paragraph", () => {
|
|
expect(reactRender("Hello world\n")).toBe("<p>Hello world</p>");
|
|
});
|
|
|
|
test("bold text", () => {
|
|
expect(reactRender("**bold**\n")).toBe("<p><strong>bold</strong></p>");
|
|
});
|
|
|
|
test("italic text", () => {
|
|
expect(reactRender("*italic*\n")).toBe("<p><em>italic</em></p>");
|
|
});
|
|
|
|
test("nested bold and italic", () => {
|
|
expect(reactRender("**bold *and italic***\n")).toBe("<p><strong>bold <em>and italic</em></strong></p>");
|
|
});
|
|
|
|
test("strikethrough", () => {
|
|
expect(reactRender("~~deleted~~\n")).toBe("<p><del>deleted</del></p>");
|
|
});
|
|
|
|
test("inline code", () => {
|
|
expect(reactRender("`code`\n")).toBe("<p><code>code</code></p>");
|
|
});
|
|
|
|
test("link", () => {
|
|
expect(reactRender("[click](https://example.com)\n")).toBe('<p><a href="https://example.com">click</a></p>');
|
|
});
|
|
|
|
test("link with title", () => {
|
|
expect(reactRender('[click](https://example.com "title")\n')).toBe(
|
|
'<p><a href="https://example.com" title="title">click</a></p>',
|
|
);
|
|
});
|
|
|
|
test("image", () => {
|
|
expect(reactRender("\n")).toBe('<p><img src="img.png" alt="alt"/></p>');
|
|
});
|
|
|
|
test("hr", () => {
|
|
expect(reactRender("---\n")).toBe("<hr/>");
|
|
});
|
|
|
|
test("br", () => {
|
|
const html = reactRender("line1 \nline2\n");
|
|
expect(html).toContain("<br/>");
|
|
expect(html).toContain("line1");
|
|
expect(html).toContain("line2");
|
|
});
|
|
|
|
test("blockquote", () => {
|
|
expect(reactRender("> quoted\n")).toBe("<blockquote><p>quoted</p></blockquote>");
|
|
});
|
|
|
|
test("unordered list", () => {
|
|
expect(reactRender("- a\n- b\n")).toBe("<ul><li>a</li><li>b</li></ul>");
|
|
});
|
|
|
|
test("ordered list", () => {
|
|
expect(reactRender("1. a\n2. b\n")).toBe('<ol start="1"><li>a</li><li>b</li></ol>');
|
|
});
|
|
|
|
test("ordered list with start", () => {
|
|
const html = reactRender("3. a\n4. b\n");
|
|
expect(html).toContain('<ol start="3">');
|
|
});
|
|
|
|
test("table", () => {
|
|
const html = reactRender("| A | B |\n|---|---|\n| 1 | 2 |\n");
|
|
expect(html).toContain("<table>");
|
|
expect(html).toContain("<thead>");
|
|
expect(html).toContain("<tbody>");
|
|
expect(html).toContain("<th>A</th>");
|
|
expect(html).toContain("<td>1</td>");
|
|
});
|
|
|
|
test("mixed document", () => {
|
|
const html = reactRender(`# Title
|
|
|
|
Hello **world**, this is *important*.
|
|
|
|
- item one
|
|
- item two
|
|
`);
|
|
expect(html).toContain("<h1>Title</h1>");
|
|
expect(html).toContain("<strong>world</strong>");
|
|
expect(html).toContain("<em>important</em>");
|
|
expect(html).toContain("<li>item one</li>");
|
|
expect(html).toContain("<li>item two</li>");
|
|
});
|
|
|
|
test("entities are decoded", () => {
|
|
const html = reactRender("& < >\n");
|
|
expect(html).toContain("&"); // React re-escapes & in output
|
|
expect(html).toContain("<");
|
|
expect(html).toContain(">");
|
|
});
|
|
|
|
test("headingIds produce id attribute", () => {
|
|
const html = reactRender("## Hello World\n", undefined, { headings: { ids: true } });
|
|
expect(html).toBe('<h2 id="hello-world">Hello World</h2>');
|
|
});
|
|
|
|
test("code block renders as pre", () => {
|
|
const html = reactRender("```\ncode here\n```\n");
|
|
expect(html).toContain("<pre>");
|
|
expect(html).toContain("code here");
|
|
});
|
|
|
|
test("nested blockquote with formatting", () => {
|
|
const html = reactRender("> **bold** in quote\n");
|
|
expect(html).toBe("<blockquote><p><strong>bold</strong> in quote</p></blockquote>");
|
|
});
|
|
|
|
test("link inside heading", () => {
|
|
const html = reactRender("# [Bun](https://bun.sh)\n");
|
|
expect(html).toBe('<h1><a href="https://bun.sh">Bun</a></h1>');
|
|
});
|
|
|
|
test("multiple paragraphs", () => {
|
|
const html = reactRender("First paragraph.\n\nSecond paragraph.\n");
|
|
expect(html).toBe("<p>First paragraph.</p><p>Second paragraph.</p>");
|
|
});
|
|
|
|
test("reactVersion 18 produces correct structure", () => {
|
|
const result = Markdown.react("# Hello\n", undefined, { reactVersion: 18 });
|
|
const els = result.props.children;
|
|
expect(els[0].type).toBe("h1");
|
|
expect(els[0].props.children).toEqual(["Hello"]);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Component overrides (render + react)
|
|
// ============================================================================
|
|
|
|
// (render() is callback-based, component overrides are only for react())
|
|
|
|
describe("Bun.markdown.react component overrides", () => {
|
|
const REACT_TRANSITIONAL_SYMBOL = Symbol.for("react.transitional.element");
|
|
const REACT_ELEMENT_SYMBOL = Symbol.for("react.element");
|
|
|
|
/** Helper: get fragment children */
|
|
function children(md: string, components?: any, opts?: any): any[] {
|
|
return Markdown.react(md, components, opts).props.children;
|
|
}
|
|
|
|
test("function component override replaces type", () => {
|
|
function MyHeading({ children }: any) {
|
|
return React.createElement("div", { className: "heading" }, ...children);
|
|
}
|
|
const el = children("# Hello\n", { h1: MyHeading })[0];
|
|
expect(el.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(el.type).toBe(MyHeading);
|
|
expect(el.props.children).toEqual(["Hello"]);
|
|
});
|
|
|
|
test("string override in react mode", () => {
|
|
const el = children("# Hello\n", { h1: "section" })[0];
|
|
expect(el.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(el.type).toBe("section");
|
|
expect(el.props.children).toEqual(["Hello"]);
|
|
});
|
|
|
|
test("multiple component overrides", () => {
|
|
function P({ children }: any) {
|
|
return React.createElement("div", null, ...children);
|
|
}
|
|
function Strong({ children }: any) {
|
|
return React.createElement("b", null, ...children);
|
|
}
|
|
const els = children("Hello **world**\n", { p: P, strong: Strong });
|
|
expect(els[0].type).toBe(P);
|
|
const strong = els[0].props.children[1];
|
|
expect(strong.type).toBe(Strong);
|
|
});
|
|
|
|
test("boolean override is ignored in react mode", () => {
|
|
expect(children("# Hello\n", { h1: true })[0].type).toBe("h1");
|
|
});
|
|
|
|
test("override with reactVersion 18", () => {
|
|
const el = children("# Hello\n", { h1: "custom-h1" }, { reactVersion: 18 })[0];
|
|
expect(el.$$typeof).toBe(REACT_ELEMENT_SYMBOL);
|
|
expect(el.type).toBe("custom-h1");
|
|
});
|
|
|
|
test("link override preserves href prop", () => {
|
|
function Link({ href, children }: any) {
|
|
return React.createElement("a", { href, className: "custom" }, ...children);
|
|
}
|
|
const link = children("[click](https://example.com)\n", { a: Link })[0].props.children[0];
|
|
expect(link.type).toBe(Link);
|
|
expect(link.props.href).toBe("https://example.com");
|
|
});
|
|
|
|
test("image override preserves src and alt props", () => {
|
|
function Img(props: any) {
|
|
return React.createElement("img", props);
|
|
}
|
|
const img = children("\n", { img: Img })[0].props.children[0];
|
|
expect(img.type).toBe(Img);
|
|
expect(img.props.src).toBe("pic.jpg");
|
|
expect(img.props.alt).toBe("photo");
|
|
});
|
|
|
|
test("hr override in react mode", () => {
|
|
const el = children("---\n", { hr: "custom-hr" })[0];
|
|
expect(el.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL);
|
|
expect(el.type).toBe("custom-hr");
|
|
expect(el.props).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("Bun.markdown.react renderToString with component overrides", () => {
|
|
test("function component renders custom HTML", () => {
|
|
function Heading({ children }: any) {
|
|
return React.createElement("div", { className: "title" }, ...children);
|
|
}
|
|
const html = reactRender("# Hello\n", { h1: Heading });
|
|
expect(html).toBe('<div class="title">Hello</div>');
|
|
});
|
|
|
|
test("multiple custom components", () => {
|
|
function P({ children }: any) {
|
|
return React.createElement("section", null, ...children);
|
|
}
|
|
function Strong({ children }: any) {
|
|
return React.createElement("b", null, ...children);
|
|
}
|
|
const html = reactRender("Hello **world**\n", { p: P, strong: Strong });
|
|
expect(html).toBe("<section>Hello <b>world</b></section>");
|
|
});
|
|
|
|
test("custom link component", () => {
|
|
function Link({ href, children }: any) {
|
|
return React.createElement("a", { href, target: "_blank" }, ...children);
|
|
}
|
|
const html = reactRender("[click](https://example.com)\n", { a: Link });
|
|
expect(html).toBe('<p><a href="https://example.com" target="_blank">click</a></p>');
|
|
});
|
|
|
|
test("custom image component", () => {
|
|
function Img({ src, alt }: any) {
|
|
return React.createElement("figure", null, React.createElement("img", { src, alt }));
|
|
}
|
|
const html = reactRender("\n", { img: Img });
|
|
expect(html).toBe('<p><figure><img src="pic.jpg" alt="photo"/></figure></p>');
|
|
});
|
|
|
|
test("custom code block with language", () => {
|
|
function Code({ language, children }: any) {
|
|
return React.createElement("pre", { "data-lang": language || "text" }, ...children);
|
|
}
|
|
const html = reactRender("```js\nconst x = 1;\n```\n", { pre: Code });
|
|
expect(html).toContain('data-lang="js"');
|
|
expect(html).toContain("const x = 1;");
|
|
});
|
|
|
|
test("custom list components", () => {
|
|
function List({ children }: any) {
|
|
return React.createElement("div", { className: "list" }, ...children);
|
|
}
|
|
function Item({ children }: any) {
|
|
return React.createElement("span", null, ...children);
|
|
}
|
|
const html = reactRender("- a\n- b\n", { ul: List, li: Item });
|
|
expect(html).toBe('<div class="list"><span>a</span><span>b</span></div>');
|
|
});
|
|
|
|
test("override only specific elements", () => {
|
|
function H1({ children }: any) {
|
|
return React.createElement("h1", { className: "big" }, ...children);
|
|
}
|
|
const html = reactRender("# Title\n\nParagraph\n", { h1: H1 });
|
|
expect(html).toBe('<h1 class="big">Title</h1><p>Paragraph</p>');
|
|
});
|
|
});
|