Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions scripts/check-og.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
232 changes: 232 additions & 0 deletions scripts/check-og.ts
Original file line number Diff line number Diff line change
@@ -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 <meta property|name="..."> tags, keyed by property. */
function metas(html: string): Map<string, string> {
const found = new Map<string, string>();
const tag = /<meta\s+(?:property|name)="([^"]+)"\s+content="([^"]*)"/g;

for (const [, key, value] of html.matchAll(tag)) {
found.set(key, value);
}

return found;
}

/**
* 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.
*/
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 (
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) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode the image before accepting it as valid

When an image is truncated after its dimension header, this returns a size and the CI check accepts a card that scrapers cannot decode. A 24-byte file containing only a PNG signature and IHDR dimensions, with no complete IHDR data, IDAT, CRCs, or IEND, is reported as valid; the GIF, JPEG, and WebP branches similarly return before validating the complete file. The check therefore needs a real decoder or sufficient structural validation before declaring the asset usable.

Useful? React with 👍 / 👎.

}

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject off-origin URLs before mapping them into dist

When a cover uses an absolute URL on another host, this discards the origin and validates only the corresponding local pathname. For example, https://does-not-exist.invalid/layout/icon.png passes whenever dist/layout/icon.png exists, even though no scraper can retrieve the referenced image. Check that the URL has the expected site origin before mapping it into dist, or fetch and validate off-origin images instead.

Useful? React with 👍 / 👎.

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());
7 changes: 5 additions & 2 deletions src/layouts/global.astro
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ const proUrl = import.meta.env.MODE === "staging" ? "https://moq.wtf" : "https:/
<meta property="og:description" content={fullDescription} />
<meta property="og:url" content={pageUrl} />
<meta property="og:site_name" content="Media over QUIC" />
<!-- No og:image:width/height: covers live in public/ at whatever size they
were drawn, so any hardcoded pair is a lie for most pages. Scrapers measure
the image themselves when the dimensions are absent; when they're present and
wrong they're believed, and a card claiming to be smaller than Twitter's
300x157 minimum gets downgraded to a thumbnail. -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="163" />
<meta property="og:image:height" content="150" />

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
Expand Down
2 changes: 1 addition & 1 deletion src/pages/blog/open-for-business.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Loading