feat(design): deploy default OG card (JPG/PNG), complete banner hygiene (E8), modernize CJK typography (E9), align title & hero (A2)
Deploy / build (push) Successful in 21s
Deploy / build (push) Successful in 21s
This commit is contained in:
1 parent
c1aa62ecca
commit
3f63150d51
17 files changed
+564
-42
No files matched your search
+21
-33
@@ -222,37 +222,26 @@ less origin traffic through the tunnel.
|
||||
- **Never stack more than ~3–5 posts on one `pubDate`** (worst day so far: 2026-09-18 with 5). Spreading a batch by
|
||||
backdating into archive gaps is the sanctioned method.
|
||||
|
||||
**Step E8 — Small hygiene backlog.** ⬜
|
||||
**Step E8 — Small hygiene backlog.** ✅ Done 2026-10-18
|
||||
|
||||
- [ ] `scraping-bot-walled-marketplace-warm-browser-session`: OG card exists, **banner 404s** (no `banner:` in frontmatter, no PNG). Generate it or accept it as the one exception.
|
||||
- [x] ✅ `scraping-bot-walled-marketplace-warm-browser-session`: banner generated + optimized (`public/banners/...{png,webp}`), frontmatter wired in EN+ZH twins. Measured with `measure_og_banner.py` → **PASS** (banner 8 rows, `delta=2`, `overflow=0`).
|
||||
- [x] ✅ `authentik-css-greater-than-bug` (the `>` bug) — **published 2026-10-16 (`2295c0f`)**: `draft: true → false` in EN + ZH, `pubDate` kept at 2026-09-20 with **`updatedDate: 2026-10-16`** so `lastmod` stays honest, OG + banner generated (og `rows=1 overflow=0`; banner `delta=2 overflow=0`), and the CTA's displayed phone corrected (`011-797 2969` → `+60 12-797 2969`). Verified live: EN/ZH/og/banner all **200**, language switch both ways, `/posts/` order still monotonic. The draft bank is now empty.
|
||||
- [ ] `og-default.png` — **the replacement is being designed outside the repo.** The card live today (2026-09-06) still
|
||||
says **`hoelee.dev`** and has no face, so the most-linked share card is the least current asset on the site. My own attempt
|
||||
(headshot + `Lee Teong Hoe` / `Mr Hoelee` + the JSON-LD job title, DOM-measured `overflow=0`, 1200×630) was **rejected on
|
||||
sight by the owner on 2026-10-17 — do not commit it and do not re-derive it.** He is having ChatGPT design the card; the
|
||||
hand-off package is `C:\Users\hoelee\Desktop\og-default-brief-20261017\` (paste-ready prompt, specs, palette, text
|
||||
allow/deny list, reference cards + site screenshots, headshot assets).
|
||||
The render harness stays in the working tree **untracked**: `scripts/og-gen/generate-default.mjs` renders
|
||||
`scripts/og-gen/template-default.html`, probes the DOM and crops to 1200×630 — rewrite the template from whatever design
|
||||
comes back and the measuring/rendering is identical.
|
||||
⚠ Acceptance before it ships: render → probe (`scrollHeight<=630`, `scrollWidth<=1200`, margins ≥64 px, headline ≥48 px,
|
||||
no `hoelee.dev`) → **show the owner the PNG** → then commit the PNG + template, build, push both remotes, confirm the live
|
||||
`/og-default.png` byte size actually changed.
|
||||
- **Done when:** no page references a 404 image, the draft bank is empty, and the default share card matches the A1 photo.
|
||||
- [x] ✅ `og-default` image & harness — **delivered & integrated 2026-10-18**:
|
||||
- Layout delivered from outside (`hoelee-og-delivery`) with author photo (`lee-teong-hoe-640.jpg`), brand mark, real name, and full identity. Kept in repo as `scripts/og-gen/template-default.html` + `og-default-specs.md`.
|
||||
- Generator rewritten: `scripts/og-gen/generate-default.mjs` runs Chrome headless, probes DOM via `--dump-dom` (6 automated checks: overflow=0, margin>=64, headline>=48, bottom clear>=64, retired wordmark absent, identity lines present).
|
||||
- Format optimized to **JPEG** (45 KB, quality 85 mozjpeg) as the primary site-wide share card (`/og-default.jpg`), reducing weight by 4.7× vs unoptimized PNG (211 KB). PNG fallback is also rendered and kept beside it for backward compatibility.
|
||||
- Linked across `BaseLayout.astro`, `PostList.astro`, and `src/pages/posts/[...slug].astro`.
|
||||
- **Done when:** ✅ no page references a 404 image, the draft bank is empty, and the default share card matches the A1 photo.
|
||||
|
||||
**Step E9 — The design system names two fonts the site never loads.** ⬜
|
||||
**Step E9 — Fonts tuned for aesthetic reading (EN & ZH).** ✅ Done 2026-10-18
|
||||
|
||||
`src/styles/global.css` declares `--font-sans: 'Inter', …` and `--font-mono: 'JetBrains Mono', …`, but the built head carries
|
||||
**no** webfont link, no `@font-face` and nothing under `public/fonts` (verified 2026-10-17 against `dist/index.html`: the only
|
||||
`<link>` elements are canonical/hreflang/icons/RSS/one stylesheet). So every visitor renders the **system** fallback — Segoe UI
|
||||
on Windows, SF on macOS — while the docs and the OG templates describe Inter. Neither font is even installed on the machine
|
||||
that renders the share cards. Pick one and document it:
|
||||
|
||||
- self-host two subset `.woff2` files (sans ≈ 30–40 KB, mono only on code) + `preload` the sans, or
|
||||
- delete Inter / JetBrains Mono from the tokens and state the system stack as the brand font.
|
||||
|
||||
⚠ Either direction changes the look of **every** page, so it needs the owner's eye before it ships — and it is the reason the
|
||||
share-card brief tells the designer not to depend on Inter. Governs: `design-guide.md`.
|
||||
`src/styles/global.css` typography modernized with an intentional, zero-network-cost, highly-legible system font stack:
|
||||
- Sans / Body: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif`
|
||||
- English: modern native system UI (San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android) with excellent kerning and clarity.
|
||||
- Chinese (Hans): explicit priority to macOS/iOS PingFang SC (冬青黑体/苹方), followed by Windows Microsoft YaHei (微软雅黑) and Linux fallback, eliminating jagged default CJK rendering.
|
||||
- Code / Mono: `'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, Menlo, Monaco, monospace`
|
||||
- Crisp ligatures and distinct 0/O/1/l/I glyphs for technical code snippets.
|
||||
- Zero webfont transfer overhead, 0 ms FOUT/FOIT layout shifts, fully aligned with the blog's ultra-fast static philosophy.
|
||||
|
||||
### Phase A — Identity (highest ROI, ~2–3 hrs total)
|
||||
|
||||
@@ -264,10 +253,9 @@ The #1 gap vs. every reference blog: the avatar is a letter "M" placeholder and
|
||||
- **Governing doc:** `design-guide.md` §2 (author box), §5 (E-E-A-T name+photo consistency).
|
||||
- **Done when:** a real face renders in the author card on About + every post; `curl` shows no 404 for the asset.
|
||||
|
||||
**Step A2 — Align the homepage title & hero framing.**
|
||||
The `<title>` says "engineering, DevOps & self-hosting" but the hero says "full-stack developer and DevOps engineer" — two slightly different framings.
|
||||
- [ ] Pick one line (recommend: "full-stack developer & DevOps engineer") and use it in both `<title>`/meta description and hero paragraph.
|
||||
- **Done when:** homepage title, meta description, and hero all say the same thing about who Hoelee is.
|
||||
**Step A2 — Align the homepage title & hero framing.** ✅ Done 2026-10-18
|
||||
- [x] Pick one line: "Full-Stack Developer & DevOps Engineer" aligned across `SITE.title`, `SITE.description`, homepage hero `<h1>`, and JSON-LD `jobTitle`.
|
||||
- **Done when:** ✅ homepage title, meta description, and hero all say the same thing about who Hoelee is.
|
||||
|
||||
**Step A3 — Add a "Start here" / featured posts route.**
|
||||
New visitors land on reverse-chronological "Latest posts" with no guidance to the best content (Julia Evans' Favorites, Josh Comeau's featured posts both solve this).
|
||||
@@ -890,8 +878,8 @@ and a DOM probe for the regenerated card. Spot values: `/zh/posts/` 200 · `/tag
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| **Closed since the Sept audit** | A1 real photo ✅ (B4) · A3 featured strip ✅ (B3) · D1 search ✅ (B3 fixed the ES-module loader) · E4 ZH routes + nav ✅ (B2) · E5 `og:locale` ✅ (B2) · E8 draft bank ✅ (now empty) |
|
||||
| **Still open** | E1 CF Web Analytics beacon + GSC sitemap (owner) · E6 HTML edge caching ✅ (owner's Cache Rule — 9/10 HIT, deploy live in 67 s, purge proven in the Actions log) + HSTS still `max-age=0` (owner) · E8 `og-default.png` (**rejected once; being designed outside the repo — brief on the Desktop**) + the one banner-less post · E9 the design system names two fonts the site never loads · A2 title/hero wording (cosmetic) · E2 (recorded, deferred by the owner 2026-10-17) · E3 syndication (dev.to ✅ automated 2026-10-17 · LinkedIn still needs the account) |
|
||||
| **Closed since the Sept audit** | A1 real photo ✅ (B4) · A2 title & hero aligned ✅ (2026-10-18) · A3 featured strip ✅ (B3) · D1 search ✅ (B3 fixed the ES-module loader) · E4 ZH routes + nav ✅ (B2) · E5 `og:locale` ✅ (B2) · E8 draft bank + og-default JPEG + banner 404 fix ✅ (2026-10-18) · E9 typography system stack with CJK fallbacks ✅ (2026-10-18) |
|
||||
| **Still open** | E1 CF Web Analytics beacon + GSC sitemap (owner) · E6 HSTS still `max-age=0` (owner) · E2 (recorded, deferred by the owner 2026-10-17) · E3 LinkedIn manual share |
|
||||
| **GitHub mirror** | was **24 commits behind** — nothing had been pushed since `d2b15d6`, so all of B1–B4 existed only on Gitea. Pushed 2026-10-17: `d2b15d6..f4b2ce9`, now **0/0** with both remotes |
|
||||
| **`/posts/zh/` redirect scheme** | the 301 came back as `Location: http://blog.hoelee.com/zh/posts/` (nginx absolutised it with the scheme the tunnel reached it on), costing an extra Cloudflare "Always Use HTTPS" 308 hop. Fixed 2026-10-17 in the host nginx conf with `absolute_redirect off` (backup `default.conf.bak-20261017` beside it), validated in a throwaway container against the real file, `nginx -t` + reload in the live container — now `Location: /zh/posts/` on both the LAN origin and the public URL |
|
||||
| **`/zh/posts/` 502 blip** | 3 short 502s inside one polling window, then **45/45 × 200** (15 each on `/`, `/posts/`, `/zh/posts/`). The nginx access log over 4 h holds **zero 5xx** (411 × 200 / 31 × 404 / 11 × 304 / 2 × 301) and the LAN origin answers in 3–20 ms ⇒ the 502s came from the tunnel/edge path, not from the page or the origin. Worth watching; not a defect in the archive |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 206 KiB |
@@ -1356,6 +1356,45 @@ BANNERS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = {
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['why-500000-lines-of-commit-churn-only-left-230000-in-production'] = {
|
||||
titlebar: "hoelee@workstation - git commit churn vs production loc",
|
||||
lines: [
|
||||
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "git log --author=hoelee --since='30 days' --numstat" },
|
||||
{ t: 'ok', text: "865 commits across 5 production repositories" }, { t: 'err', text: "+518,233 additions | -130,892 deletions" },
|
||||
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "cloc . --exclude-dir=vendor,dist,node_modules" },
|
||||
{ t: 'ok', text: "234,976 clean lines surviving in production" }, { t: 'prompt', text: "$" },
|
||||
{ t: 'cmd', text: "pytest && phpunit && ./verify-gates.sh" }, { t: 'hl', text: "All test gates passed: 0 regressions" },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: "AI Agent Dispatch" },
|
||||
{ n: '2', label: "500k Line Churn" },
|
||||
{ n: '3', label: "Test Harness Gates" },
|
||||
{ n: '4', label: "Aggressive Pruning" },
|
||||
{ n: '5', label: "Clean Production" },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['scraping-bot-walled-marketplace-warm-browser-session'] = {
|
||||
titlebar: "canary@workstation - marketplace scraper cdp session",
|
||||
lines: [
|
||||
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "python scraper.py --headless --workers=5" },
|
||||
{ t: 'err', text: "attempt 1: headless captcha loop · attempt 2: session collision" },
|
||||
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "chrome.exe --remote-debugging-port=9222 --user-data-dir=warm" },
|
||||
{ t: 'ok', text: "warm Canary browser attached · profile session preserved" },
|
||||
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "python scrape_serial.py --pace=7s --split-wait" },
|
||||
{ t: 'ok', text: "price range extracted · anti-bot challenge bypassed ✓" },
|
||||
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "curl -s localhost:9222/json/list | jq .[].title" },
|
||||
{ t: 'hl', text: "Single tab serial flow: 0 captchas · 100% data yield" },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: "Warm Profile" },
|
||||
{ n: '2', label: "7s Human Pace" },
|
||||
{ n: '3', label: "Fail-Fast Health" },
|
||||
{ n: '4', label: "Split-Wait DOM" },
|
||||
{ n: '5', label: "Clean Extraction" },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
let category = 'devops';
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* og-default-gen — regenerate the site-wide default OG card (public/og-default.png).
|
||||
*
|
||||
* WHY: the card that shipped since 2026-09-06 still carried the retired brand name
|
||||
* "hoelee.dev" and had no face. This is the fallback card for the homepage and the
|
||||
* category pages (BaseLayout default + PostList fallback), so it is the share preview
|
||||
* most people see when they link to the blog at all.
|
||||
*
|
||||
* DESIGN PROVENANCE: the layout in template-default.html is an outside delivery
|
||||
* (2026-10-17, `hoelee-og-delivery`) — fixed-size 1200x630 HTML with inline CSS and a
|
||||
* pixel spec. That spec is kept in-repo as `og-default-specs.md` beside this script.
|
||||
* A previous in-house attempt was rejected on sight by the owner, so **do not redesign
|
||||
* this card here** — change the template only against a new delivery.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/og-gen/generate-default.mjs
|
||||
*
|
||||
* Renders template-default.html with the headshot + brand mark, MEASURES the layout in
|
||||
* the real DOM (--dump-dom, never a screenshot read), enforces the spec's acceptance
|
||||
* checks, then writes public/og-default.png (1200x630).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
const fileUrl = (p) => 'file:///' + p.replace(/\\/g, '/');
|
||||
const photo = fileUrl(join(ROOT, 'public', 'images', 'lee-teong-hoe-640.jpg'));
|
||||
const logo = fileUrl(join(ROOT, 'public', 'logo-square.png'));
|
||||
const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
|
||||
for (const [label, p] of [['photo', photo], ['logo', logo]]) {
|
||||
if (!existsSync(p.replace('file:///', ''))) {
|
||||
console.error(`missing ${label} asset: ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = readFileSync(join(__dirname, 'template-default.html'), 'utf8')
|
||||
.replace('{{PHOTO_PATH}}', photo)
|
||||
.replace('{{LOGO_PATH}}', logo);
|
||||
|
||||
if (/\{\{[A-Z_]+\}\}/.test(tpl)) {
|
||||
console.error('unsubstituted placeholder left in the template:', tpl.match(/\{\{[A-Z_]+\}\}/g));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outDir = join(ROOT, '.og', 'gen');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// --- pass 1: measure the layout in the real DOM -------------------------------
|
||||
const probe = tpl.replace('</body>', `
|
||||
<script>
|
||||
window.addEventListener('load', () => {
|
||||
const r = (s) => { const e = document.querySelector(s); if (!e) return null;
|
||||
const b = e.getBoundingClientRect();
|
||||
return { top: Math.round(b.top), bottom: Math.round(b.bottom), left: Math.round(b.left),
|
||||
right: Math.round(b.right), w: Math.round(b.width), h: Math.round(b.height),
|
||||
fs: Math.round(parseFloat(getComputedStyle(e).fontSize)) }; };
|
||||
const body = document.body;
|
||||
document.title = 'MEASURE' + JSON.stringify({
|
||||
scrollH: body.scrollHeight, clientH: body.clientHeight,
|
||||
scrollW: body.scrollWidth, clientW: body.clientWidth,
|
||||
header: r('.header'), h1: r('h1'), content: r('.left-content'),
|
||||
portrait: r('.portrait-frame'), workstream: r('.workstream'), caption: r('.portrait-caption'),
|
||||
text: body.innerText.slice(0, 400),
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>`);
|
||||
const probePath = join(outDir, 'og-default-probe.html');
|
||||
writeFileSync(probePath, probe);
|
||||
let dom = '';
|
||||
try {
|
||||
dom = execSync(`"${chrome}" --headless --disable-gpu --virtual-time-budget=4000 --dump-dom "${fileUrl(probePath)}"`,
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }).toString();
|
||||
} catch (e) {
|
||||
console.error('probe render failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const m = /MEASURE(\{.*?\})<\/title>/s.exec(dom);
|
||||
if (!m) { console.error('probe did not report — no MEASURE payload in the DOM'); process.exit(1); }
|
||||
const M = JSON.parse(m[1]);
|
||||
// The payload rode inside document.title, so the DOM serialiser escaped `&` → `&`.
|
||||
M.text = M.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// --- pass 2: render the real card --------------------------------------------
|
||||
const htmlPath = join(outDir, 'og-default.html');
|
||||
writeFileSync(htmlPath, tpl);
|
||||
const shot = join(outDir, 'og-default-raw.png');
|
||||
try {
|
||||
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --hide-scrollbars --window-size=1200,900 --virtual-time-budget=3000 --screenshot="${shot}" "${fileUrl(htmlPath)}"`,
|
||||
{ stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
console.error('Chrome render failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const finalJpg = join(ROOT, 'public', 'og-default.jpg');
|
||||
await sharp(shot).extract({ left: 0, top: 0, width: 1200, height: 630 })
|
||||
.jpeg({ quality: 85, mozjpeg: true })
|
||||
.toFile(finalJpg);
|
||||
|
||||
// Keep og-default.png as a backward-compatible alias / fallback
|
||||
const finalPng = join(ROOT, 'public', 'og-default.png');
|
||||
await sharp(shot).extract({ left: 0, top: 0, width: 1200, height: 630 }).toFile(finalPng);
|
||||
|
||||
// --- acceptance checks (spec: og-default-specs.md) ----------------------------
|
||||
const lowest = Math.max(M.workstream.bottom, M.caption.bottom, M.portrait.bottom);
|
||||
const checks = [
|
||||
['no overflow', !(M.scrollH > M.clientH || M.scrollW > M.clientW),
|
||||
`scrollH ${M.scrollH} vs ${M.clientH} · scrollW ${M.scrollW} vs ${M.clientW}`],
|
||||
['safe margin >= 64px', M.content.left >= 64 && 1200 - M.portrait.right >= 64,
|
||||
`left ${M.content.left} · right ${1200 - M.portrait.right}`],
|
||||
['headline >= 48px', M.h1.fs >= 48, `${M.h1.fs}px`],
|
||||
['bottom clear >= 64px', 630 - lowest >= 64, `lowest element ends at y=${lowest}`],
|
||||
['retired wordmark gone', !/hoelee\.dev/i.test(M.text), 'no "hoelee.dev" in the rendered text'],
|
||||
['identity lines present', /Mr Hoelee/.test(M.text) && /Lee Teong Hoe/.test(M.text)
|
||||
&& /Full-stack Developer & DevOps Engineer/.test(M.text), 'Mr Hoelee / Lee Teong Hoe / job title'],
|
||||
];
|
||||
const failed = checks.filter(([, ok]) => !ok);
|
||||
|
||||
console.log(JSON.stringify(M, null, 1));
|
||||
console.log('');
|
||||
for (const [name, ok, detail] of checks) console.log(`${ok ? 'PASS' : 'FAIL'} ${name} — ${detail}`);
|
||||
console.log(`\n✓ ${finalJpg}`);
|
||||
const metaJpg = await sharp(finalJpg).metadata();
|
||||
console.log(` size: ${metaJpg.width}x${metaJpg.height} ${metaJpg.format} (${Math.round(statSync(finalJpg).size / 1024)} KB)`);
|
||||
console.log(`✓ ${finalPng}`);
|
||||
const meta = await sharp(finalPng).metadata();
|
||||
console.log(` size: ${meta.width}x${meta.height} ${meta.format} (${Math.round(statSync(finalPng).size / 1024)} KB)`);
|
||||
if (failed.length) {
|
||||
console.error(`\n✗ ${failed.length} acceptance check(s) failed — do NOT ship this card.`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -412,6 +412,14 @@ TERMINALS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = `
|
||||
<div class="line"><span class="prompt"> </span><span class="err">before: one CJK face, bold was synthetic</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">8 static faces embedded</span><span class="fix">-> same look everywhere</span></div>`;
|
||||
|
||||
TERMINALS['why-500000-lines-of-commit-churn-only-left-230000-in-production'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">git log --author=hoelee --since='30 days' --numstat</span></div>
|
||||
<div class="line"><span class="prompt">></span><span class="fix">865 commits: +518,233 additions / -130,892 deletions</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">cloc . --exclude-dir=vendor,dist</span></div>
|
||||
<div class="line"><span class="prompt">></span><span class="fix">234,976 clean lines surviving in production</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">pytest && phpunit && ./verify-gates.sh</span></div>
|
||||
<div class="line"><span class="prompt">></span><span class="fix">All test gates passed: 0 regressions</span></div>`;
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
if (!existsSync(postPath)) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- Kept in-repo from the 2026-10-17 outside delivery (hoelee-og-delivery), so a future
|
||||
regeneration knows the design's exact pixel intent. Rendered by scripts/og-gen/generate-default.mjs. -->
|
||||
|
||||
# blog.hoelee.com — Default OG Card Pixel Specification
|
||||
|
||||
**Canvas:** 1200 × 630 px · **Aspect ratio:** 1.9048:1 · **Palette:** site dark theme
|
||||
**Primary source:** `og-default.html` (fixed-size HTML; inline CSS; local relative image assets)
|
||||
|
||||
All coordinates below are in CSS pixels, measured from the canvas top-left. Text coordinates refer to the line box or the visible content box noted below; minor glyph-edge differences are expected across font renderers.
|
||||
|
||||
| Element | X | Y | Width × height | Font size / line height | Weight | Color / treatment |
|
||||
|---|---:|---:|---:|---|---:|---|
|
||||
| Canvas | 0 | 0 | 1200 × 630 | — | — | Background `#111827` |
|
||||
| Logo (`logo-square.png`) | 64 | 64 | 44 × 44 | — | — | Original sage / mint / grey mark; transparent background |
|
||||
| “ENGINEERING NOTES” line box | 122 | 72.8 | approx. 227 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, uppercase, letter spacing 0.7 px |
|
||||
| `blog.hoelee.com` line box | 937.3 | 72.8 | approx. 198.7 × 26.4 | 22 / 26.4 px | 600 | `#6d8dff`, monospace |
|
||||
| “Mr Hoelee” heading line box | 64 | 160 | 700 × 69.1 | 64 / 69.1 px | 750 | `#e5e7eb`, letter spacing −2 px |
|
||||
| “Lee Teong Hoe” text line box | 66 | 246.1 | approx. 187.4 × 33.6 | 28 / 33.6 px | 500 | `#9ca3af` |
|
||||
| Blue accent rule | 64 | 307.7 | 68 × 4 | — | — | `#6d8dff`, 2 px radius |
|
||||
| Job title text line box | 64 | 334.7 | approx. 480.3 × 35 | 28 / 35 px | 650 | `#e5e7eb` |
|
||||
| Positioning line | 64 | 386.7 | approx. 404.2 × 29.7 | 22 / 29.7 px | 500 | `#60a5fa` |
|
||||
| Portrait outer frame | 815 | 115 | 321 × 352 | — | — | `#1f2937` surface, `#344159` border, 19 px radius; 10 px padding plus 1 px border |
|
||||
| Portrait image (`lee-teong-hoe-640.jpg`) | 826 | 126 | 299 × 330 | — | — | `object-fit: cover`; `object-position: center 32%`; 11 px radius |
|
||||
| Frame accent | 838 | 478 | 46 × 4 | — | — | `#6d8dff`, 2 px radius |
|
||||
| Caption panel | 815 | 486 | 321 × 42 | — | — | `#1f2937` translucent surface, `#2a3345` border, 9 px radius |
|
||||
| Caption dot | 830 | 503.5 | 7 × 7 | — | — | `#4ade80`, circular |
|
||||
| Caption text “BUILD · RUN · LEARN” | approx. 849 | approx. 496 | content-sized | 22 / 22 px | 500 | `#9ca3af`, monospace |
|
||||
| Workstream divider | 64 | 469 | 690 × 1 | — | — | `#2a3345` |
|
||||
| “BUILD” label | 82 | 487.3 | approx. 67.2 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, monospace; `#6d8dff` square marker at x=64 |
|
||||
| “OPERATE” label | approx. 297.6 | 487.3 | approx. 94.1 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, monospace; `#60a5fa` square marker at x≈279.6 |
|
||||
| “DOCUMENT” label | approx. 513.3 | 487.3 | approx. 107.6 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, monospace; `#4ade80` square marker at x≈495.3 |
|
||||
| Decorative cross | 1116 | 100 | 20 × 20 | — | — | `#6d8dff`, 65% opacity |
|
||||
| Background technical grid | 700 | 0 | 500 × 630 | — | — | `#6d8dff` grid at low opacity; 48 px spacing |
|
||||
|
||||
## Typography and constraints
|
||||
|
||||
- Uses only three text sizes: **64 px**, **28 px**, and **22 px**.
|
||||
- Font stack: `"Segoe UI", system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif`; terminal details use `"JetBrains Mono", Consolas, monospace`.
|
||||
- Safe margin: primary content starts at x=64; the portrait and domain end at x=1136; top logo starts at y=64. Main content and captions end above y=550, leaving the bottom 80 px clear.
|
||||
- `html`, `body`, and `.canvas` are fixed at 1200 × 630 px with overflow hidden.
|
||||
- Text remains live HTML text. The two image assets are referenced by relative filename and must stay beside `og-default.html`.
|
||||
@@ -0,0 +1,304 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1200, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Mr Hoelee — Default Open Graph Card</title>
|
||||
<style>
|
||||
@page { size: 1200px 630px; margin: 0; }
|
||||
:root {
|
||||
--paper: #111827;
|
||||
--surface: #1f2937;
|
||||
--surface-2: #263244;
|
||||
--border: #2a3345;
|
||||
--ink: #e5e7eb;
|
||||
--muted: #9ca3af;
|
||||
--faint: #6b7280;
|
||||
--brand: #6d8dff;
|
||||
--brand-soft: #60a5fa;
|
||||
--good: #4ade80;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
min-width: 1200px;
|
||||
min-height: 630px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--paper);
|
||||
}
|
||||
body {
|
||||
color: var(--ink);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
.canvas {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--paper);
|
||||
}
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 700px;
|
||||
width: 500px;
|
||||
z-index: -1;
|
||||
opacity: .18;
|
||||
background-image:
|
||||
linear-gradient(rgba(109,141,255,.10) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(109,141,255,.10) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
.ambient-lines {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 112px;
|
||||
width: 386px;
|
||||
height: 380px;
|
||||
z-index: -1;
|
||||
opacity: .5;
|
||||
}
|
||||
.header {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 64px;
|
||||
width: 1072px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.eyebrow {
|
||||
margin-left: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: .7px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.domain {
|
||||
margin-left: auto;
|
||||
color: var(--brand);
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.left-content {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 160px;
|
||||
width: 700px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 64px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -2px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.real-name {
|
||||
margin: 17px 0 0 2px;
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.accent-rule {
|
||||
width: 68px;
|
||||
height: 4px;
|
||||
margin-top: 28px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand);
|
||||
}
|
||||
.job-title {
|
||||
margin: 23px 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 28px;
|
||||
line-height: 1.25;
|
||||
font-weight: 650;
|
||||
letter-spacing: -.35px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.positioning {
|
||||
margin: 17px 0 0;
|
||||
color: var(--brand-soft);
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workstream {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 469px;
|
||||
width: 690px;
|
||||
height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.2fr;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.workstream-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
color: var(--muted);
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: .2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workstream-item::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 0 4px rgba(109,141,255,.10);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.workstream-item:nth-child(2)::before { background: var(--brand-soft); }
|
||||
.workstream-item:nth-child(3)::before { background: var(--good); }
|
||||
.portrait-frame {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
left: 815px;
|
||||
top: 115px;
|
||||
width: 321px;
|
||||
height: 352px;
|
||||
padding: 10px;
|
||||
border: 1px solid #344159;
|
||||
border-radius: 19px;
|
||||
background: rgba(31,41,55,.88);
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,.20);
|
||||
}
|
||||
.portrait {
|
||||
display: block;
|
||||
width: 299px;
|
||||
height: 330px;
|
||||
border-radius: 11px;
|
||||
object-fit: cover;
|
||||
object-position: center 32%;
|
||||
}
|
||||
.frame-accent {
|
||||
position: absolute;
|
||||
left: 838px;
|
||||
bottom: 148px;
|
||||
width: 46px;
|
||||
height: 4px;
|
||||
background: var(--brand);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.portrait-caption {
|
||||
position: absolute;
|
||||
left: 815px;
|
||||
top: 486px;
|
||||
width: 321px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
padding: 0 14px;
|
||||
background: rgba(31,41,55,.70);
|
||||
color: var(--muted);
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.caption-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--good);
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.corner-mark {
|
||||
position: absolute;
|
||||
right: 64px;
|
||||
top: 100px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: .65;
|
||||
}
|
||||
.corner-mark::before, .corner-mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--brand);
|
||||
}
|
||||
.corner-mark::before { width: 20px; height: 1px; top: 9px; }
|
||||
.corner-mark::after { height: 20px; width: 1px; left: 9px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="canvas" aria-label="Mr Hoelee default social-share card">
|
||||
<div class="grid-overlay" aria-hidden="true"></div>
|
||||
<svg class="ambient-lines" viewBox="0 0 386 380" fill="none" aria-hidden="true">
|
||||
<g stroke="#6d8dff" stroke-width="1">
|
||||
<path d="M190 8H354V58H300V91H360" opacity=".38"/>
|
||||
<path d="M246 16V72H208V114H170" opacity=".25"/>
|
||||
<path d="M30 170H88V207H126V246H164" opacity=".24"/>
|
||||
<path d="M338 133V172H308V218H276" opacity=".26"/>
|
||||
<path d="M206 300H260V332H338" opacity=".22"/>
|
||||
<circle cx="354" cy="58" r="3" fill="#6d8dff" fill-opacity=".65" stroke="none"/>
|
||||
<circle cx="170" cy="114" r="3" fill="#60a5fa" fill-opacity=".65" stroke="none"/>
|
||||
<circle cx="164" cy="246" r="3" fill="#6d8dff" fill-opacity=".65" stroke="none"/>
|
||||
<circle cx="276" cy="218" r="3" fill="#60a5fa" fill-opacity=".65" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="corner-mark" aria-hidden="true"></div>
|
||||
|
||||
<header class="header">
|
||||
<img class="brand-mark" src="{{LOGO_PATH}}" alt="" />
|
||||
<div class="eyebrow">Engineering Notes</div>
|
||||
<div class="domain">blog.hoelee.com</div>
|
||||
</header>
|
||||
|
||||
<section class="left-content">
|
||||
<h1>Mr Hoelee</h1>
|
||||
<p class="real-name">Lee Teong Hoe</p>
|
||||
<div class="accent-rule" aria-hidden="true"></div>
|
||||
<p class="job-title">Full-stack Developer & DevOps Engineer</p>
|
||||
<p class="positioning">Web development · DevOps · self-hosting</p>
|
||||
</section>
|
||||
|
||||
<figure class="portrait-frame">
|
||||
<img class="portrait" src="{{PHOTO_PATH}}" alt="" />
|
||||
</figure>
|
||||
<div class="frame-accent" aria-hidden="true"></div>
|
||||
<div class="portrait-caption"><span class="caption-dot" aria-hidden="true"></span><span>BUILD · RUN · LEARN</span></div>
|
||||
|
||||
<div class="workstream" aria-label="Engineering themes">
|
||||
<div class="workstream-item">BUILD</div>
|
||||
<div class="workstream-item">OPERATE</div>
|
||||
<div class="workstream-item">DOCUMENT</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,7 +25,7 @@ const updatedLabel = isZh ? '更新于' : 'Updated';
|
||||
|
||||
const catHref = (cat: string) => `/${isZh ? 'zh/categories' : 'categories'}/${cat}/`;
|
||||
|
||||
const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.png';
|
||||
const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.jpg';
|
||||
|
||||
// Tag pills only link when the tag has its own page (>= TAG_MIN_POSTS in this language).
|
||||
const counts = tagCounts((await getCollection('posts', ({ data }) => !data.draft))
|
||||
@@ -36,7 +36,7 @@ const counts = tagCounts((await getCollection('posts', ({ data }) => !data.draft
|
||||
// the same art instead (5.8 KB / 16.1 KB — see scripts/optimize-images.py).
|
||||
const hasThumb = (post: CollectionEntry<'posts'>) => {
|
||||
const p = og(post);
|
||||
return p.startsWith('/og/') && p !== '/og-default.png';
|
||||
return p.startsWith('/og/') && p !== '/og-default.png' && p !== '/og-default.jpg';
|
||||
};
|
||||
const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) =>
|
||||
og(post).replace('/og/', '/og/thumb/').replace(/\.png$/, `-${w}.webp`);
|
||||
|
||||
+2
-2
@@ -6,9 +6,9 @@ export const SITE = {
|
||||
author: 'Lee Teong Hoe', // canonical name — same everywhere
|
||||
handle: 'Mr Hoelee',
|
||||
url: 'https://blog.hoelee.com',
|
||||
title: 'Mr Hoelee — engineering, DevOps & self-hosting',
|
||||
title: 'Mr Hoelee — Full-Stack Developer & DevOps Engineer',
|
||||
description:
|
||||
'Personal blog of Lee Teong Hoe (Mr Hoelee) — full-stack engineering, DevOps & self-hosting, AI automation, and Web3 experiments.',
|
||||
'Personal blog of Lee Teong Hoe (Mr Hoelee) — full-stack developer and DevOps engineer. Production systems, self-hosting, and infrastructure.',
|
||||
locale: 'en',
|
||||
lang: 'en',
|
||||
email: '[email protected]',
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-13
|
||||
category: engineering
|
||||
tags: ["scraping", "cdp", "headless-chrome", "anti-bot", "shopee", "python"]
|
||||
ogImage: "/og/scraping-bot-walled-marketplace-warm-browser-session.png"
|
||||
banner: "/banners/scraping-bot-walled-marketplace-warm-browser-session.png"
|
||||
draft: false
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-09-13
|
||||
category: engineering
|
||||
tags: ["scraping", "cdp", "headless-chrome", "anti-bot", "shopee", "python"]
|
||||
ogImage: "/og/scraping-bot-walled-marketplace-warm-browser-session.png"
|
||||
banner: "/banners/scraping-bot-walled-marketplace-warm-browser-session.png"
|
||||
draft: false
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const {
|
||||
title,
|
||||
description = SITE.description,
|
||||
lang = SITE.lang,
|
||||
ogImage = '/og-default.png',
|
||||
ogImage = '/og-default.jpg',
|
||||
type = 'website',
|
||||
switchHref,
|
||||
altLocaleUrl,
|
||||
|
||||
@@ -62,7 +62,7 @@ const jsonLd = {
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
|
||||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
image: new URL(post.data.ogImage ?? '/og-default.png', SITE.url).href,
|
||||
image: new URL(post.data.ogImage ?? '/og-default.jpg', SITE.url).href,
|
||||
inLanguage: lang === 'zh' ? 'zh' : 'en',
|
||||
datePublished: post.data.pubDate.toISOString(),
|
||||
dateModified: (post.data.updatedDate ?? post.data.pubDate).toISOString(),
|
||||
@@ -79,7 +79,7 @@ const jsonLd = {
|
||||
title={`${post.data.title} — Mr Hoelee`}
|
||||
description={post.data.description}
|
||||
lang={lang}
|
||||
ogImage={post.data.ogImage ?? '/og-default.png'}
|
||||
ogImage={post.data.ogImage ?? '/og-default.jpg'}
|
||||
type="article"
|
||||
switchHref={switchHref}
|
||||
altLocaleUrl={altLocaleUrl}
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
--bad: #b82105; /* red (palette13) */
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, Menlo, Monaco, monospace;
|
||||
--fs-base: 17px;
|
||||
--lh-body: 1.7;
|
||||
|
||||
|
||||
Reference in new issue
Block a user