Mr Hoelee
+Lee Teong Hoe
+ +Full-stack Developer & DevOps Engineer
+Web development · DevOps · self-hosting
+diff --git a/docs/project-state.md b/docs/project-state.md index 9c46ff5..eaee6a1 100644 --- a/docs/project-state.md +++ b/docs/project-state.md @@ -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 -`` 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 `
Lee Teong Hoe
+ +Full-stack Developer & DevOps Engineer
+Web development · DevOps · self-hosting
+', ` + +`); +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); +} diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs index 7ce9698..c4a0c0a 100644 --- a/scripts/og-gen/generate.mjs +++ b/scripts/og-gen/generate.mjs @@ -412,6 +412,14 @@ TERMINALS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = `
`; +TERMINALS['why-500000-lines-of-commit-churn-only-left-230000-in-production'] = ` +
+
+
+
+
+
`; + // ---------- read frontmatter ---------- const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); if (!existsSync(postPath)) { diff --git a/scripts/og-gen/og-default-specs.md b/scripts/og-gen/og-default-specs.md new file mode 100644 index 0000000..5e03433 --- /dev/null +++ b/scripts/og-gen/og-default-specs.md @@ -0,0 +1,41 @@ + + +# 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`. diff --git a/scripts/og-gen/template-default.html b/scripts/og-gen/template-default.html new file mode 100644 index 0000000..3a75f98 --- /dev/null +++ b/scripts/og-gen/template-default.html @@ -0,0 +1,304 @@ + + +
+ + + +
+ + +
+