Skip to content

Commit 42a61c0

Browse files
committed
Rework OG image pipeline
* 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.
1 parent 22ee553 commit 42a61c0

File tree

10 files changed

+107
-107
lines changed

10 files changed

+107
-107
lines changed

.eleventy.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ const pluginRss = require("@11ty/eleventy-plugin-rss");
22

33
const { formatDate } = require("./lib/filters/date");
44
const { blogPermalink, indexPermalink } = require("./lib/filters/permalink");
5-
const { titleImage } = require("./lib/filters/title-image");
65
const { normalizeUrl } = require("./lib/filters/url");
76
const { insertWbr } = require("./lib/filters/wbr");
87
const { getMarkdownIt } = require("./lib/plugins/markdown");
@@ -46,7 +45,6 @@ module.exports = config => {
4645
config.addFilter("blogPermalink", blogPermalink);
4746
config.addFilter("indexPermalink", indexPermalink);
4847
config.addFilter("normalizeUrl", normalizeUrl);
49-
config.addJavaScriptFunction("titleImage", titleImage);
5048

5149
if (shouldOptimize) {
5250
config.addTransform("imageopt", imageopt);

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
.cache
33
public
44
node_modules
5-
lib/filters/manual-tests/images
5+
lib/manual-tests/images

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This static website is built with [Eleventy](https://www.11ty.dev/).
1111
Blog post data and content are generated in the following chain:
1212

1313
1. The markdown files in `posts` provide the slug, title, content and optionally image (used for `og:image`).
14-
2. `posts/posts.11tydata.js` specifies the layout and sets a generated `image` if not specified.
14+
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`.
1515
3. `_includes/post.njk` renders the post page content and sets a permalink.
1616
4. `_includes/base.njk` render the entire content.
1717
5. (content only) Transformers optimize the HTML and images.
@@ -22,7 +22,6 @@ The `posts` collection is used for the following purposes:
2222

2323
- Render individual post pages.
2424
- Render the all posts page with `blog/archives.njk`.
25-
- 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`.
2625

2726
### Static assets
2827

lib/filters/permalink.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ const path = require("path");
22

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

17-
/*
19+
/**
1820
* Create a permalink for an index page.
1921
*
2022
* - The first page: index.html (the top page)
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable no-console */
22
const fs = require("fs").promises;
33
const path = require("path");
4-
const { titleImage } = require("../title-image");
4+
const { createTitleImage } = require("../title-image");
55

66
// Test various titles quickly
77
const titles = [
@@ -37,10 +37,10 @@ async function generateImages() {
3737

3838
// Generate and write images in parallel.
3939
const promises = titles.map(async title => {
40-
const image = await titleImage(title, "shuheikagawa.com");
40+
const image = createTitleImage(title, "shuheikagawa.com");
4141
const outputPath = path.join(
4242
imagesDir,
43-
`${title.replace(/[^a-zA-Z0-9]/g, "_")}.png`
43+
`${title.replace(/[^a-zA-Z0-9]/g, "_")}.svg`
4444
);
4545
await fs.writeFile(outputPath, image);
4646
return outputPath;
Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
const path = require("path");
22
const { createCanvas, registerFont } = require("canvas");
33

4+
/**
5+
* The width of title image, which is used as OpenGraph image.
6+
*/
7+
const titleImageWidth = 680;
8+
49
const fontsDir = path.resolve("node_modules", "@fontsource");
510

611
registerFont(
@@ -104,8 +109,11 @@ function fitTextIntoRectangle({ ctx, text, maxFontSize, rect }) {
104109
);
105110
}
106111

112+
/**
113+
* Creates a title image as SVG and returns a Buffer.
114+
*/
107115
function createTitleImage(title, subtitle) {
108-
const width = 680;
116+
const width = titleImageWidth;
109117
const height = 357;
110118
const xPadding = 30;
111119
const paddingTop = 20;
@@ -117,50 +125,43 @@ function createTitleImage(title, subtitle) {
117125
const titleColor = "#000000";
118126
const subtitleColor = "#000000";
119127

120-
return new Promise((resolve, reject) => {
121-
const canvas = createCanvas(width, height);
122-
const ctx = canvas.getContext("2d");
123-
124-
ctx.fillStyle = backgroundColor;
125-
ctx.fillRect(0, 0, canvas.width, canvas.height);
126-
127-
const { lines, fontSize } = fitTextIntoRectangle({
128-
ctx,
129-
text: title,
130-
maxFontSize: titleFontSize,
131-
rect: {
132-
x: xPadding,
133-
y: paddingTop,
134-
width: width - xPadding * 2,
135-
height:
136-
height -
137-
paddingTop -
138-
paddingAboveSubtitle -
139-
subtitleFontSize -
140-
paddingBottom
141-
}
142-
});
128+
const canvas = createCanvas(width, height, "svg");
129+
const ctx = canvas.getContext("2d");
130+
131+
ctx.fillStyle = backgroundColor;
132+
ctx.fillRect(0, 0, canvas.width, canvas.height);
133+
134+
const { lines, fontSize } = fitTextIntoRectangle({
135+
ctx,
136+
text: title,
137+
maxFontSize: titleFontSize,
138+
rect: {
139+
x: xPadding,
140+
y: paddingTop,
141+
width: width - xPadding * 2,
142+
height:
143+
height -
144+
paddingTop -
145+
paddingAboveSubtitle -
146+
subtitleFontSize -
147+
paddingBottom
148+
}
149+
});
143150

144-
ctx.fillStyle = titleColor;
145-
ctx.font = getTitleFont(fontSize);
146-
lines.forEach(({ text, x, y }) => {
147-
ctx.fillText(text, x, y);
148-
});
149-
150-
ctx.fillStyle = subtitleColor;
151-
ctx.font = getSubtitleFont(subtitleFontSize);
152-
ctx.fillText(subtitle, xPadding, height - paddingBottom);
153-
154-
canvas.toBuffer((err, result) => {
155-
if (err) {
156-
reject(err);
157-
} else {
158-
resolve(result);
159-
}
160-
}, "image/png");
151+
ctx.fillStyle = titleColor;
152+
ctx.font = getTitleFont(fontSize);
153+
lines.forEach(({ text, x, y }) => {
154+
ctx.fillText(text, x, y);
161155
});
156+
157+
ctx.fillStyle = subtitleColor;
158+
ctx.font = getSubtitleFont(subtitleFontSize);
159+
ctx.fillText(subtitle, xPadding, height - paddingBottom);
160+
161+
return canvas.toBuffer();
162162
}
163163

164164
module.exports = {
165-
titleImage: createTitleImage
165+
createTitleImage,
166+
titleImageWidth
166167
};

lib/transformers/imageopt.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,28 @@ const sizeOf = util.promisify(require("image-size"));
88

99
const shouldOptimize = !!process.env.OPTIMIZE;
1010

11-
// File extension to a format name that eleventy-img understands.
12-
// Don't process .gif because eleventy-img messes up animated gif.
11+
/**
12+
* File extension to a format name that eleventy-img understands.
13+
*
14+
* Not including .gif because eleventy-img messes up animated gif.
15+
*/
1316
const extensionToFormat = {
1417
".jpeg": "jpeg",
1518
".jpg": "jpeg",
1619
".png": "png"
1720
};
1821

22+
/**
23+
* eleventy-img options for image output path and URL.
24+
*
25+
* Useful for outputting images into the `cached` directory where images can be
26+
* cached indefinitely.
27+
*/
28+
const imageOutputPathOptions = {
29+
urlPath: "/cached/",
30+
outputDir: "./public/cached/"
31+
};
32+
1933
async function handleImg(img) {
2034
const alt = img.getAttribute("alt");
2135
let src = img.getAttribute("src");
@@ -43,8 +57,7 @@ async function handleImg(img) {
4357
// smaller than the biggest logical width (700px).
4458
// Also, the maximum size is 700px * 2 to support up to 2x displays.
4559
widths: [700, 900, 1100, 1400],
46-
urlPath: "/cached/",
47-
outputDir: "./public/cached/"
60+
...imageOutputOptions
4861
};
4962

5063
let stats;
@@ -131,5 +144,7 @@ async function imageopt(content, outputPath) {
131144
}
132145

133146
module.exports = {
134-
imageopt
147+
imageopt,
148+
extensionToFormat,
149+
imageOutputPathOptions
135150
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"serve": "http-server",
1111
"test": "jest",
1212
"tdd": "jest --watch",
13-
"test-text-images": "node lib/filters/manual-tests/title-image.js",
13+
"test-text-images": "node lib/manual-tests/title-image.js",
1414
"cy:run": "cypress run",
1515
"cy:open": "cypress open",
1616
"cy:run:live": "Cypress_baseUrl=https://shuheikagawa.com cypress run",

source/blog/title-image.11ty.js

Lines changed: 0 additions & 21 deletions
This file was deleted.

source/posts/posts.11tydata.js

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,46 @@
11
const Image = require("@11ty/eleventy-img");
2+
const path = require("path");
23

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

610
// Provide common fields for all the posts.
711
module.exports = {
812
layout: "layouts/post",
913
eleventyComputed: {
10-
async image({ image, page }) {
11-
// Use the `image` from front matter if possible.
14+
async image({ image, title, site }) {
15+
let source;
16+
let format;
1217
if (image) {
13-
// Optimize JPEG
14-
// TODO: Support other formats.
15-
if (image.startsWith("/images/") && image.endsWith(".jpg")) {
16-
try {
17-
const stats = await Image("source" + image, {
18-
formats: ["jpeg"],
19-
widths: [800],
20-
// TODO: Share these with imageopt.
21-
urlPath: "/cached/",
22-
outputDir: "./public/cached/"
23-
});
24-
if (stats.jpeg[0]) {
25-
return stats.jpeg[0].url;
26-
}
27-
} catch (err) {
28-
console.error(
29-
"[posts.11tydata.js] Failed to optimize OG image",
30-
image,
31-
err
32-
);
33-
}
34-
}
18+
// Use the `image` from front matter if possible.
19+
source = "source" + image;
20+
format = extensionToFormat[path.extname(source)];
21+
} else {
22+
// Otherwise, generate a title image.
23+
source = createTitleImage(title, site.twitter.subtitle);
24+
// Convert SVG to PNG.
25+
format = "png";
3526
}
36-
// Otherwise, use the one generated by `blog/title-image.11ty.js`.
37-
return `/${blogPermalink(page, "title.png")}`;
27+
28+
if (!format) {
29+
throw new Error(`Unsupported title image format: ${source}`);
30+
}
31+
32+
// Resize and optimize the image.
33+
const stats = await Image(source, {
34+
formats: [format],
35+
widths: [titleImageWidth],
36+
...imageOutputPathOptions
37+
});
38+
if (!stats[format] || stats[format].length !== 1) {
39+
throw new Error(
40+
`Invalid title image optimization result: ${JSON.stringify(stats)}`
41+
);
42+
}
43+
return stats[format][0].url;
3844
}
3945
}
4046
};

0 commit comments

Comments
 (0)