Replace static OG image with auto-generated JPEG using site fonts

Add scripts/generate-og-image.js that renders the OG image with Playwright
using the actual site fonts (Cormorant Garamond, Instrument Sans, Space
Grotesk) and dynamically counts skills/commands from source/. Switch from
PNG to JPEG (512 KB → 25 KB). Run with `bun run og-image`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-04 09:52:14 -08:00
co-authored by Claude Opus 4.6
parent 98f5d839fd
commit a94e237359
7 changed files with 218 additions and 36 deletions
+2 -1
View File
@@ -24,7 +24,8 @@
"dev": "bun run server/index.js",
"start": "bun --production run server/index.js",
"test": "bun test",
"screenshot": "bun run scripts/screenshot-antipatterns.js"
"screenshot": "bun run scripts/screenshot-antipatterns.js",
"og-image": "bun run scripts/generate-og-image.js"
},
"type": "module"
}
+2 -2
View File
@@ -13,7 +13,7 @@
<meta property="og:url" content="https://impeccable.style">
<meta property="og:title" content="Impeccable: Design skills for AI coding tools">
<meta property="og:description" content="1 skill, 17 commands, and curated anti-patterns for impeccable frontend design. Works with Cursor, Claude Code, Gemini CLI, and Codex CLI.">
<meta property="og:image" content="https://impeccable.style/og-image.png">
<meta property="og:image" content="https://impeccable.style/og-image.jpg">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
@@ -21,7 +21,7 @@
<meta name="twitter:creator" content="@pbakaus">
<meta name="twitter:title" content="Impeccable: Design skills for AI coding tools">
<meta name="twitter:description" content="1 skill, 17 commands, and curated anti-patterns for impeccable frontend design.">
<meta name="twitter:image" content="https://impeccable.style/og-image.png">
<meta name="twitter:image" content="https://impeccable.style/og-image.jpg">
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f5f3ef"/>
<stop offset="100%" style="stop-color:#e8e4df"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg)"/>
<!-- Decorative slash -->
<text x="950" y="480" font-family="system-ui, -apple-system, sans-serif" font-size="400" font-weight="300" fill="#1a1a1a" opacity="0.06">/</text>
<!-- Main title -->
<text x="80" y="280" font-family="Georgia, 'Times New Roman', serif" font-size="96" font-weight="400" fill="#1a1a1a">Impeccable</text>
<!-- Tagline -->
<text x="80" y="360" font-family="system-ui, -apple-system, sans-serif" font-size="32" fill="#6b7280">Design skills for AI coding tools</text>
<!-- Stats -->
<text x="80" y="460" font-family="ui-monospace, monospace" font-size="24" fill="#1a1a1a">
<tspan fill="#1a1a1a" font-weight="500">1</tspan><tspan fill="#6b7280"> skill</tspan>
<tspan fill="#6b7280" dx="20">+</tspan>
<tspan fill="#1a1a1a" font-weight="500" dx="20">17</tspan><tspan fill="#6b7280"> commands</tspan>
<tspan fill="#6b7280" dx="20">+</tspan>
<tspan fill="#6b7280" dx="20">anti-patterns</tspan>
</text>
<!-- URL -->
<text x="80" y="560" font-family="ui-monospace, monospace" font-size="20" fill="#9ca3af">impeccable.style</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -137,7 +137,7 @@ async function build() {
await buildStaticSite();
// Copy root-level static assets that need stable (unhashed) URLs
const staticAssets = ['og-image.png', 'robots.txt', 'sitemap.xml', 'favicon.svg', 'apple-touch-icon.png'];
const staticAssets = ['og-image.jpg', 'robots.txt', 'sitemap.xml', 'favicon.svg', 'apple-touch-icon.png'];
const buildDir = path.join(ROOT_DIR, 'build');
for (const asset of staticAssets) {
const src = path.join(ROOT_DIR, 'public', asset);
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Generate OG Image
*
* Renders the OG image using Playwright with proper Google Fonts.
* Counts skills and commands dynamically from the source/ directory.
*
* Usage: bun run og-image
*/
import { chromium } from 'playwright';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
const OUTPUT_PATH = path.join(ROOT_DIR, 'public', 'og-image.jpg');
// Count skills and commands from source directory
function getCounts() {
const skillsDir = path.join(ROOT_DIR, 'source', 'skills');
const commandsDir = path.join(ROOT_DIR, 'source', 'commands');
const skills = fs.existsSync(skillsDir)
? fs.readdirSync(skillsDir).filter(f => fs.statSync(path.join(skillsDir, f)).isDirectory()).length
: 0;
const commands = fs.existsSync(commandsDir)
? fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')).length
: 0;
return { skills, commands };
}
async function generateOgImage() {
const { skills, commands } = getCounts();
console.log(`Detected ${skills} skill(s), ${commands} command(s)`);
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;600;700&family=Instrument+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
overflow: hidden;
background: #f5f2ee;
position: relative;
}
/* Subtle noise texture via radial gradient */
body::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 30% 20%, rgba(200, 50, 120, 0.04) 0%, transparent 60%),
radial-gradient(ellipse at 80% 80%, rgba(200, 50, 120, 0.03) 0%, transparent 50%);
}
.container {
position: relative;
width: 100%;
height: 100%;
padding: 72px 80px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* Decorative slash */
.slash {
position: absolute;
right: 60px;
bottom: -40px;
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 500px;
font-weight: 300;
color: rgba(0, 0, 0, 0.04);
line-height: 1;
user-select: none;
}
.top {
display: flex;
flex-direction: column;
gap: 16px;
}
.title {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 108px;
font-weight: 400;
color: #1a1a1a;
letter-spacing: -0.02em;
line-height: 1;
}
.tagline {
font-family: 'Instrument Sans', system-ui, sans-serif;
font-size: 32px;
font-weight: 400;
color: #888;
margin-top: 4px;
}
.bottom {
display: flex;
flex-direction: column;
gap: 20px;
}
.stats {
display: flex;
align-items: center;
gap: 24px;
font-family: 'Space Grotesk', monospace;
font-size: 22px;
}
.stat {
display: flex;
align-items: center;
gap: 6px;
}
.stat-number {
font-weight: 600;
color: #1a1a1a;
}
.stat-label {
color: #888;
font-weight: 400;
}
.stat-sep {
color: #ccc;
font-size: 18px;
}
.url {
font-family: 'Space Grotesk', monospace;
font-size: 18px;
color: #aaa;
letter-spacing: 0.02em;
}
</style>
</head>
<body>
<div class="slash">/</div>
<div class="container">
<div class="top">
<div class="title">Impeccable</div>
<div class="tagline">Design fluency for AI coding tools</div>
</div>
<div class="bottom">
<div class="stats">
<div class="stat">
<span class="stat-number">${skills}</span>
<span class="stat-label">${skills === 1 ? 'skill' : 'skills'}</span>
</div>
<span class="stat-sep">+</span>
<div class="stat">
<span class="stat-number">${commands}</span>
<span class="stat-label">${commands === 1 ? 'command' : 'commands'}</span>
</div>
<span class="stat-sep">+</span>
<div class="stat">
<span class="stat-label">anti-patterns</span>
</div>
</div>
<div class="url">impeccable.style</div>
</div>
</div>
</body>
</html>`;
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
});
await page.setContent(html, { waitUntil: 'networkidle' });
// Wait for fonts to load
await page.evaluate(() => document.fonts.ready);
await page.screenshot({
path: OUTPUT_PATH,
type: 'jpeg',
quality: 85,
});
await browser.close();
const size = (fs.statSync(OUTPUT_PATH).size / 1024).toFixed(0);
console.log(`Generated ${OUTPUT_PATH} (${size} KB)`);
}
generateOgImage().catch(err => {
console.error('Failed to generate OG image:', err);
process.exit(1);
});