apacheli IconAPACHELI

Bun's New Markdown API is Awesome

DevelopmentBun's New Markdown API is Awesome

Bun v1.3.8 added a built-in API for parsing Markdown content. You can access it with Bun.markdown.

This API introduces three ways of rendering Markdown:

You can read the official blog on how to use these functions. For the purposes of this blog, I want to focus on Bun.markdown.react() and how you can use it for your site.

What is Markdown?

Markdown is a simple markup language designed to be easily editable and readable in any basic text editor. It became a popular choice for online forums and blogging due to its ease of use.

Here's an example written in plain text:

# A Title

Hello, **Bold**!

*This text is italic.*

The example above yields the following when rendered in HTML:

A Title

Hello, Bold!

This text is italic.

Here are some nice Markdown tools that I recommend you check out:

Templating with JSX

Bun.markdown.react() is pretty much designed to be used with JSX. Keep in mind, it doesn't respect your tsconfig.json compiler options. Instead, it creates its own React-compatible components. Unfortunately, Preact fails to render these components because of how its check system works, so I ended up writing my own renderer. I could alternatively use react-dom/server, but it's 20x slower according to my benchmarks.

For this example, I want to make a basic site using Markdown for my blog and JSX for my pages. Let's start with this directory structure:

my-app/
|- src/
|  |- blog/
|  |  |- hello.md
|  |- pages/
|  |  |- index.jsx
|  |- main.jsx
|  |- tsconfig.json

Let's also add my custom renderer with the following command:

$ bun add https://github.com/apacheli/jsx

Edit tsconfig.json to use it:

{
    "compilerOptions": {
        "jsxImportSource": "@apacheli/jsx"
    }
}

Let's start by editing src/main.jsx to include this (mildly complex) boilerplate:

// src/main.jsx
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { render } from "@apacheli/jsx";

const cwd = process.cwd();

const PAGES_SRC = join(cwd, "./src/pages");
const PAGES_DIST = join(cwd, "./dist");

const replaceExtension = (name, ext) => {
    const i = name.lastIndexOf(".");
    return i > 0 ? name.substring(0, i) + ext : name + ext;
};

for (const page of await readdir(PAGES_SRC)) {
    const mod = await import(join(PAGES_SRC, page));
    const html = render(<mod.default />);
    await Bun.write(
        join(PAGES_DIST, replaceExtension(page, ".html")),
        html,
    );
}

A breakdown of what this code does:

replaceExtension() is a helper function that replaces the extension of a file with a different one or adds one if it didn't already have one.

The modules must have a default export of a function that returns a JSX component. It should look like the following:

// src/pages/index.jsx
export default () => {
    return (
        <p>Hello, World!</p>
    );
};

Now let's add some Markdown rendering:

// src/main.jsx
const BLOGS_SRC = join(cwd, "./src/blog");
const BLOGS_DIST = join(cwd, "./dist/blog");

for (const page of await readdir(BLOGS_SRC)) {
    const file = Bun.file(join(BLOGS_SRC, page));
    const component = Bun.markdown.react(await file.text());
    const html = render(component);
    await Bun.write(
        join(BLOGS_DIST, replaceExtension(page, ".html")),
        html,
    );
}

A breakdown of what this code does:

Now you can add some basic Markdown content to src/blog/hello.md.

# My Blog

Hello!

Syntax Highlighting

You can make your code blocks stand out more by adding extra HTML to certain keywords. You can do this by using lowlight (based on Highlight.js) and hast-util-to-jsx-runtime.

import { Fragment, jsx, jsxs } from "@apacheli/jsx";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { common, createLowlight } from "lowlight";

const lowlight = createLowlight(common);

const Code = ({ language, children }) => {
    const tree = lowlight.highlight(language, children.join(""));
    return (
        <pre data-language={language}>
            <code>{toJsxRuntime(tree, { Fragment, jsx, jsxs })}</code>
        </pre>
    );
};

Bun.markdown.react(..., {
    pre: Code,
});

For some popular CSS themes, check out the Highlight.js repository.

This is a pretty clunky implementation, but it works. You can alternatively use Prism.js, which is slightly faster (about 8% from my testing) but supports fewer language features.

Adding Front Matter

Front Matter (or Frontmatter) is a simple way of adding metadata to a Markdown document with YAML. It was popularized by Jekyll, a static site generator written in Ruby.

There's no formal specification for Front Matter, but most parsers will usually define it as --- at the top of the document.

---
title: Hello!
---

# My Document

We can write a simple parser that extracts and returns this data as a JavaScript object. We should also take advantage of Bun's built-in YAML parser for even more performance.

const parseFrontmatter = (text) => {
    if (text.startsWith("---")) {
        const end = text.indexOf("\n---", 3);
        if (end > -1) {
            return [
                text.substring(end + 4),
                Bun.YAML.parse(text.substring(3, end)),
            ];
        }
        // Optionally, throw an error here
    }
    return [text, {}];
};
Tip

Try steering away from using regular expressions (regex). They're pretty slow and difficult to understand even for experienced developers.

If you want to get super fancy, you can make it dynamic and add TOML support:

const parsers = {
    "---": Bun.YAML.parse,
    "+++": Bun.TOML.parse,
};

const parseFrontmatter = (text) => {
    for (const delimiter in parsers) {
        if (text.startsWith(delimiter)) {
            const end = text.indexOf(`\n${delimiter}`, delimiter.length);
            if (end > -1) {
                return [
                    text.substring(end + delimiter.length + 1),
                    parsers[delimiter](text.substring(delimiter.length, end)),
                ];
            }
            // Optionally, throw an error here
        }
    }
    return [text, {}];
};

My Thoughts on Bun's Future

The Markdown API is probably my favorite recent addition to Bun. As someone who likes to build things from scratch for the sake of performance, I avoid dependencies as much as possible. Furthermore, it gets me out of situations like the axios situation and the node-ipc drama. You should really be using the web standard fetch() API, anyway. It's just shrimply better.

With that being said, I hope they expand on this API more to support MDX, given that modern web development is built on components. It'd also be great if they implemented renderToString() in Zig so that I don't have to maintain my renderer.