diff --git a/Cargo.lock b/Cargo.lock
index 20012ce3d0cf..53d99bccc1b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -987,8 +987,10 @@ dependencies = [
"bun_paths",
"bun_sys",
"bun_url",
+ "lol_html",
"scopeguard",
"strum",
+ "typed-arena",
]
[[package]]
@@ -2861,6 +2863,12 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
[[package]]
name = "typenum"
version = "1.20.0"
diff --git a/Cargo.toml b/Cargo.toml
index 012f23a486ab..8e4714d09d3b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -364,6 +364,7 @@ thiserror = "2"
smallvec = "1"
itoa = "1"
encoding_rs = "0.8.35"
+typed-arena = "2"
# 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..21fe3cd25154
--- /dev/null
+++ b/bench/snippets/html-to-markdown.mjs
@@ -0,0 +1,95 @@
+// 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";
+
+const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", hr: "---" });
+td.use(gfm);
+td.remove(["script", "style"]);
+
+const section = i => `
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Use
+ the Loose item with a paragraph. And a quote 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}.
+
See bun.com.
"); +// "# Hello _world_\n\nSee [bun.com](https://bun.com)." +``` + +Whole pages work the same way as fragments: + +```ts +const res = await fetch("https://bun.com/docs/runtime/markdown"); +const md = Bun.markdown.fromHTML(await res.text()); +``` + +Bun tokenizes the input with the same parser that powers [`HTMLRewriter`](/runtime/html-rewriter) and applies the HTML tree-construction rules that decide structure: omitted end tags (`a
b`, `
after
", + "\\-->\n\nafter", + ], + ["script comment without close", "ok
", "ok"], + [ + "plaintext swallows everything", + "a
b", + "a\n\n\\&\\\\\\
b", + ], + [ + "CDATA only in foreign content", + "
a
b
", + "a\n\nx\\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")).toBe(""); + }); + }); + + describe("options", () => { + test("headingStyle: setext", () => { + expect(fromHTML("a\n bx
', { codeBlockStyle: "indented" }), + ).toBe(" a\n b\n\nx"); + }); + test("fence: ~~~", () => { + expect(fromHTML("~~~\nx", { fence: "~~~" })).toBe("~~~~\n~~~\nx\n~~~~");
+ });
+ test("emDelimiter / strongDelimiter", () => {
+ expect(fromHTML("a b
", { emDelimiter: "*", strongDelimiter: "__" })).toBe( + "*a* __b__", + ); + }); + test("br: backslash", () => { + expect(fromHTML("a
b
a
caf\u00e9 ok
"); + expect(fromHTML(bytes)).toBe("caf\u00e9 **ok**"); + expect(fromHTML(bytes.buffer)).toBe("caf\u00e9 **ok**"); + expect(fromHTML(Buffer.from(bytes))).toBe("caf\u00e9 **ok**"); + }); + test("invalid UTF-8 bytes become U+FFFD instead of throwing", () => { + expect(fromHTML(new Uint8Array([0x3c, 0x70, 0x3e, 0x61, 0xff, 0x62]))).toBe("a\ufffdb"); + }); + test("lone surrogate in a JS string does not throw", () => { + expect(typeof fromHTML("a\ud800b
")).toBe("string"); + }); + test("option getters run before the input buffer is read", () => { + // A getter that detaches the buffer must not leave the converter + // reading freed memory: options are evaluated first, then the (now + // empty) buffer is read. + const buf = new Uint8Array(new TextEncoder().encode("hello
")); + const opts = { + get headingStyle() { + buf.buffer.transfer(); + return "atx"; + }, + }; + expect(fromHTML(buf, opts as any)).toBe(""); + }); + test("NUL bytes", () => { + expect(fromHTML("a\u0000b
")).toBe("ab"); + }); + test("SharedArrayBuffer-backed input is snapshotted", () => { + const src = new TextEncoder().encode("caf\u00e9 ok
"); + const shared = new Uint8Array(new SharedArrayBuffer(src.length)); + shared.set(src); + expect(fromHTML(shared)).toBe("caf\u00e9 **ok**"); + }); + test("negativecloses an open
", "
a
b", "a\n\nb"], + ["
through phrasing content", "
ab xc", "ab\n\nc"],
+ ["