Skip to content

Instantly share code, notes, and snippets.

@danii1
Last active May 19, 2026 09:09
Show Gist options
  • Select an option

  • Save danii1/c55c34c62d37f3c6fb8091a38dfebb37 to your computer and use it in GitHub Desktop.

Select an option

Save danii1/c55c34c62d37f3c6fb8091a38dfebb37 to your computer and use it in GitHub Desktop.
generate-social.ts — brand-aligned social header generator (Bun + Playwright) for devintern.com
#!/usr/bin/env bun
/**
* Generate brand-aligned social header images (Twitter 1500x500 by default)
* from a tiny TypeScript config. No designer needed; edit two strings and
* re-run.
*
* Why Playwright? Rendering text with the *exact* brand font is fiddly with
* pure image libraries — letter-spacing, kerning, and font-metric centering
* vary between Skia, libvips, and ImageMagick. Rendering through a real
* browser gives you pixel-perfect output that matches your website's
* typography. We embed the woff2 font as base64 inside an inline @font-face
* so the result is identical on any machine, with or without Inter installed.
*
* Why hash-cached? The render is ~3-4s per variant (cold-start a browser,
* load fonts, screenshot). Hashing the inputs and writing the hash next to
* the PNG means re-running is instant when nothing has changed.
*
* Usage:
* bun add -d playwright @fontsource-variable/inter
* bun x playwright install chromium
* bun run generate-social.ts # render stale variants
* bun run generate-social.ts --force # re-render everything
*
* Output is written to `public/marketing/social/<slug>.png` at 2x DPR.
*/
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { chromium } from "playwright";
// Bump when the rendering logic itself changes (font, layout, sizes) so old
// caches invalidate even though the user-facing variant config is unchanged.
const RENDER_VERSION = "2";
// 2x DPR keeps the text crisp on retina previews / when zoomed.
const DEVICE_SCALE_FACTOR = 2;
const ROOT = new URL("..", import.meta.url).pathname;
const OUT_DIR = join(ROOT, "public/marketing/social");
const HASH_FILE = join(OUT_DIR, ".hashes.json");
// Resolve the woff2 path through Bun's module resolver. Works in standalone
// projects, monorepos with hoisted node_modules, and pnpm-style nested setups.
const FONT_PATH = Bun.resolveSync(
"@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
ROOT,
);
// Brand palette. Swap these out for your own colors.
const BRAND = {
bg: "#0b1326",
fg: "#dae2fd",
muted: "#c7c4d7",
teal: "#4fdbc8",
};
type Variant = {
slug: string;
width: number;
height: number;
// Two-line headline. Line 1 is muted (the setup), line 2 is the foreground
// punch line. Either can be an empty string for a single-line layout.
line1: string;
line2: string;
};
// === The config ===
// Edit `line1` / `line2`, run the script, drop the PNG into Twitter.
// Add more entries to render multiple variants in one pass (e.g. dark/light,
// A/B copy, different shapes for Twitter / LinkedIn / OpenGraph).
const variants: Variant[] = [
{
slug: "twitter-header",
width: 1500,
height: 500,
line1: "Skip the routine PRs.",
line2: "Ship the ones worth writing.",
},
];
// === Caching ===
// Hash every input that affects the rendered pixels. If the hash is unchanged
// AND the PNG exists, skip the render. Cheap, deterministic, no timestamps.
function variantHash(v: Variant, fontHash: string): string {
return createHash("sha256")
.update(
[RENDER_VERSION, v.slug, `${v.width}x${v.height}`, v.line1, v.line2, fontHash].join("|"),
)
.digest("hex")
.slice(0, 16);
}
async function readHashes(): Promise<Record<string, string>> {
try {
return JSON.parse(await readFile(HASH_FILE, "utf8"));
} catch {
return {};
}
}
// === Rendering ===
// The HTML that Playwright will screenshot. We use Flexbox to center text,
// which avoids every gotcha that SVG-text centering brings (font-metric
// quirks, "central" baseline vs visual bbox center, etc.) The browser does
// the layout — we just take a picture of it.
function renderHtml(v: Variant, fontDataUri: string): string {
// Twitter resizes the header heavily on mobile / in profile cards, and the
// bottom-left corner gets covered by the profile photo. We park the text
// slightly above center and the wordmark in the bottom-right to stay clear.
return `<!doctype html>
<html><head><meta charset="utf-8"><style>
@font-face {
font-family: "Inter";
src: url("${fontDataUri}") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: block;
}
html, body {
margin: 0; padding: 0;
width: ${v.width}px; height: ${v.height}px;
background: ${BRAND.bg};
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: ${BRAND.fg};
overflow: hidden;
}
/* Subtle teal vignette so the banner doesn't feel flat. */
body::before {
content: "";
position: absolute; inset: 0;
background: radial-gradient(120% 80% at 85% 20%, rgba(79, 219, 200, 0.10), transparent 60%);
pointer-events: none;
}
.frame {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
flex-direction: column;
padding: 0 120px;
text-align: center;
}
.line1 {
font-size: 60px;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1;
color: ${BRAND.muted};
white-space: nowrap;
}
.line2 {
font-size: 76px;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1;
margin-top: 22px;
white-space: nowrap;
}
/* Brand chip in the bottom-right corner. */
.wordmark {
position: absolute;
right: 56px;
bottom: 44px;
font-size: 32px;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1;
}
.wordmark .dev { color: ${BRAND.teal}; }
.wordmark .intern { color: ${BRAND.fg}; }
/* Small teal dot for visual rhythm, sits over the headline. */
.accent {
position: absolute;
left: 50%;
top: 88px;
transform: translateX(-50%);
width: 10px; height: 10px; border-radius: 999px;
background: ${BRAND.teal};
box-shadow: 0 0 24px rgba(79, 219, 200, 0.7);
}
</style></head>
<body>
<span class="accent"></span>
<div class="frame">
${v.line1 ? `<div class="line1">${v.line1}</div>` : ""}
${v.line2 ? `<div class="line2">${v.line2}</div>` : ""}
</div>
<div class="wordmark"><span class="dev">dev</span><span class="intern">intern</span></div>
</body></html>`;
}
// === Main ===
async function main() {
const force = process.argv.includes("--force");
// Read the font once and inline it as a base64 data URI. ~25 KB after
// base64-encoding, but the resulting PNG renders with the exact brand
// typography on any machine — no system-font fallbacks, no surprises.
const fontBuf = await readFile(FONT_PATH);
const fontDataUri = `data:font/woff2;base64,${fontBuf.toString("base64")}`;
const fontHash = createHash("sha256").update(fontBuf).digest("hex").slice(0, 8);
const existing = await readHashes();
const stale = variants.filter((v) => {
if (force) return true;
const png = join(OUT_DIR, `${v.slug}.png`);
return existing[v.slug] !== variantHash(v, fontHash) || !existsSync(png);
});
if (stale.length === 0) {
console.log(`Social: ${variants.length} variants already up to date.`);
return;
}
await mkdir(OUT_DIR, { recursive: true });
console.log(`Social: rendering ${stale.length} variants at ${DEVICE_SCALE_FACTOR}x`);
const browser = await chromium.launch();
try {
for (const v of stale) {
const ctx = await browser.newContext({
viewport: { width: v.width, height: v.height },
deviceScaleFactor: DEVICE_SCALE_FACTOR,
reducedMotion: "reduce",
});
const page = await ctx.newPage();
await page.setContent(renderHtml(v, fontDataUri), { waitUntil: "load" });
// Critical: wait for the @font-face to be parsed AND fetched before
// screenshotting, otherwise the first frame may use a fallback font.
await page.evaluate(async () => {
await document.fonts.ready;
});
const buf = await page.screenshot({
type: "png",
clip: { x: 0, y: 0, width: v.width, height: v.height },
});
await writeFile(join(OUT_DIR, `${v.slug}.png`), buf);
existing[v.slug] = variantHash(v, fontHash);
console.log(` ✓ ${v.slug} (${v.width}x${v.height})`);
await ctx.close();
}
} finally {
await browser.close();
}
await writeFile(HASH_FILE, JSON.stringify(existing, null, 2));
}
await main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment