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 ---