Skip to content

Commit 1b1b77a

Browse files
TimMikeladzeclaude
andcommitted
fix(app): serve the OG image as an opaque PNG at a fresh URL
Twitter/X was rendering no preview for devbar.sh even though the page and image both check out server-side: the crawler gets 200s for the HTML and the image, the SPA rewrite does not swallow it, and robots.txt allows it. Two things left that X is known to trip on. The image was RGBA. Every pixel was fully opaque, but canvas toDataURL always emits an alpha channel and X is unreliable with alpha PNGs. The generator now reads raw pixels off the canvas and encodes the PNG here as truecolor (color type 2, no alpha) using node:zlib, so no new dependency. Output is pixel identical and drops from 165 KB to 90 KB. X also caches card data per URL for about a week, negative results included, and there is no public validator left to purge it. Bumping to og-v3.png gives crawlers a URL they have never resolved. og.png and og-v2.png redirect to it, and it now carries an immutable long-lived Cache-Control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aryuPfj68QeQdMmsQuTBy
1 parent dd4274b commit 1b1b77a

6 files changed

Lines changed: 85 additions & 12 deletions

File tree

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ Run `bun run dev` - This starts a Bun + React preview app at http://localhost:38
3333

3434
### Social preview image
3535

36-
The Open Graph image at `app/public/og-v2.png` is generated from the HTML template in `scripts/og-image.html`. After editing the template, regenerate the PNG with `bun run og` (renders at 2x via Playwright, downscaled to 1200x630) and commit both files.
36+
The Open Graph image at `app/public/og-v3.png` is generated from the HTML template in `scripts/og-image.html`. After editing the template, regenerate the PNG with `bun run og` (renders at 2x via Playwright, downscaled to 1200x630, written as an opaque RGB PNG with no alpha channel — X is unreliable with alpha-channel PNGs) and commit both files.
3737

38-
Social crawlers cache the image by URL and rarely re-fetch it, so a redesign that keeps the same filename will keep showing the old preview. When the artwork changes meaningfully, bump the version suffix (`og-v2.png``og-v3.png`) in `scripts/generate-og.ts` and in the `og:image`, `twitter:image`, and JSON-LD `image` tags in `app/index.html`.
38+
Social crawlers cache the image by URL and rarely re-fetch it, so a redesign that keeps the same filename will keep showing the old preview. When the artwork changes meaningfully, bump the version suffix (`og-v3.png``og-v4.png`) in `scripts/generate-og.ts`, in the `og:image`, `og:image:secure_url`, `twitter:image`, and JSON-LD `image` tags in `app/index.html`, and in the redirect and header entries in `vercel.json`.
3939

4040
### README screenshots
4141

app/index.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
content="A drop-in inspector for any website. Point at a change, capture the exact context, and route it to your AI agent, a GitHub issue, or your team."
5454
/>
5555
<meta property="og:site_name" content="devbar.sh" />
56-
<meta property="og:image" content="https://devbar.sh/og-v2.png" />
56+
<meta property="og:image" content="https://devbar.sh/og-v3.png" />
57+
<meta property="og:image:secure_url" content="https://devbar.sh/og-v3.png" />
5758
<meta property="og:image:width" content="1200" />
5859
<meta property="og:image:height" content="630" />
5960
<meta property="og:image:type" content="image/png" />
@@ -74,7 +75,7 @@
7475
name="twitter:description"
7576
content="A drop-in inspector for any website. Point at a change, capture the exact context, and route it to your AI agent, a GitHub issue, or your team."
7677
/>
77-
<meta name="twitter:image" content="https://devbar.sh/og-v2.png" />
78+
<meta name="twitter:image" content="https://devbar.sh/og-v3.png" />
7879
<meta
7980
name="twitter:image:alt"
8081
content="devbar.sh — point at any UI, ship the change with your agent"
@@ -109,7 +110,7 @@
109110
],
110111
"description": "Open-source drop-in inspector that turns any UI change into agent-ready context for your AI agent, a GitHub issue, or your team.",
111112
"url": "https://devbar.sh",
112-
"image": "https://devbar.sh/og-v2.png",
113+
"image": "https://devbar.sh/og-v3.png",
113114
"applicationCategory": "DeveloperApplication",
114115
"applicationSubCategory": "Annotation & QA",
115116
"operatingSystem": "Any",

app/public/og-v2.png

-162 KB
Binary file not shown.

app/public/og-v3.png

88 KB
Loading

scripts/generate-og.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,85 @@
11
/**
2-
* Renders scripts/og-image.html to app/public/og-v2.png (1200x630).
2+
* Renders scripts/og-image.html to app/public/og-v3.png (1200x630).
33
*
44
* Shot at 2x and downscaled back to 1200x630 so text and hairlines stay crisp
55
* in social previews. The downscale runs in the browser (canvas) to avoid
66
* pulling in a native image dependency.
77
*
8+
* The canvas hands back raw RGBA pixels and we encode the PNG here as opaque
9+
* truecolor (no alpha channel). Canvas always emits RGBA, and some social
10+
* crawlers — X in particular — are unreliable with alpha-channel PNGs even
11+
* when every pixel is fully opaque.
12+
*
813
* bun run og
914
*/
1015
import { chromium } from "@playwright/test";
1116
import { mkdir, writeFile } from "node:fs/promises";
1217
import { dirname, resolve } from "node:path";
1318
import { pathToFileURL } from "node:url";
19+
import { deflateSync } from "node:zlib";
1420

1521
const ROOT = resolve(import.meta.dirname, "..");
1622
const TEMPLATE = resolve(ROOT, "scripts/og-image.html");
17-
const OUT = resolve(ROOT, "app/public/og-v2.png");
23+
const OUT = resolve(ROOT, "app/public/og-v3.png");
1824

1925
const WIDTH = 1200;
2026
const HEIGHT = 630;
2127
const SCALE = 2;
2228

29+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
30+
let c = n;
31+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
32+
return c >>> 0;
33+
});
34+
35+
const crc32 = (buf: Buffer) => {
36+
let c = 0xffffffff;
37+
for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
38+
return (c ^ 0xffffffff) >>> 0;
39+
};
40+
41+
const chunk = (type: string, data: Buffer) => {
42+
const head = Buffer.alloc(4);
43+
head.writeUInt32BE(data.length, 0);
44+
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
45+
const crc = Buffer.alloc(4);
46+
crc.writeUInt32BE(crc32(body), 0);
47+
return Buffer.concat([head, body, crc]);
48+
};
49+
50+
/** Encode RGBA pixels as a PNG with color type 2 (truecolor, no alpha). */
51+
const encodeOpaquePng = (rgba: Uint8Array, width: number, height: number) => {
52+
// One filter byte (0 = None) plus 3 bytes per pixel, per scanline.
53+
const raw = Buffer.alloc(height * (1 + width * 3));
54+
let out = 0;
55+
for (let y = 0; y < height; y++) {
56+
raw[out++] = 0;
57+
let src = y * width * 4;
58+
for (let x = 0; x < width; x++) {
59+
raw[out++] = rgba[src];
60+
raw[out++] = rgba[src + 1];
61+
raw[out++] = rgba[src + 2];
62+
src += 4;
63+
}
64+
}
65+
66+
const ihdr = Buffer.alloc(13);
67+
ihdr.writeUInt32BE(width, 0);
68+
ihdr.writeUInt32BE(height, 4);
69+
ihdr[8] = 8; // bit depth
70+
ihdr[9] = 2; // color type: truecolor
71+
ihdr[10] = 0; // deflate
72+
ihdr[11] = 0; // adaptive filtering
73+
ihdr[12] = 0; // no interlace
74+
75+
return Buffer.concat([
76+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
77+
chunk("IHDR", ihdr),
78+
chunk("IDAT", deflateSync(raw, { level: 9 })),
79+
chunk("IEND", Buffer.alloc(0)),
80+
]);
81+
};
82+
2383
const browser = await chromium.launch();
2484
const page = await browser.newPage({
2585
viewport: { width: WIDTH, height: HEIGHT },
@@ -31,7 +91,7 @@ await page.evaluate(() => document.fonts.ready);
3191

3292
const shot = await page.screenshot({ clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT } });
3393

34-
const dataUrl = await page.evaluate(
94+
const pixels = await page.evaluate(
3595
async ({ png, width, height }) => {
3696
const bitmap = await createImageBitmap(
3797
await (await fetch(`data:image/png;base64,${png}`)).blob(),
@@ -44,14 +104,14 @@ const dataUrl = await page.evaluate(
44104
ctx.imageSmoothingEnabled = true;
45105
ctx.imageSmoothingQuality = "high";
46106
ctx.drawImage(bitmap, 0, 0, width, height);
47-
return canvas.toDataURL("image/png");
107+
return Array.from(ctx.getImageData(0, 0, width, height).data);
48108
},
49109
{ png: shot.toString("base64"), width: WIDTH, height: HEIGHT },
50110
);
51111

52112
await browser.close();
53113

54114
await mkdir(dirname(OUT), { recursive: true });
55-
await writeFile(OUT, Buffer.from(dataUrl.split(",")[1], "base64"));
115+
await writeFile(OUT, encodeOpaquePng(Uint8Array.from(pixels), WIDTH, HEIGHT));
56116

57-
console.log(`wrote ${OUT} (${WIDTH}x${HEIGHT})`);
117+
console.log(`wrote ${OUT} (${WIDTH}x${HEIGHT}, opaque RGB)`);

vercel.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,19 @@
33
"buildCommand": "bun install --frozen-lockfile && bun run build && cd app && bun install --frozen-lockfile && bunx vite build",
44
"outputDirectory": "app/dist",
55
"framework": null,
6-
"redirects": [{ "source": "/og.png", "destination": "/og-v2.png", "permanent": true }],
6+
"redirects": [
7+
{ "source": "/og.png", "destination": "/og-v3.png", "permanent": true },
8+
{ "source": "/og-v2.png", "destination": "/og-v3.png", "permanent": true }
9+
],
10+
"headers": [
11+
{
12+
"source": "/og-v3.png",
13+
"headers": [
14+
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" },
15+
{ "key": "Content-Type", "value": "image/png" }
16+
]
17+
}
18+
],
719
"rewrites": [
820
{ "source": "/api/(.*)", "destination": "/api/serverless" },
921
{ "source": "/mcp", "destination": "/api/serverless" },

0 commit comments

Comments
 (0)