From 45803ca5040bb51f94cfa6953ec8ad98b13fbe9a Mon Sep 17 00:00:00 2001
From: SUZUKI Sosuke
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Use
+ the Loose item with a paragraph. And a quote See bun.com. x Parse and render Markdown with Bun's built-in Markdown API, supporting GFM extensions and custom rendering callbacks Unstable API — This API is under active development and may change in future versions of Bun. Bun includes a fast, built-in Markdown parser written in Rust. It supports GitHub Flavored Markdown (GFM) extensions and provides three APIs: Convert a Markdown string to HTML. GFM extensions like tables, strikethrough, and task lists are enabled by default: Pass an options object as the second argument to configure the parser: All available options: Pass Pass Parse Markdown and render it using custom JavaScript callbacks. The callbacks give you full control over the output format. You can generate HTML with custom classes, ANSI terminal output, or any other string format. Each callback receives: Return a string to replace the element's rendering. Return The Return The Pass parser options as a separate third argument: Render Markdown directly to React elements. Returns a Works with Replace any HTML element with a custom React component by passing it in the second argument, keyed by tag name: You can override every HTML tag the parser produces: By default, elements use Pass any of the parser options as the third argument: ${children} ${children} one two one two a b a b a a b c x y x a b a b a b ab c a bold b a bold b Use Ctrl+C n a a H2O x2 sf a a \n\t b x a b ab a b a x ab a b 2 * 3 * 4 and _x_ snake_case_name and __dunder__ a[1] b\\c `d` - a + b 1. c # d > e =f ~~~g a - b + c 1. d # e > f a #tag and ## x a <b> c </b> <!-- d & 1 < 2 x a b a caf\u00e9 ok a\ud800b a\u0000b abc"],
+ ["stray end tags", " abcd a`: back to plain script data.
+ Script,
+ /// The script element ended (its end tag was emitted), or input did.
+ Done(Option text is a code block, headings and table cells are kept on one
line, script/style/noscript/template are dropped, line-start escapes
only apply at line start, and intraword underscores are not escaped.
- Never throws on malformed input. Tree depth is capped at 512 during
parsing (as browsers do) so hostile nesting stays linear, and the
emitter has its own recursion backstop.
- ~12-20 ms for 800 KB-1 MB real pages in a release build (7-15x faster
than turndown + gfm on the same inputs), memory linear in input.
Tests cover each rule, options, malformed and pathological input,
round-tripping Bun.markdown.html output, and differential comparison
against vendored turndown (curated snippets, a real docs page, and a
seeded structural fuzzer).
---
Cargo.lock | 109 +++
Cargo.toml | 2 +
bench/bun.lock | 8 +
bench/package.json | 2 +
bench/snippets/html-to-markdown.mjs | 98 ++
docs/project/license.mdx | 1 +
docs/runtime/markdown.mdx | 106 ++-
packages/bun-types/bun.d.ts | 118 +++
src/md/Cargo.toml | 2 +
src/md/from_html/depth.rs | 160 ++++
src/md/from_html/dom.rs | 786 +++++++++++++++
src/md/from_html/emit.rs | 896 ++++++++++++++++++
src/md/from_html/mod.rs | 230 +++++
src/md/from_html/text.rs | 331 +++++++
src/md/from_html/whitespace.rs | 165 ++++
src/md/lib.rs | 1 +
src/runtime/api/MarkdownObject.rs | 182 ++++
test/bun.lock | 8 +
test/integration/bun-types/fixture/bun.ts | 21 +
.../js/bun/md/fixtures/bun-docs-markdown.html | 128 +++
.../md/fixtures/bun-docs-markdown.turndown.md | 528 +++++++++++
.../bun/md/fixtures/regenerate-turndown.mjs | 22 +
test/js/bun/md/md-from-html.test.ts | 747 +++++++++++++++
test/package.json | 2 +
24 files changed, 4652 insertions(+), 1 deletion(-)
create mode 100644 bench/snippets/html-to-markdown.mjs
create mode 100644 src/md/from_html/depth.rs
create mode 100644 src/md/from_html/dom.rs
create mode 100644 src/md/from_html/emit.rs
create mode 100644 src/md/from_html/mod.rs
create mode 100644 src/md/from_html/text.rs
create mode 100644 src/md/from_html/whitespace.rs
create mode 100644 test/js/bun/md/fixtures/bun-docs-markdown.html
create mode 100644 test/js/bun/md/fixtures/bun-docs-markdown.turndown.md
create mode 100644 test/js/bun/md/fixtures/regenerate-turndown.mjs
create mode 100644 test/js/bun/md/md-from-html.test.ts
diff --git a/Cargo.lock b/Cargo.lock
index 20012ce3d0cf..c028058bdb18 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -987,8 +987,10 @@ dependencies = [
"bun_paths",
"bun_sys",
"bun_url",
+ "html5ever",
"scopeguard",
"strum",
+ "typed-arena",
]
[[package]]
@@ -2250,6 +2252,16 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
[[package]]
name = "id-arena"
version = "2.3.0"
@@ -2363,6 +2375,15 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.29"
@@ -2386,6 +2407,17 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
[[package]]
name = "memchr"
version = "2.8.0"
@@ -2425,6 +2457,29 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "paste"
version = "1.0.15"
@@ -2587,6 +2642,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -2792,6 +2856,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "strum"
version = "0.26.3"
@@ -2831,6 +2919,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -3002,6 +3099,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web_atoms"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
[[package]]
name = "winapi-util"
version = "0.1.11"
diff --git a/Cargo.toml b/Cargo.toml
index 012f23a486ab..5cdec9b87bce 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -364,6 +364,8 @@ thiserror = "2"
smallvec = "1"
itoa = "1"
encoding_rs = "0.8.35"
+# Spec-compliant HTML5 parser behind `Bun.markdown.fromHTML` (src/md/from_html).
+html5ever = "0.39"
# Vendored, not crates.io — see the `exclude = ["vendor"]` note above.
lol_html = { path = "vendor/lolhtml" }
bun_opaque = { path = "src/opaque" }
diff --git a/bench/bun.lock b/bench/bun.lock
index 2692ed9c3aac..bd5c1f278480 100644
--- a/bench/bun.lock
+++ b/bench/bun.lock
@@ -31,6 +31,8 @@
"strip-ansi": "^7.1.0",
"tar": "^7.4.3",
"tinycolor2": "^1.6.0",
+ "turndown": "^7.2.4",
+ "turndown-plugin-gfm": "^1.0.2",
"wrap-ansi": "^9.0.0",
"zx": "^7.2.3",
},
@@ -128,6 +130,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.17", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" } }, "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g=="],
+ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -648,6 +652,10 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+ "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
+
+ "turndown-plugin-gfm": ["turndown-plugin-gfm@1.0.2", "", {}, "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg=="],
+
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
diff --git a/bench/package.json b/bench/package.json
index 9bed18fa6801..97ce07b0aee6 100644
--- a/bench/package.json
+++ b/bench/package.json
@@ -27,6 +27,8 @@
"strip-ansi": "^7.1.0",
"tar": "^7.4.3",
"tinycolor2": "^1.6.0",
+ "turndown": "^7.2.4",
+ "turndown-plugin-gfm": "^1.0.2",
"wrap-ansi": "^9.0.0",
"zx": "^7.2.3"
},
diff --git a/bench/snippets/html-to-markdown.mjs b/bench/snippets/html-to-markdown.mjs
new file mode 100644
index 000000000000..8b6673c43132
--- /dev/null
+++ b/bench/snippets/html-to-markdown.mjs
@@ -0,0 +1,98 @@
+// HTML → Markdown: Bun.markdown.fromHTML vs turndown (+ turndown-plugin-gfm).
+//
+// bun bench/snippets/html-to-markdown.mjs # synthetic pages
+// bun bench/snippets/html-to-markdown.mjs page.html # your own saved pages
+//
+// turndown is what most JS apps use for this today; it builds a DOM with a
+// pure-JS HTML parser (domino) and walks it with regex-heavy rules, so large
+// pages cost hundreds of milliseconds of main-thread time and a lot of
+// transient garbage. The synthetic input below is shaped like a docs/article
+// page (nav, prose with inline markup, code blocks, tables, lists) and is
+// repeated to reach the sizes real pages have.
+import { readFileSync } from "node:fs";
+import { basename } from "node:path";
+import TurndownService from "turndown";
+import { gfm } from "turndown-plugin-gfm";
+import { bench, group, run } from "../runner.mjs";
+
+function turndown(html) {
+ const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", hr: "---" });
+ td.use(gfm);
+ td.remove(["script", "style"]);
+ return td.turndown(html);
+}
+
+const section = i => `
+Section ${i}: configuring the
+ thingthing() API to
+ frobnicate the widget — it’s slow fast now. See issue
+ #${1000 + i} for details, and note that
+ 2 * 3 = 6 and snake_case_names are left alone.
+
+ import { thing } from "pkg";
+
+export async function run${i}(input: string): Promise<number> {
+ const result = await thing(input, { retries: ${i % 5}, verbose: true });
+ if (!result.ok) throw new Error(\`failed: \${result.error}\`);
+ return result.value * 2;
+}
+
+
+
+
+ Option Type Default Description
+ retriesnumber3 How many times to retry before giving up.
+ verbosebooleanfalseLog each attempt to stderr.
+
+ signalAbortSignal— Cancels the in-flight attempt | and any pending retries.
+
+
+
with a break.
Figure ${i}.
+Benchmark page
${body}Hello world
`. `` subtrees are left untouched.
+//!
+//! This is a direct transcription of that state machine (including the way
+//! it re-visits an element when the walk ascends back through it) so the
+//! resulting text nodes match what turndown's rules later see.
+
+use core::ptr;
+
+use bun_core::strings;
+use html5ever::tendril::StrTendril;
+
+use super::dom::{NodeData, Ref, Tag};
+
+/// Elements whose children the walk never enters: `` (whitespace is
+/// significant) and the subtrees the converter drops wholesale, so their
+/// text cannot perturb spacing of the surrounding prose.
+#[inline]
+fn is_opaque(node: Ref<'_>) -> bool {
+ let tag = node.tag();
+ tag == Tag::Pre || tag.is_skipped()
+}
+
+pub(crate) fn collapse_whitespace(root: Ref<'_>) {
+ if root.first_child.get().is_none() || is_opaque(root) {
+ return;
+ }
+
+ let mut prev_text: Option> = None;
+ let mut keep_leading_ws = false;
+ let mut prev: Option> = None;
+ let mut node = next(prev, root);
+
+ while !ptr::eq(node, root) {
+ match &node.data {
+ NodeData::Text(cell) => {
+ let mut data = cell.borrow_mut();
+ let strip_leading = !keep_leading_ws
+ && prev_text.is_none_or(|p| p.as_text().unwrap().borrow().ends_with(' '));
+ collapse_text(&mut data, strip_leading);
+ if data.is_empty() {
+ drop(data);
+ node = remove(node);
+ continue;
+ }
+ drop(data);
+ prev_text = Some(node);
+ }
+ NodeData::Element { tag, .. } => {
+ let tag = *tag;
+ if tag.is_skipped() {
+ // Dropped subtrees behave as if absent.
+ } else if tag.is_block() || tag == Tag::Br {
+ if let Some(p) = prev_text {
+ trim_trailing_space(p);
+ }
+ prev_text = None;
+ keep_leading_ws = false;
+ } else if tag.is_void() || tag == Tag::Pre {
+ // Keep the space around inline voids (``) and inline `
`.
+ prev_text = None;
+ keep_leading_ws = true;
+ } else if prev_text.is_some() {
+ keep_leading_ws = false;
+ }
+ }
+ NodeData::Document | NodeData::Ignored => {
+ node = remove(node);
+ continue;
+ }
+ }
+ let next_node = next(prev, node);
+ prev = Some(node);
+ node = next_node;
+ }
+
+ if let Some(p) = prev_text {
+ trim_trailing_space(p);
+ if p.as_text().unwrap().borrow().is_empty() {
+ p.detach();
+ }
+ }
+}
+
+/// turndown's `next(prev, current)`: pre-order, except that arriving back at
+/// a node from its child (or sitting on an opaque node) moves on to the next
+/// sibling / parent instead of descending.
+fn next<'a>(prev: Option>, current: Ref<'a>) -> Ref<'a> {
+ let came_from_child =
+ prev.is_some_and(|p| p.parent.get().is_some_and(|pp| ptr::eq(pp, current)));
+ if came_from_child || is_opaque(current) {
+ return current
+ .next_sibling
+ .get()
+ .or_else(|| current.parent.get())
+ .expect("walk is bounded by root");
+ }
+ current
+ .first_child
+ .get()
+ .or_else(|| current.next_sibling.get())
+ .or_else(|| current.parent.get())
+ .expect("walk is bounded by root")
+}
+
+fn remove<'a>(node: Ref<'a>) -> Ref<'a> {
+ let next = node
+ .next_sibling
+ .get()
+ .or_else(|| node.parent.get())
+ .expect("removed node has a parent");
+ node.detach();
+ next
+}
+
+fn trim_trailing_space(text_node: Ref<'_>) {
+ let cell = text_node.as_text().unwrap();
+ let mut t = cell.borrow_mut();
+ if t.ends_with(' ') {
+ let n = t.len32() - 1;
+ *t = t.subtendril(0, n);
+ }
+}
+
+/// `text.replace(/[ \r\n\t]+/g, ' ')`, then drop one leading space if
+/// `strip_leading`.
+fn collapse_text(t: &mut StrTendril, strip_leading: bool) {
+ let text: &str = t;
+ let bytes = text.as_bytes();
+ // Most text nodes in real markup are either clean already or are the
+ // indentation between tags; both are settled by two SIMD scans.
+ let needs_rewrite =
+ strings::index_of_any(bytes, b"\t\n\r").is_some() || strings::contains(bytes, b" ");
+
+ if needs_rewrite {
+ let mut out = String::with_capacity(bytes.len());
+ let mut rest = text;
+ while !rest.is_empty() {
+ match strings::index_of_any(rest.as_bytes(), b" \t\n\r") {
+ Some(i) => {
+ out.push_str(&rest[..i]);
+ out.push(' ');
+ let run = rest.as_bytes()[i..]
+ .iter()
+ .take_while(|&&b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
+ .count();
+ rest = &rest[i + run..];
+ }
+ None => {
+ out.push_str(rest);
+ break;
+ }
+ }
+ }
+ *t = StrTendril::from(out);
+ }
+
+ if strip_leading && t.starts_with(' ') {
+ let n = t.len32();
+ *t = t.subtendril(1, n - 1);
+ }
+}
diff --git a/src/md/lib.rs b/src/md/lib.rs
index 38d0c83b296c..ada76516f652 100644
--- a/src/md/lib.rs
+++ b/src/md/lib.rs
@@ -6,6 +6,7 @@ pub mod autolinks;
pub(crate) mod blocks;
pub(crate) mod containers;
pub(crate) mod entity;
+pub mod from_html;
pub mod helpers;
pub mod html_renderer;
pub mod inlines;
diff --git a/src/runtime/api/MarkdownObject.rs b/src/runtime/api/MarkdownObject.rs
index f8d50c54a3ce..87bba138b7df 100644
--- a/src/runtime/api/MarkdownObject.rs
+++ b/src/runtime/api/MarkdownObject.rs
@@ -83,10 +83,192 @@ pub(crate) fn create(global_this: &JSGlobalObject) -> JSValue {
("ansi", __jsc_host_render_to_ansi, 2),
("render", __jsc_host_render, 3),
("react", __jsc_host_render_react, 3),
+ ("fromHTML", __jsc_host_from_html, 2),
],
)
}
+/// `Bun.markdown.fromHTML(html, options?)` — convert an HTML document or
+/// fragment to Markdown. `options` picks between equivalent Markdown
+/// spellings (`{ headingStyle, hr, bulletListMarker, codeBlockStyle, fence,
+/// emDelimiter, strongDelimiter, br }`) and toggles the GFM extensions
+/// (`{ tables, strikethrough, tasklists }`, all on by default).
+#[bun_jsc::host_fn]
+fn from_html(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResulthi
")).is
", { br: " " });
diff --git a/test/js/bun/md/fixtures/bun-docs-markdown.html b/test/js/bun/md/fixtures/bun-docs-markdown.html
new file mode 100644
index 000000000000..7808c64cf7d0
--- /dev/null
+++ b/test/js/bun/md/fixtures/bun-docs-markdown.html
@@ -0,0 +1,128 @@
+
\ No newline at end of file
diff --git a/test/js/bun/md/fixtures/bun-docs-markdown.turndown.md b/test/js/bun/md/fixtures/bun-docs-markdown.turndown.md
new file mode 100644
index 000000000000..d29a096a6b5f
--- /dev/null
+++ b/test/js/bun/md/fixtures/bun-docs-markdown.turndown.md
@@ -0,0 +1,528 @@
+[Skip to content](#main)
+
+[](/)
+
+[Docs](/docs)[Guides](/guides)[Reference](/reference)[Blog](/blog)
+
+Search docs, APIs, guides…/
+
+[](https://github.com/oven-sh/bun)[](/discord)[Install](/get)
+
+[](/)
+
+[Home](/)[Docs](/docs)[Guides](/guides)[Reference](/reference)[Blog](/blog)
+
+[Install Bun v1.4.2](/get)
+
+[](https://github.com/oven-sh/bun)[](/discord)[](https://x.com/bunjavascript)
+
+[Runtime](/docs)[Package Manager](/docs/pm/cli/install)[Bundler](/docs/bundler)[Test Runner](/docs/test)[Guides](/guides)[Reference](/reference)[Blog](/blog)[Feedback](/docs/feedback)
+
+Browse docs
+
+Runtime
+
+- Get Started
+
+ - [Welcome to Bun](/docs)
+ - [Installation](/docs/installation)
+ - [Quickstart](/docs/quickstart)
+ - [TypeScript](/docs/typescript)
+ - [TypeScript 6 and 7](/docs/typescript-6)
+ - [bun init](/docs/runtime/templating/init)
+ - [bun create](/docs/runtime/templating/create)
+- Core Runtime
+
+ - [Bun Runtime](/docs/runtime)
+ - [Watch Mode](/docs/runtime/watch-mode)
+ - [Debugging](/docs/runtime/debugger)
+ - [REPL](/docs/runtime/repl)
+ - [bunfig.toml](/docs/runtime/bunfig)
+- File & Module System
+
+ - [File Types](/docs/runtime/file-types)
+ - [Module Resolution](/docs/runtime/module-resolution)
+ - [JSX](/docs/runtime/jsx)
+ - [Auto-install](/docs/runtime/auto-install)
+ - [Plugins](/docs/runtime/plugins)
+ - [File System Router](/docs/runtime/file-system-router)
+- HTTP server
+
+ - [Server](/docs/runtime/http/server)
+ - [Routing](/docs/runtime/http/routing)
+ - [Cookies](/docs/runtime/http/cookies)
+ - [TLS](/docs/runtime/http/tls)
+ - [Error Handling](/docs/runtime/http/error-handling)
+ - [Metrics](/docs/runtime/http/metrics)
+- Networking
+
+ - [Fetch](/docs/runtime/networking/fetch)
+ - [WebSockets](/docs/runtime/http/websockets)
+ - [TCP](/docs/runtime/networking/tcp)
+ - [UDP](/docs/runtime/networking/udp)
+ - [DNS](/docs/runtime/networking/dns)
+- Data & Storage
+
+ - [Cookies](/docs/runtime/cookies)
+ - [File I/O](/docs/runtime/file-io)
+ - [Streams](/docs/runtime/streams)
+ - [Binary Data](/docs/runtime/binary-data)
+ - [Archive](/docs/runtime/archive)
+ - [SQL](/docs/runtime/sql)
+ - [SQLite](/docs/runtime/sqlite)
+ - [S3](/docs/runtime/s3)
+ - [Redis](/docs/runtime/redis)
+- Concurrency
+
+ - [Workers](/docs/runtime/workers)
+- Process & System
+
+ - [Environment Variables](/docs/runtime/environment-variables)
+ - [Shell](/docs/runtime/shell)
+ - [Spawn](/docs/runtime/child-process)
+ - [WebView](/docs/runtime/webview)
+ - [Cron](/docs/runtime/cron)
+- Interop & Tooling
+
+ - [Node-API](/docs/runtime/node-api)
+ - [FFI](/docs/runtime/ffi)
+ - [C Compiler](/docs/runtime/c-compiler)
+ - [Transpiler](/docs/runtime/transpiler)
+- Utilities
+
+ - [CSRF Protection](/docs/runtime/csrf)
+ - [Secrets](/docs/runtime/secrets)
+ - [Console](/docs/runtime/console)
+ - [TOML](/docs/runtime/toml)
+ - [YAML](/docs/runtime/yaml)
+ - [Markdown](/docs/runtime/markdown)
+ - [JSON5](/docs/runtime/json5)
+ - [XML](/docs/runtime/xml)
+ - [JSONL](/docs/runtime/jsonl)
+ - [HTMLRewriter](/docs/runtime/html-rewriter)
+ - [Image](/docs/runtime/image)
+ - [Hashing](/docs/runtime/hashing)
+ - [Glob](/docs/runtime/glob)
+ - [Semver](/docs/runtime/semver)
+ - [Color](/docs/runtime/color)
+ - [Utils](/docs/runtime/utils)
+- Standards & Compatibility
+
+ - [Globals](/docs/runtime/globals)
+ - [Bun APIs](/docs/runtime/bun-apis)
+ - [Web APIs](/docs/runtime/web-apis)
+ - [Node.js Compatibility](/docs/runtime/nodejs-compat)
+- Contributing
+
+ - [Roadmap](/docs/project/roadmap)
+ - [Benchmarking](/docs/project/benchmarking)
+ - [Contributing](/docs/project/contributing)
+ - [Building Windows](/docs/project/building-windows)
+ - [Bindgen](/docs/project/bindgen)
+ - [License](/docs/project/license)
+
+1. [Runtime](/docs)
+2. ›Utilities
+
+Copy page
+
+# Markdown
+
+Parse and render Markdown with Bun's built-in Markdown API, supporting GFM extensions and custom rendering callbacks
+
+**Unstable API** — This API is under active development and may change in future versions of Bun.
+
+Bun includes a fast, built-in Markdown parser written in Rust. It supports GitHub Flavored Markdown (GFM) extensions and provides three APIs:
+
+- `Bun.markdown.html()` — render Markdown to an HTML string
+- `Bun.markdown.render()` — render Markdown with custom callbacks for each element
+- `Bun.markdown.react()` — render Markdown to React JSX elements
+
+---
+
+## `Bun.markdown.html()`[#](#bun-markdown-html)
+
+Convert a Markdown string to HTML.
+
+```
+const html = Bun.markdown.html("# Hello **world**");
+// "Skip to contentMarkdown
Bun.markdown.html() — render Markdown to an HTML stringBun.markdown.render() — render Markdown with custom callbacks for each elementBun.markdown.react() — render Markdown to React JSX elementsBun.markdown.html()#const html = Bun.markdown.html("# Hello **world**");
+// "<h1>Hello <strong>world</strong></h1>\n"const html = Bun.markdown.html(`
+| Feature | Status |
+|-------------|--------|
+| Tables | ~~done~~ |
+| Strikethrough| ~~done~~ |
+| Task lists | done |
+`);Options#
const html = Bun.markdown.html("some markdown", {
+ tables: true, // GFM tables (default: true)
+ strikethrough: true, // GFM strikethrough (default: true)
+ tasklists: true, // GFM task lists (default: true)
+ tagFilter: true, // GFM tag filter for disallowed HTML tags
+ autolinks: true, // Autolink URLs, emails, and www. links
+});Option Default Description tablestrueGFM tables strikethroughtrueGFM strikethrough ( ~~text~~)taskliststrueGFM task lists ( - [x] item)autolinksfalseEnable autolinks — see Autolinks headingsfalseHeading IDs and autolinks — see Heading IDs hardSoftBreaksfalseTreat soft line breaks as hard breaks wikiLinksfalseEnable [[wiki links]]underlinefalse__text__ renders as <u> instead of <strong>latexMathfalseEnable $inline$ and $$display$$ mathcollapseWhitespacefalseCollapse whitespace in text permissiveAtxHeadersfalseATX headers without space after #noIndentedCodeBlocksfalseDisable indented code blocks noHtmlBlocksfalseDisable HTML blocks noHtmlSpansfalseDisable inline HTML tagFilterfalseGFM tag filter for disallowed HTML tags Autolinks#
true to enable all autolink types, or an object for granular control:// Enable all autolinks (URL, WWW, email)
+Bun.markdown.html("Visit www.example.com", { autolinks: true });
+
+// Enable only specific types
+Bun.markdown.html("Visit www.example.com", {
+ autolinks: { url: true, www: true },
+});Heading IDs#
true to enable both heading IDs and autolink headings, or an object for granular control:// Enable heading IDs and autolink headings
+Bun.markdown.html("## Hello World", { headings: true });
+// '<h2 id="hello-world"><a href="#hello-world">Hello World</a></h2>\n'
+
+// Enable only heading IDs (no autolink)
+Bun.markdown.html("## Hello World", { headings: { ids: true } });
+// '<h2 id="hello-world">Hello World</h2>\n'Bun.markdown.render()#const result = Bun.markdown.render("# Hello **world**", {
+ heading: (children, { level }) => `<h${level} class="title">${children}</h${level}>`,
+ strong: children => `<b>${children}</b>`,
+ paragraph: children => `<p>${children}</p>`,
+});
+// '<h1 class="title">Hello <b>world</b></h1>'Callback signature#
children — the accumulated content of the element as a stringmeta (optional) — an object with element-specific metadatanull or undefined to omit the element from the output entirely. If an element has no callback, its children pass through unchanged.Block callbacks#
Callback Meta Description heading{ level, id? }Heading level 1–6. id is set when headings: { ids: true } is enabledparagraph— Paragraph block blockquote— Blockquote block code{ language? }Fenced or indented code block. language is the info-string when specified on the fencelist{ ordered, start?, depth }depth is nesting level (0 = top-level). start is set for ordered listslistItem{ index, depth, ordered, start?, checked? }See List item meta below hr— Horizontal rule table— Table block thead— Table head tbody— Table body tr— Table row th{ align? }Table header cell. align is "left", "center", "right", or absenttd{ align? }Table data cell html— Raw HTML content List item meta#
listItem callback receives everything needed to render markers directly:index — 0-based position within the parent listdepth — the parent list's nesting level (0 = top-level)ordered — whether the parent list is orderedstart — the parent list's start number (only when ordered is true)checked — task list state (only for - [x] / - [ ] items)Inline callbacks#
Callback Meta Description strong— Strong emphasis ( **text**)emphasis— Emphasis ( *text*)link{ href: string, title?: string }Link image{ src: string, title?: string }Image codespan— Inline code ( `code`)strikethrough— Strikethrough ( ~~text~~)text— Plain text content Examples#
Custom HTML with classes#
const html = Bun.markdown.render("# Title\n\nHello **world**", {
+ heading: (children, { level }) => `<h${level} class="heading heading-${level}">${children}</h${level}>`,
+ paragraph: children => `<p class="body">${children}</p>`,
+ strong: children => `<strong class="bold">${children}</strong>`,
+});Stripping all formatting#
const plaintext = Bun.markdown.render("# Hello **world**", {
+ heading: children => children,
+ paragraph: children => children,
+ strong: children => children,
+ emphasis: children => children,
+ link: children => children,
+ image: () => "",
+ code: children => children,
+ codespan: children => children,
+});
+// "Hello world"Omitting elements#
null or undefined to remove an element from the output:const result = Bun.markdown.render("# Title\n\n\n\nHello", {
+ image: () => null, // Remove all images
+ heading: children => children,
+ paragraph: children => children + "\n",
+});
+// "Title\nHello\n"ANSI terminal output#
const ansi = Bun.markdown.render("# Hello\n\nThis is **bold** and *italic*", {
+ heading: (children, { level }) => `\x1b[1;4m${children}\x1b[0m\n`,
+ paragraph: children => children + "\n",
+ strong: children => `\x1b[1m${children}\x1b[22m`,
+ emphasis: children => `\x1b[3m${children}\x1b[23m`,
+});Nested list numbering#
listItem callback receives everything needed to render markers directly — no post-processing:const result = Bun.markdown.render("1. first\n 1. sub-a\n 2. sub-b\n2. second", {
+ listItem: (children, { index, depth, ordered, start }) => {
+ const n = (start ?? 1) + index;
+ // 1. 2. 3. at depth 0, a. b. c. at depth 1, i. ii. iii. at depth 2
+ const marker = !ordered
+ ? "-"
+ : depth === 0
+ ? `${n}.`
+ : depth === 1
+ ? `${String.fromCharCode(96 + n)}.`
+ : `${toRoman(n)}.`;
+ return " ".repeat(depth) + marker + " " + children.trimEnd() + "\n";
+ },
+ // Prepend a newline so nested lists are separated from their parent item's text
+ list: children => "\n" + children,
+});
+// 1. first
+// a. sub-a
+// b. sub-b
+// 2. secondCode block syntax highlighting#
const result = Bun.markdown.render("```js\nconsole.log('hi')\n```", {
+ code: (children, meta) => {
+ const lang = meta?.language ?? "";
+ return `<pre><code class="language-${lang}">${children}</code></pre>`;
+ },
+});Parser options#
const result = Bun.markdown.render(
+ "Visit www.example.com",
+ {
+ link: (children, { href }) => `[${children}](${href})`,
+ paragraph: children => children,
+ },
+ { autolinks: true },
+);Bun.markdown.react()#<Fragment> that you can use as a component return value.function Markdown({ text }: { text: string }) {
+ return Bun.markdown.react(text);
+}Server-side rendering#
renderToString() and React Server Components:import { renderToString } from "react-dom/server";
+
+const html = renderToString(Bun.markdown.react("# Hello **world**"));
+// "<h1>Hello <strong>world</strong></h1>"Component overrides#
function Code({ language, children }) {
+ return (
+ <pre data-language={language}>
+ <code>{children}</code>
+ </pre>
+ );
+}
+
+function Link({ href, title, children }) {
+ return (
+ <a href={href} title={title} target="_blank" rel="noopener noreferrer">
+ {children}
+ </a>
+ );
+}
+
+function Heading({ id, children }) {
+ return (
+ <h2 id={id}>
+ <a href={`#${id}`}>{children}</a>
+ </h2>
+ );
+}
+
+const el = Bun.markdown.react(
+ content,
+ {
+ pre: Code,
+ a: Link,
+ h2: Heading,
+ },
+ { headings: { ids: true } },
+);Available overrides#
Option Props Description h1–h6{ id?, children }Headings. id is set when headings: { ids: true } is enabledp{ children }Paragraph blockquote{ children }Blockquote pre{ language?, children }Code block. language is the info string (e.g. "js")hr{}Horizontal rule (no children) ul{ children }Unordered list ol{ start, children }Ordered list. start is the first item numberli{ checked?, children }List item. checked is set for task list itemstable{ children }Table thead{ children }Table head tbody{ children }Table body tr{ children }Table row th{ align?, children }Table header cell td{ align?, children }Table data cell em{ children }Emphasis ( *text*)strong{ children }Strong ( **text**)a{ href, title?, children }Link img{ src, alt?, title? }Image (no children) code{ children }Inline code del{ children }Strikethrough ( ~~text~~)br{}Hard line break (no children) React 18 and older#
Symbol.for('react.transitional.element') as the $$typeof symbol. For React 18 and older, pass reactVersion: 18 in the options (third argument):function Markdown({ text }: { text: string }) {
+ return Bun.markdown.react(text, undefined, { reactVersion: 18 });
+}Parser options#
const el = Bun.markdown.react("## Hello World", undefined, {
+ headings: { ids: true },
+ autolinks: true,
+});Hello world
\n"
+```
+
+GFM extensions like tables, strikethrough, and task lists are enabled by default:
+
+```
+const html = Bun.markdown.html(`
+| Feature | Status |
+|-------------|--------|
+| Tables | ~~done~~ |
+| Strikethrough| ~~done~~ |
+| Task lists | done |
+`);
+```
+
+### Options[#](#options)
+
+Pass an options object as the second argument to configure the parser:
+
+```
+const html = Bun.markdown.html("some markdown", {
+ tables: true, // GFM tables (default: true)
+ strikethrough: true, // GFM strikethrough (default: true)
+ tasklists: true, // GFM task lists (default: true)
+ tagFilter: true, // GFM tag filter for disallowed HTML tags
+ autolinks: true, // Autolink URLs, emails, and www. links
+});
+```
+
+All available options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `tables` | `true` | GFM tables |
+| `strikethrough` | `true` | GFM strikethrough (`~~text~~`) |
+| `tasklists` | `true` | GFM task lists (`- [x] item`) |
+| `autolinks` | `false` | Enable autolinks — see [Autolinks](#autolinks) |
+| `headings` | `false` | Heading IDs and autolinks — see [Heading IDs](#heading-ids) |
+| `hardSoftBreaks` | `false` | Treat soft line breaks as hard breaks |
+| `wikiLinks` | `false` | Enable `[[wiki links]]` |
+| `underline` | `false` | `__text__` renders as `` instead of `` |
+| `latexMath` | `false` | Enable `$inline$` and `$$display$$` math |
+| `collapseWhitespace` | `false` | Collapse whitespace in text |
+| `permissiveAtxHeaders` | `false` | ATX headers without space after `#` |
+| `noIndentedCodeBlocks` | `false` | Disable indented code blocks |
+| `noHtmlBlocks` | `false` | Disable HTML blocks |
+| `noHtmlSpans` | `false` | Disable inline HTML |
+| `tagFilter` | `false` | GFM tag filter for disallowed HTML tags |
+
+#### Autolinks[#](#autolinks)
+
+Pass `true` to enable all autolink types, or an object for granular control:
+
+```
+// Enable all autolinks (URL, WWW, email)
+Bun.markdown.html("Visit www.example.com", { autolinks: true });
+
+// Enable only specific types
+Bun.markdown.html("Visit www.example.com", {
+ autolinks: { url: true, www: true },
+});
+```
+
+#### Heading IDs[#](#heading-ids)
+
+Pass `true` to enable both heading IDs and autolink headings, or an object for granular control:
+
+```
+// Enable heading IDs and autolink headings
+Bun.markdown.html("## Hello World", { headings: true });
+// 'Hello World
\n'
+
+// Enable only heading IDs (no autolink)
+Bun.markdown.html("## Hello World", { headings: { ids: true } });
+// 'Hello World
\n'
+```
+
+---
+
+## `Bun.markdown.render()`[#](#bun-markdown-render)
+
+Parse Markdown and render it using custom JavaScript callbacks. The callbacks give you full control over the output format. You can generate HTML with custom classes, ANSI terminal output, or any other string format.
+
+```
+const result = Bun.markdown.render("# Hello **world**", {
+ heading: (children, { level }) => `Hello world
'
+```
+
+### Callback signature[#](#callback-signature)
+
+Each callback receives:
+
+1. **`children`** — the accumulated content of the element as a string
+2. **`meta`** (optional) — an object with element-specific metadata
+
+Return a string to replace the element's rendering. Return `null` or `undefined` to omit the element from the output entirely. If an element has no callback, its children pass through unchanged.
+
+### Block callbacks[#](#block-callbacks)
+
+| Callback | Meta | Description |
+| --- | --- | --- |
+| `heading` | `{ level, id? }` | Heading level 1–6. `id` is set when `headings: { ids: true }` is enabled |
+| `paragraph` | — | Paragraph block |
+| `blockquote` | — | Blockquote block |
+| `code` | `{ language? }` | Fenced or indented code block. `language` is the info-string when specified on the fence |
+| `list` | `{ ordered, start?, depth }` | `depth` is nesting level (0 = top-level). `start` is set for ordered lists |
+| `listItem` | `{ index, depth, ordered, start?, checked? }` | See [List item meta](#list-item-meta) below |
+| `hr` | — | Horizontal rule |
+| `table` | — | Table block |
+| `thead` | — | Table head |
+| `tbody` | — | Table body |
+| `tr` | — | Table row |
+| `th` | `{ align? }` | Table header cell. `align` is `"left"`, `"center"`, `"right"`, or absent |
+| `td` | `{ align? }` | Table data cell |
+| `html` | — | Raw HTML content |
+
+#### List item meta[#](#list-item-meta)
+
+The `listItem` callback receives everything needed to render markers directly:
+
+- `index` — 0-based position within the parent list
+- `depth` — the parent list's nesting level (0 = top-level)
+- `ordered` — whether the parent list is ordered
+- `start` — the parent list's start number (only when `ordered` is true)
+- `checked` — task list state (only for `- [x]` / `- [ ]` items)
+
+### Inline callbacks[#](#inline-callbacks)
+
+| Callback | Meta | Description |
+| --- | --- | --- |
+| `strong` | — | Strong emphasis (`**text**`) |
+| `emphasis` | — | Emphasis (`*text*`) |
+| `link` | `{ href: string, title?: string }` | Link |
+| `image` | `{ src: string, title?: string }` | Image |
+| `codespan` | — | Inline code (`` `code` ``) |
+| `strikethrough` | — | Strikethrough (`~~text~~`) |
+| `text` | — | Plain text content |
+
+### Examples[#](#examples)
+
+#### Custom HTML with classes[#](#custom-html-with-classes)
+
+```
+const html = Bun.markdown.render("# Title\n\nHello **world**", {
+ heading: (children, { level }) => `
`;
+ },
+});
+```
+
+### Parser options[#](#parser-options)
+
+Pass parser options as a separate third argument:
+
+```
+const result = Bun.markdown.render(
+ "Visit www.example.com",
+ {
+ link: (children, { href }) => `[${children}](${href})`,
+ paragraph: children => children,
+ },
+ { autolinks: true },
+);
+```
+
+---
+
+## `Bun.markdown.react()`[#](#bun-markdown-react)
+
+Render Markdown directly to React elements. Returns a `${children}Hello world
"
+```
+
+### Component overrides[#](#component-overrides)
+
+Replace any HTML element with a custom React component by passing it in the second argument, keyed by tag name:
+
+```
+function Code({ language, children }) {
+ return (
+
+
+ );
+}
+
+function Link({ href, title, children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Heading({ id, children }) {
+ return (
+ {children}
+
+ {children}
+
+ );
+}
+
+const el = Bun.markdown.react(
+ content,
+ {
+ pre: Code,
+ a: Link,
+ h2: Heading,
+ },
+ { headings: { ids: true } },
+);
+```
+
+#### Available overrides[#](#available-overrides)
+
+You can override every HTML tag the parser produces:
+
+| Option | Props | Description |
+| --- | --- | --- |
+| `h1`–`h6` | `{ id?, children }` | Headings. `id` is set when `headings: { ids: true }` is enabled |
+| `p` | `{ children }` | Paragraph |
+| `blockquote` | `{ children }` | Blockquote |
+| `pre` | `{ language?, children }` | Code block. `language` is the info string (e.g. `"js"`) |
+| `hr` | `{}` | Horizontal rule (no children) |
+| `ul` | `{ children }` | Unordered list |
+| `ol` | `{ start, children }` | Ordered list. `start` is the first item number |
+| `li` | `{ checked?, children }` | List item. `checked` is set for task list items |
+| `table` | `{ children }` | Table |
+| `thead` | `{ children }` | Table head |
+| `tbody` | `{ children }` | Table body |
+| `tr` | `{ children }` | Table row |
+| `th` | `{ align?, children }` | Table header cell |
+| `td` | `{ align?, children }` | Table data cell |
+| `em` | `{ children }` | Emphasis (`*text*`) |
+| `strong` | `{ children }` | Strong (`**text**`) |
+| `a` | `{ href, title?, children }` | Link |
+| `img` | `{ src, alt?, title? }` | Image (no children) |
+| `code` | `{ children }` | Inline code |
+| `del` | `{ children }` | Strikethrough (`~~text~~`) |
+| `br` | `{}` | Hard line break (no children) |
+
+### React 18 and older[#](#react-18-and-older)
+
+By default, elements use `Symbol.for('react.transitional.element')` as the `$$typeof` symbol. For React 18 and older, pass `reactVersion: 18` in the options (third argument):
+
+```
+function Markdown({ text }: { text: string }) {
+ return Bun.markdown.react(text, undefined, { reactVersion: 18 });
+}
+```
+
+### Parser options[#](#parser-options-1)
+
+Pass any of the [parser options](#options) as the third argument:
+
+```
+const el = Bun.markdown.react("## Hello World", undefined, {
+ headings: { ids: true },
+ autolinks: true,
+});
+```
+
+[PreviousYAML](/docs/runtime/yaml)[NextJSON5](/docs/runtime/json5)
+
+[Edit this page on GitHub](https://github.com/oven-sh/bun/blob/main/docs/runtime/markdown.mdx)[View as Markdown](/docs/runtime/markdown.md)
+
+On this page
+
+- [`Bun.markdown.html()`](#bun-markdown-html)
+ - [Options](#options)
+- [`Bun.markdown.render()`](#bun-markdown-render)
+ - [Callback signature](#callback-signature)
+ - [Block callbacks](#block-callbacks)
+ - [Inline callbacks](#inline-callbacks)
+ - [Examples](#examples)
+ - [Parser options](#parser-options)
+- [`Bun.markdown.react()`](#bun-markdown-react)
+ - [Server-side rendering](#server-side-rendering)
+ - [Component overrides](#component-overrides)
+ - [React 18 and older](#react-18-and-older)
+ - [Parser options](#parser-options-1)
+
+Ask AI
diff --git a/test/js/bun/md/fixtures/regenerate-turndown.mjs b/test/js/bun/md/fixtures/regenerate-turndown.mjs
new file mode 100644
index 000000000000..605358314be2
--- /dev/null
+++ b/test/js/bun/md/fixtures/regenerate-turndown.mjs
@@ -0,0 +1,22 @@
+// Regenerates `*.turndown.md` next to each `*.html` fixture: the reference
+// output of turndown@7.2.4 + turndown-plugin-gfm@1.0.2 configured with the
+// same spellings as Bun.markdown.fromHTML's defaults. md-from-html.test.ts
+// compares against these files so the (slow under debug builds) JS converter
+// does not have to run over whole pages at test time.
+//
+// bun test/js/bun/md/fixtures/regenerate-turndown.mjs
+import { readdirSync, readFileSync, writeFileSync } from "fs";
+import { join } from "path";
+import TurndownService from "turndown";
+import { gfm } from "turndown-plugin-gfm";
+
+const dir = import.meta.dir;
+for (const name of readdirSync(dir)) {
+ if (!name.endsWith(".html")) continue;
+ const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", hr: "---" });
+ td.use(gfm);
+ td.remove(["script", "style", "noscript", "template", "title", "head"]);
+ const out = td.turndown(readFileSync(join(dir, name), "utf8"));
+ writeFileSync(join(dir, name.replace(/\.html$/, ".turndown.md")), out + "\n");
+ console.log(name, "->", out.length, "chars");
+}
diff --git a/test/js/bun/md/md-from-html.test.ts b/test/js/bun/md/md-from-html.test.ts
new file mode 100644
index 000000000000..341ac9c6a7f6
--- /dev/null
+++ b/test/js/bun/md/md-from-html.test.ts
@@ -0,0 +1,747 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "fs";
+import { join } from "path";
+import TurndownService from "turndown";
+// @ts-ignore untyped
+import { gfm } from "turndown-plugin-gfm";
+
+const { fromHTML } = Bun.markdown;
+
+// ---------------------------------------------------------------------------
+// Rule-by-rule expectations. These pin the exact output for the default
+// options; they follow turndown's rule set with the documented deviations
+// (single space after list markers, `~~` strikethrough, header-less tables
+// still become pipe tables, bare `` is a code block).
+// ---------------------------------------------------------------------------
+describe("Bun.markdown.fromHTML", () => {
+ describe("block rules", () => {
+ const cases: [string, string, string][] = [
+ ["paragraphs", "onetwo", "one\n\ntwo"],
+ [
+ "headings atx",
+ "a
b
c
d
e
f
",
+ "# a\n\n## b\n\n### c\n\n#### d\n\n##### e\n\n###### f",
+ ],
+ ["heading with inline markup", "Hello there
", "## Hello _there_ `x`"],
+ ["blockquote", "x
", "> one\n>\n> two"],
+ ["nested blockquote", "
", "> a\n>\n> > b"],
+ ["hr", "
b
", "- one\n- two"],
+ ["ordered list", "
", "1. one\n2. two"],
+ ["ordered list start", '
', "7. a\n8. b"],
+ [
+ "nested list",
+ "
",
+ "- a\n - b\n - c\n- d",
+ ],
+ ["nested ordered indent follows marker width", "
", "1. a\n - b"],
+ ["loose list items", "
", "- a\n\n- b\n\n c"],
+ ["list after paragraph", "\n
", "- a\n- b"],
+ [
+ "fenced code block with language",
+ '
',
+ "```ts\nconst x: number = 1;\n```",
+ ],
+ [
+ "code block keeps markdown characters literally",
+ "const x: number = 1;\n
",
+ "```\n*a* _b_ [c]\n # not a heading\n```",
+ ],
+ [
+ "fence grows past backtick runs in the code",
+ "*a* _b_ [c]\n # not a heading
",
+ "````\n```\ninner\n```\n````",
+ ],
+ ["bare pre is a code block", "```\ninner\n```line 1\n line 2
", "```\nline 1\n line 2\n```"],
+ [
+ "pre with highlighted spans",
+ '',
+ "```js\nlet x;\n```",
+ ],
+ ["lang- class prefix", 'let x;
', "```rust\nfn f() {}\n```"],
+ [
+ "pre lang attribute (GitHub API HTML)",
+ 'fn f() {}
',
+ "```toml\n[a]\nb = 1\n```",
+ ],
+ [
+ "heading content is one line",
+ "[a]\nb = 1Brand
",
+ "# Brand tag _line_ x",
+ ],
+ ["leading newline after tag line
x is dropped by the HTML parser", "
\nx\n
", "```\nx\n```"],
+ ["dl/dt/dd are blocks", "
", "term\n\ndef"],
+ ["details/summary are blocks", "", "More\n\nHidden text"],
+ ["empty blocks vanish", "More
Hidden text foo() nowa`b ```x*a* [b]
b
![*x* [y]](a.png)
ba b c
b", "a\n\nb"],
+ ];
+ for (const [name, html, md] of cases) {
+ test(name, () => expect(fromHTML(html)).toBe(md));
+ }
+ });
+
+ describe("escaping", () => {
+ const cases: [string, string, string][] = [
+ ["emphasis characters", "
is escaped", "
- b
", "- \\- x\n- 3\\. y"],
+ ["hash without following space is literal", "One
Two
Three
", { headingStyle: "setext" })).toBe(
+ "One\n===\n\nTwo\n---\n\n### Three",
+ );
+ // Underline is as wide as the (flattened) text, in characters not bytes.
+ expect(fromHTML("caf\u00e9
", { headingStyle: "setext" })).toBe("caf\u00e9 ok\n=======");
+ });
+ test("hr", () => {
+ for (const hr of ["---", "***", "___", "- - -", "* * *", "_ _ _"] as const) {
+ expect(fromHTML("
ok
", { hr })).toBe(hr);
+ }
+ });
+ test("bulletListMarker", () => {
+ expect(fromHTML("
", { bulletListMarker: "*" })).toBe("* a");
+ expect(fromHTML("
", { bulletListMarker: "+" })).toBe("+ a");
+ });
+ test("codeBlockStyle: indented", () => {
+ expect(
+ fromHTML('a\n b
", { fence: "~~~" })).toBe("~~~~\n~~~\nx\n~~~~");
+ });
+ test("emDelimiter / strongDelimiter", () => {
+ expect(fromHTML("~~~\nx
baa
", undefined)).toBe("# a");
+ expect(fromHTML("a
", null as any)).toBe("# a");
+ expect(fromHTML("a
", { headingStyle: undefined, whatever: 1 } as any)).toBe("# a");
+ });
+ test("invalid enum values throw", () => {
+ expect(() => fromHTML("a
", { headingStyle: "loud" } as any)).toThrow(/headingStyle/);
+ expect(() => fromHTML("
", { hr: "-*-" } as any)).toThrow(/hr/);
+ expect(() => fromHTML("
", { br: " " } as any)).toThrow(/br/);
+ expect(() => fromHTML("
", { bulletListMarker: 1 } as any)).toThrow(/bulletListMarker/);
+ });
+ test("missing input throws", () => {
+ expect(() => (fromHTML as any)()).toThrow();
+ expect(() => fromHTML(null as any)).toThrow();
+ expect(() => fromHTML(123 as any)).toThrow();
+ });
+ });
+
+ describe("input types", () => {
+ test("Uint8Array / ArrayBuffer / Buffer", () => {
+ const bytes = new TextEncoder().encode("oops
"],
+ ["p inside button inside a", ""],
+ ["unterminated comment", "more cell bad {
+ inner: S,
+ log: RefCell {
+ fn new(inner: S) -> Self {
+ Recorder {
+ inner,
+ log: RefCell::new(Vec::new()),
+ text: RefCell::new(String::new()),
+ }
+ }
+ fn flush(&self) {
+ let mut t = self.text.borrow_mut();
+ if !t.is_empty() {
+ self.log.borrow_mut().push(format!("text {:?}", *t));
+ t.clear();
+ }
+ }
+ }
+
+ impl {
+ type Handle = S::Handle;
+ fn process_token(&self, token: Token, line: u64) -> TokenSinkResult",
+ "e",
+ "",
+ "",
+ "",
+ "",
+ "& & & <<> ∉ ¬it; ¬i; &xyz; &; & & &",
+ "",
+ "a\r\nb\rc\n\rd\0e",
+ "
after
", + "\\-->\n\nafter", + ], + ["script comment without close", "ok
", "ok"], + [ + "plaintext swallows everything", + "a
b", + "a\n\n\\&\\\\\\
b", + ], + [ + "CDATA only in foreign content", + "
a
a
| b | c |
a
| b | c |
x
', + "x", + ], + ["BOM at start is dropped, elsewhere kept", "ab
", "ab"], + ["EOF inside a tag drops it", 'kept
a`: back to plain script data. - Script, - /// The script element ended (its end tag was emitted), or input did. - Done(Option`/``/`