🚀

【Next.js】next-mdx-remote を使った MDX ブログでシンタックスハイライトをかける

2023/09/03に公開

(本記事は私のブログの記事の転載になります。)

Markdown とは

Markdown とは一定のルールで書かれたテキスト形式のデータを HTML 形式のデータに変換できる言語です。
Next.js も Markdown から HTML を生成して表示できます。

ルールというのは例えば、以下のように見出しや箇条書きを「#」や「-」で表現します。

# 見出し

本文

- 箇条書き 1
- 箇条書き 2
- 箇条書き 3

image

普通の Markdown では変換前の素のテキストに HTML タグや JSX を仕込めませんが、 MDX という、Markdown にそれらを仕込める形式があって、私はそれを使っています。
Next.js で MDX を使うには、next-mdx-remote というプラグインを使うのがおすすめです。

https://www.npmjs.com/package/next-mdx-remote

なお、(宣伝になりますが)以下の本にて next-mdx-remote の導入方法について記載しています。

https://zenn.dev/yuta_extend/books/a23ce072bdf8b8

Markdown のコードブロックにシンタックスハイライトをかける

ここからが本題。

技術ブログを見ると、以下のようにプログラムコードのサンプルが強調表示されているのを見かけることがあると思います。

// アラートを表示する
function alert() {
  window.alert("アラート");
}

これが今回実装したい シンタックスハイライト です。
これを実装するにはまずは react-syntax-highlighter というプラグインを導入します。

https://www.npmjs.com/package/react-syntax-highlighter

導入方法はとっても簡単。以下の 2 つのコマンドを実行するだけです。

> npm i react-syntax-highlighter
> npm i --save-dev @types/react-syntax-highlighter

次に、Markdown でコードブロックを書いたときに呼び出すコンポーネントを作成します。TypeScript 対応の tsx 形式で書いていきます。

CodeBlock.tsx
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";

type Props = {
  className?: string;
  children?: React.ReactNode;
};
const CodeBlock: React.FC<Props> = ({ className, children = "" }: Props) => {
  const match = /language-(\w+)/.exec(className || "");
  const language = match && match[1] ? match[1] : "";
  const code = String(children).replace(/\n$/, "");
  return (
    <>
      <div>
        <SyntaxHighlighter language={language} style={atomDark}>
          {code}
        </SyntaxHighlighter>
      </div>
    </>
  );
};

export default CodeBlock;

<SyntaxHighlighter>language には、CodeBlock.tsx に渡された className の値を加工して設定します。
className の値は 「language-(言語)」 の形式なので、正規表現を使って言語の部分だけ切り出して language に設定します。

また、<SyntaxHighlighter>style は、以下のリストからお好みで選んで設定します。
私は atomDark を選びました。
https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_STYLES_PRISM.MD

最後に code ですが、こちらはコードブロック内の本文の改行を消すだけです。これも正規表現を使うと簡単に実現します。

これで CodeBlock.tsx はいったん完成です。

続いて、この CodeBlock.tsx をコードブロックのコンポーネントとして呼び出します。
gray-matternext-mdx-remote のプラグインを使っていますが、他のプラグインを使う場合は、適宜読み替えてください。

PostPage.tsx
import type { InferGetStaticPropsType, NextPage } from "next";
import { ReactNode } from "react";
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote";
import CodeBlock from "CodeBlock";

export async function getStaticProps() {
  const postFilePath = path.join(process.cwd(), "public/sample.mdx");
  const source = fs.readFileSync(postFilePath);
  const { content, data } = matter(source);
  const mdxSource = await serialize(content, {
    mdxOptions: {
      remarkPlugins: [],
      rehypePlugins: [],
    },
    scope: data,
  });

  return {
    props: {
      mdxSource: mdxSource,
    },
  };
}

const components = {
  code: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => (
    <CodeBlock {...props} />
  ),
};

type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
  return <MDXRemote {...props.mdxSource} components={components} />;
};

export default PostPage;

最後に、読み込む MDX ファイルを作成します。

public/sample.mdx
```js
// アラートを表示する
function alert() {
  window.alert("アラート");
}
```

コードブロックの「js」の部分はハイライトする言語を示しています。以下のリンク先の言語に対応しています。
https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_PRISM.MD

うまく読み込まれると、以下のように表示されます。

// アラートを表示する
function alert() {
  window.alert("アラート");
}

コードブロックにファイル名を表示する

この調子でコードブロックにファイル名を表示してみましょう。
ファイル名は以下のように言語設定の後に「:」区切りで書くことにします。

public/sample.mdx
```js:public/sample.mdx
// アラートを表示する
function alert() {
  window.alert("アラート");
}
```

そして、CodeBlock.tsx 側では、正規表現でファイル名を取得します。

CodeBlock.tsx
  ~ 省略 ~
  const match = /language-(\w+)(:?.*)/.exec(className || "");
  const language = match && match[1] ? match[1] : "";
  const fileName = match && match[2] ? match[2].slice(1) : "";
  const code = String(children).replace(/\n$/, "");
  ~ 省略 ~

次にファイル名の配置ですが、<SyntaxHighlighter> タグのラッパーを用意して、
その中に配置すれば OK です。

ただし、 CSS で装飾したい場合は <SyntaxHighlighter> タグにもデフォルトで CSS が効いているので、これを上書きする必要があります。

これは styled-jsx を使って以下のように記述できます。

CodeBlock.tsx
  ~ 省略 ~
  const syntaxHighlighterClass = fileName
    ? "code-block-with-title"
    : "code-block";
  return (
    <>
      <div className="code-block-wrapper">
        {fileName && <div className="code-block-title">{fileName}</div>}
        <SyntaxHighlighter
          language={language}
          style={atomDark}
          className={syntaxHighlighterClass}
        >
          {code}
        </SyntaxHighlighter>
      </div>
      <style jsx>{`
        .code-block-wrapper {
          font-size: 0.9rem;
          margin-bottom: 2rem;
        }
        .code-block-title {
          display: inline-block;
          border-radius: 0.3rem 0.3rem 0 0;
          background-color: #323e52;
          padding: 0.55rem 1rem;
          color: white;
          font-size: 0.8rem;
          font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
            monospace;
        }
      `}</style>
      <style jsx global>{`
        .code-block {
          border-radius: 0.3rem !important;
          padding: 1.5rem !important;
        }
        .code-block-with-title {
          border-radius: 0 0.3rem 0.3rem 0.3rem !important;
          padding: 1.5rem !important;
          margin-top: 0 !important;
        }
      `}</style>
    </>
  );
};

export default CodeBlock;

最終的には以下のようになりました。

CodeBlock.tsx
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";

type Props = {
  className?: string;
  children?: React.ReactNode;
};
const CodeBlock: React.FC<Props> = ({ className, children = "" }: Props) => {
  const match = /language-(\w+)(:?.*)/.exec(className || "");
  const language = match && match[1] ? match[1] : "";
  const fileName = match && match[2] ? match[2].slice(1) : "";
  const code = String(children).replace(/\n$/, "");
  const syntaxHighlighterClass = fileName
    ? "code-block-with-title"
    : "code-block";
  return (
    <>
      <div className="code-block-wrapper">
        {fileName && <div className="code-block-title">{fileName}</div>}
        <SyntaxHighlighter
          language={language}
          style={atomDark}
          className={syntaxHighlighterClass}
        >
          {code}
        </SyntaxHighlighter>
      </div>
      <style jsx>{`
        .code-block-wrapper {
          font-size: 0.9rem;
          margin-bottom: 2rem;
        }
        .code-block-title {
          display: inline-block;
          border-radius: 0.3rem 0.3rem 0 0;
          background-color: #323e52;
          padding: 0.55rem 1rem;
          color: white;
          font-size: 0.8rem;
          font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
            monospace;
        }
      `}</style>
      <style jsx global>{`
        .code-block {
          border-radius: 0.3rem !important;
          padding: 1.5rem !important;
        }
        .code-block-with-title {
          border-radius: 0 0.3rem 0.3rem 0.3rem !important;
          padding: 1.5rem !important;
          margin-top: 0 !important;
        }
      `}</style>
    </>
  );
};

export default CodeBlock;

参考文献

https://goodlife.tech/posts/react-markdown-code-highlight/
https://amirardalan.com/blog/syntax-highlight-code-in-markdown

Discussion