This commit is contained in:
@@ -15,90 +15,56 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 顶部品牌条 */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 34px 48px 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 10px;
|
||||
width: 48px; height: 48px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #ffffff;
|
||||
padding: 4px;
|
||||
background: #ffffff; padding: 4px;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.brand {
|
||||
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.3px;
|
||||
font-weight: 700; font-size: 22px; color: #ffffff; letter-spacing: 0.3px;
|
||||
}
|
||||
.brand .domain { color: #6b7c99; font-weight: 500; font-size: 19px; }
|
||||
|
||||
/* 中间:分类徽章 */
|
||||
.tag {
|
||||
margin: 40px 48px 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
padding: 7px 16px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
width: fit-content; padding: 7px 16px; border-radius: 6px;
|
||||
background: rgba(41, 92, 255, 0.16);
|
||||
border: 1px solid rgba(41, 92, 255, 0.5);
|
||||
color: #7599ff;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: #7599ff; font-size: 16px; letter-spacing: 0.8px; text-transform: uppercase;
|
||||
}
|
||||
.tag .dot { width: 8px; height: 8px; border-radius: 50%; background: #295cff; }
|
||||
|
||||
/* 标题 */
|
||||
.title {
|
||||
margin: 30px 48px 0;
|
||||
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 52px;
|
||||
line-height: 1.15;
|
||||
color: #ffffff;
|
||||
max-width: 1000px;
|
||||
font-weight: 700; font-size: 40px;
|
||||
line-height: 1.15; color: #ffffff; max-width: 1000px;
|
||||
}
|
||||
.title .accent { color: #ff5c5c; }
|
||||
|
||||
/* 终端窗口 */
|
||||
.terminal {
|
||||
margin: 34px 48px 0;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
background: #161e2e; border: 1px solid #2a3550; border-radius: 10px;
|
||||
padding: 22px 28px;
|
||||
font-family: "JetBrains Mono", "Fira Code", Menlo, monospace;
|
||||
font-size: 21px;
|
||||
line-height: 1.6;
|
||||
max-width: 1040px;
|
||||
font-size: 21px; line-height: 1.6; max-width: 1040px;
|
||||
}
|
||||
.terminal .line { display: flex; gap: 12px; }
|
||||
.terminal .prompt { color: #7599ff; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255, 92, 92, 0.10);
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.terminal .err { color: #ff5c5c; background: rgba(255, 92, 92, 0.10); padding: 1px 8px; border-radius: 4px; }
|
||||
.terminal .fix { color: #3fb950; }
|
||||
|
||||
/* 底部 */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 0 48px 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto; padding: 0 48px 34px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.footer .tags { color: #6b7c99; font-size: 17px; }
|
||||
.footer .tags span { margin-right: 18px; color: #7599ff; }
|
||||
@@ -111,32 +77,19 @@
|
||||
<div class="brand">Mr Hoelee <span class="domain">· blog.hoelee.com</span></div>
|
||||
</div>
|
||||
|
||||
<div class="tag"><span class="dot"></span>DevOps · Debugging</div>
|
||||
<div class="tag"><span class="dot"></span>devops · DevOps</div>
|
||||
|
||||
<div class="title">
|
||||
Telegram Bot Notifications<br>That <span class="accent">Died at 400</span>
|
||||
</div>
|
||||
<div class="title">Why Telegram Bot Notifications Die: IPv6, DNS,<br>and a 400 That Took a Day</div>
|
||||
|
||||
<div class="terminal">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="tags">
|
||||
<span>#docker</span><span>#telegram</span><span>#ipv6</span><span>#dns</span>
|
||||
</div>
|
||||
<div class="tags"><span>#docker</span><span>#telegram</span><span>#python</span><span>#dns</span><span>#ipv6</span><span>#debugging</span><span>#portainer</span></div>
|
||||
<div class="url">blog.hoelee.com</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,44 @@
|
||||
# og-gen — per-post OG image generator
|
||||
|
||||
Generates a branded 1200×630 social-share image for a blog post, matching the
|
||||
site's cobalt-blue `#295cff` branding, the `logo-square.png` mark, and the
|
||||
"terminal / debugging" visual style.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node scripts/og-gen/generate.mjs <post-slug>
|
||||
```
|
||||
|
||||
Reads frontmatter (`title`, `category`, `tags`) from
|
||||
`src/content/posts/<slug>.md`, fills `template.html`, renders with headless
|
||||
Chrome, and writes `public/og/<slug>.png`.
|
||||
|
||||
The post's frontmatter needs `ogImage: /og/<slug>.png` so the blog serves it
|
||||
(layout already renders `og:image` + width/height; PostList shows it as a
|
||||
card thumbnail).
|
||||
|
||||
## Customizing the terminal box
|
||||
|
||||
Each post shows a "terminal" panel (the red error / green fix lines). Add an
|
||||
entry keyed by slug in `TERMINALS` inside `generate.mjs`, using these tokens:
|
||||
|
||||
```html
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">command</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">error line</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix</span><span class="fix">→ done ✓</span></div>
|
||||
```
|
||||
|
||||
No entry → a generic default terminal.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe`
|
||||
(override with `CHROME_PATH` env var).
|
||||
- Node 20+.
|
||||
|
||||
## Notes
|
||||
|
||||
- PNG (not JPG) is intentional: for flat UI/terminal art, PNG is smaller and
|
||||
sharper than JPG (see commit history).
|
||||
- The `logo-square.png` referenced lives at `public/logo-square.png`.
|
||||
@@ -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(', ')}`);
|
||||
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1200px; height: 630px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
color: #e6edf3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 34px 48px 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 48px; height: 48px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #ffffff; padding: 4px;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.brand {
|
||||
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 700; font-size: 22px; color: #ffffff; letter-spacing: 0.3px;
|
||||
}
|
||||
.brand .domain { color: #6b7c99; font-weight: 500; font-size: 19px; }
|
||||
|
||||
.tag {
|
||||
margin: 40px 48px 0;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
width: fit-content; padding: 7px 16px; border-radius: 6px;
|
||||
background: rgba(41, 92, 255, 0.16);
|
||||
border: 1px solid rgba(41, 92, 255, 0.5);
|
||||
color: #7599ff; font-size: 16px; letter-spacing: 0.8px; text-transform: uppercase;
|
||||
}
|
||||
.tag .dot { width: 8px; height: 8px; border-radius: 50%; background: #295cff; }
|
||||
|
||||
.title {
|
||||
margin: 30px 48px 0;
|
||||
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 700; font-size: {{TITLE_SIZE}}px;
|
||||
line-height: 1.15; color: #ffffff; max-width: 1000px;
|
||||
}
|
||||
.title .accent { color: #ff5c5c; }
|
||||
|
||||
.terminal {
|
||||
margin: 34px 48px 0;
|
||||
background: #161e2e; border: 1px solid #2a3550; border-radius: 10px;
|
||||
padding: 22px 28px;
|
||||
font-family: "JetBrains Mono", "Fira Code", Menlo, monospace;
|
||||
font-size: 21px; line-height: 1.6; max-width: 1040px;
|
||||
}
|
||||
.terminal .line { display: flex; gap: 12px; }
|
||||
.terminal .prompt { color: #7599ff; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .err { color: #ff5c5c; background: rgba(255, 92, 92, 0.10); padding: 1px 8px; border-radius: 4px; }
|
||||
.terminal .fix { color: #3fb950; }
|
||||
|
||||
.footer {
|
||||
margin-top: auto; padding: 0 48px 34px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.footer .tags { color: #6b7c99; font-size: 17px; }
|
||||
.footer .tags span { margin-right: 18px; color: #7599ff; }
|
||||
.footer .url { color: #6b7c99; font-size: 17px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="avatar"><img src="{{LOGO_PATH}}" alt=""></div>
|
||||
<div class="brand">Mr Hoelee <span class="domain">· blog.hoelee.com</span></div>
|
||||
</div>
|
||||
|
||||
<div class="tag"><span class="dot"></span>{{CATEGORY}} · {{KIND}}</div>
|
||||
|
||||
<div class="title">{{TITLE_HTML}}</div>
|
||||
|
||||
<div class="terminal">
|
||||
{{TERMINAL_HTML}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="tags">{{TAGS_HTML}}</div>
|
||||
<div class="url">blog.hoelee.com</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user