1

I'm trying to write an MDX2-to-HTML compiler. I've got most of it working (that is, I can compile a simple .mdx file to HTML), but without importable components.

I have read through the entire MDX2 website, but it's not quite clicking for me. I have a bunch of successful experiments that do various parts, but I haven't quite seen the whole yet.

The Objective

I want to compile a set of MDX2 strings (one main fragment and a set of components) into a static HTML component-- preferrably without accessing the disk.

(This is not an absolute requirement, but I want to run it in an AWS lambda, and I won't have access to all the files at deployment time. I could use the /tmp dir of the lambda, but would rather not for various reasons.)

What I've Tried

Here's what I have so far. This works; it compiles MDX2 to HTML without touching the disk (aside from loading the entrypoint in this example, but it could just as easily be a string from elsewhere). But it doesn't let me do things like import MDX components, and it feels really dirty with the string manipulation I'm doing.

First, my .mdx file:

//example.mdx

# Title

export const Thing = ({foo}) => <>{foo}</>

# Hello, <Thing foo="bar"/>!

This is the content.

Then I have a .js file:

// example.js
import fs from 'node:fs/promises'
import { compile } from '@mdx-js/mdx'
import { renderToString } from 'react-dom/server';
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";

/**
 * Using a vm to compile the mdx.  Why?  It seems like a way I can add
 * components accessible to the entry markdown file.  Is there a better way?
 *
 * This is the only way I could figure out how to _use_ the compiled module
 * without touching the filesystem.  Is a vfile a better choice?  I haven't
 * used them yet.
 */
import {NodeVM} from 'vm2';

/**
 * Load and compile the ./example.mdx file.  For reasons I don't quite
 * understand, I have to remove any import statements and the default
 * export.  If I don't do this, it throws an error (this could be a VM
 * issue and a reason not to use it).
 */
let compiled = String(await compile(await fs.readFile('example.mdx')))
  .replace(/import .*;/, '')
  .replace(/export default MDXContent;/, '')
;

/**
 * Here, grab the named export from the module.
 */
let namedExport = compiled.match(/export const (\w+)/);
if (namedExport) {
  namedExport = namedExport[1];
}

// Get the compiled version and remove the `export const`
compiled = compiled.replace(/export const (\w+)/, "$1");

/**
 * The compild version of this module needs to export MDXContent and the
 * named export (if there is one). We do that here.
 */
compiled += `
module.exports = {
  MDXContent,
  ${namedExport || "_void: ''"},
}
`;

/**
 * Set up our NodeVM.
 */
const vm = new NodeVM({
  console: 'inherit',
  sandbox: {
    // React resources needed by the module.
    _Fragment,
    _jsx,
    _jsxs,
  },
  require: {
    external : true,
    builtin  : ['fs','path'],
    root     : './',
    external : true,
    mock: {},
  },
});

// Now we've got a component.
let component = vm.run(compiled);

/**
 * For reasons I don't understand, when I render this I get empty HTML
 * comments. This removes them.
 */
console.log(
  renderToString(component.MDXContent())
  .replace(/<!-- -->/igsm,'')
);

And here's my package.json:

{
  "name": "mdx",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@mdx-js/loader": "^2.3.0",
    "@mdx-js/mdx": "^2.3.0",
    "@mdx-js/node-loader": "^2.3.0",
    "@mdx-js/react": "^2.3.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-jsx": "^1.0.0",
    "vm2": "^3.9.17"
  }
}

This works-- but feels way too hacky. Since we're using unified under the hood, surely I can just render to static HTML? From what I understand, MDX2 (via @mdx-js/mdx) basically does something like:

mdx → hast → esast

to get from MDX2 to a compiled component. I understand that the esast step is to resolve imports and such. I feel like I should be able to go from there back to hast and just render the fragment, but I don't quite get it.

Next steps ...?

I would love to take a set of MDX2 (and maybe MD) strings and compile everything into a fragment without accessing the disk. I saw mdx-bundler (linked from here in the mdx docs), but I couldn't get it to work. It looks promising, though.

Here's an example of what I would like to get working:

ComponentA

Component A says: {props.data}

ComponentB

<ul>{props.items.map(item => (<li>{item}</li>))}</ul>

entrypoint markdown

# Entrypoint

<ComponentA data="foo"/>
<ComponentB items={1,2,3}/>

Output:

<h1>Entrypoint</h1>

<p>Component A says: foo</p>
<ul><li>1</li><li>2</li><li>3</li></ul>

With some javascript that looks something like:

...
const ComponentA = 'Component A says: {foo}';
const ComponentB = componentBFromSomewhere;
const entrypoint = entrypointFromSomewhere;

function mdxToHtml (e, opt) {
  // ... magic!
}

console.log(mdxToHtml(entrypoint, {components: {ComponentA, ComponentB}}));

Or something like that. Help?

Sir Robert
  • 4,686
  • 7
  • 41
  • 57

0 Answers0