🖼

Next.js で Markdown 中の画像を next/image に対応させる

2022/08/17に公開

問題

Next.js の SSG 機能で Markdown から HTML を生成するときに、

![Dog](/images/dog.png)

のように埋め込まれた画像を next/image の <Image /> コンポーネントに展開したい。

<Image
  src="/images/dog.png"
  alt="Dog"
  // static import していない画像については画像の大きさが事前に分かっている必要がある
  width={"???"}
  height={"???"}
/>

前提

  • 画像は Next.js プロジェクトの public ディレクトリ内で管理している (eg. ~/public/images/dog.png)。
  • Markdown の処理には unified エコシステムの remark/rehype を用いる(もしくは内部的にこれらを利用しているライブラリを使う)。

解決法

  1. rehype で Markdown をパースして HTML に変換する際、![alt](src) の画像のサイズ(width, height)を取得して <img /> のプロパティに埋め込むための rehype プラグインを作る。
  2. <img /><NextImage /> に変換する。ここは react-markdown や next-mdx-remote など、だいたいのライブラリはよしなにやってくれる。
Markdown: ![alt](src)
  |
  | 手順 1: width と height を取得
  ↓
HTML: <img src={src} alt={alt} width={width} height={height} />
  |
  | 手順 2: <img /> を <NextImage /> に変換
  ↓
React: <NextImage src={src} alt={alt} width={width} height={height} />

実装

<img /> のプロパティ設定に unist-util-visit と、画像のサイズ取得に image-size を使う。

npm i unist-util-visit image-size
lib/imgSize.js
import sizeOf from "image-size";
import { visit } from "unist-util-visit";

function rehypeImageSize() {
  return (tree, _file) => {
    visit(tree, "element", (node) => {
      if (node.tagName === "img") {
        // ![Dog](/images/dog.png) の場合、src = "/images/dog.png"
        const src = node.properties.src;

        const { width, height } = sizeOf("public" + node.properties.src);
        node.properties.width = width;
        node.properties.height = height;
      }
    });
  };
}

export default rehypeImageSize;

あとはこの rehypeImageSize を Markdown レンダラーに使用しているライブラリの rehype プラグインに指定すればよい。

例1: react-markdown

import NextImage from "next/image";
import ReactMarkdown from "react-markdown";
import rehypeImageSize from "~/lib/imgSize";

const components = {
  img: (props) => <NextImage {...props} />,
};

const MDContent = ({ children }) => {
  return (
    <ReactMarkdown rehypePlugins={[rehypeImageSize]} components={components}>
      {children}
    </ReactMarkdown>
  );
};

例2: next-mdx-remote

pages/posts/[slug].ts
import NextImage from "next/image";
import { MDXRemote } from "next-mdx-remote";
import { serialize } from 'next-mdx-remote/serialize';
import rehypeImageSize from "~/lib/imgSize";

const components = {
  img: (props) => <NextImage {...props} />,
};

export default function ContentPage({ mdxSource }) {
  return <MDXRemote {...mdxSource} components={components} />;
}

export async function getStaticProps() {
  const content = getContent();
  const mdxSource = serialize(content, {
    mdxOptions: {
      rehypePlugins: [rehypeImageSize],
    }
  });
  return { props: { mdxSource } }
}

余談

SSG するときの Markdown ライブラリの選定

react-markdown は ブラウザの JS バンドルサイズが肥大化 (最低でも +40kB~) するので、静的生成するのであれば Markdown の処理は react-markdown には頼らず getStaticProps 内で行うべきだと個人的には思っている(next-mdx-remote の README を読むと、ブラウザバンドルを極力減らしたくてこのようなパッケージが生まれたことも伺える)。

Discussion