Automatic Social Cards With Gatsby
When I decided to revamp my personal website and move my content away from Medium, one thing I knew I'd be losing was some of the network effects that Medium affords. I started thinking of ways to get the same benefits outside of Medium's platform. The lowest hanging fruit was to ensure that were people to share my content on social media, there was a good chance of it looking interesting enough to earn a click.
Having worked for a few startups in growth mode, I was familiar with the importance of open graph tags. They're not much fun to implement, but once the work is done they tend to drive real value. I'm not a growthy startup funded by venture capitalists, but I don't see the harm in applying some of the lessons to my own shameless self-promotion.
What are social cards
You might have come across them under lots of different names, Twitter cards, open graph snippets, embed tags, etc. Whatever you call them, they're collectively referring to the way a link to your content is displayed when included in another site such as Twitter or Facebook. They work by reading standardised metadata included in the HTML of the shared page. Different sites may have slightly different requirements, so you'll want to be as thorough as possible when setting up your tags. Most social platforms will have some kind of guide available:
On-demand Social Images
I wanted to have social images that would be attractive enough to build interest; but being a programmer, I have an urge to go to unreasonable lengths in the pursuit of automating manual work — so I needed to try and do something clever.
You'll probably be familiar with some sites that generate their social images automatically. Medium and Spotify are obvious examples for me, but the practice is so common that it's likely you've stumbled across many others.
I already had some experience with generating images on-demand from earlier in my career, when I made more than one web service for resizing images; so it didn't seem like a crazy idea to try and build something similar. Fortuitously, the folks at Zeit recently published an article detailing how they do this very thing for their own content, and once I understood their methodology it seemed like I had all the pieces necessary to do the same.
The Plan
This website is built using Gatsby, a static-site generator. This means all the HTML pages are generated at build-time. Whilst I could have deployed a service (similar to what Zeit did) to generate my social-images on-demand, it's cheaper and simpler to incorporate it into the site build process so that they're served statically.
The meat of the work is in the process that generates the image. I chose to generate the image by building an HTML document and taking a screenshot of it using Puppeteer, this is pretty much identical to the methodology Zeit used, and you can explore their own code if you're interested. For those who don't know, Puppeteer is headless version of Google Chrome which you can control programmatically. Simply put, if you have an HTML document somewhere that Puppeter can access, you can convert it to an image by taking a screenshot. The advantage of doing this with HTML rather using an image library, is that I can take advantage of browser layout engines to avoid breaking the design when using content of varying sizes.
Designing the image.
Like all my design work, I followed a pretty haphazard (some might say non-existent) process of jumping between browser and Sketch. Ultimately I chose to go with a design which was an abstract cariacature of the actual layout of my content. I did think of using the actual content of the article alongside the title, but realised that since I'd already be including a short excerpt in the open graph description, to include the same text again in the image would be redundant.
Building it with Gatsby
In parallel with designing how the image should look (I don't follow waterfall here), I figured out how to actually build the thing. I won't go into the specifics of generating HTML from data (I used React, but it was almost certainly overkill), and will focus on how to generate the image from the HTML, and how to integrate it into Gatsby.
Generating an image from some HTML
The following code snippets will show you how to generate some images from your HTML, I've included comments inline to explain what each bit is doing:
import { writeFile } from "fs";
import { resolve } from "path";
import { createHash } from "crypto";
import { promisify } from "util";
const writeFileAsync = promisify(writeFile);
/**
* Writes a file to the cache location
*/
async function writeCachedFile(CACHE_DIR, key, contents, extension) {
// I'm using the title as the key for the hash, because it's the only
// thing which impacts the final image. If you were to have something
// more elaborate, you should just use the HTML as the hash instead.
const fileName =
createHash("md5").update(key).digest("hex") + "." + extension;
const absolutePath = resolve(CACHE_DIR, fileName);
await writeFileAsync(absolutePath, contents);
return absolutePath;
}
/*
* Returns the path to an image generated from the provided HTML.
*/
async function imageFromHtml(CACHE_DIR, browser, title, html) {
// Write the HTML to a file and get its filename
const filePath = await writeCachedFile(CACHE_DIR, title, html, "html");
const page = await browser.newPage();
// Navigate to our saved HTML
await page.goto(`file://${filePath}`);
// My HTML includes webfonts, so make sure they're ready
await page.evaluateHandle("document.fonts.ready");
// Set the viewport to the desired dimensions of the image
await page.setViewport({ width: 2048, height: 1170 });
// Take a screenshot, we use PNG because it's higher quality - and the
// compression works well for images which contain a lot of areas of
// solid colour.
const file = await page.screenshot({ type: "png" });
// Write the screenshot to a file, and return its filename
return writeCachedFile(CACHE_DIR, title, file, "png");
}
I use the above code in combination with my code to generate HTML as follows:
/*
* Takes a post (probably a Gatsby node of some kind), generates some HTML,
* saves a screenshot, then returns the path to the saved image.
*/
export default async function postToImage(CACHE_DIR, browser, post) {
const title = post.frontmatter.title;
// This renders some React to HTML, nothing too clever here.
// I haven't included my actual code for this because it's
// highly specific to my preferences.
const html = getSocialCardHtml(post);
return imageFromHtml(CACHE_DIR, browser, title, html);
}
Integrating with Gatsby
This next part was the hardest, because it required going off the beaten track a bit. All the work will be done inside your gatsby-node.js
file (if you're not familiar with the basics of this file, I strongly suggest you look at Gatsby's documentation, otherwise most of this won't make any sense). I personally use gatsby-mdx
for my content creation, which means I'm interesting in hooking into creation of mdx
nodes. I've stripped the following code down to just what's necessary for attaching the images, so this is a little simpler than what I actually have.
// gatsby-node.js
// This is mainly so I can use ES modules and JSX in my postToImage code.
// You'll therefore need a custom babelrc file for this. But this is complete
// optional, you're perfectly free to adapt the previous code to work with
// your version of Node without Babel.
require("@babel/register");
const path = require("path");
// puppeteer, fs-extra and gatsby-source-filesystem will need to be installed
const puppeteer = require("puppeteer");
const fs = require(`fs-extra`);
const {
createFileNode: baseCreateFileNode,
} = require(`gatsby-source-filesystem/create-file-node`);
// Import our image generation function
const postToImage = require("./src/postToImage").default;
/*
* This is a bit hacky, but lets us create a Gatsby file node programmatically.
*/
async function createFileNode(path, createNode, createNodeId, parentNodeId) {
const fileNode = await baseCreateFileNode(path, createNodeId);
fileNode.parent = parentNodeId;
createNode(fileNode, {
name: `gatsby-source-filesystem`,
});
return fileNode;
}
let browser = null;
exports.onPreInit = async () => {
// Launch a Puppeteer browser at the start of the build
browser = await puppeteer.launch({ headless: true });
};
exports.onPostBuild = async () => {
// Close the browser at the end
await browser.close();
};
exports.onCreateNode = async ({
node,
actions,
createNodeId,
store,
cache,
}) => {
const { createNodeField, createNode } = actions;
const program = store.getState().program;
// We need to store our generated images somewhere that persists
// between builds, so let's use Gatsby's cache.
const CACHE_DIR = path.resolve(`${program.directory}/.cache/social/`);
await fs.ensureDir(CACHE_DIR);
// I only care about Mdx nodes
if (node.internal.type === `Mdx`) {
try {
// Generate our image from the node
const ogImage = await postToImage(CACHE_DIR, browser, node);
// Create the file node for the image
const ogImageNode = await createFileNode(
ogImage,
createNode,
createNodeId,
node.id
);
// Attach the image to our Mdx node
createNodeField({
name: "socialImage___NODE",
node,
value: ogImageNode.id,
});
} catch (e) {
console.log(e);
}
}
};
Finishing up
Once you've done all this, you'll be able to query the socialImage
field via Gatsby's GraphQL API just like anything else. The query for my article pages looks a bit like this:
query($id: String) {
post: mdx(fields: { id: { eq: $id } }) {
# I've omitted the fields unrelated to the social image
fields {
socialImage {
childImageSharp {
original {
width
height
src
}
}
}
}
}
}
You should now be in a position to use a library like React Helmet to add the necessary tags to your page to build the open graph and twitter cards, have fun!
After doing all this, I was informed there's already a Gatsby plugin that does much of this. If you don't need anything too fancy, you could easily use that instead. My approach is more complicated to set up, but gives you full control over the appearance of the generated image.
Demystifying GraphQL Connections
If you’ve used GraphQL for a while, it’s likely come across its (formally Relay’s) Connection Specification whether you’ve used it or not. It’s a pattern for implementing cursor-based pagination in Gr…
The Passionless Developer
For the longest time, a vocal opinion I would hear frequently was that good developers need to be passionate about programming. I’d be exposed to this opinion from my peers, from those senior to me, f…