This commit is contained in:
1 parent
ef6b54b2f1
commit
7f25f9ae4d
6 files changed
+286
-68
No files matched your search
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* og-gen — generate a 1200x630 OG image for a blog post.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/og-gen/generate.mjs <slug>
|
||||
*
|
||||
* Reads the post's frontmatter from src/content/posts/<slug>.md (title,
|
||||
* category, tags), fills the HTML template, and renders it to
|
||||
* public/og/<slug>.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 <slug>');
|
||||
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': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">docker exec monitor python check_notify.py</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">telegram sendPhoto failed: 400 nginx/1.30.1</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix = IPv6 · DNS · multipart</span><span class="fix">→ delivered ✓</span></div>`,
|
||||
};
|
||||
|
||||
const DEFAULT_TERMINAL = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">engineering · devops · self-hosting</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">read the full post →</span></div>`;
|
||||
|
||||
// ---------- 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, '<').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)}<br>${esc(line2)}`;
|
||||
|
||||
const terminalHtml = TERMINALS[slug] || DEFAULT_TERMINAL;
|
||||
|
||||
const tagsHtml = tags.map((t) => `<span>#${esc(t)}</span>`).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(', ')}`);
|
||||
Reference in new issue
Block a user