diff --git a/.og/gen/authentik-major-upgrade-gotchas-banner.html b/.og/gen/authentik-major-upgrade-gotchas-banner.html new file mode 100644 index 0000000..0d0585b --- /dev/null +++ b/.og/gen/authentik-major-upgrade-gotchas-banner.html @@ -0,0 +1,133 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + devops/ + authentik-major-upgrade-gotchas +
+ +
+ +
+
+ + root@dsm — authentik upgrade +
+
+
$docker compose pull authentik-worker
Pulling authentik:2026.8.1 ... done
$docker compose up -d && docker logs -f authentik
authorization_flow not found · SSO broken
trusted_proxy_cidrs was reset · storage mount changed
$fix = authorization_flow → authentication_flow
 → SSO restored ✓
+
+
+ +
+
12025.8 → 2026.8
2SSO broken
3flow renamed
4trusted proxy
5restored ✓
+
+ + \ No newline at end of file diff --git a/.og/gen/authentik-major-upgrade-gotchas-banner.png b/.og/gen/authentik-major-upgrade-gotchas-banner.png new file mode 100644 index 0000000..3d07c86 Binary files /dev/null and b/.og/gen/authentik-major-upgrade-gotchas-banner.png differ diff --git a/.og/gen/banner-in-article.png b/.og/gen/banner-in-article.png new file mode 100644 index 0000000..0751c24 Binary files /dev/null and b/.og/gen/banner-in-article.png differ diff --git a/.og/gen/banner-sample.html b/.og/gen/banner-sample.html new file mode 100644 index 0000000..4f07b3f --- /dev/null +++ b/.og/gen/banner-sample.html @@ -0,0 +1,154 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + devops/ + telegram-notifications-die +
+ +
+ +
+
+ + root@dsm — carousell-monitor +
+
+
$docker logs carousell-monitor --tail 8
+
2026-09-08 20:45:41INFOscraping "uniform" — 49 cards parsed
+
2026-09-08 20:45:42INFO2 new listings → archiving to NocoDB
+
2026-09-08 20:45:44WARNtelegram sendPhoto failed: 400 nginx/1.30.1
+
2026-09-08 20:45:44WARNtelegram sendMessage failed: 400 nginx/1.30.1
+
$getent hosts api.telegram.org
+
2001:67c:4e8:f004::9api.telegram.org  ← IPv6 only, no A record
+
$fix = extra_hosts → IPv4 · multipart join → binary-safe
+
 → delivered ✓
+
$
+
+
+ +
+
1Collect listings
+
+
2sendPhoto 400
+
+
3DNS → IPv6 only
+
+
4multipart fix
+
+
5delivered ✓
+
+ + \ No newline at end of file diff --git a/.og/gen/banner-sample.png b/.og/gen/banner-sample.png new file mode 100644 index 0000000..b1d10e5 Binary files /dev/null and b/.og/gen/banner-sample.png differ diff --git a/.og/gen/hello-world-banner.html b/.og/gen/hello-world-banner.html new file mode 100644 index 0000000..3d12f99 --- /dev/null +++ b/.og/gen/hello-world-banner.html @@ -0,0 +1,133 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + notes/ + hello-world +
+ +
+ +
+
+ + ~/hoelee-blog — first commit +
+
+
$git init hoelee-blog && git add -A
$git commit -m "Hello, world"
[main 0000001] Hello, world ✓
technical writing · self-hosting · build log
$about → blog.hoelee.com
+
+
+ +
+
1Why this blog
2what I build
3self-hosting
4learn in public
+
+ + \ No newline at end of file diff --git a/.og/gen/hello-world-banner.png b/.og/gen/hello-world-banner.png new file mode 100644 index 0000000..0f93131 Binary files /dev/null and b/.og/gen/hello-world-banner.png differ diff --git a/.og/gen/how-i-built-the-digikedai-telegram-bot-banner.html b/.og/gen/how-i-built-the-digikedai-telegram-bot-banner.html new file mode 100644 index 0000000..86b8e43 --- /dev/null +++ b/.og/gen/how-i-built-the-digikedai-telegram-bot-banner.html @@ -0,0 +1,133 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + case-studies/ + how-i-built-the-digikedai-telegram-bot +
+ +
+ +
+
+ + DigiKedai — AI support bot +
+
+
>user: "does this course have a free trial?"
bot: yes — here's your trial account ✓
(answered in < 2s, no human in the loop)
>user: "which package should I buy?"
bot: recommends + provisions a free account
$stack = grammY · LiteLLM · Cloudflare tunnel · 24/7
+
+
+ +
+
1User asks
2grammY webhook
3LiteLLM
4AI answers
5provisions ✓
+
+ + \ No newline at end of file diff --git a/.og/gen/how-i-built-the-digikedai-telegram-bot-banner.png b/.og/gen/how-i-built-the-digikedai-telegram-bot-banner.png new file mode 100644 index 0000000..eb03b1c Binary files /dev/null and b/.og/gen/how-i-built-the-digikedai-telegram-bot-banner.png differ diff --git a/.og/gen/how-i-host-this-blog-banner.html b/.og/gen/how-i-host-this-blog-banner.html new file mode 100644 index 0000000..f7dab0d --- /dev/null +++ b/.og/gen/how-i-host-this-blog-banner.html @@ -0,0 +1,133 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + case-studies/ + how-i-host-this-blog +
+ +
+ +
+
+ + root@unraid — deploy pipeline +
+
+
$git push origin main
→ git.hoelee.com/hoelee/hoelee-blog
INFOGitea Actions → build (Astro 5)
INFOnpm ci && npm run build → dist/
INFOdeploy → unRaid runner → nginx
✓ served via Cloudflare (cache + SSL)
git-as-CMS · zero-downtime deploy
+
+
+ +
+
1git push
2Gitea Actions
3Astro build
4unRaid nginx
5Cloudflare
+
+ + \ No newline at end of file diff --git a/.og/gen/how-i-host-this-blog-banner.png b/.og/gen/how-i-host-this-blog-banner.png new file mode 100644 index 0000000..a66647b Binary files /dev/null and b/.og/gen/how-i-host-this-blog-banner.png differ diff --git a/.og/gen/why-telegram-bot-notifications-die-banner.html b/.og/gen/why-telegram-bot-notifications-die-banner.html new file mode 100644 index 0000000..dbf0627 --- /dev/null +++ b/.og/gen/why-telegram-bot-notifications-die-banner.html @@ -0,0 +1,133 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + devops/ + why-telegram-bot-notifications-die +
+ +
+ +
+
+ + root@dsm — carousell-monitor +
+
+
2026-09-08 20:45:41
INFOscraping "uniform" — 49 cards parsed
2026-09-08 20:45:42
INFO2 new listings → archiving to NocoDB
2026-09-08 20:45:44
WARNtelegram sendPhoto failed: 400 nginx/1.30.1
2026-09-08 20:45:44
WARNtelegram sendMessage failed: 400 nginx/1.30.1
$getent hosts api.telegram.org
2001:67c:4e8:f004::9
api.telegram.org ← IPv6 only, no A record
$fix = extra_hosts → IPv4 · multipart join → binary-safe
 → delivered ✓
+
+
+ +
+
1Collect listings
2sendPhoto 400
3DNS → IPv6 only
4multipart fix
5delivered ✓
+
+ + \ No newline at end of file diff --git a/.og/gen/why-telegram-bot-notifications-die-banner.png b/.og/gen/why-telegram-bot-notifications-die-banner.png new file mode 100644 index 0000000..eeeb5d9 Binary files /dev/null and b/.og/gen/why-telegram-bot-notifications-die-banner.png differ diff --git a/public/banners/authentik-major-upgrade-gotchas.png b/public/banners/authentik-major-upgrade-gotchas.png new file mode 100644 index 0000000..3d07c86 Binary files /dev/null and b/public/banners/authentik-major-upgrade-gotchas.png differ diff --git a/public/banners/hello-world.png b/public/banners/hello-world.png new file mode 100644 index 0000000..0f93131 Binary files /dev/null and b/public/banners/hello-world.png differ diff --git a/public/banners/how-i-built-the-digikedai-telegram-bot.png b/public/banners/how-i-built-the-digikedai-telegram-bot.png new file mode 100644 index 0000000..eb03b1c Binary files /dev/null and b/public/banners/how-i-built-the-digikedai-telegram-bot.png differ diff --git a/public/banners/how-i-host-this-blog.png b/public/banners/how-i-host-this-blog.png new file mode 100644 index 0000000..a66647b Binary files /dev/null and b/public/banners/how-i-host-this-blog.png differ diff --git a/public/banners/why-telegram-bot-notifications-die.png b/public/banners/why-telegram-bot-notifications-die.png new file mode 100644 index 0000000..eeeb5d9 Binary files /dev/null and b/public/banners/why-telegram-bot-notifications-die.png differ diff --git a/scripts/banner-gen/README.md b/scripts/banner-gen/README.md new file mode 100644 index 0000000..94843c7 --- /dev/null +++ b/scripts/banner-gen/README.md @@ -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 +``` + +Reads the post's category from frontmatter, fills `template.html`, renders +with headless Chrome, and writes `public/banners/.png`. + +Then add `banner: /banners/.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/.png` (1200×630, 2:1) for social sharing. +- banner-gen → `public/banners/.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. \ No newline at end of file diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs new file mode 100644 index 0000000..06d567b --- /dev/null +++ b/scripts/banner-gen/generate.mjs @@ -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 [--all] + * + * Reads the post's frontmatter, fills the template, renders with headless + * Chrome, and writes public/banners/.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/.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 | --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, '>'); + +// 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 = `${esc(l.text) || ' '}`; + if (next && next.t !== 'prompt') { + row += `${esc(next.text)}`; + i += 1; + } + html += `
${row}
`; + } else { + html += `
${esc(l.text)}
`; + } + i += 1; + } + return html; +} + +function renderFlow(flow) { + let html = ''; + flow.forEach((s, idx) => { + html += `
${s.n}${esc(s.label)}
`; + if (idx < flow.length - 1) html += `
`; + }); + 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}`); \ No newline at end of file diff --git a/scripts/banner-gen/template.html b/scripts/banner-gen/template.html new file mode 100644 index 0000000..b61a43d --- /dev/null +++ b/scripts/banner-gen/template.html @@ -0,0 +1,133 @@ + + + + + + + +
+
+ +
+
+ blog.hoelee.com/ + {{CATEGORY}}/ + {{SLUG}} +
+ +
+ +
+
+ + {{TITLEBAR}} +
+
+ {{TERMINAL_HTML}} +
+
+ +
+ {{FLOW_HTML}} +
+ + \ No newline at end of file diff --git a/src/content.config.ts b/src/content.config.ts index c35b6ba..c0facf8 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), }), }); diff --git a/src/content/posts/authentik-major-upgrade-gotchas.md b/src/content/posts/authentik-major-upgrade-gotchas.md index 857c73c..2f23be8 100644 --- a/src/content/posts/authentik-major-upgrade-gotchas.md +++ b/src/content/posts/authentik-major-upgrade-gotchas.md @@ -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 — diff --git a/src/content/posts/hello-world.md b/src/content/posts/hello-world.md index 9f1f420..3e6d63d 100644 --- a/src/content/posts/hello-world.md +++ b/src/content/posts/hello-world.md @@ -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 diff --git a/src/content/posts/how-i-built-the-digikedai-telegram-bot.md b/src/content/posts/how-i-built-the-digikedai-telegram-bot.md index cf0c401..f3c6cfe 100644 --- a/src/content/posts/how-i-built-the-digikedai-telegram-bot.md +++ b/src/content/posts/how-i-built-the-digikedai-telegram-bot.md @@ -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 diff --git a/src/content/posts/how-i-host-this-blog.md b/src/content/posts/how-i-host-this-blog.md index 3e98cba..8f02f8c 100644 --- a/src/content/posts/how-i-host-this-blog.md +++ b/src/content/posts/how-i-host-this-blog.md @@ -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. diff --git a/src/content/posts/why-telegram-bot-notifications-die.md b/src/content/posts/why-telegram-bot-notifications-die.md index 444e0ad..f3d2166 100644 --- a/src/content/posts/why-telegram-bot-notifications-die.md +++ b/src/content/posts/why-telegram-bot-notifications-die.md @@ -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 diff --git a/src/content/posts/zh/authentik-major-upgrade-gotchas.md b/src/content/posts/zh/authentik-major-upgrade-gotchas.md index 69a3af4..79806e3 100644 --- a/src/content/posts/zh/authentik-major-upgrade-gotchas.md +++ b/src/content/posts/zh/authentik-major-upgrade-gotchas.md @@ -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 diff --git a/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md b/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md index cbc67bc..e7d3e65 100644 --- a/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md +++ b/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md @@ -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 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。 diff --git a/src/content/posts/zh/how-i-host-this-blog.md b/src/content/posts/zh/how-i-host-this-blog.md index f6c3ca5..cb4c144 100644 --- a/src/content/posts/zh/how-i-host-this-blog.md +++ b/src/content/posts/zh/how-i-host-this-blog.md @@ -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 --- 这个博客本身,就是我用来说明「我到底会做什么」的一个项目。下面是完整的 diff --git a/src/content/posts/zh/why-telegram-bot-notifications-die.md b/src/content/posts/zh/why-telegram-bot-notifications-die.md index 2294bad..ce1934a 100644 --- a/src/content/posts/zh/why-telegram-bot-notifications-die.md +++ b/src/content/posts/zh/why-telegram-bot-notifications-die.md @@ -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 上新上架的商品,一旦有新品就通过 diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index 41615e2..b0f3269 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -62,6 +62,11 @@ const switchHref = translation ? `/posts/${translation}/` : undefined; }} >
+ {post.data.banner && ( +
+ +
+ )}

{post.data.title}

diff --git a/src/styles/global.css b/src/styles/global.css index 051db5a..5ab98aa 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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;