🌳 Evergreen tutorial

Using Puppeteer with 11ty to automate generating social share images

planted on in: Programming, JavaScript and 11ty.
~2,487 words, about a 13 min read.

I recently added social sharing images to this website (see Adding enhanced opengraph meta) and initially found it difficult to find tutorials automating the process of image generation without using a paid for third party solution like Dynapictures.

Rik Schennink(@rikschennink) shared how to generate social image covers with Eleventy and node-canvas. I liked Rik's solution for its simplicity however the idea of editing a social share image template in canvas felt complex and intimidating; I wanted to use something familiar, I wanted to use HTML + CSS.

Eventually I found similar solutions by Michael Harley and Stephanie Eckles, both using Puppeteer.

Overall the process is straightforward enough:

  • have an og-image.njk template that makes use of 11ty's paginator to output one page per post; this is the template for the image
  • have an og-posts.njk template that outputs a json file containing each posts slug and the location of each template as output by og-image.
  • a standalone script run by npm that uses the json output by og-posts to pass each of the templates output by og-image through Puppeteer and saving the resulting image into an assets directory
  • a filter added to 11ty that checks if a post's social image has been generated and returns its href for use by the main template to output og image meta tags.

In order for the standalone script to run, 11ty first needs to generate its required files, first I created og-image.njk:

---
pagination:
data: collections.post
size: 1
alias: article
permalink: functions/_posts/{{ article.data.title | slugify }}/og-image.html
permalinkBypassOutputDir: true
eleventyExcludeFromCollections: true
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
background-color: #18181c;
color: #e1dede;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 1280px;
height: 640px;
padding: 32px 48px;
}
.container{
height:370px;
}
h1{
font-size: 150px;
word-break: break-word;
word-wrap: break-word;
}
</style>
</head>
<body>
<div class="container">
<h1 class="output">{{ article.data.title }}</h1>
</div>
<script>
(function () {
const output = document.querySelector('.output');
const outputContainer = document.querySelector('.container');

function resize_to_fit() {
let fontSize = window.getComputedStyle(output).fontSize;
output.style.fontSize = (parseFloat(fontSize) - 1) + 'px';

if (output.clientHeight >= outputContainer.clientHeight) {
resize_to_fit();
}
}

resize_to_fit();
})();
</script>
</body>
</html>

The snippet of JavaScript on the above was sourced from this stack overflow answer, it makes the font-size dynamic to the text being contained. This means the more text you have the smaller the font-size is set in order to fill the space.

Next og-posts.njk is created to output json thus:

---
title: "Open Graph: posts.json"
permalink: functions/_posts.json
permalinkBypassOutputDir: true
eleventyExcludeFromCollections: true
---
[{% for post in collections.post %}
{
"slug":"{{ post.data.title | slugify }}",
"template":"functions/_posts/{{ post.data.title | slugify }}/og-image.html"
}{% if not loop.last %},{% endif %}
{% endfor %}]

In both cases permalinkBypassOutputDir and eleventyExcludeFromCollections are set true in the files front-matter in order to output to the source folder rather than publishing them and to ensure the generated files are excluded from any collections.

Now when I run 11ty I see a newly created functions/_posts.json file and functions/_posts directory filled with html files for each post.

None of these files need to be committed to git, I then added the following to my .gitignore:

functions/_posts.json
functions/_posts

With the image source templates and their meta file created a new create-og-images.js file is placed in the functions directory:

const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');

const makeImage = async (src, dist) => {
const html = fs.readFileSync(src, 'utf8');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
await page.setViewport({
width: 1280,
height: 640,
deviceScaleFactor: 1
});
await page.screenshot({
path: dist,
quality: 70
});
await browser.close();
};

const fileReadable = (path) => {
if (fs.existsSync(path)) return true;
console.warn(`Unable to open: ${path}`);
return false;
}

(async () => {
try {
let posts = require("./_posts.json");
const promises = [];

posts = posts.map((post) => {
return {
...post,
src: path.join(process.cwd(), post.template),
dist: path.join(process.cwd(), `_assets/og-image/${post.slug}.jpg`),
};
}).filter((post) => !(fileReadable(post.dist) === true || fileReadable(post.src) === false))

if (posts.length > 0) {
process.setMaxListeners(posts.length + 10);

posts.forEach((post) => {
console.log(`Processing ${post.template}`);
promises.push(makeImage(post.src, post.dist));
});

await Promise.all(promises);
}
console.log(`Completed processing ${posts.length} items`);
} catch (e) {
console.warn(e.message);
process.exit(1);
}
})();

When run via npm this script does the following:

  • loops functions/_posts.json with array map and computes the template src and social image dist path names
  • filters the mapped list of posts and excludes any entry where either src or dist do not exist
  • setMaxListeners to 10 + the number of posts to generate images for, if zero bails early
  • loops filtered list using array forEach padding src and dist values to makeImage.

The makeImage function is a short wrapper around Puppeteer, it does the following:

  • reads src file
  • launches puppeteer and awaits a new page load
  • sets the page content from src file content
  • sets page viewport for social image dimensions
  • saves to dist a screenshot of the viewport
  • closes puppeteer

This script is invoked via npm by adding it to the "scripts" section of your package.json file:

{
"scripts": {
"og-images": "node functions/create-og-images.js"
}
}

By this point you will be able to run npm run og-images and see the _assets/og-images folder fill with jpg's generated from the templates in functions/_posts. The image generation side of things is now complete.

On the 11ty side I then added a ogImageFromSlug filter (as shown below,) this looks up the image file to check it exists and if so returns the absolute url to where it should be.

// filters.js
const path = require('path');
const fs = require('fs');
const metadata = require('../_data/metadata.js');

module.exports = {
ogImageFromSlug: (slug) => {
const filename = `${slug}.jpg`;
const filepath = path.join(process.cwd(), `_assets/og-image/${filename}`);

return fs.existsSync(filepath)
? `${metadata.url}/img/og-image/${filename}`
: null;
},
};

This filter is used in my main template to conditionally add the og-image meta tag if an image is available:

{% if ogImageHref %}
<meta property="og:image" content="{{ ogImageHref }}">
<meta name="twitter:card" content="summary_large_image"/>
{% else %}
<meta name="twitter:card" content="summary"/>
{% endif %}

Finally, because the images are output to the _assets/og-image folder, I need to tell 11ty to copy that to img/og-image when publishing:

eleventyConfig.addPassthroughCopy({
'./_assets/og-image': './img/og-image',
});
Page History

This page was first added to the repository on September 16, 2022 in commit 4ad85f49 and has since been amended 3 times. View the source on GitHub.

  1. refactor(#304): move files into src folder
  2. chore: add missing tags
  3. bugfix: add missing raw block
  4. feat: publish articles relating to #85 and #119