-
-
Save deepakness/eab8d275b51418f2c093151dff6d16b4 to your computer and use it in GitHub Desktop.
Script to generate OG images for my 11ty blog.
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
| /** | |
| * Generate OG Images using Satori | |
| * This script generates 1200x630 JPEG images: | |
| * - Co-located with blog posts and raw notes (og.jpeg in post folder) | |
| * - In public/img/og/ for standalone pages ([slug].jpeg) | |
| * Skips posts/pages that already have og.png, og.jpeg, or og.jpg | |
| */ | |
| import fs from 'fs'; | |
| import path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| import satori from 'satori'; | |
| import { Resvg } from '@resvg/resvg-js'; | |
| import sharp from 'sharp'; | |
| import crypto from 'crypto'; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| const rootDir = path.join(__dirname, '..'); | |
| // Configuration | |
| const CACHE_FILE = path.join(rootDir, '.og-cache.json'); | |
| const BLOG_DIR = path.join(rootDir, 'content', 'blog'); | |
| const RAW_DIR = path.join(rootDir, 'content', 'raw'); | |
| const CONTENT_DIR = path.join(rootDir, 'content'); | |
| const PAGES_OG_DIR = path.join(rootDir, 'public', 'img', 'og'); | |
| const PHOTO_PATH = path.join(rootDir, 'public', 'img', 'deepakness-new.jpg'); | |
| // Pages to skip (feeds, sitemaps, error pages, data files) | |
| const SKIP_PAGES = [ | |
| 'index.njk', // Homepage handled separately | |
| 'feed.md', | |
| 'sitemap.xml.njk', | |
| 'search-index.njk', | |
| 'raw-notes.xml.njk', | |
| '404.md', | |
| 'webmention.md', | |
| ]; | |
| // Homepage config (title/description from _includes/layouts/home.njk) | |
| const HOMEPAGE = { | |
| slug: 'home', | |
| title: 'An Internet Generalist', | |
| description: 'An internet generalist exploring the edges of AI, tech, marketing, and more.', | |
| layoutPath: path.join(rootDir, '_includes', 'layouts', 'home.njk'), | |
| }; | |
| // Load fonts | |
| const fontRegular = fs.readFileSync(path.join(rootDir, 'public', 'fonts', 'HelveticaNeueRoman.otf')); | |
| const fontBold = fs.readFileSync(path.join(rootDir, 'public', 'fonts', 'HelveticaNeueBold.otf')); | |
| // Load and encode photo as base64 | |
| const photoBuffer = fs.readFileSync(PHOTO_PATH); | |
| const photoBase64 = `data:image/jpeg;base64,${photoBuffer.toString('base64')}`; | |
| // Load or create cache | |
| function loadCache() { | |
| try { | |
| if (fs.existsSync(CACHE_FILE)) { | |
| return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')); | |
| } | |
| } catch (e) { | |
| console.log('Cache file not found or invalid, starting fresh'); | |
| } | |
| return {}; | |
| } | |
| function saveCache(cache) { | |
| fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2)); | |
| } | |
| // Generate MD5 hash for content | |
| function generateHash(content) { | |
| return crypto.createHash('md5').update(content).digest('hex'); | |
| } | |
| // Check if folder already has an OG image | |
| function hasExistingOgImage(folderPath) { | |
| const ogFiles = ['og.png', 'og.jpeg', 'og.jpg']; | |
| return ogFiles.some(file => fs.existsSync(path.join(folderPath, file))); | |
| } | |
| // Check if post has image frontmatter (custom OG image) | |
| function hasImageFrontmatter(frontmatter) { | |
| return frontmatter && frontmatter.image && frontmatter.image.length > 0; | |
| } | |
| // Parse frontmatter from markdown file | |
| function parseFrontmatter(content) { | |
| const frontmatterRegex = /^---\n([\s\S]*?)\n---/; | |
| const match = content.match(frontmatterRegex); | |
| if (!match) return null; | |
| const frontmatter = {}; | |
| const lines = match[1].split('\n'); | |
| let currentKey = null; | |
| for (const line of lines) { | |
| // Check for array items | |
| if (line.startsWith('- ') && currentKey) { | |
| if (!Array.isArray(frontmatter[currentKey])) { | |
| frontmatter[currentKey] = []; | |
| } | |
| frontmatter[currentKey].push(line.slice(2).trim()); | |
| continue; | |
| } | |
| // Check for key-value pairs | |
| const colonIndex = line.indexOf(':'); | |
| if (colonIndex > 0) { | |
| const key = line.slice(0, colonIndex).trim(); | |
| let value = line.slice(colonIndex + 1).trim(); | |
| // Remove quotes if present | |
| if ((value.startsWith('"') && value.endsWith('"')) || | |
| (value.startsWith("'") && value.endsWith("'"))) { | |
| value = value.slice(1, -1); | |
| } | |
| currentKey = key; | |
| frontmatter[key] = value || null; | |
| } | |
| } | |
| return frontmatter; | |
| } | |
| // Get all posts from a directory | |
| function getPosts(directory, collection) { | |
| const posts = []; | |
| if (!fs.existsSync(directory)) { | |
| return posts; | |
| } | |
| const items = fs.readdirSync(directory, { withFileTypes: true }); | |
| for (const item of items) { | |
| if (item.isDirectory()) { | |
| const folderPath = path.join(directory, item.name); | |
| const indexPath = path.join(folderPath, 'index.md'); | |
| if (fs.existsSync(indexPath)) { | |
| const content = fs.readFileSync(indexPath, 'utf-8'); | |
| const frontmatter = parseFrontmatter(content); | |
| if (frontmatter) { | |
| posts.push({ | |
| slug: item.name, | |
| folderPath, | |
| collection, | |
| title: frontmatter.title || item.name, | |
| description: frontmatter.description || '', | |
| hasExistingOg: hasExistingOgImage(folderPath), | |
| hasCustomImage: hasImageFrontmatter(frontmatter), | |
| contentHash: generateHash(frontmatter.title + (frontmatter.description || '')) | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| return posts; | |
| } | |
| // Get homepage as a special case | |
| function getHomepage() { | |
| const ogPath = path.join(PAGES_OG_DIR, `${HOMEPAGE.slug}.jpeg`); | |
| const hasExistingOg = fs.existsSync(ogPath); | |
| // Check if layout already has image frontmatter | |
| let hasCustomImage = false; | |
| if (fs.existsSync(HOMEPAGE.layoutPath)) { | |
| const content = fs.readFileSync(HOMEPAGE.layoutPath, 'utf-8'); | |
| const frontmatter = parseFrontmatter(content); | |
| hasCustomImage = hasImageFrontmatter(frontmatter); | |
| } | |
| return { | |
| slug: HOMEPAGE.slug, | |
| filePath: HOMEPAGE.layoutPath, | |
| collection: 'pages', | |
| title: HOMEPAGE.title, | |
| description: HOMEPAGE.description, | |
| hasExistingOg, | |
| hasCustomImage, | |
| contentHash: generateHash(HOMEPAGE.title + HOMEPAGE.description) | |
| }; | |
| } | |
| // Get standalone pages from content directory | |
| function getPages() { | |
| const pages = []; | |
| if (!fs.existsSync(CONTENT_DIR)) { | |
| return pages; | |
| } | |
| const items = fs.readdirSync(CONTENT_DIR, { withFileTypes: true }); | |
| for (const item of items) { | |
| // Only process files (not directories) | |
| if (!item.isFile()) continue; | |
| const fileName = item.name; | |
| // Skip files in SKIP_PAGES list | |
| if (SKIP_PAGES.includes(fileName)) continue; | |
| // Only process .njk and .md files | |
| if (!fileName.endsWith('.njk') && !fileName.endsWith('.md')) continue; | |
| const filePath = path.join(CONTENT_DIR, fileName); | |
| const content = fs.readFileSync(filePath, 'utf-8'); | |
| const frontmatter = parseFrontmatter(content); | |
| // Skip files without frontmatter or without title | |
| if (!frontmatter || !frontmatter.title) continue; | |
| // Get slug from filename (remove extension) | |
| const slug = fileName.replace(/\.(njk|md)$/, ''); | |
| // Check if OG image already exists in public/img/og/ | |
| const ogPath = path.join(PAGES_OG_DIR, `${slug}.jpeg`); | |
| const hasExistingOg = fs.existsSync(ogPath); | |
| pages.push({ | |
| slug, | |
| filePath, | |
| collection: 'pages', | |
| title: frontmatter.title, | |
| description: frontmatter.description || '', | |
| hasExistingOg, | |
| hasCustomImage: hasImageFrontmatter(frontmatter), | |
| contentHash: generateHash(frontmatter.title + (frontmatter.description || '')) | |
| }); | |
| } | |
| return pages; | |
| } | |
| // Simple wireframe globe icon (circle + meridian + equator) | |
| // Accent color | |
| const accentColor = '#3364ff'; | |
| // Generate OG image SVG using Satori | |
| async function generateOgSvg(title, description) { | |
| // Use full title and description | |
| const displayTitle = title; | |
| const displayDesc = description; | |
| const svg = await satori( | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| height: '100%', | |
| width: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| backgroundColor: '#ffffff', | |
| fontFamily: 'Helvetica Neue', | |
| position: 'relative', | |
| }, | |
| children: [ | |
| // Background grid pattern overlay | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| position: 'absolute', | |
| top: 0, | |
| left: 0, | |
| right: 0, | |
| bottom: 0, | |
| opacity: 0.05, | |
| backgroundImage: `linear-gradient(to right, ${accentColor} 1px, transparent 1px), linear-gradient(to bottom, ${accentColor} 1px, transparent 1px)`, | |
| backgroundSize: '40px 40px', | |
| }, | |
| }, | |
| }, | |
| // Blue accent bar at top | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| width: '100%', | |
| height: '12px', | |
| backgroundColor: accentColor, | |
| }, | |
| }, | |
| }, | |
| // Content area | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| display: 'flex', | |
| flexDirection: 'column', | |
| justifyContent: 'space-between', | |
| flex: 1, | |
| padding: '80px 128px', | |
| }, | |
| children: [ | |
| // Main content - Title and description centered | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| display: 'flex', | |
| flexDirection: 'column', | |
| justifyContent: 'center', | |
| flex: 1, | |
| }, | |
| children: [ | |
| // Title | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| fontSize: '64px', | |
| fontWeight: 700, | |
| color: '#0f172a', | |
| lineHeight: 1.1, | |
| letterSpacing: '-2px', | |
| marginBottom: '32px', | |
| }, | |
| children: displayTitle, | |
| }, | |
| }, | |
| // Description | |
| description ? { | |
| type: 'div', | |
| props: { | |
| style: { | |
| fontSize: '32px', | |
| fontWeight: 500, | |
| color: '#64748b', | |
| lineHeight: 1.4, | |
| }, | |
| children: displayDesc, | |
| }, | |
| } : null, | |
| ].filter(Boolean), | |
| }, | |
| }, | |
| // Footer with author and URL | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| borderTop: '1px solid #f1f5f9', | |
| paddingTop: '40px', | |
| marginTop: '40px', | |
| }, | |
| children: [ | |
| // Author section | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '24px', | |
| }, | |
| children: [ | |
| // Photo container with accent dot | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| position: 'relative', | |
| display: 'flex', | |
| }, | |
| children: [ | |
| // Photo with border | |
| { | |
| type: 'img', | |
| props: { | |
| src: photoBase64, | |
| width: 80, | |
| height: 80, | |
| style: { | |
| borderRadius: '50%', | |
| objectFit: 'cover', | |
| border: '4px solid #ffffff', | |
| boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', | |
| }, | |
| }, | |
| }, | |
| // Accent dot | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| position: 'absolute', | |
| bottom: '4px', | |
| right: '4px', | |
| width: '20px', | |
| height: '20px', | |
| borderRadius: '50%', | |
| backgroundColor: accentColor, | |
| border: '3px solid #ffffff', | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| // Name only | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| fontSize: '28px', | |
| fontWeight: 700, | |
| color: '#1e293b', | |
| }, | |
| children: 'DeepakNess', | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| // Website URL with globe icon | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '12px', | |
| }, | |
| children: [ | |
| // Simple wireframe globe icon | |
| { | |
| type: 'svg', | |
| props: { | |
| width: 32, | |
| height: 32, | |
| viewBox: '0 0 24 24', | |
| fill: 'none', | |
| children: [ | |
| // Outer circle | |
| { | |
| type: 'circle', | |
| props: { | |
| cx: 12, | |
| cy: 12, | |
| r: 10, | |
| stroke: '#94a3b8', | |
| strokeWidth: 2, | |
| }, | |
| }, | |
| // Vertical meridian (ellipse) | |
| { | |
| type: 'ellipse', | |
| props: { | |
| cx: 12, | |
| cy: 12, | |
| rx: 4, | |
| ry: 10, | |
| stroke: '#94a3b8', | |
| strokeWidth: 2, | |
| }, | |
| }, | |
| // Horizontal equator | |
| { | |
| type: 'path', | |
| props: { | |
| d: 'M2 12h20', | |
| stroke: '#94a3b8', | |
| strokeWidth: 2, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| { | |
| type: 'div', | |
| props: { | |
| style: { | |
| fontSize: '26px', | |
| fontWeight: 600, | |
| color: '#475569', | |
| letterSpacing: '0.5px', | |
| }, | |
| children: 'deepakness.com', | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| { | |
| width: 1200, | |
| height: 630, | |
| fonts: [ | |
| { | |
| name: 'Helvetica Neue', | |
| data: fontRegular, | |
| weight: 400, | |
| style: 'normal', | |
| }, | |
| { | |
| name: 'Helvetica Neue', | |
| data: fontRegular, | |
| weight: 500, | |
| style: 'normal', | |
| }, | |
| { | |
| name: 'Helvetica Neue', | |
| data: fontBold, | |
| weight: 600, | |
| style: 'normal', | |
| }, | |
| { | |
| name: 'Helvetica Neue', | |
| data: fontBold, | |
| weight: 700, | |
| style: 'normal', | |
| }, | |
| ], | |
| } | |
| ); | |
| return svg; | |
| } | |
| // Convert SVG to JPEG using resvg and sharp | |
| async function svgToJpeg(svg) { | |
| const resvg = new Resvg(svg, { | |
| fitTo: { | |
| mode: 'width', | |
| value: 1200, | |
| }, | |
| }); | |
| const pngData = resvg.render(); | |
| const pngBuffer = pngData.asPng(); | |
| // Convert PNG to JPEG using sharp | |
| const jpegBuffer = await sharp(pngBuffer) | |
| .jpeg({ quality: 90 }) | |
| .toBuffer(); | |
| return jpegBuffer; | |
| } | |
| // Main function | |
| async function main() { | |
| console.log('Starting OG image generation...\n'); | |
| // Ensure pages OG directory exists | |
| if (!fs.existsSync(PAGES_OG_DIR)) { | |
| fs.mkdirSync(PAGES_OG_DIR, { recursive: true }); | |
| } | |
| // Load cache | |
| const cache = loadCache(); | |
| // Get all posts, pages, and homepage | |
| const blogPosts = getPosts(BLOG_DIR, 'blog'); | |
| const rawPosts = getPosts(RAW_DIR, 'raw'); | |
| const pages = getPages(); | |
| const homepage = getHomepage(); | |
| const allItems = [...blogPosts, ...rawPosts, ...pages, homepage]; | |
| console.log(`Found ${blogPosts.length} blog posts, ${rawPosts.length} raw notes, and ${pages.length + 1} pages (incl. homepage)\n`); | |
| let generated = 0; | |
| let skipped = 0; | |
| let cached = 0; | |
| for (const item of allItems) { | |
| // Determine output path based on collection type | |
| const outputPath = item.collection === 'pages' | |
| ? path.join(PAGES_OG_DIR, `${item.slug}.jpeg`) | |
| : path.join(item.folderPath, 'og.jpeg'); | |
| const cacheKey = `${item.collection}-${item.slug}`; | |
| // Skip items that already have an OG image or custom image frontmatter | |
| if (item.hasExistingOg || item.hasCustomImage) { | |
| skipped++; | |
| continue; | |
| } | |
| // Check cache | |
| if (cache[cacheKey] === item.contentHash && fs.existsSync(outputPath)) { | |
| cached++; | |
| continue; | |
| } | |
| try { | |
| // Generate SVG | |
| const svg = await generateOgSvg(item.title, item.description); | |
| // Convert to JPEG | |
| const jpeg = await svgToJpeg(svg); | |
| // Write file | |
| fs.writeFileSync(outputPath, jpeg); | |
| // Update cache | |
| cache[cacheKey] = item.contentHash; | |
| generated++; | |
| if (item.collection === 'pages') { | |
| console.log(`Generated: pages/${item.slug}.jpeg`); | |
| } else { | |
| console.log(`Generated: ${item.collection}/${item.slug}/og.jpeg`); | |
| } | |
| } catch (error) { | |
| console.error(`Error generating ${item.collection}/${item.slug}:`, error.message); | |
| } | |
| } | |
| // Save cache | |
| saveCache(cache); | |
| console.log(`\nOG Image Generation Complete!`); | |
| console.log(` Generated: ${generated}`); | |
| console.log(` Cached: ${cached}`); | |
| console.log(` Skipped (existing og/custom image): ${skipped}`); | |
| } | |
| main().catch(console.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment