0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

やりたいこと

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

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

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

検証環境

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 と参考にした記事

準備

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 を適当に用意する

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

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

記事一覧ページ - index.tsx

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

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

$ yarn add raw-loader gray-matter 
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;

準備完了(のはずだった)。

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 を設定してやる必要がある。

$ yarn add @types/webpack-env --dev
tsconfig.json
{
  "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 を入れた。

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 をインストールする

$ yarn add react-syntax-highlighter @emotion/react @emotion/babel-plugin
$ yarn add @types/react-syntax-highlighter --dev

atom か morecule か悩んだ結果、atom に。

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 に入ってた。

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

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

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

行番号は、showLineNumberstrue とすれば出る。

        <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 に入れる。

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の導入が大変だったので手順をまとめておく

の通り。.babelrctsconfig.json を修正する。

完成

はい!綺麗綺麗!!

Screen Shot 2022-02-05 at 23.15.23.png

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?