souporserious

Codemods for MDX

3 min read

Codebases 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'
9
10const processor = unified()
11 .use(markdown)
12 .use(stringify)
13 .use(mdx)
14 .use(frontmatter)
15
16async function program() {
17 const paths = await glob(['packages/library/**/*.stories.mdx'])
18
19 paths.forEach((filePath) => {
20 const tree = processor.parse(fs.readFileSync(filePath))
21 let category = null
22 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')
32
33 if (child.name === 'Meta') {
34 const title = child.attributes.find(
35 (child) => child.name === 'title'
36 ).value
37 category = title.split('/')[0]
38 }
39
40 // Filter out elements we no longer need
41 if (isImport || isStorybooksComponent || isPropsHeading) {
42 return children
43 }
44
45 return [...children, child]
46 }, []),
47 })
48
49 parsedMarkdown = `
50---
51category: ${category}
52---
53${parsedMarkdown}
54 `.trimStart()
55
56 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 parsedMarkdown
65 )
66 })
67}
68
69program()

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

Updated:
  • javascript
  • documentation
  • jsdoc
© 2022 Travis Arnold