generate-og-image.js

  1#!/usr/bin/env node
  2
  3/**
  4 * Generate OG Image
  5 *
  6 * Renders the OG image using Playwright with proper Google Fonts.
  7 * Counts commands dynamically from the source/ directory and composes
  8 * the wordmark alongside a real screenshot of the Chrome extension
  9 * detection panel from public/assets/extension-detection.png.
 10 *
 11 * Usage: bun run og-image
 12 */
 13
 14import { chromium } from 'playwright';
 15import path from 'path';
 16import fs from 'fs';
 17import { fileURLToPath } from 'url';
 18
 19const __filename = fileURLToPath(import.meta.url);
 20const __dirname = path.dirname(__filename);
 21const ROOT_DIR = path.resolve(__dirname, '..');
 22const OUTPUT_PATH = path.join(ROOT_DIR, 'site', 'public', 'og-image.jpg');
 23const EXTENSION_IMAGE_PATH = path.join(
 24  ROOT_DIR,
 25  'public',
 26  'assets',
 27  'extension-detection.png',
 28);
 29
 30// Count sub-commands from skill/scripts/command-metadata.json (the post-v3.0
 31// single source of truth). Commands and skills were unified in v2.0; v3.0
 32// then collapsed to a single user-invocable skill (`impeccable`) with
 33// sub-commands listed in command-metadata.json.
 34function getCommandCount() {
 35  const metadataPath = path.join(ROOT_DIR, 'skill', 'scripts', 'command-metadata.json');
 36  if (!fs.existsSync(metadataPath)) return 0;
 37  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
 38  return Object.keys(metadata).length;
 39}
 40
 41// Load extension screenshot as base64 data URL so setContent is self-contained
 42function getExtensionDataUrl() {
 43  const buf = fs.readFileSync(EXTENSION_IMAGE_PATH);
 44  return `data:image/png;base64,${buf.toString('base64')}`;
 45}
 46
 47async function generateOgImage() {
 48  const commands = getCommandCount();
 49  const extensionDataUrl = getExtensionDataUrl();
 50  console.log(`Detected ${commands} command(s)`);
 51
 52  const html = `<!DOCTYPE html>
 53<html>
 54<head>
 55  <meta charset="UTF-8">
 56  <link rel="preconnect" href="https://fonts.googleapis.com">
 57  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 58  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Instrument+Sans:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
 59  <style>
 60    * { margin: 0; padding: 0; box-sizing: border-box; }
 61
 62    body {
 63      width: 1200px;
 64      height: 630px;
 65      overflow: hidden;
 66      background: #f5f2ee;
 67      position: relative;
 68      font-family: 'Instrument Sans', system-ui, sans-serif;
 69      -webkit-font-smoothing: antialiased;
 70      color: #1a1a1a;
 71    }
 72
 73    /* Soft brand glow for depth — subtle magenta accents in opposite corners */
 74    body::before {
 75      content: '';
 76      position: absolute;
 77      inset: 0;
 78      background:
 79        radial-gradient(circle at 15% 8%, rgba(200, 50, 120, 0.07) 0%, transparent 55%),
 80        radial-gradient(circle at 90% 95%, rgba(200, 50, 120, 0.05) 0%, transparent 60%);
 81      pointer-events: none;
 82    }
 83
 84    .container {
 85      position: relative;
 86      width: 100%;
 87      height: 100%;
 88      padding: 72px 80px;
 89    }
 90
 91    .content {
 92      position: relative;
 93      display: flex;
 94      flex-direction: column;
 95      justify-content: space-between;
 96      height: 100%;
 97      max-width: 560px;
 98      z-index: 2;
 99    }
100
101    .top {
102      display: flex;
103      flex-direction: column;
104      gap: 22px;
105    }
106
107    .title {
108      font-family: 'Cormorant Garamond', Georgia, serif;
109      font-size: 108px;
110      font-weight: 300;
111      font-style: italic;
112      color: #1a1a1a;
113      letter-spacing: -0.03em;
114      line-height: 0.95;
115    }
116
117    .tagline {
118      font-family: 'Cormorant Garamond', Georgia, serif;
119      font-size: 34px;
120      font-weight: 400;
121      font-style: italic;
122      color: #3a3a3a;
123      line-height: 1.3;
124      max-width: 480px;
125    }
126
127    .bottom {
128      display: flex;
129      flex-direction: column;
130      gap: 14px;
131    }
132
133    .features {
134      display: flex;
135      align-items: center;
136      gap: 14px;
137      font-family: 'Space Grotesk', monospace;
138      font-size: 19px;
139      font-weight: 500;
140      color: #1a1a1a;
141      letter-spacing: 0.005em;
142    }
143
144    .feature-sep {
145      color: #c83278;
146      font-weight: 400;
147      font-size: 22px;
148      line-height: 1;
149    }
150
151    .url {
152      font-family: 'Space Grotesk', monospace;
153      font-size: 16px;
154      color: #999;
155      letter-spacing: 0.02em;
156    }
157
158    /* Extension panel — floating product shot, anchored right-center */
159    .panel {
160      position: absolute;
161      right: 54px;
162      top: 50%;
163      width: 500px;
164      transform: translateY(-50%) rotate(-2deg);
165      border-radius: 14px;
166      overflow: hidden;
167      box-shadow:
168        0 1px 2px rgba(0, 0, 0, 0.05),
169        0 6px 14px rgba(0, 0, 0, 0.06),
170        0 24px 48px -10px rgba(30, 15, 25, 0.18),
171        0 48px 100px -20px rgba(200, 50, 120, 0.14);
172      z-index: 1;
173    }
174
175    .panel::after {
176      content: '';
177      position: absolute;
178      inset: 0;
179      border-radius: 14px;
180      border: 1px solid rgba(0, 0, 0, 0.08);
181      pointer-events: none;
182    }
183
184    .panel img {
185      display: block;
186      width: 100%;
187      height: auto;
188    }
189  </style>
190</head>
191<body>
192  <div class="container">
193    <div class="content">
194      <div class="top">
195        <div class="title">Impeccable</div>
196        <div class="tagline">Design fluency for AI harnesses</div>
197      </div>
198      <div class="bottom">
199        <div class="features">
200          <span>${commands} commands</span>
201          <span class="feature-sep">·</span>
202          <span>Chrome extension</span>
203          <span class="feature-sep">·</span>
204          <span>CLI</span>
205        </div>
206        <div class="url">impeccable.style</div>
207      </div>
208    </div>
209    <div class="panel">
210      <img src="${extensionDataUrl}" alt="Impeccable Chrome extension detection panel">
211    </div>
212  </div>
213</body>
214</html>`;
215
216  const browser = await chromium.launch();
217  const page = await browser.newPage({
218    viewport: { width: 1200, height: 630 },
219    deviceScaleFactor: 1,
220  });
221
222  await page.setContent(html, { waitUntil: 'networkidle' });
223
224  // Wait for fonts to load
225  await page.evaluate(() => document.fonts.ready);
226
227  await page.screenshot({
228    path: OUTPUT_PATH,
229    type: 'jpeg',
230    quality: 90,
231  });
232
233  await browser.close();
234
235  const size = (fs.statSync(OUTPUT_PATH).size / 1024).toFixed(0);
236  console.log(`Generated ${OUTPUT_PATH} (${size} KB)`);
237}
238
239generateOgImage().catch((err) => {
240  console.error('Failed to generate OG image:', err);
241  process.exit(1);
242});