Programming

Creating a Gatsby plugin for awesome links

How to create a simple Gatsby plugin to add FontAwesome icons to your Markdown links
March 7, 2018
--
User avatar
Adrian Perez
@blackxored

If you've been around my site lately, you would not only have noticed a big redesign and a move to Gatsby, but something more subtle, hiding under the covers, almost unnoticed... That is, of course, links.

As a visual cue for the readers, which will now know instantly which site they're about to be brought to there's an icon attached to them. This applies to only the ones I thought to be relevant to the content and audience of this site.

I've seen this pattern on a few sites, but the most recent example I drew inspiration from was this post about CoreML. In a childish way, I was envious on how awesome the links on that post looked and decided to poke around and implement this myself.

Here's a screenshot of such links on that site:

Inspiration screenshot

The blog in particular (which is open source BTW) uses raw HTML inside Markdown as part of the posts to achieve this effect. Here's an example:

<a title="GitHub" href="https://github.com/steadicat/pytorch-coreml-example">code</a>

While I loved the concept, I had a couple of problems with this approach: in addition to the manual markup that I'd have to remember for every link I wanted "enhanced", I didn't want to use CSS and static image (or SVG) icons, when the rest of my site is using CSS-in-JS (specifically, Glamorous) and icons were provided by FontAwesome. Particularly, due to how FontAwesome works, I knew that relying on only class names would be hacky at best. So I had to do a bit better.

It turns out that there's a simple way I could solve those problems, and that's by writing a simple Gatsby plugin!

Obligatory disclaimer: The plugin we're examining in this post is not yet production-ready, but I will open source it once it is, including accepting configuration options.

They say a picture of the result it's worth more than a thousands words, so let's take a look at an example from one of my recent posts, where you can see what I ended up with:

Site Links example

Plugin Spec

My requirements were quite simple:

  1. Write standard Markdown for links.
  2. Have some of these links be automatically modified in some way to include icons. I've chosen Github, Wikipedia, AppStore, Twitter and NodeJS/NPM for starters.
  3. Use already included FontAwesome icons.

With that in mind I started top-down with this piece of Markdown:

Link Test:

* [Github](https://github.com/blackxored/)
* [Wikipedia](https://wikipedia.org/example)
* [AppStore](https://itunes.apple.com/example)
* [Twitter](https://twitter.com/example)
* [Web](https://anything.com)

The snippet above, unsurprisingly, didn't do much on its own, but it served as a test for the "final product".

Plugin Setup

Skimming through the Plugin docs on Usage, Source Plugins, and a reference plugin which I knew it modified HTML in gatsby-remark-prismjs I knew I was ready to get started.

First, I added the soon-to-be plugin to my gatsby-config.js:

{
  // ...
  resolve: 'gatsby-transformer-remark',
    options: {
      plugins: [
        {
          resolve: 'gatsby-remark-images',
          options: {
            maxWidth: 690,
          },
        },
        {
          resolve: 'gatsby-remark-responsive-iframe',
        },
        'gatsby-remark-prismjs',
        'gatsby-remark-copy-linked-files',
        'gatsby-remark-autolink-headers',
        'gatsby-remark-fa-links',
        'gatsby-plugin-sharp',
      ],
    },
  // ...
}

Then I added that directory gatsby-remark-fa-links under the plugins folder at the root of my site. Gatsby allows using local plugins and while developing this seemed like the perfect approach to take.

After a bit of transpiling and build setup (which I won't bother you with, since you can see the build scripts below), I ended up with the following structure:

$ tree plugins/gatsby-remark-fa-links
.

├── src
│   ├── index.js
├── package.json
└── yarn.lock

Added the basics to our package.json:

{
  "name": "gatsby-remark-fa-links",
  "description": "A plugin to add FontAwesome icons to links for specific sites (like Github, AppStore, etc).",
  "main": "index.js",
  "version": "0.0.0-development",
  "author": "Adrian Perez <[email protected]> (https://adrianperez.codes/)",
  "license": "MIT",
  "scripts": {
    "build": "babel src --out-dir . --ignore __tests__",
    "prepublish": "cross-env NODE_ENV=production npm run build",
    "watch": "babel -w src --out-dir . --ignore __tests__"
  },
  "keywords": [
    "gatsby",
    "gatsby-plugin",
    "remark",
    "font-awesome"
  ],
  "dependencies": {
    "babel-runtime": "^6.26.0",
  },
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-jest": "^22.4.1",
    "babel-plugin-add-module-exports": "^0.2.1",
    "babel-preset-env": "^1.6.1",
    "cross-env": "^5.1.3",
    "jest": "^22.4.2",
  }
}

Now I was ready to get started. All I needed was to make sure we're transpiling and the ocassional Gatsby restart and I was good to go. So it was about time to start to figure out how to start hooking into Markdown.

Gatsby's Markdown parser/transformers is called remark, and a quick look at a different plugin made me realize I'd need a helper function to parse Markdown AST, so I added it to my dependencies in package.json.

{
  // ...
  "dependencies": {
    "babel-runtime": "^6.26.0",
    "remark": "^7.0.1",
    "unist-util-visit": "^1.3.0"
  },
  // ...
}

But before I go on diving into Markdown AST (feat not, it's easier than it sounds), I figured I'd need a way to configure which links get icons, and which icons they get indeed. A simple mapping would do for now. So I created a sites.js in my source dir:

export default {
  'fab fa-github': /github\.com/,
  'fab fa-node-js': /(npmjs\.com|nodejs\.org)/,
  'fab fa-wikipedia-w': /wikipedia\.org/,
  'fab fa-app-store': /itunes\.apple\.com/,
  'fab fa-twitter': /twitter\.com/,
};

As you can see the implementation above uses Regular Expressions that on match will return a class name corresponding to a FontAwesome icon. That should get mapping out of the way, although in the future I'd like to make these externally configurable.

I'm sure I'd be needing to import such a file from my main plugin file, so I added it right away, along with some no-op plugin definition.

import defaultSites from './sites';

const FontAwesomeLinks = () => {};

export default FontAwesomeLinks;

Markdown rewriting

With our setup in place, coupled with the mappings I ended up with in the previous section, all that was left was arguably the hardest part. I wanted to modify what Gatsby and Remark consider to be a link to include a FontAwesome element and add some additional styling rules. This is where the library unist-util-visit we added earlier comes handy, as our plugin would receive an AST as an argument and we can use this library with it. In addition, a small bit of research showed that my main interest should be, unsurprinsingly, focusing around the link type.

Armed with this piece of information, we could add a bit more structure to our plugin:

import visit from 'unist-util-visit';
import defaultSites from './sites';

const FontAwesomeLinks = ({ markdownAST }, { sites }) => {
  visit(markdownAST, 'link', node => {
    const linkMap = sites || defaultSites;

    // TODO: do something with links
  });
}

At this point, our callback will receive a remark node which we can modify, add attributes to, and more.

But we don't want to modify all links (we would if we wanted to add some generic icon to all links, which in my opinion was too noisy). Therefore, we need to only target the links that we have a mapping for, so we need some logic to ensure we're looking at the right URL.

import visit from 'unist-util-visit';
import defaultSites from './sites';

const FontAwesomeLinks = ({ markdownAST }, { sites } = {}) => {
  visit(markdownAST, 'link', node => {
    const linkMap = sites || defaultSites;

    Object.keys(linkMap).forEach(icon => {
      if (node.url.match(linkMap[icon])) {
        // TODO: do something with this particular link
      }
    });
  });
};

export default FontAwesomeLinks;

What do we exactly do with the link? Well, to add an icon viar FontAwesome we'd need a new element, something that'd look like:

<i class="fab fa-github"></i>

Hence the need to transform something like this:

<a href="https://github.com/blackxored/example">Example</a>

Into:

<a href="https://github.com/blackxored/example">
  <i class="fab fa-github"></i>
  Example
</a>

Remember, we don't want to switch to HTML inside Markdown and/or typing all this by hand.

Back to the element issue, sounds simple enough: we just need to prepend a new element to this link's children.

import visit from 'unist-util-visit';
import defaultSites from './sites';

const FontAwesomeLinks = ({ markdownAST }, { sites } = {}) => {
  visit(markdownAST, 'link', node => {
    const linkMap = sites || defaultSites;

    Object.keys(linkMap).forEach(icon => {
      if (node.url.match(linkMap[icon])) {
        node.children.unshift({
          type: 'html',
          value: `<i class="${icon}"></i>`,
        });
      }
    });
  });
};

export default FontAwesomeLinks;

With this change, all our links that match the URL RegExps in our mapping would have a new element prepended to their children, and this exactly what we wanted.

Bonus Styling

I needed to add some extra styling to add some margin between icon and text, and it would also be handy as a "plugin" to have a specific class that we could style on. So the next section is quite simple, assinging a class name, and ensuring all properties exist and are merged before assigning is what makes it a tad more complicated that just trivial. In the following snippet, hProperties refers to the HTML attributes of the node in question.

Now we're turning our previous link into something like this:

<a href="https://github.com/blackxored/example" class="remark-fa-link">
  <i class="fab fa-github"></i>
  Example
</a>
import visit from 'unist-util-visit';
import defaultSites from './sites';

const FontAwesomeLinks = ({ markdownAST }, { sites } = {}) => {
  visit(markdownAST, 'link', node => {
    const linkMap = sites || defaultSites;

    Object.keys(linkMap).forEach(icon => {
      if (node.url.match(linkMap[icon])) {
        if (!node.data) {
          node.data = { hProperties: {} };
        }

        node.children.unshift({
          type: 'html',
          value: `<i class="${icon}"></i>`,
        });

        const className = node.data.hProperties.class
          ? `${node.data.hProperties.class} remark-fa-link`
          : 'remark-fa-link';

        node.data.hProperties = Object.assign({}, node.data.hProperties, {
          class: className,
        });
      }
    });
  });
};

export default FontAwesomeLinks;

Then we can use the remark-fa-links class to add some extra style, in my case a bit of margin around the i element.

Testing our results

And that's all we needed. Let's refer back to our Markdown example from before:

Link Test:

* [Github](https://github.com/blackxored/)
* [Wikipedia](https://wikipedia.org/example)
* [AppStore](https://itunes.apple.com/example)
* [Twitter](https://twitter.com/example)
* [Web](https://anything.com)

I added the above into a blank new post, and voila!

Link Test

A quick look into DevTools reveals we've succeeded in adding our element, along with custom plugin class:

Links in DevTools

Conclusion

Writing a simple plugin like this hopefully wasn't hard, and this knowledge could help you automate things and add extra features to your Gatsby site, some will be small like this, others like sources and transformers are even more powerful. I suggest you to take a look at the documentation available to learn more.

As an exercise to the reader, I encourage you to modify our links so they open in a different window with target="_blank".

Let me know if you enjoyed this post and I've love to hear your comments on what would make a great Gatsby plugin.

~ EOF ~

Craftmanship Journey
λ
Software Engineering Blog

Stay in Touch


© 2020 Adrian Perez