lazy developers blog

react-markdownでコードをシンタックスハイライトさせる

react-markdownを使用したとき、コードブロックをシンタックスハイライトさせる方法の備忘録です。

環境

  • next : 11.0.1
  • react : 17.0.2
  • react-markdown : 6.0.2
  • react-syntax-highlighter : 13.5.2

Markdownを読み込み画面に表示させる

まずは、Markdownのファイルを読み込み画面に表示させるために必要なパッケージをnpmを使用してインストールします。

1npm i -D next react react-markdown @types/react

ソースの場所をsrcディレクトリに設定し、Markdownファイルをその中に作成します。

src/test.md
1これはインラインの`code`です
2
3```js
4alert("Hello World!");
5```

このMarkdownファイルを読み込んで画面に表示させるため、next.jsを使用します。
pages/index.tsxファイルを作成し、中身を以下のように書きます。

src/pages/index.tsx
1import path from 'path';
2import fs from 'fs';
3import ReactMarkdown from 'react-markdown';
4
5const App = ({ markdown }: { markdown: string }) => {
6  return (
7    <ReactMarkdown children={markdown} />
8  );
9};
10
11export default App;
12
13export const getStaticProps = async () => {
14  const markdown = fs.readFileSync(
15    path.join(process.cwd(), 'src/test.md'),
16    'utf8',
17  );
18  return {
19    props: { markdown },
20  };
21};
22

これを実行すると、画面には以下のように表示されます。

next.jsを使用すると、たったこれだけでMarkdownを読み込んで画面に表示させることができます。

コードをシンタックスハイライトさせる

react-markdownでコードをシンタックスハイライトさせるためには、カスタムコンポーネントを作成する必要があります。以下のリンク先に書いてあるようにコンポーネントを作成していきます。

まずはシンタックスハイライトさせるために、npmを使用してreact-syntax-highlighterと型定義ファイルをインストールします。

1npm i -D react-syntax-highlighter @types/react-syntax-highlighter

次に、src/componentsフォルダにCodeBlock.tsxという名前のファイルを作成し、以下のように書きます。 ポイントは

  • インライン場合は<code>で囲んで出力
  • language-がついているクラス名から拡張子を取得しlanguageに設定
  • コードブロックの最後の改行を削除してchildrenに設定
src/components/CodeBlock.tsx
1import { CodeComponent } from 'react-markdown/src/ast-to-react';
2import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3import { dark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
4
5const CodeBlock: CodeComponent = ({ inline, className, children }) => {
6  if (inline) {
7    return <code className={className}>{children}</code>;
8  }
9  const match = /language-(\w+)/.exec(className || '');
10  const lang = match && match[1] ? match[1] : '';
11  return (
12    <SyntaxHighlighter
13      style={dark}
14      language={lang}
15      children={String(children).replace(/\n$/, '')}
16    />
17  );
18};
19
20export default CodeBlock;
21

作成したCodeBlockコンポーネントを読み込み、componentscodeに設定します。

src/pages/index.tsx
1  return (
2    <ReactMarkdown
3      children={markdown}
4      components={{
5        code: CodeBlock,
6      }}
7    />
8  );

実行すると、インラインのコードはそのままで、コードブロックの場合以下の画像のようにシンタックスハイライトされ表示されます。

ファイル名を表示する

コードブロックにファイル名を表示したい場合、CodeBlockを修正する必要があります。
ファイル名を表示するために、コードブロックの拡張子の後ろに:をつけてその後の文字列をファイル名とします。

  • 例)index.jsというファイル名を表示する場合
1```js:index.js
2
3```

styled-componentsを使用して、コードブロックを囲む枠とファイル名のスタイルを定義します。

1import styled from 'styled-components';
2
3const CodeBlockWrapper = styled.div`
4  position: relative;
5`;
6
7const CodeBlockTitle = styled.div`
8  display: inline-block;
9  position: absolute;
10  top: -0.5em;
11  left: 0;
12  background-color: #ccc;
13  padding: 0.2em;
14  color: #000;
15`;

そして、CocdBlockを次のように変更します。
変更点は以下のとおりです。

  1. 正規表現を変更し、:以降の値からファイル名を取得する
  2. SyntaxHighlighterCodeBlockWrapperで囲み、CodeBlockTitleに取得したファイル名を表示させる
src/components/CodeBlock.tsx
1const CodeBlock: CodeComponent = ({ inline, className, children }) => {
2  if (inline) {
3    return <code className={className}>{children}</code>;
4  }
5  const match = /language-(\w+)(:.+)/.exec(className || '');
6  const lang = match && match[1] ? match[1] : '';
7  const name = match && match[2] ? match[2].slice(1) : '';
8  return (
9    <CodeBlockWrapper>
10      <CodeBlockTitle>{name}</CodeBlockTitle>
11      <SyntaxHighlighter
12        style={dark}
13        language={lang}
14        children={String(children).replace(/\n$/, '')}
15      />
16    </CodeBlockWrapper>
17  );
18};

これを実行すると、ファイル名がコードブロックの左上に表示されるようになります。

Diff+シンタックスハイライト

少し調べてみましたが、Diffをシンタックスハイライトする簡単な方法が見つからなかったため、プロパティを組み合わせて対応していきます。

以下のように、削除と追加を表したい場合があると思います。

1```js
2- alert("Hello World!");
3+ alert("Hello Joe!");
4```

このままでは以下のようにしか表示されません。

そのため、以下の条件の場合に表示を変更することにします。

  1. 拡張子の後に、アンダースコア2つ+diffがついている(__diff)
  2. 行頭に+もしくは-があり、かつ次がスペースの場合
1```js__diff
2- alert("Hello World!");
3+ alert("Hello Joe!");
4```

react-syntax-highlighterには行のスタイルを変更するlinePropsという機能があるため、それを使用していきます。

まずは、拡張子とdiff__で分割します。__がない場合はlang2undefinedになります。

1  const match = /language-(\w+)/.exec(className || '');
2  const lang = match && match[1] ? match[1] : '';
3  const [lang1, lang2] = lang.split('__');

次にMarkdownの文章を改行コードで分割して、そこから+ がついている行番号と- がついている行番号を取得します。

1  const { added, removed } = (() => {
2    const added: number[] = [];
3    const removed: number[] = [];
4    let lineNumber = 0;
5    const lines = String(children).split('\n');
6    for (let i = 0; i < lines.length; i++) {
7      lineNumber++;
8      if (/^\+\s.*$/.test(lines[i])) {
9        added.push(lineNumber);
10      }
11      if (/^\-\s.*$/.test(lines[i])) {
12        removed.push(lineNumber);
13      }
14    }
15    return { added, removed };
16  })();

そしてlinePropsに渡す関数を作成します。
+の場合は背景を青に、-の場合は背景を赤に変更していきます。

1  const lineProps: lineTagPropsFunction = (lineNumber) => {
2    let style: CSSProperties = {};
3    if (lang2 === 'diff') {
4      if (added.includes(lineNumber)) {
5        style.display = 'block';
6        style.backgroundColor = 'rgba(0, 0, 255, 0.4)';
7      }
8      if (removed.includes(lineNumber)) {
9        style.display = 'block';
10        style.backgroundColor = 'rgba(255, 0, 0, 0.4)';
11      }
12    }
13    return { style };
14  };

SyntaxHighLighterのプロパティを以下のように設定します。

  • wrapLinestrueに設定する
  • showLineNumberstrueに設定する
  • lineNumberStyledisplay:noneを設定する(行番号を表示する場合は必要なし)
  • linePropsに上で作成した関数を設定する
1    <SyntaxHighlighter
2      style={dark}
3      language={lang1}
4      children={String(children).replace(/\n$/, '')}
5      wrapLines={true}
6      showLineNumbers={true}
7      lineNumberStyle={{ display: 'none' }}
8      lineProps={lineProps}
9    />

それらをまとめると以下のようになります。

1const CodeBlock: CodeComponent = ({ inline, className, children }) => {
2  if (inline) {
3    return <code className={className}>{children}</code>;
4  }
5  const match = /language-(\w+)/.exec(className || '');
6  const lang = match && match[1] ? match[1] : '';
7  const [lang1, lang2] = lang.split('__');
8
9  const { added, removed } = (() => {
10    const added: number[] = [];
11    const removed: number[] = [];
12    let lineNumber = 0;
13    const lines = String(children).split('\n');
14    for (let i = 0; i < lines.length; i++) {
15      lineNumber++;
16      if (/^\+\s.*$/.test(lines[i])) {
17        added.push(lineNumber);
18      }
19      if (/^\-\s.*$/.test(lines[i])) {
20        removed.push(lineNumber);
21      }
22    }
23    return { added, removed };
24  })();
25
26  const lineProps: lineTagPropsFunction = (lineNumber) => {
27    let style: CSSProperties = {};
28    if (lang2 === 'diff') {
29      if (added.includes(lineNumber)) {
30        style.display = 'block';
31        style.backgroundColor = 'rgba(0, 0, 255, 0.4)';
32      }
33      if (removed.includes(lineNumber)) {
34        style.display = 'block';
35        style.backgroundColor = 'rgba(255, 0, 0, 0.4)';
36      }
37    }
38    return { style };
39  };
40
41  return (
42    <SyntaxHighlighter
43      style={dark}
44      language={lang1}
45      children={String(children).replace(/\n$/, '')}
46      wrapLines={true}
47      showLineNumbers={true}
48      lineNumberStyle={{ display: 'none' }}
49      lineProps={lineProps}
50    />
51  );
52};

実行した結果が以下のようになります。

一応、力技でDiffを分かりやすく表示することができました。
但し、このままでは複数行にわたる以下のような場合には対応できないため、実際にはもう少し処理が必要になります。

1```jsx__diff
2  <div
3-   className="test"
4+   className="test"
5  />
6```

linePropsの部分を変更し、行を囲むspanclassを設定します。

1  const lineProps: lineTagPropsFunction = (lineNumber) => {
2    let className = "";
3    if (lang2 === 'diff') {
4      if (added.includes(lineNumber)) {
5        let className = "added";
6      }
7      if (removed.includes(lineNumber)) {
8        let className = "removed";
9      }
10    }
11    return { class: className };
12  };

次にcssを読み込み、先ほど設定したクラスを以下のように設定します。

style.scss
1  .added {
2    position: relative;
3    display: block;
4    background-color: rgba(64, 174, 207, 0.3);
5
6    &::before {
7      content: '+';
8      position: absolute;
9      top: 0;
10      left: 0;
11    }
12  }
13  .remove {
14    position: relative;
15    display: block;
16    background-color: rgba(211, 94, 94, 0.3);
17
18    &::before {
19      content: '-';
20      position: absolute;
21      top: 0;
22      left: 0;
23    }
24  }

最後に、react-syntax-highlighterへ渡すchildrenから+-を削除します。

1  let text = String(children).replace(/\n$/, '');
2  if (lang2 && lang2 === 'diff') {
3    text = text.replace(/^\+ /gm, '  ').replace(/^\- /gm, '  ');
4  }

そうすれば、ハイライト付きのdiffが表示できるようになります。

jsx__diff
1  <div
2    className="test"
3    className="test"
4  />

おわりに

react-markdownでのシンタックスハイライトの方法について書きました。 コンポーネントをうまく制作することができれば、色々なことができるようになると思いました。