diff --git a/packages/bun-native-bundler-plugin-api/bundler_plugin.h b/packages/bun-native-bundler-plugin-api/bundler_plugin.h index 5578e50f105..93a4cb5c99d 100644 --- a/packages/bun-native-bundler-plugin-api/bundler_plugin.h +++ b/packages/bun-native-bundler-plugin-api/bundler_plugin.h @@ -20,9 +20,11 @@ typedef enum { BUN_LOADER_TEXT = 12, BUN_LOADER_HTML = 17, BUN_LOADER_YAML = 18, + BUN_LOADER_MD = 20, + BUN_LOADER_MDX = 21, } BunLoader; -const BunLoader BUN_LOADER_MAX = BUN_LOADER_YAML; +const BunLoader BUN_LOADER_MAX = BUN_LOADER_MDX; typedef struct BunLogOptions { size_t __struct_size; diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 0804044f99e..4c1f5473883 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1403,6 +1403,31 @@ declare module "bun" { ): import("./jsx.d.ts").JSX.Element; } + /** + * MDX related APIs. + */ + namespace mdx { + interface Options extends markdown.Options { + /** + * Sets the `@jsxImportSource` pragma in the generated JSX. + * Default: `"react"`. + */ + jsxImportSource?: string; + } + + /** + * Compile MDX source into a JSX module source string. + * + * @param input MDX source text + * @param options MDX and markdown parser options + * @returns Generated JSX source code + */ + export function compile( + input: string | NodeJS.TypedArray | DataView | ArrayBufferLike, + options?: Options, + ): string; + } + /** * JSON5 related APIs */ diff --git a/src/api/schema.zig b/src/api/schema.zig index a748f5c5186..e89e317797d 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -345,6 +345,7 @@ pub const api = struct { yaml = 19, json5 = 20, md = 21, + mdx = 22, _, pub fn jsonStringify(self: @This(), writer: anytype) !void { diff --git a/src/bake/DevServer/DirectoryWatchStore.zig b/src/bake/DevServer/DirectoryWatchStore.zig index 67a4dcb688d..32ad6cfb797 100644 --- a/src/bake/DevServer/DirectoryWatchStore.zig +++ b/src/bake/DevServer/DirectoryWatchStore.zig @@ -31,7 +31,7 @@ pub fn trackResolutionFailure(store: *DirectoryWatchStore, import_source: []cons if (!std.fs.path.isAbsolute(import_source)) return; switch (loader) { - .tsx, .ts, .jsx, .js => { + .tsx, .ts, .jsx, .js, .mdx => { if (!(bun.strings.startsWith(specifier, "./") or bun.strings.startsWith(specifier, "../"))) return; }, diff --git a/src/bun.js/ModuleLoader.zig b/src/bun.js/ModuleLoader.zig index da56b70edc1..fb7c67ac45f 100644 --- a/src/bun.js/ModuleLoader.zig +++ b/src/bun.js/ModuleLoader.zig @@ -100,7 +100,7 @@ pub fn transpileSourceCode( const disable_transpilying = comptime flags.disableTranspiling(); if (comptime disable_transpilying) { - if (!(loader.isJavaScriptLike() or loader == .toml or loader == .yaml or loader == .json5 or loader == .text or loader == .json or loader == .jsonc)) { + if (!(loader.isJavaScriptLike() or loader == .toml or loader == .yaml or loader == .json5 or loader == .text or loader == .json or loader == .jsonc or loader == .mdx)) { // Don't print "export default " return ResolvedSource{ .allocator = null, @@ -112,7 +112,7 @@ pub fn transpileSourceCode( } switch (loader) { - .js, .jsx, .ts, .tsx, .json, .jsonc, .toml, .yaml, .json5, .text, .md => { + .js, .jsx, .ts, .tsx, .json, .jsonc, .toml, .yaml, .json5, .text, .md, .mdx => { // Ensure that if there was an ASTMemoryAllocator in use, it's not used anymore. var ast_scope = js_ast.ASTMemoryAllocator.Scope{}; ast_scope.enter(); diff --git a/src/bun.js/api.zig b/src/bun.js/api.zig index 058db33e86e..b39a22a089a 100644 --- a/src/bun.js/api.zig +++ b/src/bun.js/api.zig @@ -28,6 +28,7 @@ pub const Terminal = @import("./api/bun/Terminal.zig"); pub const HashObject = @import("./api/HashObject.zig"); pub const JSONCObject = @import("./api/JSONCObject.zig"); pub const MarkdownObject = @import("./api/MarkdownObject.zig"); +pub const MdxObject = @import("./api/MdxObject.zig"); pub const TOMLObject = @import("./api/TOMLObject.zig"); pub const UnsafeObject = @import("./api/UnsafeObject.zig"); pub const JSON5Object = @import("./api/JSON5Object.zig"); diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index e0cdf54f6d7..049fac56e6a 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -65,6 +65,7 @@ pub const BunObject = struct { pub const SHA512_256 = toJSLazyPropertyCallback(Crypto.SHA512_256.getter); pub const JSONC = toJSLazyPropertyCallback(Bun.getJSONCObject); pub const markdown = toJSLazyPropertyCallback(Bun.getMarkdownObject); + pub const mdx = toJSLazyPropertyCallback(Bun.getMdxObject); pub const TOML = toJSLazyPropertyCallback(Bun.getTOMLObject); pub const JSON5 = toJSLazyPropertyCallback(Bun.getJSON5Object); pub const YAML = toJSLazyPropertyCallback(Bun.getYAMLObject); @@ -134,6 +135,7 @@ pub const BunObject = struct { @export(&BunObject.SHA512_256, .{ .name = lazyPropertyCallbackName("SHA512_256") }); @export(&BunObject.JSONC, .{ .name = lazyPropertyCallbackName("JSONC") }); @export(&BunObject.markdown, .{ .name = lazyPropertyCallbackName("markdown") }); + @export(&BunObject.mdx, .{ .name = lazyPropertyCallbackName("mdx") }); @export(&BunObject.TOML, .{ .name = lazyPropertyCallbackName("TOML") }); @export(&BunObject.JSON5, .{ .name = lazyPropertyCallbackName("JSON5") }); @export(&BunObject.YAML, .{ .name = lazyPropertyCallbackName("YAML") }); @@ -1274,6 +1276,9 @@ pub fn getJSONCObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSV pub fn getMarkdownObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { return MarkdownObject.create(globalThis); } +pub fn getMdxObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return MdxObject.create(globalThis); +} pub fn getTOMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { return TOMLObject.create(globalThis); } @@ -2076,6 +2081,7 @@ const HashObject = bun.api.HashObject; const JSON5Object = bun.api.JSON5Object; const JSONCObject = bun.api.JSONCObject; const MarkdownObject = bun.api.MarkdownObject; +const MdxObject = bun.api.MdxObject; const TOMLObject = bun.api.TOMLObject; const UnsafeObject = bun.api.UnsafeObject; const YAMLObject = bun.api.YAMLObject; diff --git a/src/bun.js/api/MdxObject.zig b/src/bun.js/api/MdxObject.zig new file mode 100644 index 00000000000..bb177a2ae51 --- /dev/null +++ b/src/bun.js/api/MdxObject.zig @@ -0,0 +1,97 @@ +pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue { + const object = JSValue.createEmptyObject(globalThis, 1); + object.put( + globalThis, + bun.String.static("compile"), + jsc.JSFunction.create(globalThis, "compile", compile, 2, .{}), + ); + return object; +} + +pub fn compile( + globalThis: *jsc.JSGlobalObject, + callframe: *jsc.CallFrame, +) bun.JSError!jsc.JSValue { + const input_value, const opts_value = callframe.argumentsAsArray(2); + + if (input_value.isEmptyOrUndefinedOrNull()) { + return globalThis.throwInvalidArguments("Expected a string or buffer to compile", .{}); + } + + var arena: bun.ArenaAllocator = .init(bun.default_allocator); + defer arena.deinit(); + + const buffer = try jsc.Node.StringOrBuffer.fromJS(globalThis, arena.allocator(), input_value) orelse { + return globalThis.throwInvalidArguments("Expected a string or buffer to compile", .{}); + }; + const input = buffer.slice(); + + const options = try parseOptions(globalThis, arena.allocator(), opts_value); + const result = mdx.compile(input, arena.allocator(), options) catch |err| return switch (err) { + error.OutOfMemory => globalThis.throwOutOfMemory(), + error.JSError, error.JSTerminated => |e| e, + error.StackOverflow => globalThis.throwStackOverflow(), + else => globalThis.throwValue(globalThis.createSyntaxErrorInstance("MDX compile error: {s}", .{@errorName(err)})), + }; + + return bun.String.createUTF8ForJS(globalThis, result); +} + +fn parseOptions(globalThis: *jsc.JSGlobalObject, allocator: std.mem.Allocator, opts_value: JSValue) bun.JSError!mdx.MdxOptions { + var options: mdx.MdxOptions = .{}; + + if (opts_value.isObject()) { + inline for (@typeInfo(md.Options).@"struct".fields) |field| { + comptime if (field.type != bool) continue; + const camel = comptime camelCaseOf(field.name); + if (try opts_value.getBooleanLoose(globalThis, camel)) |val| { + @field(options.md_options, field.name) = val; + } else if (comptime !std.mem.eql(u8, camel, field.name)) { + if (try opts_value.getBooleanLoose(globalThis, field.name)) |val| { + @field(options.md_options, field.name) = val; + } + } + } + + if (try opts_value.getStringish(globalThis, "jsxImportSource")) |import_source| { + defer import_source.deref(); + const utf8 = import_source.toUTF8(allocator); + defer utf8.deinit(); + options.jsx_import_source = try allocator.dupe(u8, utf8.slice()); + } + } + + return options; +} + +fn camelCaseOf(comptime snake: []const u8) []const u8 { + return comptime brk: { + var count: usize = 0; + for (snake) |c| { + if (c != '_') count += 1; + } + if (count == snake.len) break :brk snake; + + var buf: [count]u8 = undefined; + var i: usize = 0; + var cap_next = false; + for (snake) |c| { + if (c == '_') { + cap_next = true; + } else { + buf[i] = if (cap_next and i != 0 and c >= 'a' and c <= 'z') c - 32 else c; + i += 1; + cap_next = false; + } + } + const final = buf; + break :brk &final; + }; +} + +const bun = @import("bun"); +const jsc = bun.jsc; +const md = bun.md; +const mdx = bun.md.mdx; +const std = @import("std"); +const JSValue = jsc.JSValue; diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index d0290ad2588..b4df44c5fd9 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -13,6 +13,7 @@ macro(JSONC) \ macro(MD4) \ macro(markdown) \ + macro(mdx) \ macro(MD5) \ macro(S3Client) \ macro(SHA1) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index a0411fc38ac..29ee2c65352 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -950,6 +950,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj JSON5 BunObject_lazyPropCb_wrap_JSON5 DontDelete|PropertyCallback JSONL constructJSONLObject ReadOnly|DontDelete|PropertyCallback markdown BunObject_lazyPropCb_wrap_markdown DontDelete|PropertyCallback + mdx BunObject_lazyPropCb_wrap_mdx DontDelete|PropertyCallback TOML BunObject_lazyPropCb_wrap_TOML DontDelete|PropertyCallback YAML BunObject_lazyPropCb_wrap_YAML DontDelete|PropertyCallback Transpiler BunObject_lazyPropCb_wrap_Transpiler DontDelete|PropertyCallback diff --git a/src/bun.js/bindings/JSBuffer.cpp b/src/bun.js/bindings/JSBuffer.cpp index 5b1ccd84da0..d247d93ee2b 100644 --- a/src/bun.js/bindings/JSBuffer.cpp +++ b/src/bun.js/bindings/JSBuffer.cpp @@ -2519,6 +2519,13 @@ class JSBuffer : public JSC::JSNonFinalObject { static constexpr JSC::JSTypeRange typeRange = { Uint8ArrayType, Uint8ArrayType }; }; +// JSUint8Array::s_info is defined via explicit template instantiation in +// WebKit's JSGenericTypedArrayViewInlines.h, so the definition is not visible +// from this translation unit. Clang 21+ warns about this +// (-Wundefined-var-template); suppress it here since the symbol is guaranteed +// to be present at link time. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wundefined-var-template" const ClassInfo JSBuffer::s_info = { "Buffer"_s, &JSC::JSUint8Array::s_info, @@ -2526,6 +2533,7 @@ const ClassInfo JSBuffer::s_info = { nullptr, CREATE_METHOD_TABLE(JSBuffer) }; +#pragma clang diagnostic pop JSC_DEFINE_HOST_FUNCTION(jsBufferPrototypeFunction_compare, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { diff --git a/src/bun.js/bindings/ModuleLoader.cpp b/src/bun.js/bindings/ModuleLoader.cpp index a647e3d52aa..66433441e7e 100644 --- a/src/bun.js/bindings/ModuleLoader.cpp +++ b/src/bun.js/bindings/ModuleLoader.cpp @@ -270,13 +270,15 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: loader = BunLoaderTypeYAML; } else if (loaderString == "md"_s) { loader = BunLoaderTypeMD; + } else if (loaderString == "mdx"_s) { + loader = BunLoaderTypeMDX; } } } } if (loader == BunLoaderTypeNone) [[unlikely]] { - throwException(globalObject, scope, createError(globalObject, "Expected loader to be one of \"js\", \"jsx\", \"object\", \"ts\", \"tsx\", \"toml\", \"yaml\", \"json\", or \"md\""_s)); + throwException(globalObject, scope, createError(globalObject, "Expected loader to be one of \"js\", \"jsx\", \"object\", \"ts\", \"tsx\", \"toml\", \"yaml\", \"json\", \"md\", or \"mdx\""_s)); result.value.error = scope.exception(); (void)scope.tryClearException(); return result; diff --git a/src/bun.js/bindings/ProcessBindingNatives.cpp b/src/bun.js/bindings/ProcessBindingNatives.cpp index 1eaaaa45ff3..4d7abae4adf 100644 --- a/src/bun.js/bindings/ProcessBindingNatives.cpp +++ b/src/bun.js/bindings/ProcessBindingNatives.cpp @@ -117,6 +117,7 @@ static JSValue processBindingNativesReturnUndefined(VM& vm, JSObject* bindingObj internal/fs/glob processBindingNativesGetter PropertyCallback internal/fs/streams processBindingNativesGetter PropertyCallback internal/html processBindingNativesGetter PropertyCallback + internal/mdx processBindingNativesGetter PropertyCallback internal/http processBindingNativesGetter PropertyCallback internal/http/FakeSocket processBindingNativesGetter PropertyCallback internal/linkedlist processBindingNativesGetter PropertyCallback diff --git a/src/bun.js/bindings/headers-handwritten.h b/src/bun.js/bindings/headers-handwritten.h index 785e5380620..3e01f44b58a 100644 --- a/src/bun.js/bindings/headers-handwritten.h +++ b/src/bun.js/bindings/headers-handwritten.h @@ -256,7 +256,8 @@ const BunLoaderType BunLoaderTypeTOML = 9; const BunLoaderType BunLoaderTypeWASM = 10; const BunLoaderType BunLoaderTypeNAPI = 11; const BunLoaderType BunLoaderTypeYAML = 19; -const BunLoaderType BunLoaderTypeMD = 20; +const BunLoaderType BunLoaderTypeMD = 21; +const BunLoaderType BunLoaderTypeMDX = 22; #pragma mark - Stream diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 9d6a86cdfaf..8f15050d577 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -511,7 +511,7 @@ pub const LinkerContext = struct { const loader = loaders[record.source_index.get()]; switch (loader) { - .jsx, .js, .ts, .tsx, .napi, .sqlite, .json, .jsonc, .json5, .yaml, .html, .sqlite_embedded, .md => { + .jsx, .js, .ts, .tsx, .napi, .sqlite, .json, .jsonc, .json5, .yaml, .html, .sqlite_embedded, .md, .mdx => { log.addErrorFmt( source, record.range.loc, diff --git a/src/bundler/ParseTask.zig b/src/bundler/ParseTask.zig index 9dd725293c7..cfbe5d96cd2 100644 --- a/src/bundler/ParseTask.zig +++ b/src/bundler/ParseTask.zig @@ -402,6 +402,43 @@ fn getAST( ast.addUrlForCss(allocator, source, "text/html", null, transpiler.options.compile_to_standalone_html); return ast; }, + .mdx => { + const jsx_source = bun.md.mdx.compile(source.contents, allocator, .{}) catch { + log.addError( + source, + Logger.Loc.Empty, + "Failed to compile MDX", + ) catch |err| bun.handleOom(err); + return error.ParserError; + }; + + var virtual_source = source.*; + virtual_source.contents = jsx_source; + + var jsx_opts = opts; + jsx_opts.ts = true; + jsx_opts.jsx.parse = true; + jsx_opts.features.react_fast_refresh = transpiler.options.react_fast_refresh and !source.path.isNodeModule(); + + return if (try resolver.caches.js.parse( + transpiler.allocator, + jsx_opts, + transpiler.options.define, + log, + &virtual_source, + )) |res| + JSAst.init(res.ast) + else switch (jsx_opts.module_type == .esm) { + inline else => |as_undefined| try getEmptyAST( + log, + transpiler, + jsx_opts, + allocator, + source, + if (as_undefined) E.Undefined else E.Object, + ), + }; + }, .sqlite_embedded, .sqlite => { if (!transpiler.options.target.isBun()) { @@ -1201,6 +1238,7 @@ fn runWithSourceCode( const output_format = transpiler.options.output_format; + task.jsx.parse = loader.isJSX() or loader == .mdx; var opts = js_parser.Parser.Options.init(task.jsx, loader); opts.bundle = true; opts.warn_about_unbundled_modules = false; @@ -1260,7 +1298,7 @@ fn runWithSourceCode( opts.code_splitting = transpiler.options.code_splitting; opts.module_type = task.module_type; - task.jsx.parse = loader.isJSX(); + task.jsx.parse = loader.isJSX() or loader == .mdx; var unique_key_for_additional_file: FileLoaderHash = .{ .key = "", diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 579ced2944e..c56113aec12 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -4139,7 +4139,7 @@ pub const BundleV2 = struct { graph.input_files.items(.loader)[source.index.get()], parse_result.watcher_data.dir_fd, null, - bun.Environment.isWindows, + true, ); } } diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index f50a847fa3e..2f70e9405ba 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -1319,7 +1319,8 @@ pub const RunCommand = struct { }; } - _ = _bootAndHandleError(ctx, absolute_script_path.?, null); + const fast_run_loader: ?bun.options.Loader = if (strings.hasSuffixComptime(absolute_script_path.?, ".mdx")) .html else null; + _ = _bootAndHandleError(ctx, absolute_script_path.?, fast_run_loader); return true; } pub fn exec( @@ -1336,7 +1337,8 @@ pub const RunCommand = struct { // find what to run var positionals = ctx.positionals; - if (positionals.len > 0 and strings.eqlComptime(positionals[0], "run")) { + const is_run_subcommand = positionals.len > 0 and strings.eqlComptime(positionals[0], "run"); + if (is_run_subcommand) { positionals = positionals[1..]; } @@ -1519,6 +1521,11 @@ pub const RunCommand = struct { var resolved_mutable = resolved; const path = resolved_mutable.path().?; const loader: bun.options.Loader = this_transpiler.options.loaders.get(path.name.ext) orelse .tsx; + if (loader == .mdx) { + log("Resolved to MDX direct-entry mode: `{s}`", .{path.text}); + return _bootAndHandleError(ctx, path.text, .html); + } + if (loader.canBeRunByBun() or loader == .html) { log("Resolved to: `{s}`", .{path.text}); return _bootAndHandleError(ctx, path.text, loader); @@ -1533,6 +1540,11 @@ pub const RunCommand = struct { return _bootAndHandleError(ctx, target_name, .html); } } + if (strings.hasSuffixComptime(target_name, ".mdx")) { + if (strings.containsChar(target_name, '*')) { + return _bootAndHandleError(ctx, target_name, .html); + } + } } // execute a node_modules/.bin/ command, or (run only) a system command like 'ls' diff --git a/src/io/PipeReader.zig b/src/io/PipeReader.zig index 9e18d789953..ce81e72d79d 100644 --- a/src/io/PipeReader.zig +++ b/src/io/PipeReader.zig @@ -760,7 +760,7 @@ pub const WindowsBufferedReader = struct { return Type.onReaderError(@as(*Type, @ptrCast(@alignCast(this))), err); } fn loop(this: *anyopaque) *Async.Loop { - return Type.loop(@as(*Type, @alignCast(@ptrCast(this)))); + return Type.loop(@as(*Type, @ptrCast(@alignCast(this)))); } }; return .{ diff --git a/src/js/internal/html.ts b/src/js/internal/html.ts index 1af882828ac..02b81058756 100644 --- a/src/js/internal/html.ts +++ b/src/js/internal/html.ts @@ -9,8 +9,22 @@ const path = require("node:path"); const env = Bun.env; +function shouldUseMdxMode(args: string[]) { + for (let i = 1, length = args.length; i < length; i++) { + const arg = args[i]; + if (arg.endsWith(".mdx")) return true; + if ((arg.includes("*") || arg.includes("{")) && arg.includes(".mdx")) return true; + } + return false; +} + // This function is called at startup. async function start() { + if (shouldUseMdxMode(argv)) { + const mdxInternal = require("internal/mdx"); + return mdxInternal.start(); + } + let args: string[] = []; const cwd = process.cwd(); let hostname = "localhost"; @@ -140,7 +154,7 @@ yourself with Bun.serve(). return acc.slice(0, i); }); - if (path.platform === "win32") { + if (process.platform === "win32") { longestCommonPath = longestCommonPath.replaceAll("\\", "/"); } @@ -258,7 +272,7 @@ yourself with Bun.serve(). hostname, // Retry with a different port up to 4 times. - port: defaultPort++, + port: ++defaultPort, fetch(_req: Request) { return new Response("Not found", { status: 404 }); diff --git a/src/js/internal/mdx.ts b/src/js/internal/mdx.ts new file mode 100644 index 00000000000..83a8a079f65 --- /dev/null +++ b/src/js/internal/mdx.ts @@ -0,0 +1,510 @@ +// MDX Dev Server — loaded when you pass a '.mdx' entry point to Bun. +// +// Architecture overview: +// 1. A Bun.plugin compiles .mdx → TSX in-memory via Bun.mdx.compile() +// 2. For each .mdx file, a tiny HTML shell + entry.js scaffold is written +// to the system temp dir (os.tmpdir). entry.js imports the original .mdx +// by absolute path — the plugin intercepts the load and returns compiled +// TSX with loader:"tsx". +// 3. The bundler resolves all imports within the compiled TSX relative to +// the original .mdx file's directory. This is critical: relative imports +// (e.g. '../components/Foo') and workspace package imports (e.g. +// '@org/pkg') resolve correctly because the resolution base is the .mdx +// file's location, not the temp directory. +// 4. react/react-dom are resolved via a node_modules symlink in the temp dir +// pointing to the project's node_modules. +// +// Why a plugin instead of pre-compiling to a temp .tsx file? +// - Writing compiled TSX to a temp dir breaks import resolution (the bundler +// resolves relative to the temp dir, not the original source location). +// - Writing compiled TSX adjacent to the .mdx file pollutes the user's +// project with temporary artifacts. +// - The plugin approach keeps everything in-memory. The bundler sees the +// original .mdx path and resolves imports from its directory. +// +// HMR: the .mdx file is in the bundler's dependency graph (entry.js imports +// it directly). The plugin re-compiles on each load, so file changes trigger +// automatic re-bundling through the dev server's built-in file watcher. + +import type { HTMLBundle, Server } from "bun"; +const initial = performance.now(); +const argv = process.argv; + +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const env = Bun.env; + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function emitMdxWrapperScript(mdxAbsolutePath: string) { + // Use string concatenation to avoid the build preprocessor's import-extraction regex + // from matching import statements inside this template literal. + const imp = "import"; + // Import the original .mdx file by absolute path. A Bun.plugin registered + // before bundling intercepts .mdx loads, compiles to TSX in-memory, and + // returns it with loader:"tsx". The bundler resolves imports from the .mdx + // file's directory — no temp file written next to the source. + const escapedPath = mdxAbsolutePath.replace(/\\/g, "/").replace(/'/g, "\\'"); + return [ + imp + ' React from "react";', + imp + ' { createRoot } from "react-dom/client";', + imp + " MDXContent from '" + escapedPath + "';", + "", + 'const rootEl = document.getElementById("root");', + "if (!rootEl) {", + ' throw new Error("Missing #root mount element for MDX page");', + "}", + "", + "const root = createRoot(rootEl);", + "root.render(React.createElement(MDXContent, {}));", + ].join("\n"); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function emitMdxHtmlShell(wrapperScriptName: string, title: string) { + return ` + + + + + ${escapeHtml(title)} + + + +
+ + + +`; +} + +async function start() { + let args: string[] = []; + const cwd = process.cwd(); + let hostname = "localhost"; + let port: number | undefined = undefined; + let enableConsoleLog = false; + + const parseHostnameAndPort = (value: string) => { + let parsedHostname = value; + let parsedPort: number | undefined = undefined; + + if (parsedHostname.includes(":")) { + // Bracketed IPv6 host, optionally followed by :port. + if (parsedHostname.startsWith("[") && parsedHostname.includes("]")) { + const closingBracketIndex = parsedHostname.indexOf("]"); + const host = parsedHostname.slice(1, closingBracketIndex); + const trailing = parsedHostname.slice(closingBracketIndex + 1); + parsedHostname = host; + + if (trailing.startsWith(":")) { + const portString = trailing.slice(1); + if (portString.length > 0) { + parsedPort = parseInt(portString, 10); + } + } + } else { + // Non-bracketed host: split on the final colon so IPv6 colons are preserved. + const lastColonIndex = parsedHostname.lastIndexOf(":"); + if (lastColonIndex !== -1) { + const host = parsedHostname.slice(0, lastColonIndex); + const portString = parsedHostname.slice(lastColonIndex + 1); + if (host.length > 0 && !host.endsWith(":") && portString.length > 0) { + parsedHostname = host; + parsedPort = parseInt(portString, 10); + } + } + } + } + + return { hostname: parsedHostname, port: parsedPort }; + }; + + for (let i = 1, argvLength = argv.length; i < argvLength; i++) { + const arg = argv[i]; + const isFileLikeArg = + arg.includes("*") || arg.includes("**") || arg.includes("{") || arg.includes("/") || arg.includes("\\"); + + if (!arg.endsWith(".mdx") && !isFileLikeArg) { + if (arg.startsWith("--hostname=")) { + const parsed = parseHostnameAndPort(arg.slice("--hostname=".length)); + hostname = parsed.hostname; + if (parsed.port !== undefined) { + port = parsed.port; + } + } else if (arg.startsWith("--port=")) { + port = parseInt(arg.slice("--port=".length), 10); + } else if (arg.startsWith("--host=")) { + const parsed = parseHostnameAndPort(arg.slice("--host=".length)); + hostname = parsed.hostname; + if (parsed.port !== undefined) { + port = parsed.port; + } + } else if (arg === "--console") { + enableConsoleLog = true; + } else if (arg === "--no-console") { + enableConsoleLog = false; + } + + if (arg === "--help") { + console.log(` +Bun v${Bun.version} (mdx) + +Usage: + bun [...mdx-files] [options] + +Options: + + --port= + --host=, --hostname= + --console # print console logs from browser + --no-console # don't print console logs from browser +Examples: + + bun index.mdx + bun ./index.mdx ./docs/getting-started.mdx --port=3000 + bun index.mdx --host=localhost:3000 + bun index.mdx --hostname=localhost:3000 + bun ./*.mdx + bun index.mdx --console +`); + process.exit(0); + } + } + } + + for (let i = 1, argvLength = argv.length; i < argvLength; i++) { + const arg = argv[i]; + const isGlobArg = arg.includes("*") || arg.includes("**") || arg.includes("{"); + + if (arg.endsWith(".mdx") || isGlobArg) { + if (isGlobArg) { + const glob = new Bun.Glob(arg); + + for (const file of glob.scanSync(cwd)) { + let resolved = path.resolve(cwd, file); + if (resolved.includes(path.sep + "node_modules" + path.sep)) { + continue; + } + + try { + resolved = Bun.resolveSync(resolved, cwd); + } catch { + resolved = Bun.resolveSync("./" + resolved, cwd); + } + + if (resolved.includes(path.sep + "node_modules" + path.sep)) { + continue; + } + + args.push(resolved); + } + } else { + let resolved = arg; + try { + resolved = Bun.resolveSync(arg, cwd); + } catch { + resolved = Bun.resolveSync("./" + arg, cwd); + } + + if (resolved.includes(path.sep + "node_modules" + path.sep)) { + continue; + } + + args.push(resolved); + } + } + } + + if (args.length > 1) { + args = [...new Set(args)]; + } + + if (args.length === 0) { + throw new Error("No MDX files found matching " + JSON.stringify(Bun.main)); + } + + args.sort((a, b) => a.localeCompare(b)); + + let needsPop = false; + if (args.length === 1) { + args.push(process.cwd()); + needsPop = true; + } + + let longestCommonPath = args.reduce((acc, curr) => { + if (!acc) return curr; + let i = 0; + while (i < acc.length && i < curr.length && acc[i] === curr[i]) i++; + return acc.slice(0, i); + }); + + if (process.platform === "win32") { + longestCommonPath = longestCommonPath.replaceAll("\\", "/"); + } + + if (needsPop) { + args.pop(); + } + + const servePaths = args.map(arg => { + if (process.platform === "win32") { + arg = arg.replaceAll("\\", "/"); + } + const basename = path.basename(arg); + const isIndexMdx = basename === "index.mdx"; + + let servePath = arg; + if (servePath.startsWith(longestCommonPath)) { + servePath = servePath.slice(longestCommonPath.length); + } else { + const relative = path.relative(longestCommonPath, servePath); + if (!relative.startsWith("..")) { + servePath = relative; + } + } + + if (isIndexMdx && servePath.length === 0) { + servePath = "/"; + } else if (isIndexMdx) { + servePath = servePath.slice(0, -"index.mdx".length); + } + + if (servePath.endsWith(".mdx")) { + servePath = servePath.slice(0, -".mdx".length); + } + + if (servePath.endsWith("/")) { + servePath = servePath.slice(0, -1); + } + + if (servePath.startsWith("/")) { + servePath = servePath.slice(1); + } + + if (servePath === "/") servePath = ""; + + return servePath; + }); + + const Mdx = (Bun as any).mdx as { compile(input: string): string }; + + // Register a plugin so the bake dev server can load .mdx files in-memory. + // The plugin compiles MDX → TSX on each load; the bundler resolves imports + // relative to the original .mdx file's directory automatically. + // Register for both targets: "browser" is used by the bake dev server's + // client bundler, "bun" covers runtime/SSR imports of .mdx files. + const mdxPlugin = { + name: "mdx-dev-server", + setup(build: { onLoad: Function }) { + build.onLoad({ filter: /\.mdx$/ }, (args: { path: string }) => { + const source = fs.readFileSync(args.path, "utf8"); + const compiled = Mdx.compile(source); + return { contents: compiled, loader: "tsx" }; + }); + }, + }; + Bun.plugin({ ...mdxPlugin, target: "browser" }); + Bun.plugin({ ...mdxPlugin, target: "bun" }); + + // HTML shells and entry scripts are scaffolding only — put them in the + // system temp dir to avoid polluting the project tree. + const uniqueId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const tmpRoot = path.join(os.tmpdir(), `.bun-mdx-${process.pid}-${uniqueId}`); + ensureDir(tmpRoot); + + // Symlink node_modules so the bundler can resolve react/react-dom + // from entry.js in the temp directory. + const cwdNodeModules = path.join(cwd, "node_modules"); + try { + if (fs.existsSync(cwdNodeModules)) { + fs.symlinkSync(cwdNodeModules, path.join(tmpRoot, "node_modules"), "junction"); + } + } catch {} + + // Clean up generated scaffolding on exit. + process.on("exit", () => { + try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } catch {} + }); + + const htmlEntryPaths = args.map((mdxPath, index) => { + const entryDir = path.join(tmpRoot, String(index)); + ensureDir(entryDir); + + const wrapperScriptName = "entry.js"; + const wrapperScriptPath = path.join(entryDir, wrapperScriptName); + const htmlPath = path.join(entryDir, "index.html"); + + // entry.js imports the original .mdx — the plugin handles compilation. + fs.writeFileSync(wrapperScriptPath, emitMdxWrapperScript(mdxPath), "utf8"); + const titleBase = path.basename(mdxPath, ".mdx"); + fs.writeFileSync(htmlPath, emitMdxHtmlShell(wrapperScriptName, titleBase), "utf8"); + return htmlPath; + }); + + const htmlImports = await Promise.all( + htmlEntryPaths.map(arg => { + return import(arg).then(m => m.default as HTMLBundle); + }), + ); + + if (htmlImports.length === 1) { + servePaths[0] = "*"; + } + + const staticRoutes = htmlImports.reduce( + (acc, htmlImport, index) => { + const servePath = servePaths[index]; + acc["/" + servePath] = htmlImport; + return acc; + }, + {} as Record, + ); + + let server: Server; + getServer: { + try { + server = Bun.serve({ + static: staticRoutes, + development: + env.NODE_ENV !== "production" + ? { + console: enableConsoleLog, + hmr: undefined, + } + : false, + hostname, + port, + fetch() { + return new Response("Not found", { status: 404 }); + }, + }); + break getServer; + } catch (error: any) { + if (error?.code === "EADDRINUSE") { + let defaultPort = port || parseInt(env.PORT || env.BUN_PORT || env.NODE_PORT || "3000", 10); + for (let remainingTries = 5; remainingTries > 0; remainingTries--) { + try { + server = Bun.serve({ + static: staticRoutes, + development: + env.NODE_ENV !== "production" + ? { + console: enableConsoleLog, + hmr: undefined, + } + : false, + hostname, + port: ++defaultPort, + fetch() { + return new Response("Not found", { status: 404 }); + }, + }); + break getServer; + } catch (retryError: any) { + if (retryError?.code === "EADDRINUSE") { + continue; + } + throw retryError; + } + } + } + throw error; + } + } + + // HMR: the .mdx files are now in the bundler's dependency graph (imported + // directly by entry.js). The plugin re-compiles on each load, so the dev + // server's built-in file watcher handles changes automatically. + + const elapsed = (performance.now() - initial).toFixed(2); + const enableANSIColors = Bun.enableANSIColors; + + function printInitialMessage(isFirst: boolean) { + let pathnameToPrint; + if (servePaths.length === 1) { + pathnameToPrint = servePaths[0]; + } else { + const indexRoute = servePaths.find(a => a === "index" || a === "" || a === "/"); + pathnameToPrint = indexRoute !== undefined ? indexRoute : servePaths[0]; + } + + pathnameToPrint ||= "/"; + if (pathnameToPrint === "*") { + pathnameToPrint = "/"; + } + + if (enableANSIColors) { + let topLine = `${server.development ? "\x1b[34;7m DEV \x1b[0m " : ""}\x1b[1;34m\x1b[5mBun\x1b[0m \x1b[1;34mv${Bun.version}\x1b[0m`; + if (isFirst) { + topLine += ` \x1b[2mready in\x1b[0m \x1b[1m${elapsed}\x1b[0m ms`; + } + console.log(topLine + "\n"); + console.log(`\x1b[1;34m➜\x1b[0m \x1b[36m${new URL(pathnameToPrint, server!.url)}\x1b[0m`); + } else { + let topLine = `Bun v${Bun.version}`; + if (isFirst) { + if (server.development) { + topLine += " dev server"; + } + topLine += ` ready in ${elapsed} ms`; + } + console.log(topLine + "\n"); + console.log(`url: ${new URL(pathnameToPrint, server!.url)}`); + } + + if (htmlImports.length > 1 || (servePaths[0] !== "" && servePaths[0] !== "*")) { + console.log("\nRoutes:"); + const pairs: { route: string; importPath: string }[] = []; + for (let i = 0, length = servePaths.length; i < length; i++) { + pairs.push({ route: servePaths[i], importPath: args[i] }); + } + pairs.sort((a, b) => { + if (b.route === "") return 1; + if (a.route === "") return -1; + return a.route.localeCompare(b.route); + }); + for (let i = 0, length = pairs.length; i < length; i++) { + const { route, importPath } = pairs[i]; + const isLast = i === length - 1; + const prefix = isLast ? " └── " : " ├── "; + if (enableANSIColors) { + console.log(`${prefix}\x1b[36m/${route}\x1b[0m \x1b[2m→ ${path.relative(process.cwd(), importPath)}\x1b[0m`); + } else { + console.log(`${prefix}/${route} → ${path.relative(process.cwd(), importPath)}`); + } + } + } + } + + printInitialMessage(true); +} + +export default { start }; diff --git a/src/js_printer.zig b/src/js_printer.zig index 951bf3f200b..0d134bf8f3b 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -4661,6 +4661,7 @@ fn NewPrinter( .sqlite, .sqlite_embedded => p.printWhitespacer(ws(" with { type: \"sqlite\" }")), .html => p.printWhitespacer(ws(" with { type: \"html\" }")), .md => p.printWhitespacer(ws(" with { type: \"md\" }")), + .mdx => p.printWhitespacer(ws(" with { type: \"mdx\" }")), }; p.printSemicolonAfterStatement(); @@ -4689,6 +4690,7 @@ fn NewPrinter( .html => analyze_transpiled_module.ModuleInfo.FetchParameters.hostDefined(bun.handleOom(mi.str("html"))), .json5 => analyze_transpiled_module.ModuleInfo.FetchParameters.hostDefined(bun.handleOom(mi.str("json5"))), .md => analyze_transpiled_module.ModuleInfo.FetchParameters.hostDefined(bun.handleOom(mi.str("md"))), + .mdx => analyze_transpiled_module.ModuleInfo.FetchParameters.hostDefined(bun.handleOom(mi.str("mdx"))), } else .none) else .none; bun.handleOom(mi.requestModule(irp_id, fetch_parameters)); diff --git a/src/md/inlines.zig b/src/md/inlines.zig index 2e20694dddb..50a10240959 100644 --- a/src/md/inlines.zig +++ b/src/md/inlines.zig @@ -94,7 +94,7 @@ pub fn processInlineContent(self: *Parser, content: []const u8, base_off: OFF) P } } if (emit_end > text_start) try self.emitText(.normal, content[text_start..emit_end]); - if (is_hard) try self.emitText(.br, "") else try self.emitText(.softbr, ""); + if (is_hard or self.flags.hard_soft_breaks) try self.emitText(.br, "") else try self.emitText(.softbr, ""); i += 1; text_start = i; continue; diff --git a/src/md/jsx_renderer.zig b/src/md/jsx_renderer.zig new file mode 100644 index 00000000000..233c8fedfd7 --- /dev/null +++ b/src/md/jsx_renderer.zig @@ -0,0 +1,379 @@ +pub const JSXRenderer = struct { + allocator: std.mem.Allocator, + src_text: []const u8, + out: std.ArrayListUnmanaged(u8), + expression_slots: []const ExpressionSlot, + component_names: bun.StringArrayHashMap(void), + image_nesting_level: u32 = 0, + saved_img_title: []const u8 = "", + + pub const ExpressionSlot = struct { + original: []const u8, + placeholder: []const u8, + }; + + pub fn init( + allocator: std.mem.Allocator, + src_text: []const u8, + expression_slots: []const ExpressionSlot, + ) JSXRenderer { + return .{ + .allocator = allocator, + .src_text = src_text, + .out = .{}, + .expression_slots = expression_slots, + .component_names = bun.StringArrayHashMap(void).init(allocator), + }; + } + + pub fn deinit(self: *JSXRenderer) void { + for (self.component_names.keys()) |key| { + self.allocator.free(key); + } + self.out.deinit(self.allocator); + self.component_names.deinit(); + } + + pub fn renderer(self: *JSXRenderer) Renderer { + return .{ .ptr = self, .vtable = &vtable }; + } + + pub fn getOutput(self: *const JSXRenderer) []const u8 { + return self.out.items; + } + + pub const vtable: Renderer.VTable = .{ + .enterBlock = enterBlockImpl, + .leaveBlock = leaveBlockImpl, + .enterSpan = enterSpanImpl, + .leaveSpan = leaveSpanImpl, + .text = textImpl, + }; + + fn enterBlockImpl(ptr: *anyopaque, block_type: BlockType, data: u32, flags: u32) bun.JSError!void { + const self: *JSXRenderer = @ptrCast(@alignCast(ptr)); + try self.enterBlock(block_type, data, flags); + } + + fn leaveBlockImpl(ptr: *anyopaque, block_type: BlockType, data: u32) bun.JSError!void { + const self: *JSXRenderer = @ptrCast(@alignCast(ptr)); + try self.leaveBlock(block_type, data); + } + + fn enterSpanImpl(ptr: *anyopaque, span_type: SpanType, detail: SpanDetail) bun.JSError!void { + const self: *JSXRenderer = @ptrCast(@alignCast(ptr)); + try self.enterSpan(span_type, detail); + } + + fn leaveSpanImpl(ptr: *anyopaque, span_type: SpanType) bun.JSError!void { + const self: *JSXRenderer = @ptrCast(@alignCast(ptr)); + try self.leaveSpan(span_type); + } + + fn textImpl(ptr: *anyopaque, text_type: TextType, content: []const u8) bun.JSError!void { + const self: *JSXRenderer = @ptrCast(@alignCast(ptr)); + try self.text(text_type, content); + } + + fn trackComponent(self: *JSXRenderer, name: []const u8) !void { + const result = try self.component_names.getOrPut(name); + if (!result.found_existing) { + result.key_ptr.* = try self.allocator.dupe(u8, name); + } + } + + fn write(self: *JSXRenderer, bytes: []const u8) !void { + try self.out.appendSlice(self.allocator, bytes); + } + + fn writeChar(self: *JSXRenderer, c: u8) !void { + try self.out.append(self.allocator, c); + } + + fn writeComponentTagOpen(self: *JSXRenderer, name: []const u8) !void { + try self.trackComponent(name); + try self.write("<_components."); + try self.write(name); + try self.write(">"); + } + + fn writeComponentTagClose(self: *JSXRenderer, name: []const u8) !void { + try self.trackComponent(name); + try self.write(""); + } + + fn writeComponentTagSelfClose(self: *JSXRenderer, name: []const u8) !void { + try self.trackComponent(name); + try self.write("<_components."); + try self.write(name); + try self.write(" />"); + } + + fn writeAttrEscaped(self: *JSXRenderer, value: []const u8) !void { + for (value) |c| { + switch (c) { + '&' => try self.write("&"), + '<' => try self.write("<"), + '>' => try self.write(">"), + '"' => try self.write("""), + else => try self.writeChar(c), + } + } + } + + fn writeJSXEscaped(self: *JSXRenderer, value: []const u8) !void { + for (value) |c| { + switch (c) { + '{' => try self.write("{'{'}"), + '}' => try self.write("{'}'}"), + '<' => try self.write("{'<'}"), + '>' => try self.write("{'>'}"), + else => try self.writeChar(c), + } + } + } + + fn writeJSStringEscaped(self: *JSXRenderer, value: []const u8) !void { + for (value) |c| { + switch (c) { + '\\' => try self.write("\\\\"), + '"' => try self.write("\\\""), + '\n' => try self.write("\\n"), + '\r' => try self.write("\\r"), + '\t' => try self.write("\\t"), + else => try self.writeChar(c), + } + } + } + + const ExprWriteMode = enum { jsx_text, attr_text, raw }; + + fn writeRestoringExpressions(self: *JSXRenderer, content: []const u8, mode: ExprWriteMode) !void { + var i: usize = 0; + while (i < content.len) { + if (content[i] == 1) { + const sentinel_end = bun.strings.indexOfCharPos(content, 1, i + 1) orelse + return error.JSError; + const placeholder = content[i .. sentinel_end + 1]; + var restored = false; + for (self.expression_slots) |slot| { + if (bun.strings.eql(slot.placeholder, placeholder)) { + try self.write("{"); + try self.write(slot.original); + try self.write("}"); + restored = true; + break; + } + } + if (!restored) return error.JSError; + i = sentinel_end + 1; + continue; + } + switch (mode) { + .jsx_text => try self.writeJSXEscaped(content[i .. i + 1]), + .attr_text => try self.writeAttrEscaped(content[i .. i + 1]), + .raw => try self.writeChar(content[i]), + } + i += 1; + } + } + + fn enterBlock(self: *JSXRenderer, block_type: BlockType, data: u32, flags: u32) !void { + switch (block_type) { + .doc => {}, + .quote => try self.writeComponentTagOpen("blockquote"), + .ul => try self.writeComponentTagOpen("ul"), + .ol => { + try self.trackComponent("ol"); + try self.write("<_components.ol"); + if (data > 1) { + try self.write(" start={"); + try self.out.writer(self.allocator).print("{d}", .{data}); + try self.write("}"); + } + try self.write(">"); + }, + .li => try self.writeComponentTagOpen("li"), + .hr => try self.writeComponentTagSelfClose("hr"), + .h => { + const tag = switch (data) { + 1 => "h1", + 2 => "h2", + 3 => "h3", + 4 => "h4", + 5 => "h5", + else => "h6", + }; + try self.writeComponentTagOpen(tag); + }, + .code => { + try self.trackComponent("pre"); + try self.trackComponent("code"); + try self.write("<_components.pre><_components.code"); + if (flags & BLOCK_FENCED_CODE != 0 and data < self.src_text.len) { + const info_beg: usize = data; + var lang_end = info_beg; + while (lang_end < self.src_text.len and !helpers.isBlank(self.src_text[lang_end]) and + !helpers.isNewline(self.src_text[lang_end])) + { + lang_end += 1; + } + if (lang_end > info_beg) { + try self.write(" className=\"language-"); + try self.writeAttrEscaped(self.src_text[info_beg..lang_end]); + try self.write("\""); + } + } + try self.write(">"); + }, + .html => {}, + .p => try self.writeComponentTagOpen("p"), + .table => try self.writeComponentTagOpen("table"), + .thead => try self.writeComponentTagOpen("thead"), + .tbody => try self.writeComponentTagOpen("tbody"), + .tr => try self.writeComponentTagOpen("tr"), + .th => try self.writeComponentTagOpen("th"), + .td => try self.writeComponentTagOpen("td"), + } + } + + fn leaveBlock(self: *JSXRenderer, block_type: BlockType, data: u32) !void { + switch (block_type) { + .doc => {}, + .quote => try self.writeComponentTagClose("blockquote"), + .ul => try self.writeComponentTagClose("ul"), + .ol => try self.writeComponentTagClose("ol"), + .li => try self.writeComponentTagClose("li"), + .hr => {}, + .h => { + const tag = switch (data) { + 1 => "h1", + 2 => "h2", + 3 => "h3", + 4 => "h4", + 5 => "h5", + else => "h6", + }; + try self.writeComponentTagClose(tag); + }, + .code => try self.write(""), + .html => {}, + .p => try self.writeComponentTagClose("p"), + .table => try self.writeComponentTagClose("table"), + .thead => try self.writeComponentTagClose("thead"), + .tbody => try self.writeComponentTagClose("tbody"), + .tr => try self.writeComponentTagClose("tr"), + .th => try self.writeComponentTagClose("th"), + .td => try self.writeComponentTagClose("td"), + } + } + + fn enterSpan(self: *JSXRenderer, span_type: SpanType, detail: SpanDetail) !void { + switch (span_type) { + .em => try self.writeComponentTagOpen("em"), + .strong => try self.writeComponentTagOpen("strong"), + .u => try self.writeComponentTagOpen("u"), + .code => try self.writeComponentTagOpen("code"), + .del => try self.writeComponentTagOpen("del"), + .latexmath, .latexmath_display => try self.writeComponentTagOpen("span"), + .wikilink => try self.writeComponentTagOpen("a"), + .a => { + try self.trackComponent("a"); + try self.write("<_components.a href=\""); + try self.writeAttrEscaped(detail.href); + try self.write("\""); + if (detail.title.len > 0) { + try self.write(" title=\""); + try self.writeAttrEscaped(detail.title); + try self.write("\""); + } + try self.write(">"); + }, + .img => { + try self.trackComponent("img"); + self.saved_img_title = detail.title; + self.image_nesting_level += 1; + try self.write("<_components.img src=\""); + try self.writeAttrEscaped(detail.href); + try self.write("\" alt=\""); + }, + } + } + + fn leaveSpan(self: *JSXRenderer, span_type: SpanType) !void { + if (self.image_nesting_level > 0) { + if (span_type == .img) { + self.image_nesting_level -= 1; + if (self.image_nesting_level == 0) { + try self.write("\""); + if (self.saved_img_title.len > 0) { + try self.write(" title=\""); + try self.writeAttrEscaped(self.saved_img_title); + try self.write("\""); + } + try self.write(" />"); + } + } + return; + } + + switch (span_type) { + .em => try self.writeComponentTagClose("em"), + .strong => try self.writeComponentTagClose("strong"), + .u => try self.writeComponentTagClose("u"), + .a => try self.writeComponentTagClose("a"), + .code => try self.writeComponentTagClose("code"), + .del => try self.writeComponentTagClose("del"), + .latexmath, .latexmath_display => try self.writeComponentTagClose("span"), + .wikilink => try self.writeComponentTagClose("a"), + .img => {}, + } + } + + fn text(self: *JSXRenderer, text_type: TextType, content: []const u8) !void { + const in_image = self.image_nesting_level > 0; + + switch (text_type) { + .normal => try self.writeRestoringExpressions( + content, + if (in_image) .attr_text else .jsx_text, + ), + .null_char => if (in_image) { + try self.writeAttrEscaped("\xEF\xBF\xBD"); + } else { + try self.write("\u{FFFD}"); + }, + .br => if (in_image) { + try self.write(" "); + } else { + try self.write("
"); + }, + .softbr => if (in_image) { + try self.write(" "); + } else { + try self.write("\n"); + }, + .html => try self.writeRestoringExpressions(content, .raw), + .entity => try self.write(content), + .code => { + try self.write("{\""); + try self.writeJSStringEscaped(content); + try self.write("\"}"); + }, + .latexmath => try self.writeJSXEscaped(content), + } + } +}; + +const bun = @import("bun"); +const std = @import("std"); +const helpers = @import("./helpers.zig"); +const types = @import("./types.zig"); +const BLOCK_FENCED_CODE = types.BLOCK_FENCED_CODE; +const BlockType = types.BlockType; +const Renderer = types.Renderer; +const SpanDetail = types.SpanDetail; +const SpanType = types.SpanType; +const TextType = types.TextType; diff --git a/src/md/mdx.zig b/src/md/mdx.zig new file mode 100644 index 00000000000..022355c0280 --- /dev/null +++ b/src/md/mdx.zig @@ -0,0 +1,616 @@ +pub const MdxOptions = struct { + jsx_import_source: []const u8 = "react", + md_options: md.Options = .{ + .tables = true, + .strikethrough = true, + .tasklists = true, + .no_indented_code_blocks = true, + }, +}; + +pub const FrontmatterResult = struct { + yaml_content: []const u8, + content_start: u32, +}; + +pub const StmtKind = enum { import_stmt, export_stmt }; + +pub const TopLevelStatement = struct { + text: []const u8, + kind: StmtKind, +}; + +const StatementParseState = struct { + brace_depth: usize = 0, + paren_depth: usize = 0, + bracket_depth: usize = 0, + string_quote: ?u8 = null, + string_escaped: bool = false, +}; + +fn updateStatementParseState(state: *StatementParseState, line: []const u8) void { + for (line) |c| { + if (state.string_quote) |quote| { + if (state.string_escaped) { + state.string_escaped = false; + continue; + } + if (c == '\\') { + state.string_escaped = true; + continue; + } + if (c == quote) { + state.string_quote = null; + } + continue; + } + + switch (c) { + '\'', '"', '`' => state.string_quote = c, + '{' => state.brace_depth += 1, + '}' => state.brace_depth -|= 1, + '(' => state.paren_depth += 1, + ')' => state.paren_depth -|= 1, + '[' => state.bracket_depth += 1, + ']' => state.bracket_depth -|= 1, + else => {}, + } + } +} + +fn trimTrailingLineComment(line: []const u8) []const u8 { + var quote: ?u8 = null; + var escaped = false; + var i: usize = 0; + while (i < line.len) : (i += 1) { + const c = line[i]; + if (quote) |q| { + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == q) { + quote = null; + } + continue; + } + + if (c == '\'' or c == '"' or c == '`') { + quote = c; + continue; + } + + if (c == '/' and i + 1 < line.len and line[i + 1] == '/') { + return line[0..i]; + } + } + + return line; +} + +fn isStatementComplete(kind: StmtKind, line: []const u8, state: StatementParseState) bool { + if (state.string_quote != null or state.brace_depth != 0 or state.paren_depth != 0 or state.bracket_depth != 0) { + return false; + } + + const trimmed_for_completion = bun.strings.trimSpaces(trimTrailingLineComment(line)); + if (trimmed_for_completion.len == 0) return false; + + const last = trimmed_for_completion[trimmed_for_completion.len - 1]; + if (last == ';') return true; + + if (kind == .import_stmt) { + if (std.mem.indexOf(u8, trimmed_for_completion, " from ") != null) return true; + if (std.mem.lastIndexOfScalar(u8, trimmed_for_completion, '}')) |close_idx| { + const after_close = bun.strings.trimSpaces(trimmed_for_completion[close_idx + 1 ..]); + if (bun.strings.hasPrefixComptime(after_close, "from")) return true; + } + return bun.strings.hasPrefixComptime(trimmed_for_completion, "import \"") or + bun.strings.hasPrefixComptime(trimmed_for_completion, "import '"); + } + + if (last == '}' or last == ')' or last == ']') return true; + + return switch (last) { + ',', '=', ':', '+', '-', '*', '/', '%', '&', '|', '^', '?', '(', '[', '{', '\\', '.' => false, + else => true, + }; +} + +pub const ExpressionSlot = jsx_renderer.JSXRenderer.ExpressionSlot; + +pub fn extractFrontmatter(source: []const u8) ?FrontmatterResult { + if (!bun.strings.hasPrefixComptime(source, "---")) { + return null; + } + + const first_nl = bun.strings.indexOfChar(source[3..], '\n') orelse return null; + const body_start = 3 + first_nl + 1; + + var i: usize = body_start; + while (i < source.len) : (i += 1) { + if (source[i] == '\n' or i == body_start) { + const line_start = if (source[i] == '\n') i + 1 else i; + if (line_start + 3 <= source.len and bun.strings.eqlComptime(source[line_start..][0..3], "---")) { + const after_dashes = line_start + 3; + if (after_dashes >= source.len or source[after_dashes] == '\n') { + return .{ + .yaml_content = source[body_start..line_start], + .content_start = @intCast(@min(after_dashes + 1, source.len)), + }; + } + } + } + } + + return null; +} + +pub fn extractTopLevelStatements( + source: []const u8, + allocator: std.mem.Allocator, +) !struct { stmts: []TopLevelStatement, remaining: []const u8 } { + var stmts: std.ArrayListUnmanaged(TopLevelStatement) = .{}; + var remaining: std.ArrayListUnmanaged(u8) = .{}; + var stmt_buffer: std.ArrayListUnmanaged(u8) = .{}; + errdefer stmts.deinit(allocator); + errdefer remaining.deinit(allocator); + defer stmt_buffer.deinit(allocator); + + var lines = std.mem.splitScalar(u8, source, '\n'); + var seen_content = false; + var in_code_fence = false; + + while (lines.next()) |line| { + const trimmed = bun.strings.trimSpaces(line); + + if (bun.strings.hasPrefixComptime(trimmed, "```")) { + in_code_fence = !in_code_fence; + } + + const maybe_stmt = !in_code_fence and !seen_content and trimmed.len > 0 and (bun.strings.hasPrefixComptime(trimmed, "import ") or + bun.strings.hasPrefixComptime(trimmed, "import{") or + (bun.strings.hasPrefixComptime(trimmed, "export ") and !bun.strings.hasPrefixComptime(trimmed, "export default"))); + + if (maybe_stmt) { + const kind: StmtKind = if (bun.strings.hasPrefixComptime(trimmed, "import")) .import_stmt else .export_stmt; + var stmt_state: StatementParseState = .{}; + stmt_buffer.clearRetainingCapacity(); + + var stmt_line = line; + while (true) { + if (stmt_buffer.items.len > 0) { + try stmt_buffer.append(allocator, '\n'); + } + try stmt_buffer.appendSlice(allocator, stmt_line); + updateStatementParseState(&stmt_state, stmt_line); + + if (isStatementComplete(kind, stmt_line, stmt_state)) { + break; + } + + stmt_line = lines.next() orelse break; + } + + try stmts.append(allocator, .{ + .text = try allocator.dupe(u8, stmt_buffer.items), + .kind = kind, + }); + continue; + } + + if (trimmed.len > 0) seen_content = true; + try remaining.appendSlice(allocator, line); + try remaining.append(allocator, '\n'); + } + + return .{ + .stmts = try stmts.toOwnedSlice(allocator), + .remaining = try remaining.toOwnedSlice(allocator), + }; +} + +pub fn replaceExpressions( + source: []const u8, + allocator: std.mem.Allocator, +) !struct { text: []u8, slots: []ExpressionSlot } { + var slots: std.ArrayListUnmanaged(ExpressionSlot) = .{}; + var output: std.ArrayListUnmanaged(u8) = .{}; + errdefer slots.deinit(allocator); + errdefer output.deinit(allocator); + + var i: usize = 0; + var depth: usize = 0; + var expr_start: ?usize = null; + var in_code_fence = false; + var in_inline_code = false; + var expr_quote: ?u8 = null; + var expr_escaped = false; + var expr_in_line_comment = false; + var expr_in_block_comment = false; + var template_expr_depths: std.ArrayListUnmanaged(usize) = .{}; + defer template_expr_depths.deinit(allocator); + + while (i < source.len) : (i += 1) { + const c = source[i]; + + if (c == '`' and i + 2 < source.len and source[i + 1] == '`' and source[i + 2] == '`') { + in_code_fence = !in_code_fence; + try output.appendSlice(allocator, source[i .. i + 3]); + i += 2; + continue; + } + if (in_code_fence) { + try output.append(allocator, c); + continue; + } + + if (expr_start != null) { + if (expr_in_line_comment) { + if (c == '\n') expr_in_line_comment = false; + continue; + } + + if (expr_in_block_comment) { + if (c == '*' and i + 1 < source.len and source[i + 1] == '/') { + expr_in_block_comment = false; + i += 1; + } + continue; + } + + if (expr_quote) |quote| { + if (expr_escaped) { + expr_escaped = false; + continue; + } + if (c == '\\') { + expr_escaped = true; + continue; + } + if (c == quote) { + expr_quote = null; + } + continue; + } + + if (template_expr_depths.items.len > 0) { + const top_idx = template_expr_depths.items.len - 1; + const top_depth = template_expr_depths.items[top_idx]; + + if (expr_escaped) { + expr_escaped = false; + continue; + } + + if (c == '\\') { + expr_escaped = true; + continue; + } + + if (top_depth == 0) { + if (c == '`') { + _ = template_expr_depths.pop(); + continue; + } + if (c == '$' and i + 1 < source.len and source[i + 1] == '{') { + template_expr_depths.items[top_idx] = 1; + i += 1; + } + continue; + } + + if (c == '/' and i + 1 < source.len and source[i + 1] == '/') { + expr_in_line_comment = true; + i += 1; + continue; + } + + if (c == '/' and i + 1 < source.len and source[i + 1] == '*') { + expr_in_block_comment = true; + i += 1; + continue; + } + + if (c == '\'' or c == '"') { + expr_quote = c; + expr_escaped = false; + continue; + } + + if (c == '`') { + try template_expr_depths.append(allocator, 0); + expr_escaped = false; + continue; + } + + if (c == '{') { + template_expr_depths.items[top_idx] += 1; + continue; + } + + if (c == '}') { + template_expr_depths.items[top_idx] -= 1; + continue; + } + + continue; + } + + if (c == '/' and i + 1 < source.len and source[i + 1] == '/') { + expr_in_line_comment = true; + i += 1; + continue; + } + + if (c == '/' and i + 1 < source.len and source[i + 1] == '*') { + expr_in_block_comment = true; + i += 1; + continue; + } + + if (c == '\'' or c == '"') { + expr_quote = c; + expr_escaped = false; + continue; + } + + if (c == '`') { + try template_expr_depths.append(allocator, 0); + expr_escaped = false; + continue; + } + + if (c == '{') depth += 1; + if (c == '}') { + depth -= 1; + if (depth == 0) { + const expr_text = source[expr_start.? + 1 .. i]; + const slot_id = slots.items.len; + const placeholder = try std.fmt.allocPrint(allocator, "\x01MDXE{d}\x01", .{slot_id}); + try slots.append(allocator, .{ + .original = try allocator.dupe(u8, expr_text), + .placeholder = placeholder, + }); + try output.appendSlice(allocator, placeholder); + expr_start = null; + expr_quote = null; + expr_escaped = false; + expr_in_line_comment = false; + expr_in_block_comment = false; + template_expr_depths.clearRetainingCapacity(); + continue; + } + } + continue; + } + + if (c == '`') { + in_inline_code = !in_inline_code; + try output.append(allocator, c); + continue; + } + if (in_inline_code) { + try output.append(allocator, c); + continue; + } + + if (c == '{' and expr_start == null) { + expr_start = i; + depth = 1; + expr_quote = null; + expr_escaped = false; + expr_in_line_comment = false; + expr_in_block_comment = false; + template_expr_depths.clearRetainingCapacity(); + continue; + } + + try output.append(allocator, c); + } + + if (expr_start != null) { + return error.UnclosedExpression; + } + + return .{ + .text = try output.toOwnedSlice(allocator), + .slots = try slots.toOwnedSlice(allocator), + }; +} + +pub fn compile(src: []const u8, allocator: std.mem.Allocator, options: MdxOptions) ![]u8 { + const source = bun.strings.trimSpaces(src); + const fm = extractFrontmatter(source); + const content_start: usize = if (fm) |f| f.content_start else 0; + + const extracted = try extractTopLevelStatements(source[content_start..], allocator); + defer { + allocator.free(extracted.remaining); + for (extracted.stmts) |stmt| { + allocator.free(stmt.text); + } + allocator.free(extracted.stmts); + } + + const preprocessed = try replaceExpressions(extracted.remaining, allocator); + defer { + allocator.free(preprocessed.text); + for (preprocessed.slots) |slot| { + allocator.free(slot.original); + allocator.free(slot.placeholder); + } + allocator.free(preprocessed.slots); + } + + var renderer = jsx_renderer.JSXRenderer.init(allocator, preprocessed.text, preprocessed.slots); + defer renderer.deinit(); + + try md.renderWithRenderer(preprocessed.text, allocator, options.md_options, renderer.renderer()); + if (bun.strings.contains(renderer.getOutput(), "\x01MDXE")) { + return error.UnresolvedPlaceholder; + } + + var out: std.ArrayListUnmanaged(u8) = .{}; + errdefer out.deinit(allocator); + + if (options.jsx_import_source.len > 0 and !bun.strings.eql(options.jsx_import_source, "react")) { + try out.writer(allocator).print("/** @jsxImportSource {s} */\n", .{options.jsx_import_source}); + } + + for (extracted.stmts) |stmt| { + if (stmt.kind == .import_stmt) { + try out.appendSlice(allocator, stmt.text); + try out.append(allocator, '\n'); + } + } + try out.append(allocator, '\n'); + + for (extracted.stmts) |stmt| { + if (stmt.kind == .export_stmt) { + try out.appendSlice(allocator, stmt.text); + try out.append(allocator, '\n'); + } + } + + if (fm) |f| { + try out.appendSlice(allocator, "export const frontmatter = "); + try emitFrontmatterAsJson(&out, allocator, f.yaml_content); + try out.appendSlice(allocator, ";\n"); + } + + try out.appendSlice(allocator, "\nexport default function MDXContent(props) {\n"); + try out.appendSlice(allocator, " const _components = Object.assign({"); + var first = true; + for (renderer.component_names.keys()) |name| { + if (!first) try out.appendSlice(allocator, ", "); + try out.append(allocator, '"'); + try out.appendSlice(allocator, name); + try out.appendSlice(allocator, "\": \""); + try out.appendSlice(allocator, name); + try out.append(allocator, '"'); + first = false; + } + try out.appendSlice(allocator, "}, props.components);\n"); + try out.appendSlice(allocator, " return <>"); + try out.appendSlice(allocator, renderer.getOutput()); + try out.appendSlice(allocator, ";\n}\n"); + + return out.toOwnedSlice(allocator); +} + +/// Parses YAML frontmatter and serializes it as a JSON object literal. +/// Uses Bun's YAML parser which supports the full YAML spec including +/// nested objects, arrays, booleans, numbers, and multiline strings. +/// Returns error.YamlParseError if the YAML content cannot be parsed. +fn emitFrontmatterAsJson(out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, yaml_content: []const u8) !void { + ast.Expr.Data.Store.create(); + + var log = logger.Log.init(allocator); + defer log.deinit(); + + const source = logger.Source.initPathString("frontmatter.yaml", yaml_content); + const expr = yaml.YAML.parse(&source, &log, allocator) catch { + return error.YamlParseError; + }; + + try emitExprAsJson(out, allocator, expr); +} + +fn emitExprAsJson(out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, expr: ast.Expr) !void { + switch (expr.data) { + .e_object => |obj| { + try out.append(allocator, '{'); + for (obj.properties.slice(), 0..) |prop, i| { + if (i > 0) try out.appendSlice(allocator, ", "); + if (prop.key) |key| { + if (key.data.as(.e_string)) |str| { + try out.append(allocator, '"'); + try appendJsonStringEscaped(out, allocator, str.data); + try out.appendSlice(allocator, "\": "); + } else { + try out.appendSlice(allocator, "\"\":"); + } + } else { + try out.appendSlice(allocator, "\"\":"); + } + if (prop.value) |val| { + try emitExprAsJson(out, allocator, val); + } else { + try out.appendSlice(allocator, "null"); + } + } + try out.append(allocator, '}'); + }, + .e_array => |arr| { + try out.append(allocator, '['); + for (arr.items.slice(), 0..) |item, i| { + if (i > 0) try out.appendSlice(allocator, ", "); + try emitExprAsJson(out, allocator, item); + } + try out.append(allocator, ']'); + }, + .e_string => |str| { + try out.append(allocator, '"'); + try appendJsonStringEscaped(out, allocator, str.data); + try out.append(allocator, '"'); + }, + .e_number => |num| { + if (std.math.isNan(num.value) or std.math.isInf(num.value)) { + try out.appendSlice(allocator, "null"); + } else if (num.value == @trunc(num.value) and + @abs(num.value) < @as(f64, @floatFromInt(@as(i64, std.math.maxInt(i52))))) + { + var buf: [32]u8 = undefined; + const formatted = std.fmt.bufPrint(&buf, "{d}", .{@as(i64, @intFromFloat(num.value))}) catch unreachable; + try out.appendSlice(allocator, formatted); + } else { + var buf: [124]u8 = undefined; + const formatted = bun.fmt.FormatDouble.dtoa(&buf, num.value); + try out.appendSlice(allocator, formatted); + } + }, + .e_boolean => |b| { + try out.appendSlice(allocator, if (b.value) "true" else "false"); + }, + .e_null => { + try out.appendSlice(allocator, "null"); + }, + else => { + try out.appendSlice(allocator, "null"); + }, + } +} + +fn appendJsonStringEscaped(out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, bytes: []const u8) !void { + const hex_digits = "0123456789abcdef"; + for (bytes) |c| { + switch (c) { + '\\' => try out.appendSlice(allocator, "\\\\"), + '"' => try out.appendSlice(allocator, "\\\""), + '\n' => try out.appendSlice(allocator, "\\n"), + '\r' => try out.appendSlice(allocator, "\\r"), + '\t' => try out.appendSlice(allocator, "\\t"), + 0x08 => try out.appendSlice(allocator, "\\b"), + 0x0C => try out.appendSlice(allocator, "\\f"), + 0x00...0x07, 0x0B, 0x0E...0x1F => { + try out.appendSlice(allocator, "\\u00"); + try out.append(allocator, hex_digits[c >> 4]); + try out.append(allocator, hex_digits[c & 0x0F]); + }, + else => try out.append(allocator, c), + } + } +} + +const bun = @import("bun"); +const std = @import("std"); +const md = @import("./root.zig"); +const jsx_renderer = @import("./jsx_renderer.zig"); +const ast = bun.ast; +const logger = bun.logger; +const yaml = bun.interchange.yaml; diff --git a/src/md/root.zig b/src/md/root.zig index 219c51861c8..a758d62cdc1 100644 --- a/src/md/root.zig +++ b/src/md/root.zig @@ -99,6 +99,8 @@ const Flags = types.Flags; pub const entity = @import("./entity.zig"); pub const helpers = @import("./helpers.zig"); +pub const jsx_renderer = @import("./jsx_renderer.zig"); +pub const mdx = @import("./mdx.zig"); const parser = @import("./parser.zig"); const std = @import("std"); diff --git a/src/options.zig b/src/options.zig index 94b5b5c7ba1..a466734c723 100644 --- a/src/options.zig +++ b/src/options.zig @@ -636,6 +636,7 @@ pub const Loader = enum(u8) { yaml = 18, json5 = 19, md = 20, + mdx = 21, pub const Optional = enum(u8) { none = 254, @@ -702,7 +703,7 @@ pub const Loader = enum(u8) { pub fn toMimeType(this: Loader, paths: []const []const u8) bun.http.MimeType { return switch (this) { - .jsx, .js, .ts, .tsx => bun.http.MimeType.javascript, + .jsx, .js, .ts, .tsx, .mdx => bun.http.MimeType.javascript, .css => bun.http.MimeType.css, .toml, .yaml, .json, .jsonc, .json5 => bun.http.MimeType.json, .wasm => bun.http.MimeType.wasm, @@ -729,14 +730,14 @@ pub const Loader = enum(u8) { pub fn canHaveSourceMap(this: Loader) bool { return switch (this) { - .jsx, .js, .ts, .tsx => true, + .jsx, .js, .ts, .tsx, .mdx => true, else => false, }; } pub fn canBeRunByBun(this: Loader) bool { return switch (this) { - .jsx, .js, .ts, .tsx, .wasm, .bunsh => true, + .jsx, .js, .ts, .tsx, .wasm, .bunsh, .mdx => true, else => false, }; } @@ -760,6 +761,7 @@ pub const Loader = enum(u8) { map.set(.bunsh, "input.sh"); map.set(.html, "input.html"); map.set(.md, "input.md"); + map.set(.mdx, "input.mdx"); break :brk map; }; @@ -779,7 +781,7 @@ pub const Loader = enum(u8) { if (zig_str.len == 0) return null; return fromString(zig_str.slice()) orelse { - return global.throwInvalidArguments("invalid loader - must be js, jsx, tsx, ts, css, file, toml, yaml, wasm, bunsh, json, or md", .{}); + return global.throwInvalidArguments("invalid loader - must be js, jsx, tsx, ts, css, file, toml, yaml, wasm, bunsh, json, md, or mdx", .{}); }; } @@ -812,6 +814,7 @@ pub const Loader = enum(u8) { .{ "html", .html }, .{ "md", .md }, .{ "markdown", .md }, + .{ "mdx", .mdx }, }); pub const api_names = bun.ComptimeStringMap(api.Loader, .{ @@ -841,6 +844,7 @@ pub const Loader = enum(u8) { .{ "html", .html }, .{ "md", .md }, .{ "markdown", .md }, + .{ "mdx", .mdx }, }); pub fn fromString(slice_: string) ?Loader { @@ -880,6 +884,7 @@ pub const Loader = enum(u8) { .text => .text, .sqlite_embedded, .sqlite => .sqlite, .md => .md, + .mdx => .mdx, }; } @@ -907,6 +912,7 @@ pub const Loader = enum(u8) { .sqlite => .sqlite, .sqlite_embedded => .sqlite_embedded, .md => .md, + .mdx => .mdx, _ => .file, }; } @@ -921,7 +927,7 @@ pub const Loader = enum(u8) { pub fn isJavaScriptLike(loader: Loader) bool { return switch (loader) { - .jsx, .js, .ts, .tsx => true, + .jsx, .js, .ts, .tsx, .mdx => true, else => false, }; } @@ -1126,6 +1132,7 @@ const default_loaders_posix = .{ .{ ".html", .html }, .{ ".jsonc", .jsonc }, .{ ".json5", .json5 }, + .{ ".mdx", .mdx }, }; const default_loaders_win32 = default_loaders_posix ++ .{ .{ ".sh", .bunsh }, @@ -1549,7 +1556,7 @@ pub fn definesFromTransformOptions( ); } -const default_loader_ext_bun = [_]string{ ".node", ".html" }; +const default_loader_ext_bun = [_]string{ ".node", ".html", ".mdx" }; const default_loader_ext = [_]string{ ".jsx", ".json", ".js", ".mjs", diff --git a/src/transpiler.zig b/src/transpiler.zig index ac9caf5bcde..50708ac51f5 100644 --- a/src/transpiler.zig +++ b/src/transpiler.zig @@ -627,7 +627,7 @@ pub const Transpiler = struct { }; switch (loader) { - .jsx, .tsx, .js, .ts, .json, .jsonc, .toml, .yaml, .json5, .text, .md => { + .jsx, .tsx, .js, .ts, .json, .jsonc, .toml, .yaml, .json5, .text, .md, .mdx => { var result = transpiler.parse( ParseOptions{ .allocator = transpiler.allocator, @@ -980,6 +980,57 @@ pub const Transpiler = struct { allow_bytecode_cache: bool = false, }; + /// Builds common js_parser.Parser.Options shared between JS/TS and MDX + /// handlers. Callers set loader-specific overrides (jsx.parse, standard_decorators, + /// trim_unused_imports) before or after calling this. + fn buildJsParserOptions( + transpiler: *Transpiler, + this_parse: ParseOptions, + jsx: options.JSX.Pragma, + loader: options.Loader, + file_hash: ?u32, + ) js_parser.Parser.Options { + const target = transpiler.options.target; + var opts = js_parser.Parser.Options.init(jsx, loader); + + opts.features.emit_decorator_metadata = this_parse.emit_decorator_metadata; + opts.features.allow_runtime = transpiler.options.allow_runtime; + opts.features.set_breakpoint_on_first_line = this_parse.set_breakpoint_on_first_line; + opts.features.no_macros = transpiler.options.no_macros; + opts.features.runtime_transpiler_cache = this_parse.runtime_transpiler_cache; + opts.transform_only = transpiler.options.transform_only; + opts.ignore_dce_annotations = transpiler.options.ignore_dce_annotations; + opts.features.dont_bundle_twice = this_parse.dont_bundle_twice; + opts.features.commonjs_at_runtime = this_parse.allow_commonjs; + opts.module_type = this_parse.module_type; + opts.tree_shaking = transpiler.options.tree_shaking; + opts.features.inlining = transpiler.options.inlining; + opts.filepath_hash_for_hmr = file_hash orelse 0; + opts.features.auto_import_jsx = transpiler.options.auto_import_jsx; + opts.warn_about_unbundled_modules = !target.isBun(); + opts.features.inject_jest_globals = this_parse.inject_jest_globals; + opts.features.minify_syntax = transpiler.options.minify_syntax; + opts.features.minify_identifiers = transpiler.options.minify_identifiers; + opts.features.dead_code_elimination = transpiler.options.dead_code_elimination; + opts.features.remove_cjs_module_wrapper = this_parse.remove_cjs_module_wrapper; + opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags; + opts.features.repl_mode = transpiler.options.repl_mode; + opts.repl_mode = transpiler.options.repl_mode; + + if (transpiler.macro_context == null) { + transpiler.macro_context = js_ast.Macro.MacroContext.init(transpiler); + } + opts.features.top_level_await = true; + opts.macro_context = &transpiler.macro_context.?; + if (target != .bun_macro) { + opts.macro_context.javascript_object = this_parse.macro_js_ctx; + } + opts.features.is_macro_runtime = target == .bun_macro; + opts.features.replace_exports = this_parse.replace_exports; + + return opts; + } + pub fn parse( transpiler: *Transpiler, this_parse: ParseOptions, @@ -1095,64 +1146,15 @@ pub const Transpiler = struct { }; } - const target = transpiler.options.target; - var jsx = this_parse.jsx; jsx.parse = loader.isJSX(); - var opts = js_parser.Parser.Options.init(jsx, loader); - - opts.features.emit_decorator_metadata = this_parse.emit_decorator_metadata; + var opts = transpiler.buildJsParserOptions(this_parse, jsx, loader, file_hash); // emitDecoratorMetadata implies legacy/experimental decorators, as it only // makes sense with TypeScript's legacy decorator system (reflect-metadata). // TC39 standard decorators have their own metadata mechanism. opts.features.standard_decorators = !loader.isTypeScript() or !(this_parse.experimental_decorators or this_parse.emit_decorator_metadata); - opts.features.allow_runtime = transpiler.options.allow_runtime; - opts.features.set_breakpoint_on_first_line = this_parse.set_breakpoint_on_first_line; opts.features.trim_unused_imports = transpiler.options.trim_unused_imports orelse loader.isTypeScript(); - opts.features.no_macros = transpiler.options.no_macros; - opts.features.runtime_transpiler_cache = this_parse.runtime_transpiler_cache; - opts.transform_only = transpiler.options.transform_only; - - opts.ignore_dce_annotations = transpiler.options.ignore_dce_annotations; - - // @bun annotation - opts.features.dont_bundle_twice = this_parse.dont_bundle_twice; - - opts.features.commonjs_at_runtime = this_parse.allow_commonjs; - opts.module_type = this_parse.module_type; - - opts.tree_shaking = transpiler.options.tree_shaking; - opts.features.inlining = transpiler.options.inlining; - - opts.filepath_hash_for_hmr = file_hash orelse 0; - opts.features.auto_import_jsx = transpiler.options.auto_import_jsx; - opts.warn_about_unbundled_modules = !target.isBun(); - - opts.features.inject_jest_globals = this_parse.inject_jest_globals; - opts.features.minify_syntax = transpiler.options.minify_syntax; - opts.features.minify_identifiers = transpiler.options.minify_identifiers; - opts.features.dead_code_elimination = transpiler.options.dead_code_elimination; - opts.features.remove_cjs_module_wrapper = this_parse.remove_cjs_module_wrapper; - opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags; - opts.features.repl_mode = transpiler.options.repl_mode; - opts.repl_mode = transpiler.options.repl_mode; - - if (transpiler.macro_context == null) { - transpiler.macro_context = js_ast.Macro.MacroContext.init(transpiler); - } - - // we'll just always enable top-level await - // this is incorrect for Node.js files which are CommonJS modules - opts.features.top_level_await = true; - - opts.macro_context = &transpiler.macro_context.?; - if (target != .bun_macro) { - opts.macro_context.javascript_object = this_parse.macro_js_ctx; - } - - opts.features.is_macro_runtime = target == .bun_macro; - opts.features.replace_exports = this_parse.replace_exports; return switch ((transpiler.resolver.caches.js.parse( allocator, @@ -1403,6 +1405,60 @@ pub const Transpiler = struct { .input_fd = input_fd, }; }, + .mdx => { + const jsx_source = bun.md.mdx.compile(source.contents, allocator, .{}) catch |err| { + transpiler.log.addErrorFmt( + null, + logger.Loc.Empty, + transpiler.allocator, + "Failed to compile MDX: {s}", + .{@errorName(err)}, + ) catch {}; + return null; + }; + + var jsx = this_parse.jsx; + jsx.parse = true; + + var opts = transpiler.buildJsParserOptions(this_parse, jsx, .tsx, file_hash); + opts.features.standard_decorators = !this_parse.experimental_decorators; + opts.features.trim_unused_imports = transpiler.options.trim_unused_imports orelse true; + + var virtual_source = source.*; + virtual_source.contents = jsx_source; + + return switch ((transpiler.resolver.caches.js.parse( + allocator, + opts, + transpiler.options.define, + transpiler.log, + &virtual_source, + ) catch null) orelse return null) { + .ast => |value| .{ + .ast = value, + .source = source.*, + .loader = loader, + .input_fd = input_fd, + .runtime_transpiler_cache = this_parse.runtime_transpiler_cache, + }, + .cached => .{ + .ast = undefined, + .runtime_transpiler_cache = this_parse.runtime_transpiler_cache, + .source = source.*, + .loader = loader, + .input_fd = input_fd, + }, + // mdx.compile() always produces ESM source, so CJS/bytecode + // variants cannot occur here; collapsing to .source_code is intentional. + .already_bundled => .{ + .ast = undefined, + .already_bundled = .source_code, + .source = source.*, + .loader = loader, + .input_fd = input_fd, + }, + }; + }, .wasm => { if (transpiler.options.target.isBun()) { if (!source.isWebAssembly()) { diff --git a/test/js/bun/md/fixtures/mdx/complex-frontmatter.mdx b/test/js/bun/md/fixtures/mdx/complex-frontmatter.mdx new file mode 100644 index 00000000000..5dfdbe2532c --- /dev/null +++ b/test/js/bun/md/fixtures/mdx/complex-frontmatter.mdx @@ -0,0 +1,161 @@ +--- +title: "Complex Frontmatter + MDX Parse Torture Test" +description: >- + Deliberately dense MDX fixture that combines rich YAML frontmatter, ESM, + JSX, markdown constructs, and inline expressions in a single valid document. +lastUpdated: 2026-02-28 +documentType: Research +region: STUS +practice: ITMSP +customerAbbr: "TESTCUST" +year: 2026 +status: active +priority: 1 +confidence: 0.975 +nullableField: null +flags: + parserStrict: true + allowExperimental: false +metadata: + owners: + - name: "Parser Bot" + email: "parser.bot@snyder.tech" + roles: [maintainer, reviewer] + - name: "Fixture Curator" + email: "fixture.curator@snyder.tech" + roles: + - author + - qa + tags: + - mdx + - frontmatter + - bun + - remark + nested: + level1: + level2: + level3: + key: value + array: + - one + - 2 + - true + - null +multilineLiteral: |- + First line in literal block. + Second line keeps indentation. + Third line includes symbols: [] {} () : ; , . +quotedStrings: + jsonLike: '{"safe":true,"count":3}' + colonText: "A:B:C" +timings: + generatedAt: 2026-02-28T12:34:56Z + schedule: + start: "2026-03-01" + end: "2026-12-31" +matrix: + dimensions: { rows: 3, cols: 3 } + values: + - [1, 2, 3] + - [4, 5, 6] + - [7, 8, 9] +"key:with:colons": "still valid" +--- + +import React from "react"; +import { Fragment } from "react"; + +export const parserMatrix = { + name: "mdx-fixture", + version: "1.0.0", + axes: ["frontmatter", "mdx-esm", "jsx", "markdown"], + checks: [ + { id: "fm-title", enabled: true }, + { id: "jsx-inline", enabled: true }, + { id: "code-fence", enabled: true }, + ], +}; + +export const numericSequence = [0, 1, 1, 2, 3, 5, 8, 13]; + +export function summarizeChecks(checks) { + const enabled = checks.filter(c => c.enabled).length; + return `${enabled}/${checks.length} checks enabled`; +} + +export const InlineCard = ({ title, children }) => ( +
+

{title}

+
{children}
+
+); + +# {frontmatter.title} + +**Description:** {frontmatter.description} + +**Customer:** `{frontmatter.customerAbbr}` | **Year:** `{frontmatter.year}` | **Status:** `{frontmatter.status}` + +This paragraph includes inline expression math: `{2 ** 5}` and JSON-length lookup: `{parserMatrix.axes.length}`. + + +

{summarizeChecks(parserMatrix.checks)}

+

Latest update: {String(frontmatter.lastUpdated)}

+
+ +## Markdown Stress Section + +1. Ordered list with nested bullets: + - alpha + - beta + - beta.one + - beta.two +2. Second item with `inline code` and an escaped brace: `\{literal\}`. +3. Third item with a link: [Bun](https://bun.sh). + +> Blockquote with **formatting**, `code`, and a line break. +> Continues on the next line. + +### Fenced Code Samples + +```ts +export function add(a: number, b: number): number { + return a + b; +} +``` + +```json +{ + "message": "JSON fence inside MDX", + "valid": true, + "count": 3 +} +``` + +### JSX + Expressions + + +
    + {numericSequence.map((n, idx) => ( +
  • idx={idx}; value={n}; parity={n % 2 === 0 ? "even" : "odd"}
  • + ))} +
+
+ +### Markdown Table-Like Content + +| Key | Value | +| --- | --- | +| `frontmatter.title` | `{String(frontmatter.title)}` | +| `parserMatrix.name` | `{parserMatrix.name}` | +| `numericSequence.length` | `{numericSequence.length}` | + +### Final Expression + +{(() => { + const enabledIds = parserMatrix.checks + .filter(c => c.enabled) + .map(c => c.id) + .join(", "); + return `Enabled checks: ${enabledIds}`; +})()} diff --git a/test/js/bun/md/fixtures/mdx/components-and-expressions.mdx b/test/js/bun/md/fixtures/mdx/components-and-expressions.mdx new file mode 100644 index 00000000000..9563ebd787d --- /dev/null +++ b/test/js/bun/md/fixtures/mdx/components-and-expressions.mdx @@ -0,0 +1,17 @@ +--- +layout: default +--- + +import { Box, Button } from "./ui"; + +# Components and Expressions + +Inline math: {2 + 3} equals 5. + +Block expression: + +{`Array.from({ length: 3 }, (_, i) => i + 1).join(", ")`} + + + + diff --git a/test/js/bun/md/fixtures/mdx/frontmatter-and-exports.mdx b/test/js/bun/md/fixtures/mdx/frontmatter-and-exports.mdx new file mode 100644 index 00000000000..e9cb97706ec --- /dev/null +++ b/test/js/bun/md/fixtures/mdx/frontmatter-and-exports.mdx @@ -0,0 +1,16 @@ +--- +title: Frontmatter Demo +author: Test Author +tags: [mdx, test] +--- + +import { Badge } from "./Badge"; + +export const version = "1.0.0"; +export const meta = { category: "docs" }; + +# {frontmatter.title} + +By **{frontmatter.author}** + +v{version} diff --git a/test/js/bun/md/fixtures/mdx/gfm-mixed-content.mdx b/test/js/bun/md/fixtures/mdx/gfm-mixed-content.mdx new file mode 100644 index 00000000000..c2492e3638e --- /dev/null +++ b/test/js/bun/md/fixtures/mdx/gfm-mixed-content.mdx @@ -0,0 +1,19 @@ +--- +title: GFM Mixed +--- + +# GFM Mixed Content + +| Col A | Col B | +| ----- | ----- | +| 1 | 2 | +| 3 | 4 | + +- [ ] unchecked +- [x] checked + +`inline code` and **bold** and *italic*. + +--- + +Horizontal rule above. diff --git a/test/js/bun/md/fixtures/mdx/invalid-unclosed-expression.mdx b/test/js/bun/md/fixtures/mdx/invalid-unclosed-expression.mdx new file mode 100644 index 00000000000..6cb0db25f15 --- /dev/null +++ b/test/js/bun/md/fixtures/mdx/invalid-unclosed-expression.mdx @@ -0,0 +1,9 @@ +--- +title: Invalid +--- + +# Broken + +This expression is never closed: {1 + 2 + +More text here. diff --git a/test/js/bun/md/fixtures/mdx/nested-structure.mdx b/test/js/bun/md/fixtures/mdx/nested-structure.mdx new file mode 100644 index 00000000000..0f08cef191a --- /dev/null +++ b/test/js/bun/md/fixtures/mdx/nested-structure.mdx @@ -0,0 +1,22 @@ +--- +depth: 0 +--- + +# Level 1 + +## Level 2 + +### Level 3 + +> Blockquote +> Multiple lines + +1. Ordered +2. List + +- Unordered +- Items + +```js +const x = 1; +``` diff --git a/test/js/bun/md/mdx.test.ts b/test/js/bun/md/mdx.test.ts new file mode 100644 index 00000000000..a375cfbcaa0 --- /dev/null +++ b/test/js/bun/md/mdx.test.ts @@ -0,0 +1,794 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import fs from "node:fs"; +import path from "node:path"; + +const fixtureDir = path.join(import.meta.dir, "fixtures", "mdx"); +const repoRoot = path.resolve(import.meta.dir, "../../../.."); +const repoNodeModules = path.join(repoRoot, "node_modules"); + +function linkNodeModules(dir: string) { + fs.symlinkSync(repoNodeModules, path.join(dir, "node_modules"), "junction"); +} + +function extractFrontmatterObjectLiteral(compiled: string): string { + const prefix = "export const frontmatter = "; + const start = compiled.indexOf(prefix); + if (start === -1) { + throw new Error("frontmatter export not found in compiled output"); + } + + const objectStart = start + prefix.length; + if (compiled[objectStart] !== "{") { + throw new Error("frontmatter export does not start with an object literal"); + } + + let depth = 0; + for (let i = objectStart; i < compiled.length; i++) { + const ch = compiled[i]; + if (ch === "{") depth++; + if (ch === "}") { + depth--; + if (depth === 0) { + return compiled.slice(objectStart, i + 1); + } + } + } + + throw new Error("frontmatter object literal was not closed"); +} + +/** Matches the "url: http://..." line printed by the dev server. */ +const URL_REGEX = /url:\s*(\S+)/; +/** The last-route marker (└──) used to wait until the full route tree is printed. */ +const LAST_ROUTE_ENTRY = "\u2514\u2500\u2500"; +/** Matches a single route entry like " ├── /docs → docs/index.mdx". */ +const ROUTE_ENTRY_REGEX = /[├└]──\s+(\/\S*)\s+→\s+(\S+)/g; + +const Mdx = (Bun as any).mdx as { + compile( + input: string | Uint8Array, + options?: { jsxImportSource?: string; hardSoftBreaks?: boolean; hard_soft_breaks?: boolean }, + ): string; +}; + +describe("Bun.mdx.compile", () => { + test("compiles markdown to JSX module", () => { + const output = Mdx.compile("# Hello\n\nWorld"); + expect(output).toContain("export default function MDXContent"); + expect(output).toContain("_components.h1"); + expect(output).toContain("Hello"); + }); + + test("supports frontmatter and top-level statements", () => { + const output = Mdx.compile( + ["---", "title: Demo", "---", 'import { X } from "./x"', "export const year = 2026", "", "# Heading"].join("\n"), + ); + expect(output).toContain('import { X } from "./x"'); + expect(output).toContain("export const year = 2026"); + expect(output).toContain('export const frontmatter = {"title": "Demo"}'); + }); + + test("preserves inline expressions", () => { + const output = Mdx.compile("Count: {props.count}"); + expect(output).toContain("{props.count}"); + }); + + test("preserves expressions with closing brace in template/string literals", () => { + const templateExpr = "Value: {`has } brace`}"; + const templateOut = Mdx.compile(templateExpr); + expect(templateOut).toContain("{`has } brace`}"); + + const singleQuotedExpr = "Value: {'has } brace'}"; + const singleQuotedOut = Mdx.compile(singleQuotedExpr); + expect(singleQuotedOut).toContain("{'has } brace'}"); + }); + + test("skips braces inside line comments", () => { + const src = "Result: {value\n// ignore }\n+ 1}"; + const output = Mdx.compile(src); + expect(output).toContain("{value\n// ignore }\n+ 1}"); + // Regression guard: if brace counting closes early, trailing text gets rendered as markdown. + expect(output).not.toContain("

+ 1}

"); + }); + + test("skips braces inside block comments", () => { + const src = "Result: {value /* } */ + rest}"; + const output = Mdx.compile(src); + expect(output).toContain("{value /* } */ + rest}"); + expect(output).not.toContain("

+ rest}

"); + }); + + test("skips braces inside double-quoted strings", () => { + const src = 'Value: {"has } brace"}'; + const output = Mdx.compile(src); + expect(output).toContain('{"has } brace"}'); + }); + + test("handles template literals with ${...} containing braces", () => { + const src = "Value: {`${obj.a}`}"; + const output = Mdx.compile(src); + expect(output).toContain("{`${obj.a}`}"); + + const nested = "Value: {`${fn({a:1})}`}"; + const nestedOut = Mdx.compile(nested); + expect(nestedOut).toContain("{`${fn({a:1})}`}"); + }); + + test("handles nested template literals", () => { + const src = "Value: {`outer ${`inner ${x}`}`}"; + const output = Mdx.compile(src); + expect(output).toContain("{`outer ${`inner ${x}`}`}"); + }); + + test("handles escaped quotes inside strings within expressions", () => { + const src = "Value: {'it\\'s } here'}"; + const output = Mdx.compile(src); + expect(output).toContain("{'it\\'s } here'}"); + expect(output).not.toContain("

here'}

"); + }); + + test("handles comments inside template expression interpolations", () => { + const src = "Value: {`${value /* } */}`}"; + const output = Mdx.compile(src); + expect(output).toContain("{`${value /* } */}`}"); + }); + + test("supports multiline top-level import statements", () => { + const src = ["import {", " Box,", " Button,", '} from "./ui";', "", "# Heading"].join("\n"); + + const output = Mdx.compile(src); + + expect(output).toContain('import {\n Box,\n Button,\n} from "./ui";'); + expect(output).toContain("export default function MDXContent"); + expect(output).not.toContain("

Box,

"); + expect(output).not.toContain("

} from "./ui";

"); + }); + + test("supports multiline top-level export statements with trailing comments", () => { + const src = ["export const label =", ' "hello" + // keep concatenating', ' " world";', "", "# Heading"].join( + "\n", + ); + + const output = Mdx.compile(src); + + expect(output).toContain('export const label =\n "hello" + // keep concatenating\n " world";'); + expect(output).toContain("export default function MDXContent"); + expect(output).not.toContain("

" world";

"); + }); + + test("typed array input accepted", () => { + const buf = new TextEncoder().encode("# Hello\n\nTypedArray"); + const output = Mdx.compile(buf); + expect(output).toContain("export default function MDXContent"); + expect(output).toContain("Hello"); + expect(output).toContain("TypedArray"); + }); + + test("jsxImportSource and option aliases hardSoftBreaks and hard_soft_breaks", () => { + const src = "# Hi\n\nLine2"; + const outReact = Mdx.compile(src, { jsxImportSource: "react" }); + expect(outReact).not.toContain("@jsxImportSource react"); + + const outPreact = Mdx.compile(src, { jsxImportSource: "preact" }); + expect(outPreact).toContain("@jsxImportSource preact"); + + const baseline = Mdx.compile("a\nb"); + const outHardCamel = Mdx.compile("a\nb", { hardSoftBreaks: true }); + const outHardSnake = Mdx.compile("a\nb", { hard_soft_breaks: true }); + expect(outHardCamel).toBe(outHardSnake); + expect(outHardCamel).not.toBe(baseline); + }); + + test("invalid arguments throw", () => { + expect(() => Mdx.compile(undefined as any)).toThrow("Expected a string or buffer to compile"); + expect(() => Mdx.compile(null as any)).toThrow("Expected a string or buffer to compile"); + }); + + test("jsxImportSource accepts undefined, null, empty string, and coerces numbers", () => { + const src = "# Hi"; + const outDefault = Mdx.compile(src); + expect(outDefault).toContain("export default function MDXContent"); + + const outUndefined = Mdx.compile(src, { jsxImportSource: undefined }); + expect(outUndefined).toContain("export default function MDXContent"); + expect(outUndefined).not.toContain("@jsxImportSource"); + + const outNull = Mdx.compile(src, { jsxImportSource: null as any }); + expect(outNull).toContain("export default function MDXContent"); + expect(outNull).not.toContain("@jsxImportSource"); + + const outEmpty = Mdx.compile(src, { jsxImportSource: "" }); + expect(outEmpty).toContain("export default function MDXContent"); + expect(outEmpty).not.toContain("@jsxImportSource"); + + const outNumber = Mdx.compile(src, { jsxImportSource: 42 as any }); + expect(outNumber).toContain("export default function MDXContent"); + expect(outNumber).toContain("@jsxImportSource 42"); + }); + + test("jsxImportSource rejects Symbol values", () => { + expect(() => Mdx.compile("# Hi", { jsxImportSource: Symbol("x") as any })).toThrow(/jsxImportSource|string/i); + }); + + test("frontmatter supports arrays, booleans, numbers, and nested objects", () => { + const src = [ + "---", + "tags: [alpha, beta, gamma]", + "draft: true", + "version: 3", + "author:", + " name: Alice", + " url: https://example.com", + "---", + "", + "# Content", + ].join("\n"); + + const output = Mdx.compile(src); + const frontmatter = JSON.parse(extractFrontmatterObjectLiteral(output)); + expect(frontmatter).toEqual({ + tags: ["alpha", "beta", "gamma"], + draft: true, + version: 3, + author: { + name: "Alice", + url: "https://example.com", + }, + }); + }); + + test("complex fixture compiles with deep frontmatter and no placeholder leakage", () => { + const src = fs.readFileSync(path.join(fixtureDir, "complex-frontmatter.mdx"), "utf8"); + const output = Mdx.compile(src); + const frontmatter = JSON.parse(extractFrontmatterObjectLiteral(output)); + + expect(frontmatter.title).toBe("Complex Frontmatter + MDX Parse Torture Test"); + expect(frontmatter.flags).toEqual({ + parserStrict: true, + allowExperimental: false, + }); + expect(frontmatter.metadata.owners).toEqual([ + { + name: "Parser Bot", + email: "parser.bot@snyder.tech", + roles: ["maintainer", "reviewer"], + }, + { + name: "Fixture Curator", + email: "fixture.curator@snyder.tech", + roles: ["author", "qa"], + }, + ]); + expect(frontmatter.matrix).toEqual({ + dimensions: { rows: 3, cols: 3 }, + values: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + expect(frontmatter.nullableField).toBeNull(); + expect(frontmatter["key:with:colons"]).toBe("still valid"); + + expect(output).toContain("export const parserMatrix ="); + expect(output).toContain("export const numericSequence ="); + expect(output).toContain("export default function MDXContent"); + expect(output).toContain("Enabled checks:"); + expect(output).not.toContain("MDXE"); + }); + + test("malformed mdx throws syntax-like error", () => { + expect(() => Mdx.compile("---\n\n{unclosed")).toThrow(/compile error|syntax|unexpected|parse/i); + }); + + test("compiles real fixture documents", () => { + const expectByFile: Record = { + "complex-frontmatter.mdx": ["export const parserMatrix =", "export const numericSequence =", "_components.table"], + "frontmatter-and-exports.mdx": ["export const frontmatter", 'export const version = "1.0.0"'], + "components-and-expressions.mdx": ["Box", "Button"], + "gfm-mixed-content.mdx": ["_components.table", "_components.code"], + "nested-structure.mdx": ["_components.blockquote", "_components.ol"], + }; + const files = fs.readdirSync(fixtureDir).filter(f => f.endsWith(".mdx") && !f.startsWith("invalid-")); + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + const fullPath = path.join(fixtureDir, file); + const src = fs.readFileSync(fullPath, "utf8"); + const output = Mdx.compile(src); + expect(output).toContain("export default function MDXContent"); + const expectations = expectByFile[file]; + expect(expectations).toBeDefined(); + for (const substr of expectations!) expect(output).toContain(substr); + } + const staleKeys = Object.keys(expectByFile).filter(k => !files.includes(k)); + expect(staleKeys).toEqual([]); + }); +}); + +describe("MDX loader integration", () => { + test("imports mdx from tsx entrypoint", async () => { + using dir = tempDir("mdx-loader", { + "entry.tsx": ` + import Post, { frontmatter } from "./post.mdx"; + console.log(typeof Post); + console.log(frontmatter.title); + `, + "post.mdx": ` +--- +title: Integration +--- + +# Hello from MDX + `, + }); + linkNodeModules(String(dir)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.tsx"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("function\nIntegration"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("import/export heavy mdx entrypoint runtime test", async () => { + using dir = tempDir("mdx-heavy", { + "entry.tsx": ` + import Page, { frontmatter, meta } from "./page.mdx"; + console.log(typeof Page); + console.log(frontmatter.title); + console.log(meta.version); + `, + "page.mdx": ` +--- +title: Heavy +--- +import { Box } from "./Box"; +export const meta = { version: "2.0" }; + +# {frontmatter.title} + + `, + "Box.tsx": "export function Box() { return
Box
; }", + }); + linkNodeModules(String(dir)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.tsx"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("function\nHeavy\n2.0"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("complex fixture runtime SSR contains evaluated content and no placeholders", async () => { + const fixture = fs.readFileSync(path.join(fixtureDir, "complex-frontmatter.mdx"), "utf8"); + using dir = tempDir("mdx-complex-runtime", { + "entry.tsx": ` + import React from "react"; + import { renderToStaticMarkup } from "react-dom/server"; + import Page, { frontmatter } from "./page.mdx"; + + const html = renderToStaticMarkup(React.createElement(Page)); + console.log(frontmatter.title); + console.log("HAS_SUMMARY:" + html.includes("3/3 checks enabled")); + console.log("HAS_ENABLED:" + html.includes("Enabled checks: fm-title, jsx-inline, code-fence")); + console.log("HAS_MDXE:" + html.includes("MDXE")); + `, + "page.mdx": fixture, + }); + linkNodeModules(String(dir)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.tsx"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("Complex Frontmatter + MDX Parse Torture Test"); + expect(stdout).toContain("HAS_SUMMARY:true"); + expect(stdout).toContain("HAS_ENABLED:true"); + expect(stdout).toContain("HAS_MDXE:false"); + expect(stdout).not.toContain("\x01MDXE"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("malformed mdx import reports compile failure", async () => { + using dir = tempDir("mdx-bad", { + "entry.tsx": 'import Bad from "./bad.mdx"; console.log(Bad);', + "bad.mdx": "---\n---\n\n{unclosed expression", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.tsx"], + cwd: String(dir), + env: bunEnv, + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toMatch(/Failed to compile MDX:\s*[A-Za-z_]\w*/); + expect(exitCode).not.toBe(0); + }); +}); + +describe("MDX transpiler integration", () => { + test("Bun.Transpiler with loader mdx transformSync", () => { + const transpiler = new Bun.Transpiler({ loader: "mdx" }); + const result = transpiler.transformSync("# Hello MDX"); + expect(result).toContain("export default function MDXContent"); + expect(result).toContain("Hello"); + }); +}); + +const READ_UNTIL_TIMEOUT_MS = 30_000; + +async function readUntil( + proc: Bun.Subprocess, + predicate: (text: string) => boolean, + timeoutMs = READ_UNTIL_TIMEOUT_MS, +) { + const stdout = proc.stdout; + if (!stdout || typeof stdout === "number") { + throw new Error("Expected subprocess stdout to be piped"); + } + const reader = stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + let timeoutId: ReturnType | undefined; + try { + const result = await Promise.race([ + (async () => { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + output += decoder.decode(chunk.value, { stream: true }); + if (predicate(output)) { + return output; + } + } + output += decoder.decode(); + return undefined; + })(), + new Promise<"timeout">( + resolve => + (timeoutId = setTimeout(() => { + resolve("timeout"); + }, timeoutMs)), + ), + ]); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (result === "timeout") { + reader.cancel().catch(() => {}); + throw new Error(`readUntil: timed out after ${timeoutMs}ms.\nAccumulated output:\n${output}`); + } + if (result === undefined) { + throw new Error(`readUntil: stream ended without predicate match.\nAccumulated output:\n${output}`); + } + return result; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + reader.releaseLock(); + } +} + +describe("test helpers", () => { + test("readUntil throws on timeout", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "setTimeout(() => {}, 60000)"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await expect(readUntil(proc, () => false, 500)).rejects.toThrow(/timed out/i); + }); + + test("readUntil resolves before timeout", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('ready')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await readUntil(proc, text => text.includes("ready"), 5_000); + expect(output).toContain("ready"); + }); +}); + +describe("MDX direct serve mode", () => { + test("bun file.mdx serves HTML shell", async () => { + using dir = tempDir("mdx-serve", { + "index.mdx": `# Hello`, + }); + linkNodeModules(String(dir)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.mdx", "--port=0"], + cwd: String(dir), + env: { + ...bunEnv, + NO_COLOR: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await readUntil(proc, text => URL_REGEX.test(text)); + const urlMatch = output.match(URL_REGEX); + expect(urlMatch).not.toBeNull(); + + const response = await fetch(urlMatch![1]); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(`
`); + expect(html).toContain(`