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.md1これはインラインの`code`です 2 3```js 4alert("Hello World!"); 5```
このMarkdownファイルを読み込んで画面に表示させるため、next.js
を使用します。
pages/index.tsx
ファイルを作成し、中身を以下のように書きます。
src/pages/index.tsx1import 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.tsx1import { 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
コンポーネントを読み込み、components
のcode
に設定します。
src/pages/index.tsx1 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
を次のように変更します。
変更点は以下のとおりです。
- 正規表現を変更し、
:
以降の値からファイル名を取得する SyntaxHighlighter
をCodeBlockWrapper
で囲み、CodeBlockTitle
に取得したファイル名を表示させる
src/components/CodeBlock.tsx1const 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```
このままでは以下のようにしか表示されません。
そのため、以下の条件の場合に表示を変更することにします。
- 拡張子の後に、アンダースコア2つ+diffがついている(
__diff
) - 行頭に
+
もしくは-
があり、かつ次がスペースの場合
1```js__diff 2- alert("Hello World!"); 3+ alert("Hello Joe!"); 4```
react-syntax-highlighter
には行のスタイルを変更するlineProps
という機能があるため、それを使用していきます。
まずは、拡張子とdiff
を__
で分割します。__
がない場合はlang2
はundefined
になります。
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
のプロパティを以下のように設定します。
wrapLines
をtrue
に設定するshowLineNumbers
をtrue
に設定するlineNumberStyle
にdisplay: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
の部分を変更し、行を囲むspan
にclass
を設定します。
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.scss1 .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__diff1 <div 2 className="test" 3 className="test" 4 />
おわりに
react-markdown
でのシンタックスハイライトの方法について書きました。
コンポーネントをうまく制作することができれば、色々なことができるようになると思いました。