Automated social sharing images with Eleventy and Puppeteer
This post is part of a multi-part series on how I built my site using Eleventy. These are all the posts in the series. Check them out if that's your jam.
Last updated: August 25, 2021
Since first writing my walkthrough on how I setup automated social sharing images, I've continued to learn and improve the process I'm using. While there's still room for improvements, I'm a lot less embarrassed about what I've comme up with.
I don't use any sort of automated build process on Netlify or Github to do this so I feel like my requirements are pretty straight forward. I build my site, and that spits out files on my local file system and I then upload that to GitHub Pages. Easy breezy.
Process overview
First, let me give an overview of my process. I've created two .njk
files and a .js
file.
og-image.njk
- This creates an HTML file that produces a page that look like the social sharing image preview that I want. We'll open this page with puppeteer and snap a screenshot of the page. This screenshot becomes the image that we'll reference in the<head>
of our HTML documents using the<meta property="og:image">
tag.posts-json.njk
- This creates a json file with a list of all the posts. Our javascript file will use this to iterate through all of our posts.og-images.js
- This is our javascript file. It uses posts.json and iterates through all of our posts. If the og-image.jpg file is missing, then it generates the screenshot image. If it's already there, it does nothing.
Let's get started!
Subpage for capturing the screenshot
First, let's create a layout to generate the little subpage for each posts.
<!-- src/og-image.njk -->
---
pagination:
data: collections.posts
size: 1
alias: article
permalink: /posts/{{ article.data.date | date }}/{{ article.data.title | slug }}/og-image.html
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">
<meta name="robots" content="noindex,nofollow">
<style>
a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}*{box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto, Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji", "Segoe UI Symbol"}.container{background-color:#191919;color:#fff;display:flex;flex-direction:column;justify-content:space-between;width:1280px;height:640px;padding:32px 48px;border-top:20px solid #f59f02}.special-accent{color:#6cac4d}.post-footer{display:flex;justify-content:space-between}.post-footer img{border-radius:8px}.left-justify{align-items:center;display:flex;height:75px}.date{align-items:center;display:flex;font-size:2rem}.handle{margin:0 12px;font-size:2rem}h1{font-size:4.60rem;font-weight:bold}h2{font-size:2.5rem;margin-top:15px;font-style:italic}
</style>
<title>Open graph preview page for {{ article.data.title }}</title>
</head>
<body>
<div class="container">
<div class="post-text">
<h1>{{ article.data.title }}</h1>
<h2>{{ article.data.desc }}</h2>
</div>
<div class="post-footer">
<div class="left-justify">
<img src="/assets/images/avatar-75x75.jpeg" alt=""> <div class="handle">@obsolete<span class="special-accent">29</span></div>
</div>
<div class="date">{{ article.data.date | dateformat }}</div>
</div>
</div>
</body>
</html>
Now when I run npm run build
, I get a page that looks like the screenshot below. It exists under /posts/post-slug/og-image.html. Nice.
Build posts.json template
Let's create posts-json.njk
. This Nunjucks template will generate the posts.json file. I'm adding eleventyExcludeFromCollections because I don't want the output file to show up in the sitemap.
---
permalink: _temp/posts.json
permalinkBypassOutputDir: true
eleventyExcludeFromCollections: true
---
[ {% for post in collections.posts %}
{
"filepath":"{{ post.inputPath }}",
"url":"{{ post.url }}"
}{% if loop.last == false %},{% endif %}
{% endfor %}]
The template generates the following json when we build our site.
[
{
"filepath":"./src/posts/hello-world/index.md",
"url":"/posts/2012/01/28/hello-world!/"
},
{
"filepath":"./src/posts/my-blog-michael-harley/index.md",
"url":"/posts/2012/01/30/my-blog-gah-wtf/"
},
{
"filepath":"./src/posts/why-im-an-atheist/index.md",
"url":"/posts/2012/01/30/why-i-am-an-atheist/"
}
]
Since this file is built every time we run the build script, I don't need this to be under version control. Let's add the _temp directory to .gitignore.
# .gitignore
node_modules
_temp
Puppeteer
I've seen some other solutions that are generating open graph social sharing images in a different way but I still prefer to just capture a screenshot. I want my solution (and my site!) to be self contained, without relaying on build processes on Github or Netlify. My preferred workflow is to generate my site then git push the updated static content up to my hosting provider.
This time around, I started from scratch without really looking at anyone else's solution. Everything starts with puppeteer. I found the documentation to be good and their examples worked perfectly for me. I created a new folder at the top level of my project called _functions
and then created a new file called og-images.js
. Here is the code I started with by plugging in one of my posts:
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://127.0.0.1:5500/posts/hiking-black-mountain-2021/og-image/index.html');
await page.setViewport({
width: 600,
height: 315,
deviceScaleFactor: 2
});
await page.screenshot({ path: '../obsolete29.com.v2/posts/hiking-black-mountain-2021/og-image/og-image.jpeg' });
await browser.close();
})();
Now when I run node _functions\og-images.js
, a screenshot is captured! Nice.
Iterating over my posts to capture the screenshot
Ok cool, I can capture a screenshot of a page so let's use the posts.json
file to iterate over all our posts. If the image already exists, let's do nothing. If there is no image present, let's snap the screenshot and place it in the root of the post folder. Here is the code I landed on and am currently using to generate my open graph images:
const puppeteer = require('puppeteer');
fs = require('fs');
const data = fs.readFileSync('_temp/posts.json', 'utf8');
const posts = JSON.parse(data);
const localhost = 'http://127.0.0.1:5500';
const localdir = '../obs29.com.v3';
posts.forEach(post => {
try {
let localImage = localdir + post.url + 'og-image.jpg';
if(!fs.existsSync(localImage)) {
console.log("Processing " + post.url + "...");
(async () => {
let localPage = localhost + post.url + 'og-image.html';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(localPage);
await page.setViewport({
width: 1280,
height: 640,
deviceScaleFactor: 1
});
await page.screenshot({
path: localImage,
quality: 70
});
await browser.close();
})();
}
} catch (err) {
console.error(err);
}
});
Improvements
- Currently, before I run
og-images.js
, I have to manually start live-server in vs-code so puppeteer has something to load. I'd like to implement live-server in my process so that it checks to see if the site is available and if it isn't, start the live-server locally. - I'd prefer if I didn't place the og-image.html file into the post root of the actual site as once I've snapped the screenshot, that page isn't used any longer. I'd like to use a temp directory for that instead.
- I need to add a flag into my script so that I can regenerate all images. If I change the look of my social sharing image, I need to be able to overwrite the existing one.
Ok that's it for today. Thanks for reading my post!
This post is part of a multi-part series on how I built my site using Eleventy. These are all the posts in the series. Check them out if that's your jam.