It took me a long time to understand how to compile mdx
and run it with React, without using any bundler, nextjs, or whatever.
Why? I want full control over how, when, where my mdx
content is compiled. Just "importing mdx" and using it as a component is too "magic".
This guide uses only the mdx compiler to convert and run mdx content with React π‘
pnpm add @mdx-js/mdx
@mdx-js/mdx
is all that you need, this is how π
How mdx
becomes React code
mdx
content is just plain text (a string
).
Some **mdx** content
@mdx-js/mdx
is a compiler that takes astring
and outputs some javascript code.
π Nothing more, nothing less π
All the "magic" is inside the compiler:
- Allow to provide custom components
- Convert markdown to jsx
- Run custom plugins (e.g. code highlight)
All we need to care is compiling and running the javascript output.
This is what an mdx bundler does: it takes care of the compilation step without you noticing, and just provides you with a runnable component
Compiling mdx to javascript
@mdx-js/mdx
provides a compile
function.
compile
takes some mdx content (a string
in the example) and some options.
To allow executing the resulting javascript we need to specify the output format as
function-body
π
import { compile } from "@mdx-js/mdx";
const make = (content: string) =>
compile(content, {
outputFormat: "function-body",
remarkPlugins: [],
rehypePlugins: [],
});
compile
returns a VFile
.
globalThis.String
converts a VFile
it to executable javascript.
import { compile } from "@mdx-js/mdx";
const make = (content: string) =>
compile(content, {
outputFormat: "function-body",
remarkPlugins: [],
rehypePlugins: [],
});
const mdxToJavascript = (content: string): string =>
globalThis.String(
make(content)
);
For example the following .mdx
content is compiled to:
Some **mdx** content
"use strict";
const {jsx: _jsx, jsxs: _jsxs} = arguments[0];
function _createMdxContent(props) {
const _components = {
p: "p",
strong: "strong",
...props.components
};
return _jsxs(_components.p, {
children: ["Some ", _jsx(_components.strong, {
children: "mdx"
}), " content"]
});
}
function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
return {
default: MDXContent
};
Important: The result of
mdxToJavascript
is a plainstring
.
Without "function-body"
the same mdx is compiled to:
import {jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
function _createMdxContent(props) {
const _components = {
p: "p",
strong: "strong",
...props.components
};
return _jsxs(_components.p, {
children: ["Some ", _jsx(_components.strong, {
children: "mdx"
}), " content"]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
Running mdx in React
We now have a string
of javascript code that we want to execute in React.
@mdx-js/mdx
provides arun
function to do just that.
run
returns the result of executing the compiled javascript: an object containing a default
value that represent the runnable component.
import { run } from "@mdx-js/mdx";
import * as runtime from "react/jsx-runtime";
export default async function MdxComponent() {
const { default: MDXContent } = await run(
compiledMdx, /// π Your compiled mdx content from before (`compile`)
{ ...runtime }
);
return (
<MDXContent
components={{
/// πͺ Provide custom React components to MDX
}}
/>
);
}
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
Complete example: mdx
and effect
with custom plugin (shiki
)
Here is the code I am using in my app to compile mdx:
@mdx-js/mdx
: Mdx compiler and runnereffect
: Reusable servicesshiki
: Code blocks highlighter
First I created a service to highlight code using shiki
:
This allows to create the highlighter only once and use it multiple times using
Layer
fromeffect
πͺ
import { Context, Effect, Layer } from "effect";
import { getHighlighter } from "shiki";
const make = Effect.promise(() =>
getHighlighter({ themes: ["one-dark-pro"], langs: ["ts"] })
);
export class ShikiHighlighter extends Context.Tag("ShikiHighlighter")<
ShikiHighlighter,
Effect.Effect.Success<typeof make>
>() {
static readonly Live = Layer.effect(this, make);
}
I use this in another service that implements a custom rehype
plugin to highlight code blocks:
import { Context, Effect, Layer } from "effect";
import type { Root } from "hast";
import { toString as hastToString } from "hast-util-to-string";
import { visit } from "unist-util-visit";
import * as ShikiHighlighter from "./ShikiHighlighter";
const make = Effect.map(
ShikiHighlighter.ShikiHighlighter, /// π Dependency on `ShikiHighlighter`
(highlighter) => () => (tree: Root) => {
visit(tree, "element", (node, index) => {
if (node.tagName === "pre") {
const code = node.children[0];
if (code.type === "element" && code.tagName === "code") {
const codeString = hastToString(node);
const hastCode = highlighter.codeToHast(codeString, {
theme: "one-dark-pro",
lang: "ts",
});
const pre = hastCode.children[0];
if (pre.type === "element" && pre.tagName === "pre") {
node.properties = pre.properties;
node.children = pre.children;
}
}
}
});
}
);
export class ShikiPlugin extends Context.Tag("ShikiPlugin")<
ShikiPlugin,
Effect.Effect.Success<typeof make>
>() {
static readonly Live = Layer.effect(this, make).pipe(
Layer.provide(ShikiHighlighter.ShikiHighlighter.Live)
);
}
Finally, I provide the plugin to an Mdx
services that executes compile
from @mdx-js/mdx
:
import { compile } from "@mdx-js/mdx";
import { Context, Data, Effect, Layer } from "effect";
import * as ShikiPlugin from "./ShikiPlugin";
export class MdxCompileError extends Data.TaggedError("MdxCompileError")<
Readonly<{
error: unknown;
}>
> {}
const make = Effect.map(
ShikiPlugin.ShikiPlugin, /// π Provide custom plugin
(plugin) => (content: string) =>
Effect.tryPromise({
try: () =>
compile(content, {
rehypePlugins: [plugin],
outputFormat: "function-body",
}),
catch: (error) => new MdxCompileError({ error }),
})
);
export class Mdx extends Context.Tag("Mdx")<
Mdx,
Effect.Effect.Success<typeof make>
>() {
static readonly Live = Layer.effect(this, make).pipe(
Layer.provide(ShikiPlugin.ShikiPlugin.Live)
);
}
Now I can use the Mdx
service to compile any string
to javascript and run it as a React component πͺ
This setup allows full control over your mdx content.
You can read a list of mdx files from any source (file system, remote, database) and use the Mdx
service to convert it and run it in React π
If you are interested to learn more, follow me on Twitter at @SandroMaglione and subscribe to my newsletter here below π
Thanks for reading!