From 0fa7563fc18dc093a9d1869ee9f0dbf3f7ea4a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:29:53 +0000 Subject: [PATCH 1/3] Fix the social card for "Open For Business" The post's cover was viewers-1000.svg, one of the four scaling icons from the body. No scraper renders SVG for a social card -- Facebook, X, LinkedIn, Slack, Discord and iMessage all skip the image and fall back to a bare text card. It's the only post of the 25 with a non-raster cover, which is why this one link looked broken and the rest didn't. The icon wouldn't have worked even if SVG were supported: it's a 256x256 black-on-transparent glyph meant to sit on the page's dark background. splash.png shipped in the same commit as the post and was never referenced. It's the moq.pro hero at 2324x1206 -- a 1.93:1 image, which is the card aspect ratio -- so it was drawn for this and just never got wired up. Also drop og:image:width/height. They were hardcoded to 163x150 and emitted on every page regardless of the actual cover, so they were wrong everywhere: boat.png is 1920x1080, and even the /layout/icon.png default they seem to describe is really 325x300. Wrong dimensions are worse than none, because scrapers believe them -- 163x150 is under X's 300x157 floor for summary_large_image, so a correct large card gets downgraded to a thumbnail. Omitting them lets scrapers measure the image themselves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KXDsRD3HaMB1MU3kP767ak --- src/layouts/global.astro | 7 +++++-- src/pages/blog/open-for-business.mdx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) 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 --- From 963a156ae1e23343ac4aa2e0bdcafc3c97f18b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:56:29 +0000 Subject: [PATCH 2/3] Check social cards in CI Nothing caught the SVG cover in the previous commit, because nothing looks at the cards. CI runs `bun run check` (biome + tsc) and `bun test`, and the three test files cover Worker routing, vanity imports and the broadcast URL scheme. None of it touches Open Graph, and CI never built the site at all, so a cover pointing at a missing file wouldn't have failed either. The tags are assembled in the layout from post frontmatter, so the built HTML is the only place the result is visible. This builds in CI and asserts over dist/**/*.html: every page has og:title, og:description, og:url and og:image, and every image reference is an absolute https URL pointing at a raster file that exists in the build. Broken and merely suboptimal are kept apart. An SVG, a missing file or a relative URL means no image renders at all, so those fail. An undersized cover still renders, just as a thumbnail instead of a large card, so those warn and let the build pass -- which cover to use is the author's call, and two posts already sit under the threshold (kixelCat.png at 240x240 and moqbs.png at 256x256). Verified against the real bug: restoring the .svg cover and rebuilding fails the check with "og:image is .svg, which scrapers won't render", and pointing a cover at a nonexistent file fails with the resolved path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KXDsRD3HaMB1MU3kP767ak --- .github/workflows/pr.yml | 6 ++ scripts/check-og.ts | 184 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 scripts/check-og.ts 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.ts b/scripts/check-og.ts new file mode 100644 index 0000000..6046abb --- /dev/null +++ b/scripts/check-og.ts @@ -0,0 +1,184 @@ +#!/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 = /= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { height: buf.readUInt16BE(at + 5), width: buf.readUInt16BE(at + 7) }; + } + + at += 2 + length; + } + } + + 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); + if (size && (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; +} + +if (!existsSync(DIST)) { + console.error(`no ${DIST}/ -- run \`astro build\` first`); + process.exit(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`); + process.exit(1); +} + +console.log(`Social cards OK across ${files.length} pages.`); From 2a527f9d7e58358c3acbffd8fe3f60ebf959a421 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 21 Aug 2026 09:00:35 -0700 Subject: [PATCH 3/3] Reject invalid social card image headers --- scripts/check-og.test.ts | 20 ++++++ scripts/check-og.ts | 146 ++++++++++++++++++++++++++------------- 2 files changed, 117 insertions(+), 49 deletions(-) create mode 100644 scripts/check-og.test.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 index 6046abb..84baee7 100644 --- a/scripts/check-og.ts +++ b/scripts/check-og.ts @@ -55,33 +55,72 @@ function metas(html: string): Map { } /** - * The pixel dimensions of a PNG or JPEG, or undefined for formats we don't - * parse. Only the size matters here, so this reads headers rather than pulling - * in an image library for a check that runs on a handful of files. + * The pixel dimensions of an image, or undefined when its bytes don't match the + * extension. Only the size matters here, so this validates headers rather than + * pulling in an image library for a check that runs on a handful of files. */ -function dimensions(path: string): { width: number; height: number } | undefined { +export function dimensions(path: string, ext: string): { width: number; height: number } | undefined { const buf = readFileSync(path); // PNG: a fixed IHDR chunk, width and height as big-endian u32 at byte 16. - if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + if ( + ext === ".png" && + buf.length >= 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 (buf[0] === 0xff && buf[1] === 0xd8) { + if ((ext === ".jpg" || ext === ".jpeg") && buf[0] === 0xff && buf[1] === 0xd8) { let at = 2; - while (at + 9 < buf.length) { - if (buf[at] !== 0xff) break; - const marker = buf[at + 1]; - const length = buf.readUInt16BE(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) { - return { height: buf.readUInt16BE(at + 5), width: buf.readUInt16BE(at + 7) }; + if (length < 7) break; + return { height: buf.readUInt16BE(at + 3), width: buf.readUInt16BE(at + 5) }; } - at += 2 + length; + 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) }; } } @@ -123,8 +162,12 @@ function checkImage(key: string, value: string): { errors: string[]; warnings: s return { ...none, errors: [`${key} has no file at ${path}: ${value}`] }; } - const size = dimensions(path); - if (size && (size.width < MIN_WIDTH || size.height < MIN_HEIGHT)) { + 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: [ @@ -136,49 +179,54 @@ function checkImage(key: string, value: string): { errors: string[]; warnings: s return none; } -if (!existsSync(DIST)) { - console.error(`no ${DIST}/ -- run \`astro build\` first`); - process.exit(1); -} +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); + 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 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}`); - } + 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; - // 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 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)); } - 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 (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; + } -if (broken.length) { - console.error(`Broken social cards in ${broken.length} of ${files.length} pages:\n`); - console.error(`${broken.join("\n\n")}\n`); - process.exit(1); + console.log(`Social cards OK across ${files.length} pages.`); + return 0; } -console.log(`Social cards OK across ${files.length} pages.`); +if (import.meta.main) process.exit(main());