loading...

Automated Social Sharing Images with Puppeteer, 11ty, and Netlify

5t3ph profile image Stephanie Eckles ・8 min read

Learn how to leverage the powerfully flexible templating capabilities of 11ty to generate templates for automating social sharing screenshots with Puppeteer within your Netlify build.


After launching my first 11ty site - ModernCSS.dev - I was about to share it on Twitter when I realized it would be without an image.

I chased down several rabbit holes and found resources that got me close but didn't quite accomplish the following:

  • Generate the images using only build artifacts with no published extra HTML
  • Be accomplished within the build process and not dependent on an external Netlify function

SΓΈren's post outlined a process that got me very close on how to go about generating the template, and Gersom's post filled in the gaps to help get Puppeteer and the Netlify build part working.

Let's go over the combined solution that is also wholly contained within the build process with no extraneous production artifacts.

Create the HTML Template for Screenshots

This is where 11ty immediately shines - we can use an existing collection of data and define a new layout template that is only produced as a build artifact.

If you are already generating dynamic templates from data, you can create a new file in that location.

If this is your first data template, create generate/social-images.njk. The directory name is up to you, "generate" is not a reserved or special name in 11ty. It does need to be in what you've defined as your input directory, or in the project root if you have not changed the input. 11ty will then be able to find it and use it without anything else pointing to it.

You also need to create the directory where our node function and related build artifacts, including the compiled template, will live. I chose to create a functions at the project root.

Next, here's an example screenshot Nunjuck template that you can modify to fit your design:

---
permalink: functions/template.html
permalinkBypassOutputDir: true
---
<!doctype html> 
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500&family=Kanit:ital,wght@0,800;1,600&display=swap" rel="stylesheet">
    {% set css %}
      {% include "src/css/style.css" %}
    {% endset %}
    <style>
      {{ css | safe }}
    </style>
</head>
<body>
  <header>
    <h1></h1>
    <p>MySite.com | Tagline</p>
  </header>
</body> 

The frontmatter regarding permalinks does the magic of dropping the compiled HTML in functions/template.html, and use of permalinkBypassOutputDir: true ensures this is only a build artifact. You can actually exclude functions/template.html with .gitignore if you'd like since it will be generated in the build command.

A few more important notes:

  • You can choose another template language for this, but it should render a valid full HTML document
  • Be sure to include the viewport meta tag so that the resize function properly handles any styles, such as fluid typography
  • Inline whatever CSS you're using - the example shows how you can do that
  • Create placeholder text elements that we'll populate in the Puppeteer function, here we have just an h1

Generate Image Data as JSON

Gersom's post was actually about using GraphQL data since his solution was intended for Gatsby, so it included a tight for loop to repeatedly populate the HTML template on-demand.

I was having difficulty figuring out how to loop over the posts given this is a build-only process and I didn't want to generate individual HTML files to "visit" and screenshot. Then I came upon the clever idea to actually use 11ty's templating power to generate a JSON file (originally for use as data for site search).

In a similar fashion as our HTML template, we generate this with the intention of only being a build artifact and drop it in our functions directory:

---
permalink: functions/posts.json
permalinkBypassOutputDir: true
---
[{% for post in collections.posts %}
    {
   "title":"{{post.data.post.title | jsonTitle}}",
   "slug":"{{post.data.post.title | slug}}}"
    }{% if forloop.last == false %},{% endif %}
{% endfor %}] 

In this example, we are grabbing from an existing collection called "posts". Check out the docs on collections if that part is confusing.

We use the slug filter to create a filename-friendly string that we'll use as the image filename.

Since it's JSON, I created a filter to correctly format the data.

filter: jsonTitle

Two primary functions:

  1. Adds non-breaking spaces between the last 3 words purely for vanity on the visual of not having orphans on the last line of text
  2. Escapes any " found as those would invalidate the JSON by causing breaks
eleventyConfig.addFilter("jsonTitle", (str) => {
  let title = str.replace(/((.*)\s(.*)\s(.*))$/g, "$2&nbsp;$3&nbsp;$4");
  title = title.replace(/"(.*)"/g, '\\"$1\\"');
  return title;
});

Add Required Dependencies

To make this work both in the build pipeline and locally, you need to install the following. The dev v. prod dependency is important because it will allow chrome-aws-lambda to select which version of Puppeteer to use based on environment context.

npm i chrome-aws-lambda puppeteer-core
npm i -D puppeteer

Update Netlify Build Command

As described by Gersom, we need to define the AWS_LAMBDA_FUNCTION_NAME due to Netlify's build defining NODE_ENV=development which causes chrome-aws-lambda to select the wrong Puppeteer.

The solution is to define the build command via your netlify.toml file, and prepend the variable:

[build]
  command = "AWS_LAMBDA_FUNCTION_NAME=trickpuppeteer npm run build"

Create the Node Function to Generate the Images

Ok - we have our templates and our data ready to go!

(If you haven't already, run eleventy to make sure our templates generate as expected so far.)

Once again, this is hugely thanks to Gersom, but there are a few updates to his solution.

Create a file in the functions directory called images.js or similar. Be sure not to .gitignore this file.

First, we require chorome-aws-lambda and the file path functions.

const chromium = require("chrome-aws-lambda");
const fs = require("fs");
const path = require("path");

Next, we begin our async function which the rest of our options will go within.

(async () => {
  // We'll be filling this in
})();

Next, we launch Chromium/Puppeteer and begin the newPage().

const browser = await chromium.puppeteer.launch({
  args: chromium.args,
  executablePath: await chromium.executablePath,
  headless: chromium.headless,
});

const page = await browser.newPage();

Then we load our HTML template as well as the post JSON which we expect to be in the same directory as this function, as well as render the initial HTML into the headless Chromium browser instance. There is also an additional check on whether all assets are ready to conclude if the document is fully rendered.

// Load html from template
const html = fs.readFileSync(path.resolve(__dirname, "./template.html")).toString();

// Get generated post json
const posts = require("./posts.json");

// Render HTML
// Wait for 0 network connections to ensure webfonts downloaded
await page.setContent(html, {
  waitUntil: ["networkidle0"],
});

// Wait until the document is fully rendered
await page.evaluateHandle("document.fonts.ready");

Following that, we use the setViewport function to the recommended social sharing image size of 600x315 and also use device scaling to technically double that in consideration of retina displays.

// Set the viewport to your preferred image size
await page.setViewport({
  width: 600,
  height: 315,
  deviceScaleFactor: 2,
});

Then we create a directory within our production output directory (by default this is _site, I have customized mine to be public).

Through trial and error, this needs to be in the output because we will run this process after eleventy has created the posts due to needing up-to-date JSON data and possibly CSS. So, we can't rely on eleventy to move the generated images.

// Create an img directory in the output folder
const dir = path.resolve(__dirname, "../public/img");
if (!fs.existsSync(dir)) fs.mkdirSync(dir);

And now we loop through our post data and use it to update the HTML template after which the screenshot can be taken and the image saved directly to the previously created output directory.

// Go over all the posts
for (const post of posts) {
  // Update the H1 element with the post title
  await page.evaluate((post) => {
    // We use `innerHTML` since we added `&nbsp;`
    const title = document.querySelector("h1");
    title.innerHTML = post.title;

    // If you have other data to insert, 
    // find the DOM elements and update that here
  }, post);

  // Optional just for progress feedback
  console.log(`Image: ${post.slug}.png`);

  // Save a screenshot to public/img/slug-of-post.png
  await page.screenshot({
    path: `${dir}/${post.slug}.png`,
    type: "png",
    clip: { x: 0, y: 0, width: 600, height: 315 },
  });
}

Finally, we close the headless browser instance - ensure this is outside the for loop.

await browser.close();

Add Social Sharing Tags to Post Templates

You should now be able to successfully generate images, but we need to add specific meta tags into the appropriate templates to actually use them.

Prepare for Absolute URLs

Relative URLs will not work for the social network scrapers, so you may want to define a local .env file and associated module export to use the environment variable of URL which is provided as a Netlify build environment var and will be the production site base URL, ex. https://example.com.

For local setup, first npm i dotenv --save-dev if you do not already have a .env in use.

Then in .env at the root of your project define URL=http://localhost:8080 which is the default for 11ty.

You can then create a global data file that needs to be located in _data/ within your input directory, such as site.js with the following:

module.exports = {
  url: process.env.URL,
};

Which you can access as site.url within your template files, and which will update to your prod URL once deployed on Netlify.

Add og and Twitter Card Tags

For general use, the following are sufficient for sharing on Twitter and Facebook. Other sites tend to look for these as well, but I did not invest much time in figuring out all there is to know on handling for that, so YMMV!

Within what is likely an existing template for your content, add the following to the HTML <head> - the example is for a Nunjuck template. These are the minimum required tags.

<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ post.title }}">
<meta name="twitter:description" content="{{ post.description }}">

<meta property="og:image" content="{{ site.url }}/img/{{ post.title | slug }}.png" />
<meta name="twitter:image" content="{{ site.url }}/img/{{ post.title | slug }}.png" />

Note the use of the previously discussed site.url, as well as the use of the slug filter against the post.title to create the image file name.

Important:: Once deployed to Netlify, recall that even on a feature branch deploy the image URLs will evaluate to your production domain, so to test on feature deploys you may have to manually alter the URL to test if they exist.

Add to Build Script

Don't forget to append this to your build script!

Here's one way:

"scripts": {
  "screenshot": "node functions/images.js",
  "build": "eleventy ; npm run screenshot",
}

If you have a separate develop script, append it there, too.

Validate on Social Sharing Tools

Visit the following to make sure your meta tags and images are all working together:

Feedback welcome!

Have you created a similar process or know of improvements that can be made? Drop a comment!

Posted on by:

5t3ph profile

Stephanie Eckles

@5t3ph

(she/her) ✍️ ModernCSS.dev, πŸ‘©πŸΌβ€πŸŽ¨ StyleStage.dev, πŸ‘©β€πŸ’» Lead design system dev, πŸ‘©β€πŸ« @eggheadio instructor, πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘§ mom

Discussion

markdown guide
 

Hi Stephanie,

After investigating various approaches to generate social images automatically, I settled on using yours as my starting point. This article has been very helpful in getting it implemented in my site (cdnplanet.com) and Eleventy setup. It's all done now, including a some optimizations that make sense for me, for example check with fs to not generate the image if it already exists.

cdnplanet.com/static/img/social/ge...

Bye!
Aaron

 

Hey Aaron,

That's awesome to hear, thanks for letting me know! πŸ’«

Really nice image, too!

 

Updated with a better solution if using webfonts. The change was:

await page.setContent(html, {
    // This was previously `domcontentloaded`
    // Now waits for 0 network connections presuming fonts down downloading
    waitUntil: ["networkidle0"],
  });
 

Nice! I use Gatsby and I do it semi-manually using a modified version of the script from this tutorial.

I run the script before I publish each post, and it uses some stuff that is stored in the frontmatter of the post (the title, an emoji and a link to an image from Unsplash) to generate two separate images for Twitter and DEV.

As a side note - your website looks amazing! I'm loving the the subtle rainbow / gradients 😍

 

Cool! I use Gatsby at my day job, it was my intro to JAMstack 😊

And thanks so much! πŸ’«

 

Great post, are there any screenshot examples for the blog posts?

 

Thanks! The ones I'm generating for the site mentioned are basically mini versions of the header that you see for posts. The wonderful part is that you can use CSS to achieve any design needed! Think of it as essentially an "iPhone in landscape mode" size canvas. You can populate the base HTML with something mimicking your real content to create the design, and start by allowing it to be picked up by 11ty for easier review, just shrink the viewport down to 600x315 πŸ‘πŸ» Hope that helps!

 

I was looking for a link to the jpg file :).

Ha, ok! Here's one - I update only the title and the episode number:

moderncss.dev/img/animated-image-g...

 
 

Do you have the whole script completed? I cannot get it to work and wonder if I have made a mistake somewhere

 

The completed script is represented here, but you can also download the starter I created that uses this to explore what might be different: 11ty-netlify-jumpstart.netlify.app/