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:

Before (title & description)
After (title, description & image)

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 divs 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:

An ugly but clear mess of teasers squashed together.

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:

With the styling in place, the page looks like this (I’ve highlighted one of the teasers to show its structure):

Two properly styled teasers, the second highlighted.

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 voila ! 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.


  1. I loop over a posts collection I’ve previously created, which excludes drafts and system pages, but you could also use collections.all and filter out whatever you don’t want to include.
  2. To be clear, I’m not concerned with semantics or accessibility on this page, since it isn’t meant for visitors.