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