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, '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 user-invocable, non-deprecated skills from source/skills/
 31// (In v2.0, commands and skills were unified — every command is a skill.)
 32function getCommandCount() {
 33  const skillsDir = path.join(ROOT_DIR, 'source', 'skills');
 34  if (!fs.existsSync(skillsDir)) return 0;
 35
 36  let count = 0;
 37  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
 38    if (!entry.isDirectory()) continue;
 39    const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
 40    if (!fs.existsSync(skillFile)) continue;
 41
 42    const content = fs.readFileSync(skillFile, 'utf8');
 43    const fm = content.match(/^---\n([\s\S]*?)\n---/);
 44    if (!fm) continue;
 45
 46    const frontmatter = fm[1];
 47    const isUserInvocable = /^user-invocable:\s*true\s*$/m.test(frontmatter);
 48    const isDeprecated = /^description:\s*["']?DEPRECATED/mi.test(frontmatter);
 49
 50    if (isUserInvocable && !isDeprecated) count++;
 51  }
 52  return count;
 53}
 54
 55// Load extension screenshot as base64 data URL so setContent is self-contained
 56function getExtensionDataUrl() {
 57  const buf = fs.readFileSync(EXTENSION_IMAGE_PATH);
 58  return `data:image/png;base64,${buf.toString('base64')}`;
 59}
 60
 61async function generateOgImage() {
 62  const commands = getCommandCount();
 63  const extensionDataUrl = getExtensionDataUrl();
 64  console.log(`Detected ${commands} command(s)`);
 65
 66  const html = `<!DOCTYPE html>
 67<html>
 68<head>
 69  <meta charset="UTF-8">
 70  <link rel="preconnect" href="https://fonts.googleapis.com">
 71  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 72  <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">
 73  <style>
 74    * { margin: 0; padding: 0; box-sizing: border-box; }
 75
 76    body {
 77      width: 1200px;
 78      height: 630px;
 79      overflow: hidden;
 80      background: #f5f2ee;
 81      position: relative;
 82      font-family: 'Instrument Sans', system-ui, sans-serif;
 83      -webkit-font-smoothing: antialiased;
 84      color: #1a1a1a;
 85    }
 86
 87    /* Soft brand glow for depth — subtle magenta accents in opposite corners */
 88    body::before {
 89      content: '';
 90      position: absolute;
 91      inset: 0;
 92      background:
 93        radial-gradient(circle at 15% 8%, rgba(200, 50, 120, 0.07) 0%, transparent 55%),
 94        radial-gradient(circle at 90% 95%, rgba(200, 50, 120, 0.05) 0%, transparent 60%);
 95      pointer-events: none;
 96    }
 97
 98    .container {
 99      position: relative;
100      width: 100%;
101      height: 100%;
102      padding: 72px 80px;
103    }
104
105    .content {
106      position: relative;
107      display: flex;
108      flex-direction: column;
109      justify-content: space-between;
110      height: 100%;
111      max-width: 560px;
112      z-index: 2;
113    }
114
115    .top {
116      display: flex;
117      flex-direction: column;
118      gap: 22px;
119    }
120
121    .title {
122      font-family: 'Cormorant Garamond', Georgia, serif;
123      font-size: 108px;
124      font-weight: 300;
125      font-style: italic;
126      color: #1a1a1a;
127      letter-spacing: -0.03em;
128      line-height: 0.95;
129    }
130
131    .tagline {
132      font-family: 'Cormorant Garamond', Georgia, serif;
133      font-size: 34px;
134      font-weight: 400;
135      font-style: italic;
136      color: #3a3a3a;
137      line-height: 1.3;
138      max-width: 480px;
139    }
140
141    .bottom {
142      display: flex;
143      flex-direction: column;
144      gap: 14px;
145    }
146
147    .features {
148      display: flex;
149      align-items: center;
150      gap: 14px;
151      font-family: 'Space Grotesk', monospace;
152      font-size: 19px;
153      font-weight: 500;
154      color: #1a1a1a;
155      letter-spacing: 0.005em;
156    }
157
158    .feature-sep {
159      color: #c83278;
160      font-weight: 400;
161      font-size: 22px;
162      line-height: 1;
163    }
164
165    .url {
166      font-family: 'Space Grotesk', monospace;
167      font-size: 16px;
168      color: #999;
169      letter-spacing: 0.02em;
170    }
171
172    /* Extension panel — floating product shot, anchored right-center */
173    .panel {
174      position: absolute;
175      right: 54px;
176      top: 50%;
177      width: 500px;
178      transform: translateY(-50%) rotate(-2deg);
179      border-radius: 14px;
180      overflow: hidden;
181      box-shadow:
182        0 1px 2px rgba(0, 0, 0, 0.05),
183        0 6px 14px rgba(0, 0, 0, 0.06),
184        0 24px 48px -10px rgba(30, 15, 25, 0.18),
185        0 48px 100px -20px rgba(200, 50, 120, 0.14);
186      z-index: 1;
187    }
188
189    .panel::after {
190      content: '';
191      position: absolute;
192      inset: 0;
193      border-radius: 14px;
194      border: 1px solid rgba(0, 0, 0, 0.08);
195      pointer-events: none;
196    }
197
198    .panel img {
199      display: block;
200      width: 100%;
201      height: auto;
202    }
203  </style>
204</head>
205<body>
206  <div class="container">
207    <div class="content">
208      <div class="top">
209        <div class="title">Impeccable</div>
210        <div class="tagline">Design fluency for AI harnesses</div>
211      </div>
212      <div class="bottom">
213        <div class="features">
214          <span>${commands} commands</span>
215          <span class="feature-sep">·</span>
216          <span>Chrome extension</span>
217          <span class="feature-sep">·</span>
218          <span>CLI</span>
219        </div>
220        <div class="url">impeccable.style</div>
221      </div>
222    </div>
223    <div class="panel">
224      <img src="${extensionDataUrl}" alt="Impeccable Chrome extension detection panel">
225    </div>
226  </div>
227</body>
228</html>`;
229
230  const browser = await chromium.launch();
231  const page = await browser.newPage({
232    viewport: { width: 1200, height: 630 },
233    deviceScaleFactor: 1,
234  });
235
236  await page.setContent(html, { waitUntil: 'networkidle' });
237
238  // Wait for fonts to load
239  await page.evaluate(() => document.fonts.ready);
240
241  await page.screenshot({
242    path: OUTPUT_PATH,
243    type: 'jpeg',
244    quality: 90,
245  });
246
247  await browser.close();
248
249  const size = (fs.statSync(OUTPUT_PATH).size / 1024).toFixed(0);
250  console.log(`Generated ${OUTPUT_PATH} (${size} KB)`);
251}
252
253generateOgImage().catch((err) => {
254  console.error('Failed to generate OG image:', err);
255  process.exit(1);
256});