Breathnote
ヘッドレスCMSから受け取ったMDXをNext.jsコンポーネントに変換する

ヘッドレスCMSから受け取ったMDXをNext.jsコンポーネントに変換する

Next.jsのチュートリアルでは、マークダウンをパースする方法としてremarkを利用していますが、remarkだけではnext/imageコンポーネントやカスタムリンクを使用できません。

これを解決する方法としてMDXというマークダウンにJSXを埋め込む技術が存在します。

今回はこのMDXと、リモートから受け取ったMDXをJSXにパース出来るnext-mdx-remoteというライブラリを使って、ヘッドレスCMSから受け取ったマークダウンをJSXへ変換する仕組みを作ります。

今回の参考記事は以下の通りです。

今回作るもの

機能を実装する前に、この機能を利用した際の、変換前のマークダウンと変換後のHTMLをそれぞれ載せておきます。

画像

<img>タグを記述するだけで自動的にnext/imageコンポーネントに変換されます。

本来はマークダウン書式から変換したかったのですが、next/imageに必須のwidth属性とheight属性を解決しつつマークダウン書式で記述するよりは、<img>タグを使う方がシンプルに済みそうだったため、こちらを採用しました。

MDX -> HTML
<!-- 変換前のマークダウン -->
<img src="url.png" width="850" height="50" alt="画像" />

<!-- 変換後のHTML -->
<div class="image-wrapper">
  <div style="display: inline-block; max-width: 100%; overflow: hidden; position: relative; box-sizing: border-box; margin: 0px;">
    <div style="box-sizing: border-box; display: block; max-width: 100%;">
      <img
        alt=""
        aria-hidden="true"
        role="presentation"
        src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODUwIiBoZWlnaHQ9IjQ0NSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2ZXJzaW9uPSIxLjEiLz4="
        style="max-width: 100%; display: block; margin: 0px; border: none; padding: 0px;"
      >
    </div>
    <img alt="画像"
      src="/_next/image?url=url&w=1920&q=75"
      decoding="async"
      srcset="/_next/image?url=url&w=1080&q=75 1x, /_next/image?url=url&w=1920&q=75 2x"
      style="visibility: inherit; position: absolute; inset: 0px; box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
    >
  </div>
</div>

リンク

それぞれのリンクが、next/linkを使用した内部リンクと、属性を追加した外部リンクに変換されています。

MDX -> HTML
<!-- マークダウン -->
[内部リンク](/keywords)
[外部リンク](https://twitter.com/code_shinki)

<!-- ブラウザでの出力 -->
<a href="/keywords">内部リンク</a>
<a href="https://twitter.com/code_shinki" target="_blank" rel="noopener noreferrer">外部リンク</a>

next-mdx-remoteにパースさせる

それでは早速機能を実装していきます。

next-mdx-remoteをnpmyarnで導入したら、記事ページに変更を加えましょう。

next-mdx-remoteで利用可能なメソッドはrenderToString()hydrate()の二種類です。

Post.tsx
import CustomImage from 'components/CustomImage' // 後で作ります
import CustomLink from 'components/CustomLink' // 後で作ります
import hydrate from 'next-mdx-remote/hydrate'
import renderToString from 'next-mdx-remote/render-to-string'
const rehypePrism = require('@mapbox/rehype-prism')

type Props = {
  post: PostType
}

// 1.
const components = {
  img: CustomImage, // imgタグをカスタムコンポーネントに変換
  a: CustomLink, // aタグをカスタムコンポーネントに変換
}

const Post: NextPage<Props> = ({ post }) => {
  // 3.
  const body = hydrate(post.body, { components })

  // dangerouslySetInnerHTMLを使わず直接出力
  return <div className="wrapper">{body}</div>
}

export default Post

export const getStaticProps: GetStaticProps = async (context) => {
  // 記事を取得する
  const slug = context.params.slug
  const sourcePost = await getPost(slug)

  // 2.
  const post = {
    ...sourcePost,
    body: await renderToString(sourcePost.body, {
      components,
      mdxOptions: {
        // 今回はシンタックスハイライト処理を挟んでみます
        rehypePlugins: [rehypePrism],
      },
    }),
  }

  return {
    props: {
      post,
    },
  }
}

必要な部分を抜き出し、next-mdx-remoteに関する処理にコメントをつけています。

  1. next-mdx-remoteで変換するコンポーネントを定義
  2. ビルド時にrenderToString()でマークダウンをパース
  3. クライアント実行時にhydrate()でレンダリング

今回はrenderToString()@mapbox/rehype-prismプラグインを挟むことで、シンタックスハイライトも行っています。

一見renderToString()を使ったビルド時の処理だけで良さそうに思えますが、next/imageなどのクライアント側で動作するコンポーネントhydrate()を用いたクライアント側でのレンダリングも必要になります。一度hydrate()無しで試してみましたが、変換はされるもののクライアント側での画像最適化などが行われませんでした。

専用のコンポーネントを作る

次にnext-mdx-remoteに渡すカスタムコンポーネントを作成します。

わざわざ独自のコンポーネントを作らず、直接Next.jsコンポーネントを指定しても良いですが、今後のサイトカスタマイズが容易になりますので、作ることをお勧めします。

CustomImage.tsx
import Image from 'next/image'
import React from 'react'

type Props = {
  src: string
  width: string
  height: string
  alt: string
}

const CustomImage: React.FC<Props> = ({ src, width, height, alt }) => {
  return <Image src={src} width={width} height={height} alt={alt} />
}

export default CustomImage

CustomImageでは受け取った属性をもとにnext/imageコンポーネントを返却しています。

CustomLink.tsx
import Link from 'next/link'
import React from 'react'

type Props = {
  href: string
}

const CustomLink: React.FC<Props> = ({ href, ...otherProps }) => {
  const isInternalLink = href.substr(0, 1) === '/' ? true : false

  return (
    <>
      {isInternalLink ? (
        <Link href={href}>
          <a>{otherProps.children}</a>
        </Link>
      ) : (
        <a href={href} target="_blank" rel="noopener noreferrer">
          {otherProps.children}
        </a>
      )}
    </>
  )
}

export default CustomLink

CustomLinkでは、内部リンクをnext/linkコンポーネントに変換し、外部リンクをtarget="_blank" rel="noopener noreferrer"を付与した通常のリンクに変換して返しています。

next-mdx-remoteはマークダウンから受け取った各属性をPropsに渡すので、コンポーネントを柔軟に構築することが可能です。

所感

next-mdx-remoteはなかなか便利ですが、変換対象のコンポーネントを増やせば増やすほど処理が重くなります。サーバーレス関数などと絡めた処理をする場合はタイムアウトに気を付けてください。