Easy call-outs with blockquote, MDX, and Rehype

Easy call-outs with blockquote, MDX, and Rehype

Recently, I wanted to add semi-nice callouts to this blog, similar to this one:

Close.com Callout Example

But here's the deal... I really didn't want to have to write a custom component to render it, like this:

Boo.jsx
<Callout color="red">
  My Message
</Callout>

No thanks.

What I really wanted was to be able to leverage the blockquote tag in my MDX, similar to how Rehype handles my code blocks.

With Rehype, I can simply write:

'''jsx:filename.jsx
 // ... my code here
''''

And get syntax highlighting plus a nice filename header. So for my callouts, I wanted to be able to write:

> color:"Title" And here is my content...

Wouldn't that be great? 😉

Rehype Plugins to the Rescue

After doing some digging on the best way to tap into the life-cycle of the MDX compiler with my mdx-remote setup, I found that I could use a custom rehype plugin to do exactly what I wanted.

I was already using the rehype-code-titles plugin, so I decided to start with the basics of that source code.

src/lib/rehype-blockquote-meta.js
import { visit } from 'unist-util-visit'

export default function rehypeBlockquote() {
  return function transformer(tree) {
    const visitor = (
      node,
      index,
      parent
    ) => {
      // If it's not a blockquote, do nothing
      if (!parent || node.tagName !== 'blockquote') {
        return
      }
    }

    visit(tree, 'element', visitor)
  }
}

So this pretty much does nothing but tells if the node is a blockquote. If it's not, we just return out of the tree traversal for this node.

Next, I needed to do a little bit of regex parsing on the children of the blockquote children, to find my color:"Title" pattern, and do something with it.

src/lib/rehype-blockquote-meta.js
import { visit } from 'unist-util-visit'

export default function rehypeBlockquote() {
  return function transformer(tree) {
    const visitor = (
      node,
      index,
      parent
    ) => {
      // If it's not a blockquote, do nothing
      if (!parent || node.tagName !== 'blockquote') {
        return
      }

      const blockquote = node;

      const children = blockquote?.children?.reduce((memo, child) => {
        if (child.type !== 'element') {
          memo.push(child);
          return memo;
        }

        for (const c of child.children) {
          if (c.type === 'text') {
            const res = /^([^\:]+)\:"([^"]+)/gi.exec(c.value);

            if (res && res.length) {
              const color = res[1];
              const title = res[2];

              blockquote.properties['dataBqColor'] = color;
              blockquote.properties['dataBqTitle'] = title;
              c.value = c.value.replace(/^([^\:]+)\:"([^"]+)"\s/gi, '')
            }
          }

          memo.push(c);
        }

        return memo;
      }, []);

      
      blockquote.children = children;
    }

    visit(tree, 'element', visitor)
  }
}

This is a little involved, so let's break it down.

Remember, we're traversing the AST (Abstract Syntax Tree) of the MDX. Our plugin basically looks across all the nodes in the tree, starting with the top level nodes.

So first, we assign the current node to a variable, blockquote.

const blockquote = node;

Next, we take all the children of the node, and reduce them to a new array of nodes. This will let us parse out the color and "Title", then treat the rest of the nodes normally.

const children = blockquote?.children?.reduce((memo, child) => {
  // ...
}, []);

Next, we check to see if the child is an element node. If it's not, we just push it onto the memo array and return the memo.

if (child.type !== 'element') {
  memo.push(child);
  return memo;
}

Lastly, we check to see if the child is a text node. If it is, we run our regex against the text to see if it matches our color:"Title" pattern.

if (c.type === 'text') {
  const res = /^([^\:]+)\:"([^"]+)/gi.exec(c.value);

  if (res && res.length) {
    const color = res[1];
    const title = res[2];

    blockquote.properties['dataBqColor'] = color;
    blockquote.properties['dataBqTitle'] = title;
    c.value = c.value.replace(/^([^\:]+)\:"([^"]+)"\s/gi, '')
  }
}

When we run our regex, we're able to pull out the color and title from the text, and assign them to the blockquote node as data properties.

blockquote.properties['dataBqColor'] = color;
blockquote.properties['dataBqTitle'] = title;

With that, we can actually pass those properties to a React component, and handle them there.

Building the "Note" React Component

Now that we have our data properties on the blockquote node, we can pass them to a React component. I decided to call mine Note, and it looks like this:

src/components/Note.js
import React from 'react'

const containerClasses = 'tracking-tight -ml-4 -mr-4 px-5 py-4 sm:-ml-6 sm:-mr-6 sm:rounded-lg sm:shadow sm:px-2';
const titleClasses = 'absolute -top-[27px] py-0 rounded-t leading-6 px-5 text-xs border-b-0 z-10 font-bold border-t-4 sm:left-0';

export function Note({
  'data-bq-color': color,
  'data-bq-title': title,
  children,
}) {

  if (!color) {
    return <blockquote>{children}</blockquote>
  }

  let colorClass = `bg-${color}-50 border border-${color}-200 text-${color}-800`;

  return (
    <div className="relative my-24 w-full">
      <div className={`${containerClasses} ${colorClass}`}>
        {title ? <div className={`${titleClasses} ${colorClass}`}>{title}</div> : null}
        <div className="p-3">
          {children}
        </div>
      </div>
    </div>
  )
}

It's a pretty simple component, just pulling the data properties that were passed to the component by next-mdx-remote, then building up some TailwindCSS styles based on the color property.

Adding the "Note" Component to MDX

Lastly, I just needed to add the Note component to the MDXComponents object in src/components/MDXComponents.js:

src/components/MDXComponents.js
import React from 'react'

import { Note } from './Note'

export const MDXComponents = {
  Note,
}

And I was ready to try it out with:

my-draft.mdx
> amber:"Custom Title" Here is my nice custom call out.

Here's what I got...

Custom Title

Here is my nice custom call out.

😭 Not great. This is where I found a TailwindCSS gotcha that I didn't know about.

Interpolated TailwindCSS Classes

Apparently, the Tailwind parser traverses string literals to find classes to add to the final CSS. This is great, because it means you can do things like this:

<div className="bg-red-500 text-white">
  This is a red box with white text.
</div>

But what confuses it is when you try to interpolate a variable into the string. So something like this:

const color = 'red';

<div className={`bg-${color}-500 text-white`}>
  This is a red box with white text.
</div>

The compiler doesn't seem to be able to find bg-red-500 in the string, so it doesn't add it to the final CSS. This is what was happening with my Note component.

Full Disclosure
I haven't actually verified that this is how the compiler works. I'm just assuming based on behavior.

Feel free to run it down for yourself, or perhaps I will in another post.

So what was the fix? 🛠

I had to write code that ensured every color class I was using was represented by an actual string in my component file.

src/components/Note.js
import React from 'react'

const containerClasses = 'tracking-tight -ml-4 -mr-4 px-5 py-4 sm:-ml-6 sm:-mr-6 sm:rounded-lg sm:shadow sm:px-2';
const titleClasses = 'absolute -top-[27px] py-0 rounded-t leading-6 px-5 text-xs border-b-0 z-10 font-bold border-t-4 sm:left-0';

export function Note({
  'data-bq-color': color,
  'data-bq-title': title,
  children,
}) {

  if (!color) {
    return <blockquote>{children}</blockquote>
  }

  let colorClass = '';

  switch (color) {
    case 'slate':
      colorClass = 'bg-slate-50 border border-slate-200 text-slate-800';
      break;
    case 'gray':
      colorClass = 'bg-zinc-50 border border-zinc-200 text-zinc-800';
      break;
    case 'zinc':
      colorClass = 'bg-zinc-50 border border-zinc-200 text-zinc-800';
      break;
    case 'neutral':
      colorClass = 'bg-neutral-50 border border-neutral-200 text-neutral-800';
      break;
    case 'stone':
      colorClass = 'bg-stone-50 border border-stone-200 text-stone-800';
      break;
    case 'red':
      colorClass = 'bg-red-50 border border-red-200 text-red-800';
      break;
    case 'orange':
      colorClass = 'bg-orange-50 border border-orange-200 text-orange-800';
      break;
    case 'amber':
      colorClass = 'bg-amber-50 border border-amber-200 text-amber-800';
      break;
    case 'yellow':
      colorClass = 'bg-yellow-50 border border-yellow-200 text-yellow-800';
      break;
    case 'lime':
      colorClass = 'bg-lime-50 border border-lime-200 text-lime-800';
      break;
    case 'green':
      colorClass = 'bg-green-50 border border-green-200 text-green-800';
      break;
    case 'emerald':
      colorClass = 'bg-emerald-50 border border-emerald-200 text-emerald-800';
      break;
    case 'teal':
      colorClass = 'bg-teal-50 border border-teal-200 text-teal-800';
      break;
    case 'cyan':
      colorClass = 'bg-cyan-50 border border-cyan-200 text-cyan-800';
      break;
    case 'sky':
      colorClass = 'bg-sky-50 border border-sky-200 text-sky-800';
      break;
    case 'blue':
      colorClass = 'bg-blue-50 border border-blue-200 text-blue-800';
      break;
    case 'indigo':
      colorClass = 'bg-indigo-50 border border-indigo-200 text-indigo-800';
      break;
    case 'violet':
      colorClass = 'bg-violet-50 border border-violet-200 text-violet-800';
      break;
    case 'purple':
      colorClass = 'bg-purple-50 border border-purple-200 text-purple-800';
      break;
    case 'fuchsia':
      colorClass = 'bg-fuchsia-50 border border-fuchsia-200 text-fuchsia-800';
      break;
    case 'pink':
      colorClass = 'bg-pink-50 border border-pink-200 text-pink-800';
      break;
    case 'rose':
      colorClass = 'bg-rose-50 border border-rose-200 text-rose-800';
      break;
    default:
      colorClass = 'bg-yellow-50 border border-yellow-200 text-yellow-800';  
  }

  return (
    <div className="relative my-24 w-full">
      <div className={`${containerClasses} ${colorClass}`}>
        {title ? <div className={`${titleClasses} ${colorClass}`}>{title}</div> : null}
        <div className="p-3">
          {children}
        </div>
      </div>
    </div>
  )
}

I have to be honest, I don't love this. It feels like the Tailwind compiler should be smart enough to interpolate those strings on its own, based on the AST it's reading from the JSX file.

But, ć'est la vie. It works, and I can move on with my life.

Now, with that in place, I can create any Tailwind color note I want.

> red:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.
> orange:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.
> yellow:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.
> green:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.
> blue:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.
> indigo:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.
> violet:"Custom Title" Here is my nice custom call out.
Custom Title
Here is my nice custom call out.

All the colors of the Tailwind Rainbow. 🌈

Conclusion

Once you get the hang of it, writing Rehype parsers for your MDX blog is pretty easy. I hope this helps you get confident enough to try some cool stuff!

Happy coding! 🤓 ⌨️

If you enjoyed this article, please consider following me on Twitter

Subscribe to the Newsletter

Subscribe for exclusive tips, strategies, and resources to launch, grow, & build your SaaS team.

Share this article on: