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:
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:
My requirements were quite simple:
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".
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;
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.
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.
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!
A quick look into DevTools reveals we've succeeded in adding our element, along with custom plugin class:
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.