The problem
I run about twenty small sites that publish new articles every day through an automated pipeline. For a long time every single article on every single site shared one identical og:image.
og:image is the <meta property="og:image" content="..."> tag that decides which picture shows up when someone pastes your URL into X or Slack. When three of your articles land in the same timeline with the same picture, they read as one article. Whatever is different about them never gets across.
There are three obvious ways to fix this:
- Call a hosted image-generation API (pay per image)
- Add an image library such as
sharporcanvas - Emit SVG and point og:image at it
Option 3 is out: the major social platforms do not render SVG for og:image. X and Slack both ignore it and fall back. Option 1 costs money that scales with article count. Option 2 pulls in a native build step, and I run this pipeline on both macOS and Windows, so I did not want one more thing that breaks differently per platform.
What I ended up with: lay the card out in HTML and CSS, then let the Chrome that is already installed take a screenshot of it. Zero npm dependencies, zero recurring cost. Here is how it works, plus the four traps I walked into.
The pipeline
The flow from an article's metadata to the meta tag on the published page looks like this:
article metadata (title / badge / accent / siteLabel / domain / expired)
↓ templateHtml() … build a fixed 1200×630 HTML+CSS page
temp file og.html
↓ Chrome --headless=new --screenshot --window-size=1200,820
PNG (820px tall, padding at the bottom)
↓ cropPngHeight() … cut to the top 630 rows using only Node's zlib
public/articles/<slug>.og.png
↓ each site's build.js … uses this PNG if present, otherwise a shared image
the published page's <meta property="og:image">
Every step is deterministic: the same metadata always produces byte-identical output. The only inputs are the site's data/site.json and data/articles.json. One article's metadata renders one card. The caller looks like this:
const { generateOgImages } = require('./automation/scripts/ogp/generate-og.js');
await generateOgImages([
{
out: 'public/articles/campaign-famima-asa-combiwari.og.png',
title: 'ファミマ朝5時〜11時「コンビ割」9月15日開始',
badge: '最大145円引き',
accent: '#6f4e37',
siteLabel: 'コーヒーのセール情報',
mark: '珈',
domain: 'coffee.autoarticles.net',
note: '価格・在庫は変動。最新はAmazonでご確認ください。',
expired: false,
},
]);
The interesting parts
Let CSS do the drawing
There is not a single line of image-manipulation code in here. The gradient, the rounded badge, the diagonal light streaks, the "expired" ribbon — all of it is CSS.
const grad = expired
? 'linear-gradient(135deg,#8a8f94,#6b7075)' // expired deals go grey
: `linear-gradient(135deg,${escHtml(accent)},${escHtml(endColor)})`;
The title font size steps down as the title gets longer, and -webkit-line-clamp:3 caps it at three lines. Those two rules alone eliminated overflow in practice:
h1{
font-size:${fontPx}px; line-height:1.28;
display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden;
}
The real payoff of staying in CSS is that you can iterate on the layout in a browser. Open the same HTML in your local Chrome and you see essentially the output. You do not get that if you are computing coordinates against an imaging library.
Take the shot
The assembled HTML goes into a temp file, and Chrome is launched exactly once to capture it. chromeBin() lazily resolves the browser binary, out is the destination PNG path, and htmlPath is the og.html written into a temp directory:
execFileSync(chromeBin(), [
'--headless=new',
'--disable-gpu',
'--hide-scrollbars',
'--no-sandbox',
'--force-device-scale-factor=1',
'--window-size=1200,820',
`--screenshot=${out}`,
fileUrl(htmlPath),
], { stdio: 'ignore' });
cropTopLeft(out, 1200, 630);
After that single call, out holds a 1200×820 PNG, and cropTopLeft trims it to the top 1200×630. The publishable file is finished at that point.
--force-device-scale-factor=1 is not optional. Without it a HiDPI machine hands you 2400×1260 and every downstream crop is wrong.
Do not redraw what has not changed
A hash of the input parameters is stored next to the PNG as <name>.og.hash, and a match means skip:
const hashPath = out.replace(/\.png$/i, '') + '.og.hash';
const h = hashOf(it); // `out` is excluded from the hash on purpose
if (!opts.force && fs.existsSync(out) && fs.existsSync(hashPath)) {
if (fs.readFileSync(hashPath, 'utf8').trim() === h) { skipped++; continue; }
}
This matters more than it sounds. A real daily build logs:
[og] coffee: 生成 4 / スキップ 152 / 計 156
Four of 156 articles were redrawn — the ones actually touched that day. Chrome startup costs 0.5–1s per image, so rendering all 156 every day would add over two minutes to the nightly run.
Four traps
1. --window-size height is the viewport height
Passing --window-size=1200,630 does not give you 630px. Chrome's --headless --screenshot treats window height as viewport height, but the captured image can extend to the full page height, leaving padding at the bottom.
The fix is to shoot big and crop: capture at --window-size=1200,820, then cut to the top 630 rows.
2. Cropping without an imaging library
sharp would make this one line, but that breaks the zero-dependency premise. Reading the PNG spec, it turns out Node's built-in zlib is enough for this specific case.
PNG pixel data is stored as scanlines, each prefixed by a one-byte filter type, all concatenated and deflated into the IDAT chunks. Crucially, a filter only ever references rows above it. So if you inflate the stream and keep the first targetH rows, what you have is a valid image of the top targetH rows — no recompute needed.
const channels = colorType === 6 ? 4 : 3; // 6=RGBA, 2=RGB
const bytesPerRow = 1 + width * channels; // leading byte is the filter type
const raw = zlib.inflateSync(Buffer.concat(idat));
const cropped = raw.subarray(0, targetH * bytesPerRow); // top targetH rows
const newIhdr = Buffer.from(ihdr);
newIhdr.writeUInt32BE(targetH, 4); // patch only the height in IHDR
const out = Buffer.concat([
PNG_SIG,
pngChunk('IHDR', newIhdr),
...passthrough,
pngChunk('IDAT', zlib.deflateSync(cropped, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0)),
]);
You do have to compute the chunk CRC32 yourself, but that is another thirty lines. Anything the code cannot handle — interlaced, non-8-bit depth, palette — throws instead of guessing. Chrome always emits 8-bit non-interlaced RGB/RGBA, so this holds in production.
This trick only covers vertical cropping. Cropping horizontally means cutting inside each row, which changes the bytes-per-row and makes the problem meaningfully harder. Here the width already matches the window width, so height is all that is needed.
3. Windows produced zero images, silently
This one hurt. The list of candidate Chrome paths contained no Windows entries at all. On a Windows box with Chrome plainly installed, the resolver threw "Chrome/Chromium not found."
Worse, the caller swallowed that exception and returned exit 0. Each site's build.js has a deliberately safe fallback: use the per-article PNG if present, otherwise the shared site image. So with generation totally dead, the build still succeeded, pages still shipped, and og:image quietly stayed on the shared image. Nothing in the logs said otherwise.
The fix came in two parts:
-
Split out a pure function.
chromeCandidates(env, platform, homedir)only assembles candidate paths and never touches the filesystem;findChrome()takes that list and checks existence. Now a test running on macOS can assert that Windows candidates are produced. - Drop the fallback. If the binary cannot be resolved, fail.
4. The same shape of bug, somewhere else
There was a second instance. Passing --site <key> resolves the site through a task definition's repoPath (e.g. /Users/hashito/git/web/coffee). On Windows that macOS-absolute path is interpreted as drive-relative, so it resolves against whatever drive the process happens to be on and does not exist. The old code then silently fell back to a different clone of the site.
That clone also has a data/ directory, so generation succeeded. The log said success. But that tree is never deployed, so nothing reached production.
The fix: path.resolve to pin the drive before dispatching, and fail when the site cannot be resolved. The fallback to a different tree is gone; --dir exists for the case where you genuinely want to point elsewhere.
The result
One of the sites running on these generated og:images: https://blog.hashito.biz
Takeaway
If a process has a fallback, count how often the fallback fires and print it. Of the four traps above, the hard one was #2 (cropping PNG with nothing but zlib), but the expensive ones were #3 and #4. In both cases a well-intentioned "safe fallback" concealed the fact that generation had died completely.
A fallback limits the damage when something goes wrong; it is not evidence that nothing went wrong. Print generated / skipped / total on every run and "generated 0, skipped 0" jumps out of the log immediately. That single line is the difference between an implementation that tells you it is broken and one that ships the same placeholder image for weeks.
This article is about my own side project. It was written with AI assistance.
Top comments (0)