70 %
Chris Biscardi

Gatsby Plugin MDX Components API Design

Currently there's an issue that can pop up with respect to MDX and compatibility with the wider Remark ecosystem. Remark AST transformations often assume HTML semantics and thus don't handle the case where valid JavaScript escape sequences can occur and need to be escaped. While this is a problem that should still be solved for wider ecosystem compatibility, we have a more powerful option available to us when using MDX: Compoennts.

Components for Remark users

I've begun work on a set of components that are going to be drop-in replacements for gatsby-remark plugins where appropriate. This list includes plugins such as gatsby-remark-prismjs and gatsby-remark-autolink-headers. Some plugins don't make sense to implement like this currently, such as gatsby-remark-images, because these plugins use Gatsby APIs to optimize images, etc.

Since I practice README-first API design, This brings up an interesting point of API design quite early in the process. The core idea is something like this:

  • [component package] Create a component that replicates the functionality of a Remark plugin
    • This component could be imported and used on it's own
  • [gatsby plugin] The above component is then configurable in a gatsby plugin
  • Gatsby plugin inserts the configured components into an MDXProvider using the wrapRootElement APIs.

In the end, what we end up with is:

JS
const components = {
pre: MDXPrismJSComponent
};
export const wrapRootElement = ({ element }) => (
<MDXProvider components={components}>
{element}
</MDXProvider>
);

We have the following two options when it comes to how we require users to use gatsby plugins for these components.

javascript
modules.exports = {
plugins: [`gatsby-mdx`, `gatsby-plugin-mdx-prismjs`]
};
javascript
modules.exports = {
plugins: [
{
resolve: `gatsby-mdx`,
options: { plugins: [`gatsby-plugin-mdx-prismjs`] }
}
]
};

That is, do we want to nest MDXProviders or not. The first plugin API option lets us include an arbitrary number of MDXProviders at the root of the tree, which implicitly lets us also consume any potential components above our position in the tree and defer to another component later if we want to. It would look something like this for implementation (simplified here, it would be multiple wrapRootElement calls from multiple plugins).

JS
const components = {
pre: MDXPrismJSComponent
};
const nestedComponents = {
h1: MDXAutoLinkHeaders
};
export const wrapRootElement = ({ element }) => (
<MDXProvider components={components}>
<MDXProvider>{element}</MDXProvider>
</MDXProvider>
);

We could, as we said before, progressively enhance components over time. If we originally only used the PrismJS component, we could add a ReactLive component by including it in a lower position in the plugin list.

JS
const components = {
pre: withMDXComponents(
({ components, live, ...props }) => {
const OriginalPre = components.pre;
if (live) {
return <ReactLive {...props} />;
} else {
return <OriginalPre {...props} />;
}
}
)
};

This is sort of complicated for people to understand and is not obvious if you're going to write an MDX component plugin for the first time. Instead what we can do is make this API explicit and documented, then merge the result into a single components object and use a single MDXProvider. The default export for the gatsby-plugin-mdx components would be an object matching the MDXProvider spec. (Take the following PrismJS example).

JS
export default {
pre: PrismJSComponent
};

Then we say "Do what you want, and return undefined if you don't want to handle it", which allows us to handle the passthrough inside of gatsby-mdx in a more complex way (which could let us optimize a bit more) instead of pushing the complexity onto component plugin authors. Our ReactLive implementation now becomes simpler:

JS
const components = {
pre: ({ live, ...props }) => {
if(live) {
return <ReactLive {...props} />
} else {
return undefined
}
})
}

and also handles the "lazy" case where someone just wants to not deal with anything but conditionally using their own component.

JS
const components = {
pre: ({ live, ...props }) => {
if(live) {
return <ReactLive {...props} />
}
})
}

In gasby-mdx we could basically write some code to attempt to render each component and if it returns undefined, try the next component (otherwise pass it through to usage).

This is ok, but we can also make this a bit more explicit too by adding guards. An example guard for the ReactLive component would be:

JS
export const guards = {
pre: ({ live }) => !!live
};

Then in gatsby-mdx, we can write code that operates more like:

JS
const pre = [
{
guard: reactLiveGuard,
Component: ReactLive
},
{
guard: undefined,
Component: PrismJS
},
{
guard: undefined,
Component: "pre"
}
];
const components = {
pre: props => {
pre.find(({ guard, Component }) => {
if (!guard || guard(props)) {
return <Component {...props} />;
}
});
}
};

This allows us explicit composition while also falling back to always render something. Some components can completely take over rendering, like PrismJS, while others can only take over if we use a specific style code block:

JS
const some = {}

Fin

So that's some of the API design I've been mulling over over the last day or so for gatsby-mdx. This should lead to a nice set of packages like gatsby-mdx-plugin-defaults and gatsby-mdx-plugin-from-remark that are drop-in enhancements to the default MDX experience and make it easier to port code from Remark.