Generate Social Image Covers With Eleventy And Node-Canvas

This static blog is generated with Eleventy and all its social images are automatically generated with node-canvas. In this tutorial we’ll set up a basic version of this script so you can use it on your blog as well.

For this tutorial we assume you already have an Eleventy site that you want to add social images to.

If you need to set up a new site you can follow the Eleventy getting started guide to set up your site.

If you’re in a hurry and just want to copy paste the code, skip to the conclusion

Installing Node-Canvas

node-canvas is an HTML Canvas implementation in Node. It allows us to draw to a canvas in Node just as we would in the browser, this makes it super easy to get started with.

Note that we use canvas instead of node-canvas to install the package.

npm install canvas

We’ll leave our canvas in the oven while we work on preparing the Eleventy script.

Configuring the Eleventy Script

We’re going to edit the .eleventy.js file. It’s the file that we use to configure our Eleventy site. If this file doesn’t exist in your project, create it and paste the contents below.

Please note that this tutorial assumes your site articles are located in the src directory and the output location is the dist directory. If your articles are in a different directory the code will still work but you might have to adjust some paths.

// .eleventy.js
module.exports = function (eleventyConfig) {
    return {
        dir: {
            input: 'src',
            output: 'dist',
        },
    };
};

Run npx @11ty/eleventy to test if the pages are generated in the dist folder.

We insert the addTransform hook to intercept the moment Eleventy generates an article so we can create our social image at the same time.

module.exports = function (eleventyConfig) {
    eleventyConfig.addTransform('social-image', async function (content) {
        // this will run each time a file is handled by Eleventy
        return content;
    });

    return {
        dir: {
            input: 'src',
            output: 'dist',
        },
    };
};

We’re only interested in articles, so let’s filter out any other files.

this.inputPath contains the location of the input file. We check if it ends with .md (for Markdown) and else we exit by returning the article contents.

module.exports = function (eleventyConfig) {
    eleventyConfig.addTransform('social-image', async function (content) {
        // only handle blog posts
        if (!this.inputPath.endsWith('.md')) return content;

        // do something with the article

        // return normal content
        return content;
    });

    return {
        dir: {
            input: 'src',
            output: 'dist',
        },
    };
};

It’s time to add the function that will generate the social image for our article.

We create the createSocialImageForArticle function that returns a Promise. It receives the location of the input article and the location of the output social image.

In the example below we use the same name as the input article but replace the extension with .jpeg.

const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // this is where we'll generate the social image
    });

module.exports = function (eleventyConfig) {
    eleventyConfig.addTransform('social-image', async function (content) {
        // only handle blog posts
        if (!this.inputPath.endsWith('.md')) return content;

        try {
            await createSocialImageForArticle(
                // our input article
                this.inputPath,

                // the output image name
                this.outputPath.replace('.html', '.jpeg')
            );
        } catch (err) {
            console.error(err);
        }

        // return normal content
        return content;
    });

    return {
        dir: {
            input: 'src',
            output: 'dist',
        },
    };
};

We’ve filtered out non-articles and have set up a function to draw our canvas and output the matching social image, let’s move on to implementing the function.

Drawing the Social Image with Node-Canvas

We’re now ready to draw the social image cover.

In the code examples below we’ll only focus on the createSocialImageForArticle function, we won’t touch any other code so the rest of the code is hidden.

const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // this is where we'll generate the social image
    });

First we need to load the node modules we’re going to use.

// for handling files
const fs = require('fs');

// for managing path information
const path = require('path');

// for creating the canvas
const { createCanvas } = require('canvas');

// our good old function
const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // this is where we'll generate the social image
    });

For this part we’ll assume the title of the article is defined in the Front Matter of the file.

---
title: My article title
---

Our goal is to draw the title of the article on the cover image. To do that we’ll have to read out the file data and “parse” the YAML Front Matter.

const fs = require('fs');
const path = require('path');
const { createCanvas } = require('canvas');

const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // read data from input file
        const data = fs.readFileSync(input, {
            encoding: 'utf-8',
        });

        // get title from file data
        const [, title] = data.match(/title:(.*)/);

        // `title` contains the article title
    });

Alright, we’re ready to start drawing. Let’s create a canvas with a white background and draw our article title to it in black.

const fs = require('fs');
const path = require('path');
const { createCanvas } = require('canvas');

const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // read data from input file
        const data = fs.readFileSync(input, {
            encoding: 'utf-8',
        });

        // get title from file data
        const [, title] = data.match(/title:(.*)/);

        // draw cover image
        const canvas = createCanvas(1024, 512);
        const ctx = canvas.getContext('2d');

        ctx.fillStyle = 'white';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        ctx.fillStyle = 'black';
        ctx.font = '64px sans-serif';
        ctx.fillText(title, 0, 64);
    });

If you’ve worked with the Canvas API before this is all very familiar.

Now for the final act, saving the canvas data to disk.

We’re writing our image before Eleventy writes the article HTML file so we need to make sure the article directory already exists.

const fs = require('fs');
const path = require('path');
const { createCanvas } = require('canvas');

const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // read data from input file
        const data = fs.readFileSync(input, {
            encoding: 'utf-8',
        });

        // get title from file data
        const [, title] = data.match(/title:(.*)/);

        // draw cover image
        const canvas = createCanvas(1024, 512);
        const ctx = canvas.getContext('2d');

        ctx.fillStyle = 'white';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        ctx.fillStyle = 'black';
        ctx.font = '64px sans-serif';
        ctx.fillText(title, 0, 64);

        // test if the output directory already exists, if not, create
        const outputDir = path.dirname(output);
        if (!fs.existsSync(outputDir))
            fs.mkdirSync(outputDir, { recursive: true });

        // write the output image
        const stream = fs.createWriteStream(output);
        stream.on('finish', resolve);
        stream.on('error', reject);
        canvas
            .createJPEGStream({
                quailty: 0.8,
            })
            .pipe(stream);
    });

We’re done. If we now run npx @11ty/eleventy we should see a JPEG pop up next to our article HTML file.

We can now reference the image in a open graph meta tag like so.

<meta
    property="og:image"
    content="https://site.domain/article-path/index.jpeg"
/>

Conclusion

Using two amazing open source tools we’ve quickly set up flexible and automated generation of social images.

The node-canvas API allows us to easily finetune our graphics. It exports useful helper methods like loadImage to quickly load and draw images on top of your canvas, and registerFont to facilitate loading and using locally hosted fonts.

Inspect the finished script below, it’s ready for some serious copy paste action.

// .eleventy.js
const fs = require('fs');
const path = require('path');
const { createCanvas } = require('canvas');

const createSocialImageForArticle = (input, output) =>
    new Promise((resolve, reject) => {
        // read data from input file
        const data = fs.readFileSync(input, {
            encoding: 'utf-8',
        });

        // get title from file data
        const [, title] = data.match(/title:(.*)/);

        // draw cover image
        const canvas = createCanvas(1024, 512);
        const ctx = canvas.getContext('2d');

        ctx.fillStyle = 'white';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        ctx.fillStyle = 'black';
        ctx.font = '64px sans-serif';
        ctx.fillText(title, 0, 64);

        // test if the output directory already exists, if not, create
        const outputDir = path.dirname(output);
        if (!fs.existsSync(outputDir))
            fs.mkdirSync(outputDir, { recursive: true });

        // write the output image
        const stream = fs.createWriteStream(output);
        stream.on('finish', resolve);
        stream.on('error', reject);
        canvas
            .createJPEGStream({
                quailty: 0.8,
            })
            .pipe(stream);
    });

module.exports = function (eleventyConfig) {
    eleventyConfig.addTransform('social-image', async function (content) {
        // only handle blog posts
        if (!this.inputPath.endsWith('.md')) return content;

        try {
            await createSocialImageForArticle(
                // our input article
                this.inputPath,

                // the output image name
                this.outputPath.replace('.html', '.jpeg')
            );
        } catch (err) {
            console.error(err);
        }

        // return normal content
        return content;
    });

    return {
        dir: {
            input: 'src',
            output: 'dist',
        },
    };
};

I share web dev tips on Twitter, if you found this interesting and want to learn more, follow me there

More articles More articles