#!/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 sharp from 'sharp'; 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 ✓
`, 'using-chinese-llm-apis-from-malaysia': `
$curl tokenrhythm.studio/v1/chat/completions
 403 · mainland CN phone required
$Alipay RMB · OpenAI-compatible key→ ¥68 credit ✓
`, '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
`, 'passbolt-hang-three-failure-modes': `
$curl -I https://pass.hoelee.com
 504 · ERR_TOO_MANY_REDIRECTS · fingerprint null
$mount passbolt.php · full fingerprint→ fixed ✓
`, 'scraping-bot-walled-marketplace-warm-browser-session': `
$CDP → shopee search "used phone" · client marketplace monitor
 /verify captcha · empty product cards
$warm session · 7s pacing · sweep.py→ 20+ listings ✓
`, 'unraid-stop-array-hangs-on-swapfile': `
$unraid → stop array · swapfile on /mnt/cache
 umount: target is busy · /proc/swaps says /dev/loop0
$swapoff -a + losetup -j at stopping_svcs→ clean stop ✓
`, 'when-smart-says-healthy-but-your-raid-is-corrupting-data': `
$scrub → re-read vdisk1.img
 csum 0x8941f998 = CRC32C(zeros) · recurring
$self-heal rewrite does NOT stick→ replace both drives ✓
`, 'self-hosting-mem0-memory-stack': `
$curl -X POST :20015/memories · X-Api-Key
 infer=true → LLM hop · slow write
$infer=false · pgvector · LiteLLM gateway→ remembers across chats ✓
`, 'shipping-an-ai-photo-editor-as-a-wordpress-plugin': `
$wp plugin list · hre-ai-remix 0.0.1 → 0.0.22
 404 …/wp-json/hre/v1admin/photos — rest_url() has no trailing slash
$fix = '/admin/…' · 58 commits · 4 were the AI→ shipped ✓
`, 'n8n-v1-to-v2-upgrade-gotchas': `
$pull n8nio/n8n:2.40.1 · restart · upgrade took 90s
 telemetry schema: executions_data_save_on_error rejected
$read the boot log · pin timeouts + limits→ 7 fixed ✓
`, 'self-healing-digital-goods-entitlements': `
$nocodb → n8n W1–W5 → alist role scopes
 public hostname: 60s latency → nginx 504 → retry storm → 503
$http://nocodb:10380 · 30ms · W4 repairs drift 03:00→ self-healing ✓
`, 'running-tts-as-a-service-with-token-sidecars': `
$reading app → GET /webhook/mtts?pass=…&text=…
 azure token expires in ~10min · google in ~1h
$2 cron sidecars write accesstoken.txt · 570s / 3500s→ 1 year uptime ✓
`, 'the-cause-was-trim-not-the-ssds': `
$btrfs device stats /mnt/ssd
 corruption_errs sdd1=27 sdb1=31 · csum 0x8941f998 = CRC32C(zeros) · both mirrors
$diskAutotrim="off" · remount,nodiscard · scrub→ 0 new errors ✓
`, 'that-dying-ssd-was-just-a-bad-sata-cable': `
$mkfs.btrfs -K -f /dev/sdd1
 WRITE FPDMA QUEUED timeouts · superblock magic doesn't match
$swap SATA cable/port · rerun mkfs→ clean · 0 errors ✓
`, }; TERMINALS['self-hosted-speech-to-text-api'] = `
$whisper-server --host 0.0.0.0 --port 20129 · large-v3 · RTX 3060
 connect ETIMEDOUT 192.168.1.123:20129 — bound to 127.0.0.1 only
$bind 0.0.0.0 · firewall LocalSubnet · n8n key gate→ 130 wpm ✓
`; TERMINALS['replacing-rdpguard-with-ipban'] = `
$ipban --install-service
 Unrecognized command or argument '--install-service'
$sc.exe create IPBAN type= own start= auto binPath= ...→ AUTO_START ✓
`; TERMINALS['patching-workbench-26-for-mariadb'] = `
 TypeError: on_session_message() missing 1 required argument
$select @@gtid_mode · MariaDB 10.11
 ERROR 1193: Unknown system variable 'gtid_mode'
$3 patches · 4 connections→ connected ✓
`; 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, '/'); // the tag chip shows " · "; kind is a human label for the section. // Were KIND to be hardcoded, every non-devops post would carry a wrong label. const KIND_BY_CATEGORY = { engineering: 'Engineering', devops: 'DevOps', ai: 'AI', web3: 'Web3', tutorials: 'Tutorials', 'case-studies': 'Case Study', notes: 'Notes', }; const kind = KIND_BY_CATEGORY[category] || category; let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8'); tpl = tpl .replace('{{TITLE_SIZE}}', String(titleSize)) .replace('{{LOGO_PATH}}', logoPath) .replace('{{CATEGORY}}', esc(category === kind.toLowerCase() ? category : kind)) .replace('{{KIND}}', '') .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,900 --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`); sharp(outPng) .extract({ left: 0, top: 0, width: 1200, height: 630 }) .toFile(finalPng) .then(() => { console.log(`✓ OG image generated: public/og/${slug}.png`); }) .catch((e) => { console.error('crop failed:', e.message); process.exit(1); }); console.log(` title: ${title}`); console.log(` category: ${category} | tags: ${tags.join(', ')}`);