Automated Social Sharing Images with Puppeteer, 11ty, and Netlify
Tags: tutorial, webdev, netlify, 11ty
Published: 2020-05-02 04:11:01

Learn how to leverage the powerfully flexible templating capabilities of [11ty](https://11ty.dev) to generate templates for automating social sharing screenshots with Puppeteer within your Netlify build.

---

_This is now available as a package/plugin to more easily add to your Eleventy site, or other SSG._

{% link https://dev.to/5t3ph/new-eleventy-11ty-plugin-social-images-3jmn %}

After launching my first [11ty](https://11ty.dev) site - [ModernCSS.dev](https://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](https://annualbeta.com/blog/dynamic-social-sharing-images-with-eleventy/) outlined a process that got me very close on how to go about generating the template, and [Gersom's post](https://gersom.nl/post/og-images-on-netlify/) 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:

```html
---
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](https://gersom.nl/post/og-images-on-netlify/) 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](https://www.raymondcamden.com/2019/10/20/adding-search-to-your-eleventy-static-site-with-lunr)).

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:

```json
---
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](https://www.11ty.dev/docs/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

```js
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.

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

## Update Netlify Build Command

As described by [Gersom](https://gersom.nl/post/og-images-on-netlify/), 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:

```toml
[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](https://gersom.nl/post/og-images-on-netlify/), 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.

```js
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.

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

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

```js
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.

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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.

```js
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:

```js
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.

```html
<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:

```json
"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:

- [Twitter Card Validator](https://cards-dev.twitter.com/validator)
- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) - you may need to select "Scrape again" to refresh retrieved data

## Feedback welcome!

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