Add per-post banners (16:9 hero images) + banner-gen script; add banner to all posts
Deploy / build (push) Successful in 37s
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>devops</span><span class="pipe">/</span>
|
||||
<span class="active">authentik-major-upgrade-gotchas</span>
|
||||
</div>
|
||||
<div class="logo"><img src="file:///D:/dev/hoelee-blog/public/logo-square.png" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">root@dsm — authentik upgrade</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">docker compose pull authentik-worker</span></div><div class="line" style="margin-top:6px;"><span class="dim">Pulling authentik:2026.8.1 ... done</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">docker compose up -d && docker logs -f authentik</span></div><div class="line" style="margin-top:6px;"><span class="err">authorization_flow not found · SSO broken</span></div><div class="line" style="margin-top:6px;"><span class="dim">trusted_proxy_cidrs was reset · storage mount changed</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">fix = authorization_flow → authentication_flow</span></div><div class="line" style="margin-top:6px;"><span class="prompt"> </span><span class="ok">→ SSO restored ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><span class="num">1</span>2025.8 → 2026.8</div><div class="arrow">→</div><div class="step err-step"><span class="num">2</span>SSO broken</div><div class="arrow">→</div><div class="step"><span class="num">3</span>flow renamed</div><div class="arrow">→</div><div class="step"><span class="num">4</span>trusted proxy</div><div class="arrow">→</div><div class="step"><span class="num">5</span>restored ✓</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
/* 边角氛围光晕 */
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
/* 顶部:标题栏 + logo */
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* 主终端窗口 —— 占满画面 */
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
/* 底部:排障链路 */
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>devops</span><span class="pipe">/</span>
|
||||
<span class="active">telegram-notifications-die</span>
|
||||
</div>
|
||||
<div class="logo"><img src="file:///D:/dev/hoelee-blog/public/logo-square.png" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">root@dsm — carousell-monitor</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">docker logs carousell-monitor --tail 8</span></div>
|
||||
<div class="line" style="margin-top:10px;"><span class="dim">2026-09-08 20:45:41</span><span class="prompt">INFO</span><span class="cmd">scraping "uniform" — 49 cards parsed</span></div>
|
||||
<div class="line"><span class="dim">2026-09-08 20:45:42</span><span class="prompt">INFO</span><span class="cmd">2 new listings → archiving to NocoDB</span></div>
|
||||
<div class="line"><span class="dim">2026-09-08 20:45:44</span><span class="prompt">WARN</span><span class="err">telegram sendPhoto failed: 400 nginx/1.30.1</span></div>
|
||||
<div class="line"><span class="dim">2026-09-08 20:45:44</span><span class="prompt">WARN</span><span class="err">telegram sendMessage failed: 400 nginx/1.30.1</span></div>
|
||||
<div class="line" style="margin-top:14px;"><span class="prompt">$</span><span class="cmd">getent hosts api.telegram.org</span></div>
|
||||
<div class="line"><span class="hl">2001:67c:4e8:f004::9</span><span class="dim">api.telegram.org ← IPv6 only, no A record</span></div>
|
||||
<div class="line" style="margin-top:14px;"><span class="prompt">$</span><span class="cmd">fix = extra_hosts → IPv4 · multipart join → binary-safe</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="ok">→ delivered ✓</span></div>
|
||||
<div class="line" style="margin-top:8px;"><span class="prompt">$</span><span class="cmd" style="color:#7599ff;">▌</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><span class="num">1</span>Collect listings</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step err-step"><span class="num">2</span>sendPhoto 400</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><span class="num">3</span>DNS → IPv6 only</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><span class="num">4</span>multipart fix</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><span class="num">5</span>delivered ✓</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>notes</span><span class="pipe">/</span>
|
||||
<span class="active">hello-world</span>
|
||||
</div>
|
||||
<div class="logo"><img src="file:///D:/dev/hoelee-blog/public/logo-square.png" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">~/hoelee-blog — first commit</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">git init hoelee-blog && git add -A</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">git commit -m "Hello, world"</span></div><div class="line" style="margin-top:6px;"><span class="ok">[main 0000001] Hello, world ✓</span></div><div class="line" style="margin-top:6px;"><span class="dim">technical writing · self-hosting · build log</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">about → blog.hoelee.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><span class="num">1</span>Why this blog</div><div class="arrow">→</div><div class="step"><span class="num">2</span>what I build</div><div class="arrow">→</div><div class="step"><span class="num">3</span>self-hosting</div><div class="arrow">→</div><div class="step"><span class="num">4</span>learn in public</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>case-studies</span><span class="pipe">/</span>
|
||||
<span class="active">how-i-built-the-digikedai-telegram-bot</span>
|
||||
</div>
|
||||
<div class="logo"><img src="file:///D:/dev/hoelee-blog/public/logo-square.png" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">DigiKedai — AI support bot</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="line" style="margin-top:6px;"><span class="prompt">></span><span class="cmd">user: "does this course have a free trial?"</span></div><div class="line" style="margin-top:6px;"><span class="prompt">¶</span><span class="ok">bot: yes — here's your trial account ✓</span></div><div class="line" style="margin-top:6px;"><span class="dim">(answered in < 2s, no human in the loop)</span></div><div class="line" style="margin-top:6px;"><span class="prompt">></span><span class="cmd">user: "which package should I buy?"</span></div><div class="line" style="margin-top:6px;"><span class="prompt">¶</span><span class="ok">bot: recommends + provisions a free account</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">stack = grammY · LiteLLM · Cloudflare tunnel · 24/7</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><span class="num">1</span>User asks</div><div class="arrow">→</div><div class="step"><span class="num">2</span>grammY webhook</div><div class="arrow">→</div><div class="step"><span class="num">3</span>LiteLLM</div><div class="arrow">→</div><div class="step"><span class="num">4</span>AI answers</div><div class="arrow">→</div><div class="step"><span class="num">5</span>provisions ✓</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>case-studies</span><span class="pipe">/</span>
|
||||
<span class="active">how-i-host-this-blog</span>
|
||||
</div>
|
||||
<div class="logo"><img src="file:///D:/dev/hoelee-blog/public/logo-square.png" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">root@unraid — deploy pipeline</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">git push origin main</span></div><div class="line" style="margin-top:6px;"><span class="dim">→ git.hoelee.com/hoelee/hoelee-blog</span></div><div class="line" style="margin-top:6px;"><span class="prompt">INFO</span><span class="cmd">Gitea Actions → build (Astro 5)</span></div><div class="line" style="margin-top:6px;"><span class="prompt">INFO</span><span class="cmd">npm ci && npm run build → dist/</span></div><div class="line" style="margin-top:6px;"><span class="prompt">INFO</span><span class="cmd">deploy → unRaid runner → nginx</span></div><div class="line" style="margin-top:6px;"><span class="ok">✓ served via Cloudflare (cache + SSL)</span></div><div class="line" style="margin-top:6px;"><span class="dim">git-as-CMS · zero-downtime deploy</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><span class="num">1</span>git push</div><div class="arrow">→</div><div class="step"><span class="num">2</span>Gitea Actions</div><div class="arrow">→</div><div class="step"><span class="num">3</span>Astro build</div><div class="arrow">→</div><div class="step"><span class="num">4</span>unRaid nginx</div><div class="arrow">→</div><div class="step"><span class="num">5</span>Cloudflare</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>devops</span><span class="pipe">/</span>
|
||||
<span class="active">why-telegram-bot-notifications-die</span>
|
||||
</div>
|
||||
<div class="logo"><img src="file:///D:/dev/hoelee-blog/public/logo-square.png" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">root@dsm — carousell-monitor</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="line" style="margin-top:6px;"><span class="dim">2026-09-08 20:45:41</span></div><div class="line" style="margin-top:6px;"><span class="prompt">INFO</span><span class="cmd">scraping "uniform" — 49 cards parsed</span></div><div class="line" style="margin-top:6px;"><span class="dim">2026-09-08 20:45:42</span></div><div class="line" style="margin-top:6px;"><span class="prompt">INFO</span><span class="cmd">2 new listings → archiving to NocoDB</span></div><div class="line" style="margin-top:6px;"><span class="dim">2026-09-08 20:45:44</span></div><div class="line" style="margin-top:6px;"><span class="prompt">WARN</span><span class="err">telegram sendPhoto failed: 400 nginx/1.30.1</span></div><div class="line" style="margin-top:6px;"><span class="dim">2026-09-08 20:45:44</span></div><div class="line" style="margin-top:6px;"><span class="prompt">WARN</span><span class="err">telegram sendMessage failed: 400 nginx/1.30.1</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">getent hosts api.telegram.org</span></div><div class="line" style="margin-top:6px;"><span class="hl">2001:67c:4e8:f004::9</span></div><div class="line" style="margin-top:6px;"><span class="dim">api.telegram.org ← IPv6 only, no A record</span></div><div class="line" style="margin-top:6px;"><span class="prompt">$</span><span class="cmd">fix = extra_hosts → IPv4 · multipart join → binary-safe</span></div><div class="line" style="margin-top:6px;"><span class="prompt"> </span><span class="ok">→ delivered ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><span class="num">1</span>Collect listings</div><div class="arrow">→</div><div class="step err-step"><span class="num">2</span>sendPhoto 400</div><div class="arrow">→</div><div class="step"><span class="num">3</span>DNS → IPv6 only</div><div class="arrow">→</div><div class="step"><span class="num">4</span>multipart fix</div><div class="arrow">→</div><div class="step"><span class="num">5</span>delivered ✓</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,43 @@
|
||||
# banner-gen — per-post banner/hero image generator
|
||||
|
||||
Generates a branded 1600×900 (16:9) banner image for a blog post — shown at
|
||||
the top of the article, above the title. Matches the site's cobalt-blue
|
||||
branding and the "terminal / debugging" visual language (siblings with
|
||||
`scripts/og-gen/`, which produces the 1200×630 social-share image).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node scripts/banner-gen/generate.mjs <post-slug>
|
||||
```
|
||||
|
||||
Reads the post's category from frontmatter, fills `template.html`, renders
|
||||
with headless Chrome, and writes `public/banners/<slug>.png`.
|
||||
|
||||
Then add `banner: /banners/<slug>.png` to the post frontmatter (en + zh) —
|
||||
`[...slug].astro` renders it above the H1 title.
|
||||
|
||||
## Customizing content
|
||||
|
||||
Each post's banner content lives in `BANNERS` in `generate.mjs`, keyed by
|
||||
slug, with three parts:
|
||||
|
||||
- `titlebar` — the terminal window's title-bar text
|
||||
- `lines` — terminal body rows: `{ t, text }` where `t` ∈
|
||||
`cmd` · `dim` · `err` (red) · `ok` (green) · `hl` (amber) · `prompt` (`$`)
|
||||
- `flow` — bottom pipeline steps: `{ n: '1', label: '...', err?: true }`
|
||||
|
||||
No entry → a generic default banner.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe`
|
||||
(`CHROME_PATH` to override).
|
||||
- Node 20+.
|
||||
|
||||
## Relationship to og-gen
|
||||
|
||||
- og-gen → `public/og/<slug>.png` (1200×630, 2:1) for social sharing.
|
||||
- banner-gen → `public/banners/<slug>.png` (1600×900, 16:9) for the article
|
||||
hero. The banner is a "filled" visual (terminal + flow pipeline), not a
|
||||
poster — avoid large empty margins.
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* banner-gen — generate a 1600×900 (16:9) banner/hero image for a blog post.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/banner-gen/generate.mjs <slug> [--all]
|
||||
*
|
||||
* Reads the post's frontmatter, fills the template, renders with headless
|
||||
* Chrome, and writes public/banners/<slug>.png.
|
||||
*
|
||||
* Each post's content (titlebar, terminal lines, flow steps) is defined in
|
||||
* BANNERS below, keyed by slug. Falls back to a generic default.
|
||||
*
|
||||
* The post frontmatter then needs `banner: /banners/<slug>.png` so the
|
||||
* article renders it above the title ([...slug].astro does this).
|
||||
*/
|
||||
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/banner-gen/generate.mjs <slug> | --all');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- per-post banner content ----------
|
||||
// lines: [{ t: 'cmd'|'dim'|'err'|'ok'|'hl'|'prompt', text }]
|
||||
// flow: [{ n: '1', label: '...', err?: true }]
|
||||
const BANNERS = {
|
||||
'why-telegram-bot-notifications-die': {
|
||||
titlebar: 'root@dsm — carousell-monitor',
|
||||
lines: [
|
||||
{ t: 'dim', text: '2026-09-08 20:45:41' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'scraping "uniform" — 49 cards parsed' },
|
||||
{ t: 'dim', text: '2026-09-08 20:45:42' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '2 new listings → archiving to NocoDB' },
|
||||
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendPhoto failed: 400 nginx/1.30.1' },
|
||||
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendMessage failed: 400 nginx/1.30.1' },
|
||||
{ t: 'prompt', text: '$', },
|
||||
{ t: 'cmd', text: 'getent hosts api.telegram.org' },
|
||||
{ t: 'hl', text: '2001:67c:4e8:f004::9' }, { t: 'dim', text: 'api.telegram.org ← IPv6 only, no A record' },
|
||||
{ t: 'prompt', text: '$' },
|
||||
{ t: 'cmd', text: 'fix = extra_hosts → IPv4 · multipart join → binary-safe' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ delivered ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'Collect listings' },
|
||||
{ n: '2', label: 'sendPhoto 400', err: true },
|
||||
{ n: '3', label: 'DNS → IPv6 only' },
|
||||
{ n: '4', label: 'multipart fix' },
|
||||
{ n: '5', label: 'delivered ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'authentik-major-upgrade-gotchas': {
|
||||
titlebar: 'root@dsm — authentik upgrade',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose pull authentik-worker' },
|
||||
{ t: 'dim', text: 'Pulling authentik:2026.8.1 ... done' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose up -d && docker logs -f authentik' },
|
||||
{ t: 'err', text: 'authorization_flow not found · SSO broken' },
|
||||
{ t: 'dim', text: 'trusted_proxy_cidrs was reset · storage mount changed' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = authorization_flow → authentication_flow' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ SSO restored ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: '2025.8 → 2026.8' },
|
||||
{ n: '2', label: 'SSO broken', err: true },
|
||||
{ n: '3', label: 'flow renamed' },
|
||||
{ n: '4', label: 'trusted proxy' },
|
||||
{ n: '5', label: 'restored ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-i-host-this-blog': {
|
||||
titlebar: 'root@unraid — deploy pipeline',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git push origin main' },
|
||||
{ t: 'dim', text: '→ git.hoelee.com/hoelee/hoelee-blog' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'Gitea Actions → build (Astro 5)' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'npm ci && npm run build → dist/' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'deploy → unRaid runner → nginx' },
|
||||
{ t: 'ok', text: '✓ served via Cloudflare (cache + SSL)' },
|
||||
{ t: 'dim', text: 'git-as-CMS · zero-downtime deploy' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'git push' },
|
||||
{ n: '2', label: 'Gitea Actions' },
|
||||
{ n: '3', label: 'Astro build' },
|
||||
{ n: '4', label: 'unRaid nginx' },
|
||||
{ n: '5', label: 'Cloudflare' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-i-built-the-digikedai-telegram-bot': {
|
||||
titlebar: 'DigiKedai — AI support bot',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "does this course have a free trial?"' },
|
||||
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: yes — here\'s your trial account ✓' },
|
||||
{ t: 'dim', text: '(answered in < 2s, no human in the loop)' },
|
||||
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "which package should I buy?"' },
|
||||
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: recommends + provisions a free account' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = grammY · LiteLLM · Cloudflare tunnel · 24/7' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'User asks' },
|
||||
{ n: '2', label: 'grammY webhook' },
|
||||
{ n: '3', label: 'LiteLLM' },
|
||||
{ n: '4', label: 'AI answers' },
|
||||
{ n: '5', label: 'provisions ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'hello-world': {
|
||||
titlebar: '~/hoelee-blog — first commit',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git init hoelee-blog && git add -A' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git commit -m "Hello, world"' },
|
||||
{ t: 'ok', text: '[main 0000001] Hello, world ✓' },
|
||||
{ t: 'dim', text: 'technical writing · self-hosting · build log' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'about → blog.hoelee.com' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'Why this blog' },
|
||||
{ n: '2', label: 'what I build' },
|
||||
{ n: '3', label: 'self-hosting' },
|
||||
{ n: '4', label: 'learn in public' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_BANNER = {
|
||||
titlebar: 'root@host — shell',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'engineering · devops · self-hosting' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the full post →' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'start' }, { n: '2', label: 'work' }, { n: '3', label: 'ship' },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
let category = 'devops';
|
||||
if (existsSync(postPath)) {
|
||||
const raw = readFileSync(postPath, 'utf8');
|
||||
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
||||
const c = fm.match(/^category\s*:\s*(.+)$/m)?.[1]?.trim();
|
||||
if (c) category = c.replace(/["']/g, '');
|
||||
}
|
||||
|
||||
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// render terminal lines
|
||||
function renderLines(lines) {
|
||||
// group into visual lines: each entry has t + text; consecutive dim/prompt/cmd share a row
|
||||
let html = '';
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
if (l.t === 'prompt') {
|
||||
// prompt may be followed by a cmd/err/ok/hl/dim on same row
|
||||
const next = lines[i + 1];
|
||||
const promptClass = l.text === '$' ? 'prompt' : 'prompt';
|
||||
let row = `<span class="${promptClass}">${esc(l.text) || ' '}</span>`;
|
||||
if (next && next.t !== 'prompt') {
|
||||
row += `<span class="${next.t}">${esc(next.text)}</span>`;
|
||||
i += 1;
|
||||
}
|
||||
html += `<div class="line" style="margin-top:6px;">${row}</div>`;
|
||||
} else {
|
||||
html += `<div class="line" style="margin-top:6px;"><span class="${l.t}">${esc(l.text)}</span></div>`;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderFlow(flow) {
|
||||
let html = '';
|
||||
flow.forEach((s, idx) => {
|
||||
html += `<div class="step${s.err ? ' err-step' : ''}"><span class="num">${s.n}</span>${esc(s.label)}</div>`;
|
||||
if (idx < flow.length - 1) html += `<div class="arrow">→</div>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
const cfg = BANNERS[slug] || DEFAULT_BANNER;
|
||||
const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/');
|
||||
|
||||
let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8');
|
||||
tpl = tpl
|
||||
.replace('{{CATEGORY}}', esc(category))
|
||||
.replace('{{SLUG}}', esc(slug))
|
||||
.replace('{{LOGO_PATH}}', logoPath)
|
||||
.replace('{{TITLEBAR}}', esc(cfg.titlebar))
|
||||
.replace('{{TERMINAL_HTML}}', renderLines(cfg.lines))
|
||||
.replace('{{FLOW_HTML}}', renderFlow(cfg.flow));
|
||||
|
||||
mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true });
|
||||
const htmlPath = join(ROOT, '.og', 'gen', `${slug}-banner.html`);
|
||||
writeFileSync(htmlPath, tpl);
|
||||
|
||||
const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const outPng = join(ROOT, '.og', 'gen', `${slug}-banner.png`);
|
||||
try {
|
||||
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1600,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);
|
||||
}
|
||||
|
||||
const publicDir = join(ROOT, 'public', 'banners');
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
const finalPng = join(publicDir, `${slug}.png`);
|
||||
execSync(`copy /Y "${outPng}" "${finalPng}"`, { stdio: 'pipe' });
|
||||
|
||||
console.log(`✓ Banner generated: public/banners/${slug}.png`);
|
||||
console.log(` category: ${category}`);
|
||||
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>{{CATEGORY}}</span><span class="pipe">/</span>
|
||||
<span class="active">{{SLUG}}</span>
|
||||
</div>
|
||||
<div class="logo"><img src="{{LOGO_PATH}}" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">{{TITLEBAR}}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{{TERMINAL_HTML}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
{{FLOW_HTML}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,6 +25,7 @@ const posts = defineCollection({
|
||||
// When absent, the language switch falls back to the section landing page (/ or /zh/).
|
||||
translation: z.string().optional(),
|
||||
ogImage: z.string().optional(),
|
||||
banner: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [authentik, sso, docker, portainer, self-hosting, oidc, upgrade]
|
||||
ogImage: /og/authentik-major-upgrade-gotchas.png
|
||||
banner: /banners/authentik-major-upgrade-gotchas.png
|
||||
---
|
||||
|
||||
I run authentik as the single sign-on gate in front of my self-hosted stack —
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-05
|
||||
category: notes
|
||||
tags: [intro]
|
||||
ogImage: /og/hello-world.png
|
||||
banner: /banners/hello-world.png
|
||||
---
|
||||
|
||||
Welcome. This is where I write about what I build and learn — mostly
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
|
||||
ogImage: /og/how-i-built-the-digikedai-telegram-bot.png
|
||||
banner: /banners/how-i-built-the-digikedai-telegram-bot.png
|
||||
---
|
||||
|
||||
Digi Kedai sells digital products — online courses, ebooks, and templates — and
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-04
|
||||
category: case-studies
|
||||
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
|
||||
ogImage: /og/how-i-host-this-blog.png
|
||||
banner: /banners/how-i-host-this-blog.png
|
||||
---
|
||||
|
||||
This blog is itself a project I built to demonstrate the kind of work I do.
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [docker, telegram, python, dns, ipv6, debugging, portainer]
|
||||
ogImage: /og/why-telegram-bot-notifications-die.png
|
||||
banner: /banners/why-telegram-bot-notifications-die.png
|
||||
---
|
||||
|
||||
I run a small monitoring bot that watches Carousell for newly-listed
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [authentik, sso, docker, portainer, self-hosting, oidc, upgrade]
|
||||
ogImage: /og/authentik-major-upgrade-gotchas.png
|
||||
banner: /banners/authentik-major-upgrade-gotchas.png
|
||||
---
|
||||
|
||||
我把 authentik 当作自托管体系前面的单点登录闸门——邮箱、仪表盘、Synology
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
|
||||
ogImage: /og/how-i-built-the-digikedai-telegram-bot.png
|
||||
banner: /banners/how-i-built-the-digikedai-telegram-bot.png
|
||||
---
|
||||
|
||||
Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:*「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」*。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
|
||||
ogImage: /og/how-i-host-this-blog.png
|
||||
banner: /banners/how-i-host-this-blog.png
|
||||
---
|
||||
|
||||
这个博客本身,就是我用来说明「我到底会做什么」的一个项目。下面是完整的
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [docker, telegram, python, dns, ipv6, debugging, portainer]
|
||||
ogImage: /og/why-telegram-bot-notifications-die.png
|
||||
banner: /banners/why-telegram-bot-notifications-die.png
|
||||
---
|
||||
|
||||
我跑着一个小监控机器人,专门盯着 Carousell 上新上架的商品,一旦有新品就通过
|
||||
|
||||
@@ -62,6 +62,11 @@ const switchHref = translation ? `/posts/${translation}/` : undefined;
|
||||
}}
|
||||
>
|
||||
<article>
|
||||
{post.data.banner && (
|
||||
<figure class="article-banner">
|
||||
<img src={post.data.banner} alt="" loading="lazy" width="1600" height="900" />
|
||||
</figure>
|
||||
)}
|
||||
<header class="article-head">
|
||||
<h1>{post.data.title}</h1>
|
||||
<div class="meta">
|
||||
|
||||
@@ -170,6 +170,18 @@ article a { text-decoration: underline; text-underline-offset: 2px; }
|
||||
time { color: var(--faint); font-size: 0.88rem; }
|
||||
|
||||
/* ---------- Article ---------- */
|
||||
.article-banner {
|
||||
margin: 0 0 2rem;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
line-height: 0;
|
||||
}
|
||||
.article-banner img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.article-head {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
|
||||