Codemods for MDX
3 min readCodebases evolve and documentation inevitably changes. In this post, we look at using a simple Node script to rewrite docs and example code from Storybooks into a custom format.
If you’ve never heard of a codemod, it basically means taking a portion of code and changing its contents, usually by way of AST transformation. We can do this for any code that has an AST, including Markdown and MDX.
The burden of maintaing docs can start to sink in once you have many docs scattered throughout your codebase. This is where codemods shine, instead of trying to write a fancy regex search and replace we can write some simple JavaScript operations and rewrite our file however we desire.
Unified
We’ll start by introducing the Unified library. This library will be responsible for taking in our MD(X) content allowing us to transform it and turn it back into either format. Since we want to rewrite MDX we can start with their boilerplate and make some modifications:
1import glob from 'fast-glob'2import fs from 'fs'3import path from 'path'4import frontmatter from 'remark-frontmatter'5import mdx from 'remark-mdx'6import markdown from 'remark-parse'7import stringify from 'remark-stringify'8import { unified } from 'unified'910const processor = unified()11 .use(markdown)12 .use(stringify)13 .use(mdx)14 .use(frontmatter)1516async function program() {17 const paths = await glob(['packages/library/**/*.stories.mdx'])1819 paths.forEach((filePath) => {20 const tree = processor.parse(fs.readFileSync(filePath))21 let category = null22 let parsedMarkdown = processor.stringify({23 type: 'root',24 children: tree.children.reduce((children, child) => {25 const isImport = child.type === 'mdxjsEsm'26 const isStorybooksComponent =27 child.type === 'mdxJsxFlowElement' &&28 ['Meta', 'Props', 'ArgsTable'].includes(child.name)29 const isPropsHeading =30 child.type === 'heading' &&31 child.children.some((child) => child.value === 'Props')3233 if (child.name === 'Meta') {34 const title = child.attributes.find(35 (child) => child.name === 'title'36 ).value37 category = title.split('/')[0]38 }3940 // Filter out elements we no longer need41 if (isImport || isStorybooksComponent || isPropsHeading) {42 return children43 }4445 return [...children, child]46 }, []),47 })4849 parsedMarkdown = `50---51category: ${category}52---53${parsedMarkdown}54 `.trimStart()5556 const basePath = path.dirname(filePath)57 fs.writeFileSync(58 path.resolve(59 basePath.includes('stories')60 ? basePath.replace('/stories', '')61 : basePath,62 'README.mdx'63 ),64 parsedMarkdown65 )66 })67}6869program()
Finding docs
Rewriting
Removing preceding heading
This is where the power of reducers shine. Since we need to not only include the props component we know there is a heading preceding the component so we can remove that if that’s the case.
We can even be extra cautious here and check to make sure it’s a heading preceding and not some other content. You can also throw console statements here or inject a greppable string to help identify and fix outlying cases.
Inserting Frontmatter
Now you might be asking what is the syntax for frontmatter? You can view the AST Explorer to easily find this out by writing out some example code.
Conclusion
- javascript
- documentation
- jsdoc