#!/usr/bin/env node /** * og-gen — generate a 1200x630 OG image for a blog post. * * Usage: * node scripts/og-gen/generate.mjs * * Reads the post's frontmatter from src/content/posts/.md (title, * category, tags), fills the HTML template, and renders it to * public/og/.png via headless Chrome. * * Terminal content (the red "error" line + green "fix" line) is looked up in * TERMINALS below by slug; fall back to a generic default if absent. * * Requirements: Google Chrome installed at the default Windows path. */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..', '..'); const slug = process.argv[2]; if (!slug) { console.error('Usage: node scripts/og-gen/generate.mjs '); process.exit(1); } // ---------- per-post terminal content (tune per post) ---------- // Each entry renders inside the terminal box. Use these tokens: // {cmd} → a normal command line // {err} → a red error line // {fix} → a green fix line const TERMINALS = { 'why-telegram-bot-notifications-die': `
$docker exec monitor python check_notify.py
 telegram sendPhoto failed: 400 nginx/1.30.1
$fix = IPv6 · DNS · multipart→ delivered ✓
`, 'hello-world': `
$git init hoelee-blog · first commit
 technical writing · self-hosting · build log
$about → blog.hoelee.com
`, 'how-i-host-this-blog': `
$git push origin main
 Gitea Actions → unRaid runner → nginx → Cloudflare ✓
$git-as-CMS · zero-downtime deploy
`, 'how-i-built-the-digikedai-telegram-bot': `
$user:"does this course have a free trial?"
 bot → yes, here's your account ✓ (24/7, no human)
$grammY · LiteLLM · Cloudflare tunnel
`, 'authentik-major-upgrade-gotchas': `
$docker compose pull authentik-worker
 authorization_flow not found · SSO broken
$uptime: 2025.8 → 2026.8fixed ✓
`, 'why-your-headless-browser-cant-scrape-everything': `
$browserless → goofish search
 非法访问 · product list stuck "loading…"
$carousell: server-rendered JSON→ parsed ✓
`, 'the-nocodb-attachment-that-wouldnt-update': `
$PATCH image path → 300 rows backfill
 path unchanged · keep the id, keep the old URL
$strip id · replace() suffix→ updated ✓
`, 'how-to-verify-a-hosting-provider-before-you-buy': `
$curl -s rdap.org/domain/vps.tld | jq .events
 registration: 2026-05 · "trusted since 2012"
$AUP grep tor · reviews · retention→ verdict ✓
`, 'how-i-vetted-20-vps-providers-with-parallel-subagents': `
$fan-out → 3 subagents × 20 providers
 whois · AUP · reviews · retention — in parallel
$merge scorecard · rank · audit trail→ verdict ✓
`, 'automating-cyberpanel-without-the-ui': `
$POST /api/verifyConnection
 404 · API prefix dropped in v2
$csrftoken → /verifyLogin → fetchWebsitesList→ sites ✓
`, 'hardening-a-tor-onion-service': `
$wget -qO- ipv4.icanhazip.com
 bind: permission denied · CapEff=0 on :80
$internal:true · :8080 · SocksPort 0→ zero egress ✓
`, 'syncing-a-self-improving-ai-agent-across-machines': `
$git pull --ff-only origin main
 refusing: local divergence · never --force
$diff -rq live skills · merge on conflict→ synced ✓
`, 'best-ai-video-generators-2026': `
$video_gen --free --compare --2026
 "free" = 4 deals · credits ≠ seconds · Sora 2 sunset 09-24
$fix = LTX-2.3 test embed · pricing checked 2026-09-11→ shipped ✓
`, 'ai-furniture-compositing-with-flux-kontext': `
$fetch fal-ai/flux-pro/kontext/multi · 2 photos in
 toDataURL: tainted canvas · may not be exported
$enhance_prompt:false · same-origin paths→ 1 room out ✓
`, 'how-i-made-my-own-songs-with-suno-ai': `
$suno "indie folk · playful whistles · sprite-girl vocals"
 lyrics + metatags → 7 songs shipped ✓
$v6: section edits · single-line swaps · voices
`, }; const DEFAULT_TERMINAL = `
$engineering · devops · self-hosting
 read the full post →
`; // ---------- read frontmatter from the post ---------- const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); if (!existsSync(postPath)) { console.error(`Post not found: ${postPath}`); process.exit(1); } const raw = readFileSync(postPath, 'utf8'); const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; const get = (key) => { const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'm')); if (!m) return undefined; let v = m[1].trim(); // strip quotes if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1); return v; }; const title = get('title') || slug.replace(/-/g, ' '); const category = get('category') || 'devops'; const tagsRaw = get('tags') || '[]'; let tags = []; // tags is a YAML flow array: [docker, telegram, "cloudflare"] const stripped = tagsRaw.replace(/^\[|\]$/g, ''); tags = stripped .split(',') .map((s) => s.trim().replace(/^["']|["']$/g, '')) .filter(Boolean); // ---------- build template substitutions ---------- // split the long title into two balanced lines for readability const words = title.split(' '); const mid = Math.ceil(words.length / 2); const line1 = words.slice(0, mid).join(' '); const line2 = words.slice(mid).join(' '); // escape HTML const esc = (s) => s.replace(/&/g, '&').replace(//g, '>'); // title size: short titles can be larger const titleLen = title.length; const titleSize = titleLen > 60 ? 40 : titleLen > 40 ? 46 : 52; const titleHtml = `${esc(line1)}
${esc(line2)}`; const terminalHtml = TERMINALS[slug] || DEFAULT_TERMINAL; const tagsHtml = tags.map((t) => `#${esc(t)}`).join(''); const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/'); let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8'); tpl = tpl .replace('{{TITLE_SIZE}}', String(titleSize)) .replace('{{LOGO_PATH}}', logoPath) .replace('{{CATEGORY}}', esc(category)) .replace('{{KIND}}', 'DevOps') .replace('{{TITLE_HTML}}', titleHtml) .replace('{{TERMINAL_HTML}}', terminalHtml) .replace('{{TAGS_HTML}}', tagsHtml); // write temp html mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true }); const htmlPath = join(ROOT, '.og', 'gen', `${slug}.html`); writeFileSync(htmlPath, tpl); // render with headless Chrome const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; const outPng = join(ROOT, '.og', 'gen', `${slug}.png`); try { execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1200,630 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' }); } catch (e) { console.error('Chrome render failed:', e.message); process.exit(1); } // move to public/og/ const publicDir = join(ROOT, 'public', 'og'); mkdirSync(publicDir, { recursive: true }); const finalPng = join(publicDir, `${slug}.png`); execSync(`copy /Y "${outPng}" "${finalPng}"`, { stdio: 'pipe' }); console.log(`✓ OG image generated: public/og/${slug}.png`); console.log(` title: ${title}`); console.log(` category: ${category} | tags: ${tags.join(', ')}`);