やりたいこと
Next.js + typescript で react-markdown
を使って markdown ドキュメントを読み込み、Code 部分は、react-syntax-highliter
で Syntax をハイライトしたい。
Code の表示は、Night Owl とし、行番号をつけたい。
ググれば色々記事が出てくるが、現状のバージョンだとコピペでは動かないなどあり、ちょっと苦労したので、そのメモ。
検証環境
...
"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
- react-syntax-highlighter/react-syntax-highlighter
- Next.jsにマークダウンのブログ機能を追加する方法
- react-markdownでコードをシンタックスハイライトさせる
- webpack - Property context does not exist on type NodeRequire
準備
next-app を typescript で作る
$ 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 を適当に用意する
---
id: '1'
title: '記事 1'
date: '2022-02-02'
summary: '記事 1 の要約'
---
```typescript:sample.tsx
コード書く
```
記事一覧ページ - index.tsx
内容の詳細は、参考記事に任せる。typescript で書いていく。
プラグインのインストール
$ yarn add raw-loader gray-matter
/** @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;
準備完了(のはずだった)。
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
を設定してやる必要がある。
$ yarn add @types/webpack-env --dev
{
"compilerOptions": {
...
"types": ["webpack-env"],
...
},
...
}
細かい話しではあるが、
require.context('../sources', true, /\.md$/)
これでファイルを読み込もうとすると、サブディレクトリ含めて読み込むことになる。
let slug = key.replace(/^.*[\\\/]/, '').slice(0, -3)
こうすると、ファイル名から .md を取ったものが slug となる。今回の場合、
├── sources // 記事を格納するディレクトリ
│ ├── sub
│ │ └── article2.md // 記事
│ └── article1.md // 記事
であるので、article1, article2 になる。サブディレクトリ名がないと扱いづらいので、
let slug = key
.replace(/^.[\\\/]/, '')
.replaceAll('/', '--')
.slice(0, -3);
として、ディレクトリの / を -- とした。article1, sub--article2 となる。もうちょっと考えた方が良いのかも知れないが、いったんこれで。
記事詳細ページ Syntax Highlight なし - [slug].tsx
package をインストールする
$ yarn add react-markdown remark-gfm
諸々プラグイン化されているようで、例えば table
などはデフォルトでは html に変換されないため、 remark-gfm
を入れた。
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 をインストールする
$ yarn add react-syntax-highlighter @emotion/react @emotion/babel-plugin
$ yarn add @types/react-syntax-highlighter --dev
atom か morecule か悩んだ結果、atom に。
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 に入ってた。
import { CodeComponent } from 'react-markdown/lib/ast-to-react';
つぎに、nightOwl は、hljs の方に入っている。
import { nightOwl } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
行番号は、showLineNumbers
を true
とすれば出る。
<SyntaxHighlighter
style={nightOwl}
language={lang}
showLineNumbers={true}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
この nightOwl テーマは、padding とかなくてキッツキツなので、CSS で調整した。wrapper で囲んで padding
を設定し、背景色を同じ(#011627
)にした。
フォントサイズは、1.6rem としているが、いつも
html {
font-size: 62.5%;
}
としているので、16px くらいになるようになっている。変数で与えても良いかも知れないが、あんまり変わるものでもないのでやめた。
これを先程の [slug].tsx に入れる。
...
import CodeBlock from '../../components/atoms/CodeBlock';
...
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ code: CodeBlock }}
skipHtml={true}
>
{props.markdownBody}
</ReactMarkdown>
...
なお、emotion の設定は、
【Next.js & TypeScript】Emotionの導入が大変だったので手順をまとめておく
の通り。.babelrc
と tsconfig.json
を修正する。
完成
はい!綺麗綺麗!!
他にもやりたいことはあるが、やってみて躓いたらまた。