ヘッドレス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>
タグを使う方がシンプルに済みそうだったため、こちらを採用しました。
<!-- 変換前のマークダウン -->
<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を使用した内部リンクと、属性を追加した外部リンクに変換されています。
<!-- マークダウン -->
[内部リンク](/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をnpm
かyarn
で導入したら、記事ページに変更を加えましょう。
next-mdx-remoteで利用可能なメソッドはrenderToString()
とhydrate()
の二種類です。
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に関する処理にコメントをつけています。
- next-mdx-remoteで変換するコンポーネントを定義
- ビルド時に
renderToString()
でマークダウンをパース - クライアント実行時に
hydrate()
でレンダリング
今回はrenderToString()
に@mapbox/rehype-prismプラグインを挟むことで、シンタックスハイライトも行っています。
一見renderToString()
を使ったビルド時の処理だけで良さそうに思えますが、next/imageなどのクライアント側で動作するコンポーネントはhydrate()
を用いたクライアント側でのレンダリングも必要になります。一度hydrate()
無しで試してみましたが、変換はされるもののクライアント側での画像最適化などが行われませんでした。
専用のコンポーネントを作る
次にnext-mdx-remoteに渡すカスタムコンポーネントを作成します。
わざわざ独自のコンポーネントを作らず、直接Next.jsコンポーネントを指定しても良いですが、今後のサイトカスタマイズが容易になりますので、作ることをお勧めします。
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コンポーネントを返却しています。
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はなかなか便利ですが、変換対象のコンポーネントを増やせば増やすほど処理が重くなります。サーバーレス関数などと絡めた処理をする場合はタイムアウトに気を付けてください。