March 13, 2022 • ☕️ 6 min read
日本語

Adding code block to technical blog is normal. 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:

gatsby-browser.js
Copy
require("prismjs/themes/prism-solarizedlight.css")
gatsby-config.js
Copy
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-button

About 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 `Markdownandremark`, 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.

astexplorer.png

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.

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

Become to this html:

Copy
<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.

index.js
Copy
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.

index.js
Copy
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
index.js
Copy
let code = parent.children[index].value;
code = code.replace(/"/gm, '&quot;').replace(/`/gm, '\\`').replace(/\$/gm, '\\$');
  1. Generate copy button’s html MarkdownAST
index.js
Copy
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>
          `,
    };
  1. Insert the generated MarkdownAST before the code block’s AST
index.js
Copy
parent.children.splice(index, 0, buttonNode);
  1. 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.

index.js
Copy
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}`;
  1. Summary

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

index.js
Copy
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
style.css
Copy
.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);
}
  1. import style.css in gatsby-browser.js
gatsby-browser.js
Copy
require("./style.css");

Add the click event of copy button

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

gatsby-browser.js
Copy
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.

copy-button-added.png

And the source code.

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


ThunderMiracle

Blog part of ThunderMiracle.com