diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 47a0a67..4fa2c1d 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -17,3 +17,9 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run check
- run: bun test
+
+ # Social cards are assembled in the layout from post frontmatter, so the
+ # built HTML is the only place a broken one shows up. Neither biome nor
+ # tsc nor the build itself can tell that a cover is an unrenderable format.
+ - run: bun astro build --mode live
+ - run: bun scripts/check-og.ts
diff --git a/scripts/check-og.test.ts b/scripts/check-og.test.ts
new file mode 100644
index 0000000..e34a34b
--- /dev/null
+++ b/scripts/check-og.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+import { dimensions } from "./check-og";
+
+describe("social card image headers", () => {
+ test("reads every allowed raster format", () => {
+ expect(dimensions("public/layout/icon.png", ".png")).toEqual({ width: 325, height: 300 });
+ expect(dimensions("public/blog/you-dont-need-it/sponge.jpg", ".jpg")).toEqual({ width: 480, height: 360 });
+ expect(dimensions("public/blog/to-wasm/duck.jpeg", ".jpeg")).toEqual({ width: 413, height: 255 });
+ expect(dimensions("public/blog/replacing-hls-dash/buffering.gif", ".gif")).toEqual({ width: 498, height: 280 });
+ expect(dimensions("public/blog/replacing-hls-dash/troll.webp", ".webp")).toEqual({ width: 217, height: 303 });
+ });
+
+ test("rejects non-image bytes with an image extension", () => {
+ expect(dimensions("package.json", ".png")).toBeUndefined();
+ });
+
+ test("rejects an image whose extension doesn't match its bytes", () => {
+ expect(dimensions("public/layout/icon.png", ".jpg")).toBeUndefined();
+ });
+});
diff --git a/scripts/check-og.ts b/scripts/check-og.ts
new file mode 100644
index 0000000..84baee7
--- /dev/null
+++ b/scripts/check-og.ts
@@ -0,0 +1,232 @@
+#!/usr/bin/env bun
+// Asserts every built page carries a social card that scrapers can actually render.
+//
+// Run in CI after `astro build`. It reads dist/, not src/, because the tags are
+// assembled in the layout from frontmatter and only the built HTML shows what a
+// scraper will really see.
+//
+// The bug that prompted this: a post shipped with `cover: ".../viewers-1000.svg"`.
+// Nothing complained -- the file existed, the path was right, the page rendered --
+// but no scraper renders SVG for a card, so the link unfurled bare. Biome and tsc
+// can't see a problem like that, and neither can a build, so a check that opens
+// the referenced image is the only thing that catches it.
+//
+// The rules below are what the major scrapers (X, Facebook, LinkedIn, Slack,
+// Discord, iMessage) agree on, so they're deliberately loose: a page fails only
+// when a card is genuinely broken, never on style.
+
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+const DIST = "dist";
+
+// Every card needs these. og:image is the one that breaks loudly, but a missing
+// title or url unfurls just as badly.
+const REQUIRED = ["og:title", "og:description", "og:url", "og:image"];
+
+// Raster only. SVG is the trap: it's a perfectly good image everywhere else on
+// the site, and every scraper refuses it.
+const RASTER = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
+
+// X's floor for summary_large_image, which is the card type the layout asks for.
+// Anything smaller silently downgrades to a thumbnail.
+const MIN_WIDTH = 300;
+const MIN_HEIGHT = 157;
+
+/** Every .html file under dist, recursively. */
+function pages(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) return pages(path);
+ return entry.name.endsWith(".html") ? [path] : [];
+ });
+}
+
+/** The content of tags, keyed by property. */
+function metas(html: string): Map {
+ const found = new Map();
+ const tag = /= 24 &&
+ buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) &&
+ buf.toString("ascii", 12, 16) === "IHDR"
+ ) {
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+ }
+
+ // JPEG: walk the segment chain to a start-of-frame marker, which carries the
+ // dimensions. Segments are 0xFF, a marker byte, then a big-endian length.
+ if ((ext === ".jpg" || ext === ".jpeg") && buf[0] === 0xff && buf[1] === 0xd8) {
+ let at = 2;
+ while (at < buf.length) {
+ while (buf[at] === 0xff) at++;
+ const marker = buf[at++];
+ if (marker === undefined || marker === 0xd9 || marker === 0xda) break;
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
+ if (at + 2 > buf.length) break;
+
+ const length = buf.readUInt16BE(at);
+ if (length < 2 || at + length > buf.length) break;
+
+ // SOF0-SOF15, excluding the DHT/JPG/DAC markers interleaved among them.
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
+ if (length < 7) break;
+ return { height: buf.readUInt16BE(at + 3), width: buf.readUInt16BE(at + 5) };
+ }
+
+ at += length;
+ }
+ }
+
+ // GIF: the logical screen width and height follow the GIF87a/GIF89a header.
+ if (ext === ".gif" && buf.length >= 10 && ["GIF87a", "GIF89a"].includes(buf.toString("ascii", 0, 6))) {
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
+ }
+
+ // WebP: validate the RIFF container and read dimensions from the payload used
+ // by its lossy, lossless, or extended encoding.
+ if (
+ ext === ".webp" &&
+ buf.length >= 20 &&
+ buf.toString("ascii", 0, 4) === "RIFF" &&
+ buf.toString("ascii", 8, 12) === "WEBP"
+ ) {
+ const chunk = buf.toString("ascii", 12, 16);
+ if (chunk === "VP8 " && buf.length >= 30 && buf.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
+ return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
+ }
+ if (chunk === "VP8L" && buf.length >= 25 && buf[20] === 0x2f) {
+ return {
+ width: 1 + buf[21] + ((buf[22] & 0x3f) << 8),
+ height: 1 + ((buf[22] & 0xc0) >> 6) + (buf[23] << 2) + ((buf[24] & 0x0f) << 10),
+ };
+ }
+ if (chunk === "VP8X" && buf.length >= 30) {
+ return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * What's wrong with one image reference. Errors mean the card is broken and the
+ * image won't appear at all; warnings mean it still renders, just worse. Only
+ * errors fail the build, because a small cover is a judgement call the author
+ * gets to make and an unrenderable one isn't.
+ */
+function checkImage(key: string, value: string): { errors: string[]; warnings: string[] } {
+ const none = { errors: [], warnings: [] };
+
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ // Relative paths resolve against the scraper's idea of the page, which is
+ // often nothing at all. They have to be absolute.
+ return { ...none, errors: [`${key} is not an absolute URL: ${value}`] };
+ }
+
+ if (url.protocol !== "https:") {
+ return { ...none, errors: [`${key} is not https: ${value}`] };
+ }
+
+ const ext = (url.pathname.match(/\.[^./]+$/)?.[0] ?? "").toLowerCase();
+ if (!RASTER.has(ext)) {
+ return { ...none, errors: [`${key} is ${ext || "extensionless"}, which scrapers won't render: ${value}`] };
+ }
+
+ // Every image the sites reference is served from the same build, so the
+ // pathname doubles as its location in dist. An off-origin image would need
+ // fetching instead, and none exists yet.
+ const path = join(DIST, url.pathname);
+ if (!existsSync(path) || !statSync(path).isFile()) {
+ return { ...none, errors: [`${key} has no file at ${path}: ${value}`] };
+ }
+
+ const size = dimensions(path, ext);
+ if (!size || size.width === 0 || size.height === 0) {
+ return { ...none, errors: [`${key} is not a valid ${ext.slice(1).toUpperCase()} file: ${value}`] };
+ }
+
+ if (size.width < MIN_WIDTH || size.height < MIN_HEIGHT) {
+ return {
+ ...none,
+ warnings: [
+ `${key} is ${size.width}x${size.height}, under the ${MIN_WIDTH}x${MIN_HEIGHT} needed for a large card, so it'll show as a thumbnail: ${value}`,
+ ],
+ };
+ }
+
+ return none;
+}
+
+function main(): number {
+ if (!existsSync(DIST)) {
+ console.error(`no ${DIST}/ -- run \`astro build\` first`);
+ return 1;
+ }
+
+ const broken: string[] = [];
+ const degraded: string[] = [];
+ const files = pages(DIST);
+
+ for (const file of files) {
+ const found = metas(readFileSync(file, "utf8"));
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ for (const key of REQUIRED) {
+ if (!found.get(key)) errors.push(`missing ${key}`);
+ }
+
+ // twitter:image is optional -- without it a card falls back to og:image, which
+ // is fine. It just has to be valid when a page does set it.
+ for (const key of ["og:image", "twitter:image"]) {
+ const value = found.get(key);
+ if (!value) continue;
+
+ const result = checkImage(key, value);
+ errors.push(...result.errors);
+ warnings.push(...result.warnings);
+ }
+
+ const list = (problems: string[]) => `${file}\n${problems.map((p) => ` ${p}`).join("\n")}`;
+ if (errors.length) broken.push(list(errors));
+ if (warnings.length) degraded.push(list(warnings));
+ }
+
+ if (degraded.length) {
+ console.warn(`Social cards that render smaller than they could, in ${degraded.length} of ${files.length} pages:\n`);
+ console.warn(`${degraded.join("\n\n")}\n`);
+ }
+
+ if (broken.length) {
+ console.error(`Broken social cards in ${broken.length} of ${files.length} pages:\n`);
+ console.error(`${broken.join("\n\n")}\n`);
+ return 1;
+ }
+
+ console.log(`Social cards OK across ${files.length} pages.`);
+ return 0;
+}
+
+if (import.meta.main) process.exit(main());
diff --git a/src/layouts/global.astro b/src/layouts/global.astro
index 878eaee..4cd274d 100644
--- a/src/layouts/global.astro
+++ b/src/layouts/global.astro
@@ -62,9 +62,12 @@ const proUrl = import.meta.env.MODE === "staging" ? "https://moq.wtf" : "https:/
+
-
-
diff --git a/src/pages/blog/open-for-business.mdx b/src/pages/blog/open-for-business.mdx
index 6899383..4e96402 100644
--- a/src/pages/blog/open-for-business.mdx
+++ b/src/pages/blog/open-for-business.mdx
@@ -3,7 +3,7 @@ layout: "@/layouts/global.astro"
title: "Open For Business"
author: kixelated
description: I have officially achieved the rank of "Capitalist Scum".
-cover: "/blog/open-for-business/viewers-1000.svg"
+cover: "/blog/open-for-business/splash.png"
date: 2026-08-17
---