Last active
May 19, 2026 09:09
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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