Add copy button to your GatsbyJS blog’s code block

Adding code block to technical blog is a common UseCase. By using GatsbyJS, it’s quite easy. What you have to do is just install gatsby-transformer-remark and gatsby-remark-prismjs. Then modify your gatsby-config.js, add following settings like this:

module.exports = {
plugins: [
{
resolve: `gatsby-transformer-remark`,
options: {
plugins: [
`gatsby-remark-prismjs`,
],
},
},
]
}

But, not like gatsby-plugin-mdx, it's not easy to add copy button to code block. So, I wrote this plugin - gatsby-remark-prismjs-copy-button. Let's see how to make it work.

TL;DR

What we need to do is just analyze markdown ASTs which are generated by gatsby-transformer-remark, and then add copy button html before the code blocks' ASTs.

I created this plugin to make it work. It can be found here.

https://github.com/thundermiracle/gatsby-remark-prismjs-copy-buttonAbout AST

AST is the abbrev. of Abstract Syntax Tree. Before compile the source code to machine readable ones, we usually transform the human readable source code to AST first. As the tree structure is much more easier for program to operate. The famous babel, eslint, webpack, etc. are using the same mechanism. Access the following web site if you want to know more about AST.

https://www.twilio.com/blog/abstract-syntax-trees

About MarkdownAST

gatsby-transformer-remark is using markdown to transpile markdown contents to MarkdownAST. You can confirm the transpiled MarkdownAST by using the following site online.

https://astexplorer.net/

Select both `Markdown and remark`, it'll automatically transpile the sample markdown contents. And if you click the code block in markdown side, it'll display its MarkdownAST like this.

The plugins in the options refer to the ASTs here and convert them to html for display. For example: gatsby-remark-prismjs converts all ASTs of type: "code" from code blocks to html within the pre tag. Here is the AST of the code block.

{
"type": "code",
"lang": "js",
"value": "console.log('!');",
"meta": null
}

Become to this html:

<div class="gatsby-highlight" data-language="javascript">
<pre class="language-javascript">
<code class="language-javascript">
console
<span class="token punctuation">.</span>
<span class="token function">log</span>
<span class="token punctuation">(</span>
<span class="token string">'!'</span>
<span class="token punctuation">)</span>
<span class="token punctuation">;</span>
</code>
</pre>
</div>

How to add a copy button

So how do I add a copy button? As introduced above, the AST in the code block is not html, so you cannot easily just append the copy button html. But you can add the html part of the copy button as an AST of type: "html" before the AST of the code block, and with a few css adjustments you can move the copy button to the right place. Here is the flow.

  1. Get ASTs for all code blocks from MarkdownASTs
  2. Insert copy button’s html before the code block’s AST
  3. Adjust the position of the copy button with css (e.g. margin-top: -10px)
  4. Add the click event of copy button

So let’s begin.

Get ASTs for all code blocks from MarkdownASTs

First, you can get all MarkdownASTs from index.js in the plugin's root folder.

module.exports = function gatsbyRemarkPrismCopyButton({ markdownAST }) {
// output all ASTs
console.log(markdownAST);
}

Then, we can get code blocks’ ASTs by filtering type: "code". We'll use unist-util-visit to do the filtering work.

module.exports = function gatsbyRemarkPrismCopyButton({ markdownAST }) {
visit(markdownAST, 'code', (node, index, parent) => {
}
}

Insert copy button’s html before the code block’s AST

  1. Get the original code from code block’s MarkdownAST for copy button
let code = parent.children[index].value;
code = code.replace(/"/gm, '&quot;').replace(/`/gm, '\\`').replace(/\$/gm, '\\$');

2. Generate copy button’s html MarkdownAST

const buttonNode = {
type: 'html',
value: `
<div class="gatsby-remark-prismjs-copy-button-container">
<div class="gatsby-remark-prismjs-copy-button" tabindex="0" role="button" aria-pressed="false" onclick="gatsbyRemarkCopyToClipboard(\`${code}\`, this)">
Copy
</div>
</div>
`,
};

3. Insert the generated MarkdownAST before the code block’s AST

parent.children.splice(index, 0, buttonNode);

4. Little tweaks after the insertion

As we mutate the MarkdownASTs directly(insert the copy button’s AST before code block), the node parameter in (node, index, parent) => {} will also be the processed node, so it will be an infinite loop. We must add a processed flag to node.lang and skip it.

const COPY_BUTTON_ADDED = 'copy-button-added-';// skip already added copy button
if (lang.startsWith(COPY_BUTTON_ADDED)) {
node.lang = lang.substring(COPY_BUTTON_ADDED.length);
return;
}
.
.
.
parent.children.splice(index, 0, buttonNode);
// add flag
node.lang = `${COPY_BUTTON_ADDED}${lang}`;

5. Summary

To summarize the above steps, the index.js will like this:

const visit = require('unist-util-visit');const COPY_BUTTON_ADDED = 'copy-button-added-';module.exports = function gatsbyRemarkCopyButton(
{ markdownAST },
) {
visit(markdownAST, 'code', (node, index, parent) => {
const lang = node.lang || '';
if (lang.startsWith(COPY_BUTTON_ADDED)) {
node.lang = lang.substring(COPY_BUTTON_ADDED.length);
return;
}
let code = parent.children[index].value;
code = code.replace(/"/gm, '&quot;').replace(/`/gm, '\\`').replace(/\$/gm, '\\$');
const buttonNode = {
type: 'html',
value: `
<div class="gatsby-remark-prismjs-copy-button-container">
<div class="gatsby-remark-prismjs-copy-button" tabindex="0" role="button" aria-pressed="false" onclick="gatsbyRemarkCopyToClipboard(\`${code}\`, this)">
Copy
</div>
</div>
`,
};
parent.children.splice(index, 0, buttonNode);node.lang = `${COPY_BUTTON_ADDED}${lang}`;
});
return markdownAST;
};

Adjust the position of the copy button with css

  1. Add style.css
.gatsby-remark-prismjs-copy-button-container {
touch-action: none;
display: flex;
justify-content: flex-end;
position: relative;
top: 37px;
left: 8px;
margin-top: -28px;
z-index: 1;
pointer-events: none;
}
.gatsby-remark-prismjs-copy-button {
cursor: pointer;
pointer-events: initial;
font-size: 13px;
padding: 3px 5px 2px;
border-radius: 3px;
color: rgba(255, 255, 255, 0.88);
}

2. import style.css in gatsby-browser.js

require("./style.css");

Add the click event of copy button

Add the gatesbyRemarkCopyToClipboard function that we used when adding the copy button html MarkdownAST.

exports.onClientEntry = () => {
window.gatsbyRemarkCopyToClipboard = (str, copyButtonDom) => {
// prevent multiple click
if (copyButtonDom.textContent === 'Copied') {
return;
}
// copy to clipboard
navigator.clipboard.writeText(str);
copyButtonDom.textContent = 'Copied!';
};
};

Finish

The added copy button looks like this.

And the source code.

https://github.com/thundermiracle/gatsby-remark-prismjs-copy-button

Originally published at https://thundermiracle.com.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store