【Next.js】next-mdx-remoteでインラインコードとコードブロックコピーボタンを実装する
2022-12-15
next-mdx-remote でインラインコードとコードブロックコピーボタンを実装する
前回の記事では、next-mdx-remote で作られた MDX のブログにファイル名つきシンタックスハイライトを実装しました。 今回はさらに、インラインコードとコードブロックコピーボタンを実装します。
next-mdx-remote の導入やコードブロックの表示方法については、前回の記事を参照ください。
【Next.js】Markdown ブログでコードブロックにシンタックスハイライトをかける | YuTa Extend
Next.jsのMarkdown(またはMDX)ブログでコードブロックにシンタックスハイライトをかけます。基本構文だけでなくファイル名を表示する方法も記載します。
https://www.yuta-extend.com/posts/20221208-code-block
import type { InferGetStaticPropsType, NextPage } from "next";
import { ReactNode } from "react";
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote";
import CodeBlock from "CodeBlock";
export async function getStaticProps() {
const postFilePath = path.join(process.cwd(), "public/sample.mdx");
const source = fs.readFileSync(postFilePath);
const { content, data } = matter(source);
const mdxSource = await serialize(content, {
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
scope: data,
});
return {
props: {
mdxSource: mdxSource,
},
};
}
const components = {
code: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => (
<CodeBlock {...props} />
),
};
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
return <MDXRemote {...props.mdxSource} components={components} />;
};
export default PostPage;
インラインコードを実装する
インラインコードですが、そのまま書くとコードブロックと区別がつかないらしく、うまく実装する方法が以下のリンク先に書かれています。
<code>
タグの親の<pre>
タグをCodeBlock.tsx
コンポーネントに渡すと、コードブロックかどうか判定できるみたいですね。
No Difference between Code Block and inline code · Issue #244 · hashicorp/next-mdx-remote
https://github.com/hashicorp/next-mdx-remote/issues/244
import CodeBlock from "./CodeBlock";
const components = {};
components.pre = CodeBlock;
export default components;
import React from 'react'
import Highlight, { defaultProps } from 'prism-react-renderer'
import vsDark from 'prism-react-renderer/themes/vsDark'
const CodeBlock = ({ children }) => {
if (!children || children.type !== 'code') return null
const {
props: { className, children: code = '' },
} = children
const language = className ? className.replace(/language-/, '') : ''
return (
<Highlight
{...defaultProps}
theme={vsDark}
code={code.trim()}
language={language}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={{ ...style, padding: '20px' }}>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))}
</div>
))}
</pre>
)}
</Highlight>
)
}
export default CodeBlock
ただし上記のやり方では TypeScript に対応していないため、本記事では TypeScript に対応した書き方にしてみます。
もともと React.ReactNode
型だった入力をコードブロック用の型に落とし込むため、型ガード
という構文を使ってみます。
import type { InferGetStaticPropsType, NextPage } from "next";
import { ReactNode } from "react";
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote";
import CodeBlock from "CodeBlock";
export async function getStaticProps() {
const postFilePath = path.join(process.cwd(), "public/sample.mdx");
const source = fs.readFileSync(postFilePath);
const { content, data } = matter(source);
const mdxSource = await serialize(content, {
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
scope: data,
});
return {
props: {
mdxSource: mdxSource,
},
};
}
// code ⇒ preに変更
const components = {
pre: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => (
<CodeBlock {...props} />
),
};
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
return <MDXRemote {...props.mdxSource} components={components} />;
};
export default PostPage;
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
interface Code {
props: { className: string; children: string };
type: string;
}
type Props = {
children?: Code | React.ReactNode;
};
// 型ガード関数
function isCodeBlock(children: any): children is Code {
return children.type === "code";
}
const CodeBlock: React.FC<Props> = ({ children }: Props) => {
// コードブロックでない場合は終了
if (!children || !isCodeBlock(children)) {
return null;
}
// コードブロックの各要素を設定
const match = /language-(\w+)(:?.*)/.exec(children.props.className || "");
const language = match && match[1] ? match[1] : "";
const fileName = match && match[2] ? match[2].slice(1) : "";
const code = String(children.props.children).replace(/\n$/, "");
const syntaxHighlighterClass = fileName
? "code-block-with-title"
: "code-block";
return (
<>
<div className="code-block-wrapper">
{fileName && <div className="code-block-title">{fileName}</div>}
<SyntaxHighlighter
language={language}
style={atomDark}
className={syntaxHighlighterClass}
>
{code}
</SyntaxHighlighter>
</div>
<style jsx>{`
.code-block-wrapper {
font-size: 0.9rem;
margin-bottom: 2rem;
}
.code-block-title {
display: inline-block;
border-radius: 0.3rem 0.3rem 0 0;
background-color: #323e52;
padding: 0.55rem 1rem;
color: white;
font-size: 0.8rem;
font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
monospace;
}
`}</style>
<style jsx global>{`
.code-block {
border-radius: 0.3rem !important;
padding: 1.5rem !important;
}
.code-block-with-title {
border-radius: 0 0.3rem 0.3rem 0.3rem !important;
padding: 1.5rem !important;
margin-top: 0 !important;
}
`}</style>
</>
);
};
export default CodeBlock;
これでコードブロックとインラインコードの判定ができました。
あとは装飾ですが、インラインコードは<p>
タグの子になっていて、コードブロックでは逆にそうならないはずなので、PostPage.tsx
側に styled-jsx を global で書いておきます。
(CodeBlock.tsx
側に書きたいところですが、CodeBlock.tsx
が一度も呼び出されないと CSS も呼び出されないので、仕方ありませんがPostPage.tsx
側に書きます)
~ 省略 ~
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
return (
<>
<MDXRemote {...props.mdxSource} components={components} />
<style jsx global>{`
p code {
background-color: #eee;
padding: 0.3rem;
margin: 0 0.2rem;
border-radius: 0.3rem;
font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
monospace;
}
`}</style>
</>
);
};
export default PostPage;
これで無事にインラインコードの装飾もできました。
コードブロックコピーボタンを実装する
次にコピーボタンの実装です。クリップボードにテキストをコピーするには react-copy-to-clipboard というプラグインを使うのが便利です。
react-copy-to-clipboard
Copy-to-clipboard React component. Latest version: 5.1.0, last published: 8 months ago. Start using react-copy-to-clipboard in your project by running `npm i react-copy-to-clipboard`. There are 1352 other projects in the npm registry using react-copy-to-clipboard.
https://www.npmjs.com/package/react-copy-to-clipboard
以下のコマンドで react-copy-to-clipboard をインストールします。
> npm i react-copy-to-clipboard
> npm i --save-dev @types/react-copy-to-clipboard
また、コピーボタンにアイコンを使いたい場合は、react-icons というプラグインを使うのが便利です。
react-icons
SVG React icons of popular icon packs using ES6 imports. Latest version: 4.7.1, last published: 15 days ago. Start using react-icons in your project by running `npm i react-icons`. There are no other projects in the npm registry using react-icons.
https://www.npmjs.com/package/react-icons
以下のコマンドで react-icons をインストールします。
> npm i react-icons
そして以下のサイトで使いたいアイコンを探し、サイト内のサンプルコードの通りに参照すればアイコンを表示できます。
React Icons
Include popular icons in your React projects easly with react-icons.
https://react-icons.github.io/react-icons
コピーボタンの実装についてですが、 「コードブロックにカーソルが入るとコピーボタンの表示/非表示を切り替える」 「コピーボタンを押すとコピー完了メッセージを出す」 といった要件で作ります。
このように状態に応じて要素や装飾に動きをつける場合は「フック(React Hooks)」という機能を使います。
使い方は以下のコードでご確認ください。
(コードのuseState()
の部分でフックを使っています)
一通り使う技術を紹介したところで、CodeBlock.tsx
の全文がこちらになります。
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { useState } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { BiCheck, BiCopy } from "react-icons/bi";
interface Code {
props: { className: string; children: string };
type: string;
}
type Props = {
children?: Code | React.ReactNode;
};
// 型ガード関数
function isCodeBlock(children: any): children is Code {
return children.type === "code";
}
const CodeBlock: React.FC<Props> = ({ children }: Props) => {
// コピーボタンの処理
const [isButtonActive, setIsButtonActive] = useState(false);
const normalStyle = {
opacity: 0,
transition: "0.1s",
};
const activeStyle = {
opacity: 1,
transition: "0.1s",
};
const copyBtnStyle = isButtonActive ? activeStyle : normalStyle;
// コピー完了メッセージの処理
const [isCopied, setCopied] = useState(false);
const handleClick = () => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
};
const copiedStyle = isCopied ? activeStyle : normalStyle;
// コードブロックでない場合は終了
if (!children || !isCodeBlock(children)) {
return null;
}
// コードブロックの各要素を設定
const match = /language-(\w+)(:?.*)/.exec(children.props.className || "");
const language = match && match[1] ? match[1] : "";
const fileName = match && match[2] ? match[2].slice(1) : "";
const code = String(children.props.children).replace(/\n$/, "");
const syntaxHighlighterClass = fileName
? "code-block-with-title"
: "code-block";
return (
<>
<div>
{fileName && <div className="code-block-title">{fileName}</div>}
<div
className="code-block-wrapper"
onMouseEnter={() => setIsButtonActive(true)}
onMouseLeave={() => setIsButtonActive(false)}
>
<div className="copied-tooltip" style={copiedStyle}>
Copied!!
</div>
<div className="copy-button" style={copyBtnStyle}>
<CopyToClipboard text={code} onCopy={() => handleClick()}>
{isCopied ? (
<BiCheck color="grey" size={20} />
) : (
<BiCopy color="grey" size={20} />
)}
</CopyToClipboard>
</div>
<SyntaxHighlighter
language={language}
style={atomDark}
className={syntaxHighlighterClass}
>
{code}
</SyntaxHighlighter>
</div>
</div>
<style jsx>{`
.code-block-wrapper {
font-size: 0.9rem;
margin-bottom: 2rem;
position: relative;
}
.code-block-title {
display: inline-block;
border-radius: 0.3rem 0.3rem 0 0;
background-color: #323e52;
padding: 0.55rem 1rem;
color: white;
font-size: 0.8rem;
font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
monospace;
}
.copy-button {
display: inline-block;
position: absolute;
top: 0.8rem;
right: 0.8rem;
background-color: rgba(50, 50, 50, 0.1);
border: 1px solid grey;
border-radius: 0.3rem;
padding: 0.2rem;
}
.copy-button:hover {
background-color: rgba(50, 50, 50, 0.9);
cursor: pointer;
}
.copied-tooltip {
position: absolute;
top: 0.8rem;
right: 3.2rem;
color: white;
background-color: rgba(50, 50, 50, 0.5);
border-radius: 0.2rem;
padding: 0.3rem;
}
`}</style>
<style jsx global>{`
.code-block {
border-radius: 0.3rem !important;
padding: 1.5rem !important;
}
.code-block-with-title {
border-radius: 0 0.3rem 0.3rem 0.3rem !important;
padding: 1.5rem !important;
margin-top: 0 !important;
}
`}</style>
</>
);
};
export default CodeBlock;
一点注意点ですが、フックの処理は分岐や return 文の前に処理しなければならない制約があります。
このサンプルではコードブロックかどうかの判定でreturn null;
を使っていますので、その前にフックの処理を記述しないと ESLint に怒られるので気を付けましょう。
(特にこだわりがなければ、ブロックの最初の方に処理を書いておくのがおすすめです)
まとめ
前回の記事では、next-mdx-remote で作られた MDX のブログにファイル名つきシンタックスハイライトを実装し、 今回はそれに対してインラインコードとコードブロックコピーボタンを実装しました。
個人的にはこれで満足いくデザインのコードブロックに仕上がりました。
次は next-mdx-remote の導入をちゃんと解説した記事を作りたいと思います。