Next.js で markdown を読み込み、code を syntax highlight する

## やりたいこと
Next.js + typescript で ```react-markdown``` を使って markdown ドキュメントを読み込み、Code 部分は、```react-syntax-highliter``` で Syntax をハイライトしたい。

Code の表示は、Night Owl とし、行番号をつけたい。

ググれば色々記事が出てくるが、現状のバージョンだとコピペでは動かないなどあり、ちょっと苦労したので、そのメモ。

## 検証環境

```json:package.json
...
  "dependencies": {
    "@emotion/babel-plugin": "^11.7.2",
    "@emotion/react": "^11.7.1",
    "gray-matter": "^4.0.3",
    "next": "12.0.10",
    "raw-loader": "^4.0.2",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "react-markdown": "^8.0.0",
    "react-syntax-highlighter": "^15.4.5",
    "remark-gfm": "^3.0.1"
  },
  "devDependencies": {
    "@types/node": "17.0.15",
    "@types/react": "17.0.39",
    "@types/react-syntax-highlighter": "^13.5.2",
    "@types/webpack-env": "^1.16.3",
    "eslint": "8.8.0",
    "eslint-config-next": "12.0.10",
    "typescript": "4.5.5"
  }
}
```

node: v16.13.1

## 使った Package と参考にした記事
- [remarkjs/react-markdown](https://github.com/remarkjs/react-markdown)
- [react-syntax-highlighter/react-syntax-highlighter](https://github.com/react-syntax-highlighter/react-syntax-highlighter)
- [Next.jsにマークダウンのブログ機能を追加する方法](https://monotein.com/blog/nextjs-making-markdown-blog)
- [react-markdownでコードをシンタックスハイライトさせる](https://goodlife.tech/posts/react-markdown-code-highlight.html)
- [webpack - Property context does not exist on type NodeRequire](https://stackoverflow.com/questions/48935663/webpack-property-context-does-not-exist-on-type-noderequire)


## 準備

next-app を typescript で作る

```bash
$ yarn create next-app markdown --typescript
```

package は説明のため、都度入れていく。

## 最終的な形（いじるものだけ）

```
├── components
│   └── atoms
│      └── CodeBlock.tsx  // Code Syntax Highlight するコンポーネント
├── pages
│   ├── articles
│   │  └── [slug].tsx     // 記事詳細を表示するページ
│   └── index.tsx         // 記事一覧を表示するページ
├── sources               // 記事を格納するディレクトリ
│   ├── sub
│   │  └── article2.md    // 記事
│   └── article1.md       // 記事
├── types
│   └── ArticleType.ts    // type の設定
├── .babelrc              // emotion 使う設定
├── next.config.js        // markdown を扱うための設定
└── tsconfig.json         // emotion と webpack-env の types の設定
```

Next.js は CSS Module 押しで、私もそっちが良いと思うけど、コンポーネントは使いまわしたくて、ファイル分けるのが嫌だったので、emotion を使った。そのあたりは本記事の本質ではないので、好きにしてもらえれば良い。


## 記事データ

読み込む対象となる markdown を適当に用意する

~~~markdown:article1.md
---
id: '1'
title: '記事 1'
date: '2022-02-02'
summary: '記事 1 の要約'
---

```typescript:sample.tsx
コード書く
```　

~~~




## 記事一覧ページ - index.tsx

内容の詳細は、参考記事に任せる。typescript で書いていく。

プラグインのインストール

```bash
$ yarn add raw-loader gray-matter 
```

```javascript:next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  webpack: function (config) {
    config.module.rules.push({
      test: /\.md$/,
      use: 'raw-loader',
    });
    return config;
  },
};

module.exports = nextConfig;
```

準備完了（のはずだった）。


```typescript:index.tsx
import type { NextPage } from 'next';
import Link from 'next/link';
import matter from 'gray-matter';
import { ListType } from '../types/ArticleType';

export const getStaticProps = async () => {
  const articles = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    const data = keys.map((key: string, index: number) => {
      let slug = key
        .replace(/^.[\\\/]/, '')
        .replaceAll('/', '--')
        .slice(0, -3);
      const value: any = values[index];
      const document = matter(value.default);
      return {
        frontmatter: document.data,
        slug: slug,
      };
    });
    return data;
  })(require.context('../sources', true, /\.md$/));

  const sortingArticles = articles.sort((a, b) => {
    return b.frontmatter.id - a.frontmatter.id;
  });

  return {
    props: {
      articles: JSON.parse(JSON.stringify(sortingArticles)),
    },
  };
};

interface Props {
  articles: Array<ListType>;
}

const Home: NextPage<Props> = (props) => {
  return (
    <>
      <p>記事一覧</p>
      {props.articles.map((article: any, index: number) => (
        <div key={index}>
          <p>{article.frontmatter.title}</p>
          <Link href={`/articles/${article.slug}`}>
            <a>Read More</a>
          </Link>
        </div>
      ))}
    </>
  );
};

export default Home;
```

これで動きそうだが、動かない。```require.context``` のところで、以下のようなエラーがでる。

```
Property 'context' does not exist on type 'NodeRequire'.
```

これを解決するためには、```@types/webpack-env``` を設定してやる必要がある。




```bash
$ yarn add @types/webpack-env --dev
```

```json:tsconfig.json
{
  "compilerOptions": {
    ...
    "types": ["webpack-env"],
    ...
  },
  ...
}
```

細かい話しではあるが、

```typescript
require.context('../sources', true, /\.md$/)
```

これでファイルを読み込もうとすると、サブディレクトリ含めて読み込むことになる。

```typescript
let slug = key.replace(/^.*[\\\/]/, '').slice(0, -3)
```

こうすると、ファイル名から .md を取ったものが slug となる。今回の場合、

```
├── sources               // 記事を格納するディレクトリ
│   ├── sub
│   │  └── article2.md    // 記事
│   └── article1.md       // 記事
```

であるので、article1, article2 になる。サブディレクトリ名がないと扱いづらいので、

```typescript
let slug = key
  .replace(/^.[\\\/]/, '')
  .replaceAll('/', '--')
  .slice(0, -3);
```

として、ディレクトリの / を -- とした。article1, sub--article2 となる。もうちょっと考えた方が良いのかも知れないが、いったんこれで。

## 記事詳細ページ Syntax Highlight なし - [slug].tsx

package をインストールする

```bash
$ yarn add react-markdown remark-gfm
```

諸々プラグイン化されているようで、例えば ```table``` などはデフォルトでは html に変換されないため、 ```remark-gfm``` を入れた。



```typescript:pages/articles/[slug].tsx
import type { NextPage } from 'next';
import matter from 'gray-matter';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ArticleType } from '../../types/ArticleType';

export const getStaticPaths = async () => {
  const articleSlugs = ((context) => {
    const keys = context.keys();
    const data = keys.map((key: string, index: number) => {
      let slug = key
        .replace(/^.[\\\/]/, '')
        .replaceAll('/', '--')
        .slice(0, -3);
      return slug;
    });
    return data;
  })(require.context('../../sources', true, /\.md$/));

  const paths = articleSlugs.map((articleSlug) => `/articles/${articleSlug}`);

  return {
    paths: paths,
    fallback: false,
  };
};

export const getStaticProps = async (context: any) => {
  const { slug } = context.params;
  const path = slug.replaceAll('--', '/');
  const data = await import(`../../sources/${path}.md`);
  const singleDocument = matter(data.default);

  return {
    props: {
      frontmatter: singleDocument.data,
      markdownBody: singleDocument.content,
    },
  };
};

const SingleArticle: NextPage<ArticleType> = (props) => {
  return (
    <div>
      <h1>{props.frontmatter.title}</h1>
      <p>{props.frontmatter.date}</p>
      <ReactMarkdown
        remarkPlugins={[remarkGfm]}
        skipHtml={true}
      >
        {props.markdownBody}
      </ReactMarkdown>
    </div>
  );
};

export default SingleArticle;
```

個別記事の md を読み込むために、-- を / に戻している。
html タグ、例えば img タグなどは、エスケープされて出たので、いったん無視するようにした（```skipHtml={true}```）。


## Syntax Highlight する - CodeBlock.tsx

package をインストールする

```bash
$ yarn add react-syntax-highlighter @emotion/react @emotion/babel-plugin
$ yarn add @types/react-syntax-highlighter --dev
```
atom か morecule か悩んだ結果、atom に。

```typescript:components/atoms/CodeBlock.tsx
import React from 'react';
import { CodeComponent } from 'react-markdown/lib/ast-to-react';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { nightOwl } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
import { css } from '@emotion/react';

const CodeBlock: CodeComponent = ({ inline, className, children }) => {
  if (inline) {
    return <code className={className}>{children}</code>;
  }

  const codeComponentWrapper = css`
    position: relative;
    padding: 2rem;
    background-color: #011627;
    margin-top: 1rem;
    font-size: 1.6rem;
  `;
  const codeComponentFileName = css`
    display: inline-block;
    position: absolute;
    top: -0.8rem;
    left: 1rem;
    background-color: #ccc;
    border-radius: 0.5rem;
    margin-bottom: 1rem;
    padding: 0.3rem 1rem;
    color: #666;
  `;

  const match = /language-(\w+)(:.+)/.exec(className || '');
  const lang = match && match[1] ? match[1] : '';
  const name = match && match[2] ? match[2].slice(1) : '';

  return (
    <>
      <div css={codeComponentWrapper}>
        <div css={codeComponentFileName}>{name}</div>
        <SyntaxHighlighter
          style={nightOwl}
          language={lang}
          showLineNumbers={true}
        >
          {String(children).replace(/\n$/, '')}
        </SyntaxHighlighter>
      </div>
    </>
  );
};

export default CodeBlock;
```

まず、CodeComponent は src ではなく、lib に入ってた。

```typescript
import { CodeComponent } from 'react-markdown/lib/ast-to-react';
```

つぎに、nightOwl は、hljs の方に入っている。

```typescript
import { nightOwl } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
```

行番号は、```showLineNumbers``` を ```true``` とすれば出る。

```typescript
        <SyntaxHighlighter
          style={nightOwl}
          language={lang}
          showLineNumbers={true}
        >
          {String(children).replace(/\n$/, '')}
        </SyntaxHighlighter>
```

この nightOwl テーマは、padding とかなくてキッツキツなので、CSS で調整した。wrapper で囲んで ```padding``` を設定し、背景色を同じ（```#011627```）にした。

フォントサイズは、1.6rem としているが、いつも

```css
html {
  font-size: 62.5%;
}
```

としているので、16px くらいになるようになっている。変数で与えても良いかも知れないが、あんまり変わるものでもないのでやめた。

これを先程の [slug].tsx に入れる。

```typescript:pages/articles/[slug].tsx
...
import CodeBlock from '../../components/atoms/CodeBlock';
...
      <ReactMarkdown
        remarkPlugins={[remarkGfm]}
        components={{ code: CodeBlock }}
        skipHtml={true}
      >
        {props.markdownBody}
      </ReactMarkdown>
...
```

なお、emotion の設定は、

[【Next.js & TypeScript】Emotionの導入が大変だったので手順をまとめておく](https://zenn.dev/iwakin999/articles/7a5e11e62ba668)

の通り。```.babelrc``` と ```tsconfig.json``` を修正する。


## 完成

はい！綺麗綺麗！！

![Screen Shot 2022-02-05 at 23.15.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/a1a8c555-c040-a260-877b-feb1f45154ec.png)

他にもやりたいことはあるが、やってみて躓いたらまた。