Automatically Generated Social Media Images with HTML, CSS, Eleventy & Puppeteer
Please note that while this technique still works, A Place For My Head no longer uses these images.
Previews of pages on A Place For My Head used to look quite bland, so I implemented automatically generated social media images, or teasers, to make them a little more attractive:
The first teasers I ever saw were probably GitHub’s. The subsequent CSS-Tricks article on the subject discusses and references many techniques, mostly using SVG templates:
I kinda dig the idea of using SVG as the thing that you template for social media images, partially because it has such fixed coordinates to design inside of, which matches my mental model of making the exact dimensions you need to design social media images. I like how SVG is so composable.
I understand the appeal, but I don’t see the sense in handling the details of text layout like breaking lines yourself when you can let the browser do it for you instead. I decided I’d create my teasers with HTML and CSS. Here’s how I did it. (I’ll explain things along the way, but some knowledge of Eleventy, HTML, CSS, and JavaScript will be helpful.)
Computing a hash
First, I needed a way to unambiguously associate images with posts. I could have directly used the
title, but instead, I decided to compute a hash of the four things that I want to include in the
image (the post title, the post streams, the date of publishing, and the site name). I put a
function named teaser
in a data file named hash.js (note that I use the esm
module to enable import
with Eleventy, but regular
require
is fine too):
// the builtin Node cryptography module
import crypto from "crypto";
// another data file
import metadata from "../source/data/metadata.json";
// a homegrown helper to make working with dates easier
import { ensureDateTime } from "../dates";
// creates a SHA256 hash from the input
function hash(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
export function teaser(title, streams, date) {
// only include the parts that are in the image:
const parts = [
title, // post title
streams.join(";"), // post streams
ensureDateTime(date).toMillis(), // the timestamp
metadata.title, // the site title
// I originally included the last Git commit for the teaser HTML and Sass,
// but it created too many false positives, so now I delete all the
// teasers and re-create them myself every time I change those
];
// you could just join all of them with nothing in between, but this feels
// cleaner to me
return hash(parts.join("\n"));
}
I abuse collections in .eleventy.js to iterate over the entries and add the hashes as custom data:
// at the top, specifying the path to my data file
import { teaser as teaserHash } from "./source/data/hash";
// inside the configuration function
eleventyConfig.addCollection("post-teasers", (collection) => {
for (const post of collection.posts) {
post.data.teaserHash = teaserHash(
post.data.title,
post.data.streams,
post.date
);
}
});
The page for the teasers
I created a new teaser.pug template file:
---
eleventyExcludeFromCollections: true
permalink: "/teasers/"
dynamicPermalink: false
---
include ../layouts/mixins/post-time.pug
doctype html
html
head
title Teasers
link(rel="stylesheet" href="/assets/css/teaser.css")
style(type="text/css")= `body { --teaser-width: ${metadata.teaserSize.width}px; --teaser-height: ${metadata.teaserSize.height}px; }`
body
p.congratulations Congratulations on finding this super-secret page filled with #[s all my secrets] teasers!
each post of collections.posts
if !postUtils.isDraft(post)
.thumbnail(data-hash=post.data.teaserHash)
.logo-wrapper
img.logo(src="/assets/images/favicon.svg")
.text
.blog-title #{metadata.title}
.post-title #{postUtils.replaceOrphans(post.data.title)}
.meta #[span.post-streams= post.data.streams.map((s) => metadata.streams[s].label).join(" · ")] · #[+post-time-for(post)]
I’m using Pug as the template language, but the same
concepts can be applied to Liquid, Nunjucks, etc. The new page displays one div
per post.[1] It sets a
data-hash
attribute on the div
s using the freshly-computed hash. Each div
contains a logo (my
new favicon) inside a wrapper element and the text (which I format with
my homegrown helper functions) placed inside another wrapper element so I can place the two side by
side. Here’s what it looks like so far, with a couple of lines of CSS snuck in so you can see
more than just the logo:
Now I can make it look the way I want it to. Under normal circumstances, I’d never specify
dimensions in pixels for anything on a page, because the web is such a fluid and varied medium.
However, since I’ll have full control in this case over both the page and the means of viewing the
page, I can save myself a lot of trouble by using only pixels here.[2] This is what my
teaser.css looks like with some tidying up and with @font-face
declarations elided for
brevity:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: #fafafa;
color: #050505;
font-family: Adobe Garamond Pro, cursive;
}
.thumbnail {
align-items: center;
display: flex;
flex-flow: row;
height: var(--teaser-height);
justify-content: space-between;
margin: 1.5em auto 0;
padding: 2em 3em;
text-align: left;
width: var(--teaser-width);
}
.logo-wrapper {
flex: 1 0 10%;
margin-right: 3em;
}
.logo {
max-height: 100%;
max-width: 100%;
}
.text {
flex: 1 1 90%;
}
.blog-title {
font-size: 40pt;
font-weight: 700;
}
.meta {
font-size: 30pt;
margin-top: 0;
}
.post-streams {
font-variant: all-small-caps;
font-weight: 600;
letter-spacing: .05em;
}
.ordinal {
font-feature-settings: "sups";
}
.meta,
.post-title {
font-variant-numeric: proportional-nums oldstyle-nums;
}
.post-title {
font-size: 72pt;
line-height: 1.1;
margin-top: 1rem;
}
.congratulations {
font-size: 25pt;
margin: 2em auto;
max-width: 43em;
text-align: center;
}
There are a few important features:
* { box-sizing: border-box; }
ensures that boxes take on the dimensions you specify inclusive of borders and padding.display: flex; flex-flow: row;
on.thumbnail
makes the logo and the text sit next to each other. Then theflex
shorthand property sets their sizes.- I specify the dimensions using custom
properties (
--teaser-width
and--teaser-height
) that I declared in thestyle
block in the template earlier to make it easier to control the size from a data file. You don’t have to. - I use
cursive
as the fallback font to make it obvious if Garamond fails to load for any reason. Again, you don’t have to.
With the styling in place, the page looks like this (I’ve highlighted one of the teasers to show its structure):
Behold, the power of Puppeteer
I wrote a script named update-teasers.js to create the images. It uses the excellent Puppeteer library to launch a headless browser window (i.e. one that behaves like a normal browser but is invisible and automated), open the teasers page directly, and save a screenshot of each individual teaser:
const { existsSync, readFileSync, unlinkSync, writeFileSync } = require("fs");
const { dirname, join } = require("path");
const fg = require("fast-glob");
const fileUrl = require("file-url");
const mkdirp = require("mkdirp").sync;
const puppeteer = require("puppeteer");
// the directory to save the images under
const TEASERS_DIRECTORY = "source/assets/images/teasers";
// the path to the HTML file (not the template)
const TEASER_PAGE_PATH = "dist/teasers/index.html";
// the HTML will refer to /assets/css/, but we don’t want to run an HTTP server,
// so we’ll rewrite that to be a regular relative path
const TEASER_CSS_RE = /"\/assets/g;
const TEASER_CSS_REPLACEMENT = '"../assets/';
// make sure the output directory exists
mkdirp(TEASERS_DIRECTORY);
// how to find the teasers on the page
const SELECTOR = ".thumbnail";
// Puppeteer is built entirely around `async`/`await`, so it’s easiest to wrap
// our code in an `async` function
export async function updateTeasers() {
console.log("Updating teasers…");
// start the browser
const browser = await puppeteer.launch({ headless: true });
try {
// create a new tab
const page = await browser.newPage();
// go to the teasers page
await page.goto(fileUrl(TEASER_PAGE_PATH));
// insert the generated teaser HTML with the paths rewritten where required
await page.setContent(
(await page.content()).replace(TEASER_CSS_RE, TEASER_CSS_REPLACEMENT)
);
// take the screenshots and get a `Set` of teaser image filenames
const knownFiles = await takeScreenshots(page);
// clean up any teaser images that are no longer required
for (const f of fg.sync("**/*", { cwd: TEASERS_DIRECTORY })) {
if (!knownFiles.has(f)) {
console.log(`Deleting ${f}...`);
unlinkSync(join(TEASERS_DIRECTORY, f));
}
}
} finally {
// always close the browser afterwards
await browser.close();
}
}
// take the screenshots and return a `Set` of teaser image filenames
async function takeScreenshots(page) {
// initialize the `Set`
const knownFiles = new Set();
// initialize a counter
let saved = 0;
// loop over the teasers on the page
for (const teaser of await page.$$(SELECTOR)) {
// call the helper function below in the context of the browser page
const hash = await page.evaluate(getHash, teaser);
// compute the filename and add it to the set of known files
const filename = `${hash}.png`;
knownFiles.add(filename);
const path = `${TEASERS_DIRECTORY}/${filename}`;
if (existsSync(path)) {
// the hash takes into account everything that can change in the posts
// themselves, so it’s safe to skip existing teasers
continue;
}
console.log(`Saving ${path}...`);
// ensure the directory to save to exists
mkdirp(dirname(path));
// take a screenshot of just this teaser and save it at the path above
await teaser.screenshot({ path });
// update the counter
saved++;
}
console.log(
`Created ${saved.toLocaleString()} new teaser(s) for ${knownFiles.size.toLocaleString()} known post(s)`
);
return knownFiles;
}
// a helper function to get the value of `data-hash`
function getHash(element) {
return element.dataset.hash;
}
// allow this to be used as a standalone script as well
if (require.main === module) {
// run our `async` function and exit in case of errors
updateTeasers().catch((e) => {
console.error(e);
process.exit(1);
});
}
I specified the fast-glob
, mkdirp
, and file-url
packages as dev dependencies using npm i -D
fast-glob file-url mkdirp. In contrast, I don’t want the rather heavyweight Puppeteer package to
be installed every time my site is built, so I installed it as an optional dependency using npm i
--save-optional puppeteer.
Next, I added the script to package.json:
"scripts": {
// other scripts elided for brevity
"teasers": "node update-teasers.js"
}
The command npm run teasers will create all missing teasers—which I commit to Git so as to avoid needing to recreate them every time the site is deployed—and delete any that are no longer used.
Associate entries with their metadata
Finally, I needed to point to these teasers in the head
of my post.pug template (the
title and description having been set already in the base template):
meta(property="og:type" content="article")
if status !== "draft"
- const path = `teasers/${teaserHash}.png`;
- const url = `${metadata.url}assets/images/${path}`;
meta(property="og:image" content=url)
meta(property="og:image:width" content=metadata.teaserSize.width)
meta(property="og:image:height" content=metadata.teaserSize.height)
meta(name="twitter:card" content="summary_large_image")
meta(name="twitter:image" content=url)
My work is complete. Anyone sharing one of my articles on social media should see the right previews.
Automating the boring parts
Running an npm script is all well and good, but, like most manual operations, it’s error prone and
tedious. We can do better than that. Eleventy provides an afterBuild
hook that runs every time the site has been rebuilt.
That’s the perfect place to update our teasers. I added this code to .eleventy.js:
// inside the Eleventy configuration function
eleventyConfig.on("afterBuild", async () => {
// updating teasers can cause a cascade of file events, so try to keep it in check
eleventyConfig.setWatchThrottleWaitTime(500);
// don’t run this in production, where I don’t install Puppeteer; expect the teasers to exist
// in Git
if (metadata.isDevelopment) {
// only import the script at runtime if required, to prevent an error about missing libraries
const { updateTeasers } = await import("./update-teasers");
await updateTeasers();
}
});
Et voilà ! Now the teasers will automatically be updated every time the site is rebuilt (in development).
The only thing to watch out for is that, despite the setWatchThrottleWaitTime
, Eleventy can still
be overwhelmed by file events on occasion when I’m creating teasers en masse. In that case, I
terminate the Eleventy process, run npm update teasers, and restart Eleventy.
Next in series: Four Months of Having A Place For My Head