Skip to content

Commit

Permalink
Rework OG image pipeline
Browse files Browse the repository at this point in the history
* Move the title image generation to a computed property of
  posts.11tydata.js. Title images are generated in memory and fed to
  eleventy-img using its SVG Buffer support.
* Optimize auto-generated title images.
  • Loading branch information
shuhei committed Jan 2, 2022
1 parent 22ee553 commit 42a61c0
Show file tree
Hide file tree
Showing 10 changed files with 107 additions and 107 deletions.
2 changes: 0 additions & 2 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const pluginRss = require("@11ty/eleventy-plugin-rss");

const { formatDate } = require("./lib/filters/date");
const { blogPermalink, indexPermalink } = require("./lib/filters/permalink");
const { titleImage } = require("./lib/filters/title-image");
const { normalizeUrl } = require("./lib/filters/url");
const { insertWbr } = require("./lib/filters/wbr");
const { getMarkdownIt } = require("./lib/plugins/markdown");
Expand Down Expand Up @@ -46,7 +45,6 @@ module.exports = config => {
config.addFilter("blogPermalink", blogPermalink);
config.addFilter("indexPermalink", indexPermalink);
config.addFilter("normalizeUrl", normalizeUrl);
config.addJavaScriptFunction("titleImage", titleImage);

if (shouldOptimize) {
config.addTransform("imageopt", imageopt);
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
.cache
public
node_modules
lib/filters/manual-tests/images
lib/manual-tests/images
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This static website is built with [Eleventy](https://www.11ty.dev/).
Blog post data and content are generated in the following chain:

1. The markdown files in `posts` provide the slug, title, content and optionally image (used for `og:image`).
2. `posts/posts.11tydata.js` specifies the layout and sets a generated `image` if not specified.
2. `posts/posts.11tydata.js` specifies the layout and prepares an OpenGraph image. If `image` is not specified in front matter, an image is generated. Regardless of image generation, the image is resized and optimized and saved into `public/cached`.
3. `_includes/post.njk` renders the post page content and sets a permalink.
4. `_includes/base.njk` render the entire content.
5. (content only) Transformers optimize the HTML and images.
Expand All @@ -22,7 +22,6 @@ The `posts` collection is used for the following purposes:

- Render individual post pages.
- Render the all posts page with `blog/archives.njk`.
- Generate OG images with `blog/title-image.11ty.js`. This JavaScript template generates a PNG image for each post. The generated images are referenced with URLs by `posts/posts.11tydata.js`.

### Static assets

Expand Down
6 changes: 4 additions & 2 deletions lib/filters/permalink.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ const path = require("path");

/**
* Create a permalink for a post page.
* 2020-11-30-oh-hi.md -> blog/2020/11/30/oh-hi/index.html
*
* For example:
* - 2020-11-30-oh-hi.md -> blog/2020/11/30/oh-hi/index.html
*/
function blogPermalink(page, filename = "index.html") {
const { name } = path.parse(page.inputPath);
Expand All @@ -14,7 +16,7 @@ function blogPermalink(page, filename = "index.html") {
return `blog/${y}/${m}/${d}/${slug}/${filename}`;
}

/*
/**
* Create a permalink for an index page.
*
* - The first page: index.html (the top page)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable no-console */
const fs = require("fs").promises;
const path = require("path");
const { titleImage } = require("../title-image");
const { createTitleImage } = require("../title-image");

// Test various titles quickly
const titles = [
Expand Down Expand Up @@ -37,10 +37,10 @@ async function generateImages() {

// Generate and write images in parallel.
const promises = titles.map(async title => {
const image = await titleImage(title, "shuheikagawa.com");
const image = createTitleImage(title, "shuheikagawa.com");
const outputPath = path.join(
imagesDir,
`${title.replace(/[^a-zA-Z0-9]/g, "_")}.png`
`${title.replace(/[^a-zA-Z0-9]/g, "_")}.svg`
);
await fs.writeFile(outputPath, image);
return outputPath;
Expand Down
85 changes: 43 additions & 42 deletions lib/filters/title-image.js → lib/title-image.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
const path = require("path");
const { createCanvas, registerFont } = require("canvas");

/**
* The width of title image, which is used as OpenGraph image.
*/
const titleImageWidth = 680;

const fontsDir = path.resolve("node_modules", "@fontsource");

registerFont(
Expand Down Expand Up @@ -104,8 +109,11 @@ function fitTextIntoRectangle({ ctx, text, maxFontSize, rect }) {
);
}

/**
* Creates a title image as SVG and returns a Buffer.
*/
function createTitleImage(title, subtitle) {
const width = 680;
const width = titleImageWidth;
const height = 357;
const xPadding = 30;
const paddingTop = 20;
Expand All @@ -117,50 +125,43 @@ function createTitleImage(title, subtitle) {
const titleColor = "#000000";
const subtitleColor = "#000000";

return new Promise((resolve, reject) => {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");

ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);

const { lines, fontSize } = fitTextIntoRectangle({
ctx,
text: title,
maxFontSize: titleFontSize,
rect: {
x: xPadding,
y: paddingTop,
width: width - xPadding * 2,
height:
height -
paddingTop -
paddingAboveSubtitle -
subtitleFontSize -
paddingBottom
}
});
const canvas = createCanvas(width, height, "svg");
const ctx = canvas.getContext("2d");

ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);

const { lines, fontSize } = fitTextIntoRectangle({
ctx,
text: title,
maxFontSize: titleFontSize,
rect: {
x: xPadding,
y: paddingTop,
width: width - xPadding * 2,
height:
height -
paddingTop -
paddingAboveSubtitle -
subtitleFontSize -
paddingBottom
}
});

ctx.fillStyle = titleColor;
ctx.font = getTitleFont(fontSize);
lines.forEach(({ text, x, y }) => {
ctx.fillText(text, x, y);
});

ctx.fillStyle = subtitleColor;
ctx.font = getSubtitleFont(subtitleFontSize);
ctx.fillText(subtitle, xPadding, height - paddingBottom);

canvas.toBuffer((err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
}, "image/png");
ctx.fillStyle = titleColor;
ctx.font = getTitleFont(fontSize);
lines.forEach(({ text, x, y }) => {
ctx.fillText(text, x, y);
});

ctx.fillStyle = subtitleColor;
ctx.font = getSubtitleFont(subtitleFontSize);
ctx.fillText(subtitle, xPadding, height - paddingBottom);

return canvas.toBuffer();
}

module.exports = {
titleImage: createTitleImage
createTitleImage,
titleImageWidth
};
25 changes: 20 additions & 5 deletions lib/transformers/imageopt.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@ const sizeOf = util.promisify(require("image-size"));

const shouldOptimize = !!process.env.OPTIMIZE;

// File extension to a format name that eleventy-img understands.
// Don't process .gif because eleventy-img messes up animated gif.
/**
* File extension to a format name that eleventy-img understands.
*
* Not including .gif because eleventy-img messes up animated gif.
*/
const extensionToFormat = {
".jpeg": "jpeg",
".jpg": "jpeg",
".png": "png"
};

/**
* eleventy-img options for image output path and URL.
*
* Useful for outputting images into the `cached` directory where images can be
* cached indefinitely.
*/
const imageOutputPathOptions = {
urlPath: "/cached/",
outputDir: "./public/cached/"
};

async function handleImg(img) {
const alt = img.getAttribute("alt");
let src = img.getAttribute("src");
Expand Down Expand Up @@ -43,8 +57,7 @@ async function handleImg(img) {
// smaller than the biggest logical width (700px).
// Also, the maximum size is 700px * 2 to support up to 2x displays.
widths: [700, 900, 1100, 1400],
urlPath: "/cached/",
outputDir: "./public/cached/"
...imageOutputOptions
};

let stats;
Expand Down Expand Up @@ -131,5 +144,7 @@ async function imageopt(content, outputPath) {
}

module.exports = {
imageopt
imageopt,
extensionToFormat,
imageOutputPathOptions
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"serve": "http-server",
"test": "jest",
"tdd": "jest --watch",
"test-text-images": "node lib/filters/manual-tests/title-image.js",
"test-text-images": "node lib/manual-tests/title-image.js",
"cy:run": "cypress run",
"cy:open": "cypress open",
"cy:run:live": "Cypress_baseUrl=https://shuheikagawa.com cypress run",
Expand Down
21 changes: 0 additions & 21 deletions source/blog/title-image.11ty.js

This file was deleted.

62 changes: 34 additions & 28 deletions source/posts/posts.11tydata.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,46 @@
const Image = require("@11ty/eleventy-img");
const path = require("path");

// Filters are not available in .11tydata.js.
const { blogPermalink } = require("../../lib/filters/permalink");
const { createTitleImage, titleImageWidth } = require("../../lib/title-image");
const {
extensionToFormat,
imageOutputPathOptions
} = require("../../lib/transformers/imageopt");

// Provide common fields for all the posts.
module.exports = {
layout: "layouts/post",
eleventyComputed: {
async image({ image, page }) {
// Use the `image` from front matter if possible.
async image({ image, title, site }) {
let source;
let format;
if (image) {
// Optimize JPEG
// TODO: Support other formats.
if (image.startsWith("/images/") && image.endsWith(".jpg")) {
try {
const stats = await Image("source" + image, {
formats: ["jpeg"],
widths: [800],
// TODO: Share these with imageopt.
urlPath: "/cached/",
outputDir: "./public/cached/"
});
if (stats.jpeg[0]) {
return stats.jpeg[0].url;
}
} catch (err) {
console.error(
"[posts.11tydata.js] Failed to optimize OG image",
image,
err
);
}
}
// Use the `image` from front matter if possible.
source = "source" + image;
format = extensionToFormat[path.extname(source)];
} else {
// Otherwise, generate a title image.
source = createTitleImage(title, site.twitter.subtitle);
// Convert SVG to PNG.
format = "png";
}
// Otherwise, use the one generated by `blog/title-image.11ty.js`.
return `/${blogPermalink(page, "title.png")}`;

if (!format) {
throw new Error(`Unsupported title image format: ${source}`);
}

// Resize and optimize the image.
const stats = await Image(source, {
formats: [format],
widths: [titleImageWidth],
...imageOutputPathOptions
});
if (!stats[format] || stats[format].length !== 1) {
throw new Error(
`Invalid title image optimization result: ${JSON.stringify(stats)}`
);
}
return stats[format][0].url;
}
}
};

0 comments on commit 42a61c0

Please sign in to comment.