Docusaurus 3: how to migrate rehype plugins

John Reilly October 9, 2023
Source

Docusaurus v3 is on the way. One of the big changes that is coming with Docusaurus 3 is MDX 3. My blog has been built with Docusaurus 2 and I have a number of rehype plugins that I use to improve the experience of the blog. These include:

  • a plugin to improve Core Web Vitals with fetchpriority / lazy loading
  • a plugin to serving Docusaurus images with Cloudinary

I wanted to migrate these plugins to Docusaurus 3. This post is about how I did that - and if you've got a rehype plugin it could probably provide some guidance on the changes you'd need to make.

What needs to change?

The Docusaurus team put out a blog post on preparing for the Docusaurus 3 migration. Part of that post mentions MDX plugins:

All the official packages (Unified, Remark, Rehype...) in the MDX ecosystem are now ES Modules only and do not support CommonJS anymore.

This affects how you write your plugins. It also has a bearing on how you import your plugins, given that the Docusaurus configuration file itself is still CommonJS. The post adds:

If you created custom Remark or Rehype plugins, you may need to refactor those, or eventually rewrite them completely, due to how the new AST is structured.

This turned out to be the case for me. I had to rewrite my plugins completely. I'll go through each of them in turn.

Migrating the fetchpriority plugin

The fetchpriority plugin is a rehype plugin that I wrote to improve the Core Web Vitals of my blog. It does this by making the first image on a page eager loaded with fetchpriority="high" and lazy loading all other images. The Docusaurus 2 / MDX 1 code looked like this:

The new plugin looks like this:

What's different? Well, a number of things; let's go through them.

CommonJS to ES Module

You'll note the old plugin has the name image-fetch-priority-rehype-plugin.js and the new plugin has the name image-fetch-priority-rehype-plugin.mjs. This is because the new plugin is an ES Module and the old plugin is CommonJS.

Further to that, the old plugin used module.exports = imageFetchPriorityRehypePlugin to expose functionality and the new plugin uses export default imageFetchPriorityRehypePlugin.

Different AST

The abstract syntax tree (AST) is different. MDX 1 and MDX 3 make different ASTs and we must migrate to the new one. Interestingly, it seems to be slightly simpler in some ways. MDX 1 surfaced both element / img nodes and jsx nodes. By contrast, MDX 3 appears to surface just mdxJsxTextElement which are similar to MDX 1's jsx nodes, but come with their own AST representation of expression based attributes in the data property.

The logic of the new plugin is similar to the old plugin, but the code is different to cater for the different AST.

And that's it - we have a new fetchpriority plugin that works with Docusaurus 3 and MDX 3!

Migrating the cloudinary plugin

Firstly, let's remind ourselves what the cloudinary plugin does. It takes an image URL and transforms it into a Cloudinary URL. So like this:

And at runtime, Cloudinary's Fetch mechanism will handle transforming the image into a format that is optimised for the browser that is requesting it.

It turns out that the fetchpriority plugin is a much more straightforward migration than the cloudinary plugin. And the reason for that is related to the aforementioned AST changes. Let's start with the old plugin:

The old plugin had two kinds of nodes it had to deal with, element and jsx. The new plugin will have to deal with just one kind of node, mdxJsxTextElement. (Just the same as with the fetchpriority plugin.)

Now you may have noticed that the JSX node in the old plugin has a slightly more complex src attribute:

That src attribute is a JavaScript expression. It's not a string. It's a JavaScript expression that will be evaluated later by webpack, and will return the path to the image in the final (webpack-based) Docusaurus build.

So transformation into a Cloudinary URL for JSX nodes is a little tougher. In the MDX 1 plugin, we needed to wrap the require expression in backticks and prefix it with https://res.cloudinary.com/${cloudName}/image/fetch/${baseUrl} where ${baseUrl} is the base URL of our website. We also need to prefix the expression with a $ to indicate that it's a JavaScript expression. Tough to read but it works.

Rereading that paragraph, I realise it's hard to understand. Perhaps easier to see it in action. Here's what we want our plugin to do to the JSX node above:

It turns out it's even tougher doing this with MDX 3 as compared to MDX 1. This is because MDX 3's AST includes all kinds of metadata around the mdxJsxAttributeValueExpression:

The data object above is a full on AST representation of the require expression. And to make a plugin that works with MDX 3, we need to use that AST representation to build up the new src attribute. This involves some string manipulation and some AST traversal. It's not pretty but it works.

Here's the new plugin:

Much is happening here. Let's go through it.

CommonJS to ES Module

This amounts to the same changes as the fetchpriority plugin. The old plugin has the name cloudinary-rehype-plugin.js and the new plugin has the name cloudinary-rehype-plugin.mjs. This is because the new plugin is an ES Module and the old plugin is CommonJS. Related to this, the old plugin used module.exports = imageCloudinaryRehypePlugin to expose functionality and the new plugin uses export default imageCloudinaryRehypePlugin.

Different AST

We're dealing with a different AST and just need to tackle the mdxJsxTextElement which are similar to MDX 1's jsx nodes, but come with their own AST representation of expression based attributes in the data property.

The hardest part of this (and it is hard / confusing) is dealing with the require expression in the src attribute. What we do is:

  1. Convert the mdxJsxTextElement to back to markdown - this is the full img element in its AST form
  2. Use a regex to find the require expression in the src attribute of the markdown
  3. Transform the require expression to a Cloudinary URL using the same mechanism as with the MDX 1 plugin
  4. Convert the markdown back to an mdxJsxTextElement using a technique adapted from mdast-util-mdx-jsx
  5. Replace the src attribute with the new src attribute including the updated require expression AST in the mdxJsxAttributeValueExpression attributes data property.

If you were to compare the MDX 1 plugin with the MDX 3 plugin, 2 and 3 from the above points are the same. Points 1, 4 and 5 are new.

With this in place we have a new plugin that works with Docusaurus 3 and MDX 3!

rehype-cloudinary-docusaurus@2

You may recall that I published an npm package named rehype-cloudinary-docusaurus which packages up the plugin to make it easy for people to use. I've updated that package to use the new plugin and it is available now. You can see the pull request here. The new version is 3.0.0.

Discussion in the ATmosphere

Loading comments...