# Project State & Enhancement Backlog
Living list of what's done and what's next for blog.hoelee.com. Work through these **one by one** โ don't batch unrelated changes. Check off items as they land.
**Status key:** โ
done ยท ๐ต in progress ยท โฌ not started
> **How to resume the project:** start at the top-most โฌ item in the **Execution Plan** (ยง2) below and work downward. Each step is self-contained, has a "done when" criterion, and references the governing doc. Don't jump ahead โ earlier steps unlock later ones.
---
## 1. Done (foundation)
- โ
Astro 5 static + Markdown, Gitea Actions CI/CD โ nginx โ Cloudflare (deployed)
- โ
Design system: hoelee.com brand palette, Inter + JetBrains Mono, light/dark, sticky nav, code copy button
- โ
Brand "Mr Hoelee" (replaced "hoelee.dev")
- โ
Author card + `Person`/`ProfilePage` JSON-LD (E-E-A-T), article meta, reading time, related posts
- โ
Full SEO: canonical, Open Graph (+dims), twitter:card, favicon (all sizes), RSS + sitemap
- โ
Category pages (`/categories/`, `/categories/[category]/`)
- โ
Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder)
- โ
Language switcher in nav โ links to the **same post** in the other language (auto-matches by `zh/` prefix)
- โ
Locale-aware nav labels (EN/ZH)
- โ
Dark mode default (light opt-in via toggle)
- โ
Case study "How I Host This Blog"
- โ
Chinese translation of the case study (`posts/zh/how-i-host-this-blog.md`)
- โ
hello-world intro post
- โ
Case study "How I Built the DigiKedai Telegram AI Bot" + Chinese twin (`posts/zh/how-i-built-the-digikedai-telegram-bot/`)
- โ
Business framing corrected (website design & development = primary; email hosting = secondary)
- โ
Knowledge guides in `docs/` + README index + `hoelee-blog` skill
- โ
Both repos public (Gitea + GitHub) with title/description/homepage/topics + `v1.0.0` release
---
## 2. Execution Plan (work top โ bottom, one step at a time)
> This plan comes from a full research pass (Sept 2026) comparing blog.hoelee.com against reference developer blogs (Simon Willison, Josh Comeau, Dan Abramov/overreacted, Julia Evans) + industry surveys. Priority is fixed: **identity & content โ discovery โ polish.** Don't reorder unless the user says so.
### Phase A โ Identity (highest ROI, ~2โ3 hrs total)
**Step A1 โ Add a real author photo (headshot).**
The #1 gap vs. every reference blog: the avatar is a letter "M" placeholder and there's no photo anywhere on the site. Every credible personal dev blog has a human face.
- [ ] User provides one headshot (square, โฅ800ร800 for avatar; also source for og).
- [ ] Replace `avatar` letter with the photo in: nav/brand (optional), author card (About + every post), `ProfilePage` JSON-LD `image`.
- [ ] Add the photo to `og-default.png` template so the default share card has a face.
- **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 `
` 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 ``/meta description and hero paragraph.
- **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).
- [ ] Add a "Start here" (or "Featured") section on the homepage surfacing 2โ3 flagship case studies.
- [ ] Optionally add a `/favorites` or `/start-here` page (defer the dedicated page until โฅ6 strong posts; the homepage strip is the immediate win).
- **Done when:** homepage shows a featured/start-here strip above or beside "Latest posts".
### Phase B โ Content (80% of value; the long game)
**Step B1 โ Write the 2nd flagship case study: "Self-Hosting a Mem0 Memory Stack".** โ
Done 2026-09-16
The Mem0 flagship is already the single highest-value unwritten post in the backlog.
- [x] Write `src/content/posts/self-hosting-mem0.md` (category `case-studies`).
- [x] Write Chinese twin `src/content/posts/zh/self-hosting-mem0.md` (same filename โ auto language-switch).
- [x] Follow the "hard job โ post" template (ยง4 content-guide) + open with "why it matters" + end with hire CTA (ยง8 post-guideline).
- **Governing doc:** `content-guide.md` ยง4/ยง8, `post-guideline.md` ยง8.
- **Done when:** โ
both EN + ZH pages live, language-switch works, hire CTA present.
**Step B1b โ Write the self-hosted STT case study.** โ
Done 2026-09-19
`self-hosted-speech-to-text-api.md` (EN + ZH): whisper.cpp on GPU + n8n auth gate +
nginx gateway, with the four build traps and the "5x faster than typing" business case.
- [x] EN + ZH posts, custom OG + banner, hire CTA.
- **Done when:** โ
both pages build, language-switch verified, images generated.
**Step B2 โ Write 2โ3 short "gotcha" posts (Google-friendly, compound over time).**
- [x] "Replacing RDPGuard With IPBan: The Traps Nobody Documents" (EN + ZH, `devops`, 2026-09-19) โ the uninstaller that unbans 12 attackers, `--install-service` doesn't exist in v4.1.0, `ExpireTime` vs `BanTime`. Both images custom.
- [x] "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB" (EN + ZH, `devops`, 2026-09-19) โ a client whose error handler crashed while reporting its own errors, masking every real failure; three patches to Oracle's bundled code, all stemming from `major >= 8` being an invalid MySQL-vs-MariaDB test. Both images custom.
- [x] "Why Chrome Forgets Its Tabs in a Container โ And How I Fixed It" (EN + ZH, `devops`, 2026-09-29) โ the container kills the browser, so it never sees a clean exit and *no* restore mechanism fires (flag, hand-edited `Preferences`, `RestoreOnStartup` policy all verified failing); the 60-second snapshot keeper that fixed it, plus the stale `Singleton*` and `custom-cont-init.d` permission traps. Both images custom.
- [x] "The Forward-Auth Gate That Verified Perfectly โ and Wasn't Live" (EN + ZH, `devops`, 2026-09-29) โ the B2 forward-auth item, delivered with a better villain than Traefik: the gate passed every local check (302 โ outpost, branded login page) while the public URL served the app directly, because that hostname is served by a Cloudflare tunnel rule that bypasses nginx; adds `skip_path_regex` (SSO for the UI, open API), the "count before you substitute" revert trap (11 vhosts share the outpost port) and the same-second reload race. Custom OG + banner, hire CTA, commit `e7e4ee5`.
- [ ] "Site-to-site OpenVPN behind CGNAT"
- [ ] "Fixing the WordPress /cv 301โ404 chain" (from own audit)
- **Governing doc:** `content-guide.md` ยง3 (post type #3), `post-guideline.md`.
- **Done when:** โฅ2 gotcha posts live (these are `notes`/`devops`, no Chinese translation required per ยง8).
- โ **Note:** `post-guideline.md` ยง8 (newer) says *every* post gets a ZH twin โ the "no Chinese required" note above is stale. The RDPGuard post was published EN + ZH.
**Step B2b โ Draft bank (written, held as `draft: true`, publish when content runs short).** โ
Drafted 2026-09-20
Two finished posts (EN + ZH, each with frontmatter pointing at OG + banner paths) sitting in the repo but
**not built or listed** โ `draft: true` excludes them from all listings and generates no pages.
| Slug | Category | Status | Assets |
|---|---|---|---|
| `migrating-codeigniter-iis-to-openlitespeed` | `engineering` | drafted, unpublished | OG + banner PNGs **not yet generated** |
| `upgrading-codeigniter-46-to-47` | `notes` | drafted, unpublished | OG + banner PNGs **not yet generated** |
**To publish one later:**
1. Flip `draft: true` โ `draft: false` in **both** `src/content/posts/.md` and `src/content/posts/zh/.md`.
2. Set the real `pubDate` (currently `2026-09-20`, the draft date) in both files.
3. Generate its images: `node scripts/og-gen/generate.mjs ` and `node scripts/banner-gen/generate.mjs ` (add a `TERMINALS[slug]` / `BANNERS[slug]` entry first for the custom panel).
4. `npm run build`, commit, `git push origin main`.
5. Verify both URLs return 200 and the language switcher links them.
- **Why these two:** `engineering` had only 1 post and `tutorials` only 1 โ the blog was ~all `devops`/`case-studies`. These put PHP/CodeIgniter (the actual day-job stack) on the blog, which is what a PHP full-stack recruiter searches for.
- **Governing doc:** `content-guide.md` ยง3/ยง4, `post-guideline.md` ยง8.
- **Done when:** both are published live with EN+ZH, custom OG + banner, and verified 200.
**Step B2c โ (unplanned) Publish "No API for Browser Translation" โ the English-mode story.** โ
Done 2026-09-29
`adding-english-mode-to-a-chinese-only-web-app` (category `engineering`, EN + ZH): the `` lie that
suppressed the browser's translate prompt, the fact that no browser-translate API exists, and the gateway-injected
dictionary + floating EN button (871 labels, 77โ88% measured coverage). Custom OG + banner, hire CTA, commit `8a9ba6a`.
- **Why:** `engineering` was the thinnest published category, and the story is a rare, searchable gotcha with hard numbers.
- **Done when:** โ
both pages 200, language switch links both ways, sitemap hreflang pair, RSS entry, OG + banner served.
**Step B2d โ (unplanned) Publish "How I Vet an Open-Source Dependency Before Betting On It".** โ
Done 2026-09-29
`vetting-an-open-source-dependency-before-you-bet-on-it` (category `devops`, EN + ZH): the six checks that
corrected six assumptions from a 1,306-line architecture spec โ repo vital signs via the GitHub API, grepping for
the feature instead of reading for it (OIDC/SSO/SAML โ 0 hits), reading a feature doc's target branch
(white-label lives on `multi-tenant`, not `main`), reading the data model rather than the feature list
(`percent`/`fixed` vs five assumed rule types), the Community-vs-Enterprise tier gate (API tokens free, SSO paid),
and whether the money rail works in-country (selfhost + manual payouts, not Stripe Connect). Custom OG + banner,
hire CTA, commit `8711136`.
- **Why:** forms a due-diligence cluster with `how-to-verify-a-hosting-provider-before-you-buy` and
`how-i-vetted-20-vps-providers-with-parallel-subagents` โ those cover vendors you *pay*, this covers code you
*depend on*. `devops` is the most differentiated category.
- **Done when:** โ
both pages 200, language switch links both ways, OG + banner served (1200ร630 / 1600ร900),
listing order monotonic on `/posts/`, `/` and `/zh/`, Gitea Actions task `success`.
- โ **Overlap to watch:** two parallel in-flight posts cover adjacent material
(`nocodb-sso-is-a-licensed-feature`, `authentik-forward-auth-gate-wasnt-live`). If those publish, add
cross-links so the trio reads as a series rather than repetition.
**Step B2f โ (unplanned) Publish the agent-safety + CDP-automation pair.** โ
Done 2026-09-29
Two posts from the DigiKedai voucher-automation session, both backdated into the archive's empty 2025
stretch (EN + ZH, custom OG + banner, commit `170f5cc`):
| Slug | Category | pubDate | updatedDate | What it is |
|---|---|---|---|---|
| `i-let-an-agent-manage-my-shopee-vouchers` | `ai` | 2025-07-08 | 2026-09-29 | the audit โ propose โ auto design, the `mode: propose` gate, and the list-lag mistake that created an unplanned RM140 voucher |
| `why-your-cdp-clicks-silently-fail` | `devops` | 2025-03-19 | 2026-09-29 | stale rect from `scroll-behavior: smooth`, coordinate clicks dropped on a hidden window, JS-dispatched events โ and how to tell the three causes apart |
- **Why 2025 dates:** 2025-01 โ 2025-10 was completely empty in the archive (nearest neighbours:
2024-09-24 and 2025-11-12). Backdating fills a real gap; `updatedDate` keeps `lastmod` / `dateModified`
honest (post-guideline backdating rule). Neither post carries a date-, month- or version-pinned sentence โ
verified with `grep -nE "20[0-9]{2}|January|โฆ|tonight|this week"` **before** moving the date.
- โ **B2's gotcha list is still open:** the three bullets there (Traefik forward-auth, OpenVPN behind CGNAT,
WordPress `/cv` 301โ404 chain) remain unstarted; `why-your-cdp-clicks-silently-fail` counts as an extra.
- โ **Generator entries deliberately NOT committed.** Another session had uncommitted edits in
`scripts/og-gen/generate.mjs` / `scripts/banner-gen/generate.mjs`, so the `TERMINALS` / `BANNERS` entries for
these two slugs were applied through throwaway `generate.local.mjs` copies (deleted afterwards) and the images
were committed as static files. **Re-add the four entries below** the next time one of these images must be
regenerated, or when the next post needs a panel in the same style:
````js
TERMINALS['i-let-an-agent-manage-my-shopee-vouchers'] = `
$python voucher_watch.py --auto
Confirm clicked ยท voucher not in the list โ the list was 10 min stale
mode=proposeโ waiting for price confirmation โ
`;
TERMINALS['why-your-cdp-clicks-silently-fail'] = `
$cdp click '.picker-item input'
clicked @591,361 ยท nothing happened ยท document.hidden=true
bringToFront + dispatch MouseEventโ picker opens โ
`;
BANNERS['i-let-an-agent-manage-my-shopee-vouchers'] = {
titlebar: 'unraid โ shopee voucher watch',
lines: [
{ t: 'cmd', text: 'python voucher_watch.py --auto' },
{ t: 'dim', text: 'vouchers have no draft state โ Confirm = live + escrow' },
{ t: 'err', text: 'Confirm clicked ยท not in list ยท judged "refused" ยท clicked twice more' },
{ t: 'ok', text: 'it existed โ the voucher list was ~10 min behind' },
{ t: 'err', text: 'cost: one unplanned RM14/29 voucher ยท exposure RM140' },
{ t: 'cmd', text: 'policy: mode=propose ยท cap RM300/month' },
{ t: 'ok', text: 'PROPOSAL RM9.60 / min RM18 ร 20 โ WAITING FOR PRICE' },
{ t: 'hl', text: 'agent-created vouchers since the gate: 0 ยท 4 live' },
],
flow: [
{ n: '1', label: 'read-only audit' },
{ n: '2', label: 'propose params' },
{ n: '3', label: 'human confirms' },
{ n: '4', label: 'create once โ' },
],
};
BANNERS['why-your-cdp-clicks-silently-fail'] = {
titlebar: 'canary โ seller centre via CDP',
lines: [
{ t: 'cmd', text: "click '.picker-item.end-picker input'" },
{ t: 'err', text: 'clicked @591,361 ยท picker never opened' },
{ t: 'dim', text: 'scroll-behavior: smooth โ rect read mid-animation' },
{ t: 'ok', text: 'scrollBehavior=auto + behavior:instant โ rect is real' },
{ t: 'err', text: 'document.hidden=true โ coordinate clicks dropped' },
{ t: 'cmd', text: "['mousedown','mouseup','click'].forEach(dispatchEvent)" },
{ t: 'ok', text: 'picker opens every time ยท Confirm lands โ' },
{ t: 'hl', text: '3 causes ยท 1 injected listener tells them apart' },
],
flow: [
{ n: '1', label: 'arm listener' },
{ n: '2', label: 'elementFromPoint' },
{ n: '3', label: 'JS-dispatch click' },
{ n: '4', label: 'opens every time โ' },
],
};
````
- **Done when:** โ
both pages 200 (EN + ZH), language switch links both ways, OG + banner served
(1200ร630 / 1600ร900), listing order monotonic on `/posts/`, `/` and `/zh/`, Gitea Actions task `success`,
and `git status` clean of other sessions' files.
**Step B3 โ Adopt the "hard job โ post" habit.**
Every solved problem becomes a `notes` entry the same week.
- [ ] Revisit cadence target: 2 posts/month โ 1/week (`content-guide.md` ยง5).
- **Done when:** 3 consecutive months hit the 2-posts/month floor.
**Step B2e โ (unplanned) Two posts out of the SSO / forward-auth session.** โ
Done 2026-09-29
Same working session that wired (and then deliberately rolled back) an authentik forward-auth gate in front of a
self-hosted app produced two posts:
| Slug | Category | What it argues | Commit |
|---|---|---|---|
| `authentik-forward-auth-gate-wasnt-live` | `devops` | the gate verified perfectly from the host while the public URL bypassed it โ two ingress layers per hostname; verify from outside and read which software answered (`x-powered-by`) | `e7e4ee5` |
| `nocodb-sso-is-a-licensed-feature` | `notes` | the OIDC env vars are real and enforced at boot, but the feature is Business+; on an unlicensed build an unauthenticated `GET /auth/oidc` throws and exits(1); MySQL meta blocks licensing; the "drop-in" community fork is abandoned (0.255.2, 2024-10-29) | `2d0de72` |
- Both EN + ZH, custom OG + banner, hire CTA; both link to each other (one-way: the NocoDB post links to the gate post).
- **Why:** the trap is rare and genuinely searchable (`authentik forward auth`, `nocodb sso self-hosted`), and both are
first-person debugging stories with measured evidence โ the moat per `content-guide.md` ยง7.
- **Done when:** โ
4 pages 200 with expected content, language switch links both ways, 4 images served as `image/png`.
**Step B2f โ (unplanned) Four monitoring posts out of one Prometheus/Grafana session.** โ
Done 2026-09-29
One working session that unified monitoring across unRaid + Synology DSM + a VPS produced four posts. All four are
**backdated** into the 2026-03-25 โ 2026-09-04 archive gap (that stretch had no posts) with `updatedDate: 2026-09-29`
holding the real date, so the sitemap `lastmod` stays honest and listings still sort by `pubDate`:
| Slug | Category | pubDate | What it argues |
|---|---|---|---|
| `smartctl-exit-code-32-skips-the-disks-that-matter` | `notes` | 2026-04-14 | `smartctl`'s exit status is a bitfield, not a boolean: `rc=32` means "SMART OK, attributes were below threshold in the past". An `if ! smartctl` guard skipped 2 of 4 SSDs โ exactly the marginal ones. Fix: mask the informational bits (32/64), export `rc` as a metric. |
| `why-your-grafana-dashboard-shows-no-data` | `devops` | 2026-05-17 | A template variable defined as `label_values(...{nodename=~"$nodename"})` filters on itself โ 0 options โ `$node` empty โ every panel No data while all targets are `up`. Also: why hand-substituting variable values during verification hides exactly this bug, and `$__all` โ `.*` in automated panel checks. |
| `your-disk-full-alert-is-lying` | `devops` | 2026-06-24 | Percentage thresholds on multi-TB volumes fire while 500 GB remains; 92% "memory used" with 4.8 GB available is cache, not pressure. Alert on consequences: bytes free, `MemAvailable`, steal >50%. Includes the "keep the comparison in the threshold condition" rule and the mount-selector exclusions. |
| `one-prometheus-for-unraid-synology-and-a-vps` | `case-studies` | 2026-07-29 | The flagship: node_exporter vs cAdvisor coverage matrix; `name!=""` for cAdvisor's non-container cgroups; "total storage" counting one NAS volume three times (`/volume1`, `/opt`, CIFS re-mount) and the dedup selector; a KVM guest exporting no CPU frequency at all (textfile collector, distinct metric name, merged with `or`); a container reporting its own ID as `nodename`. Result: 7 targets, 47 cores / 158 GHz / 142 GB / 64 TB / 155 containers on one screen. |
- All four EN + ZH, custom OG + banner, hire CTA naming "self-hosted monitoring pipelines"; no post carries an absolute
date or "recently/as of" phrasing, which is what made the backdating safe (per `post-guideline.md` backdating rule).
- **Why:** the blog had **zero** Prometheus/Grafana/monitoring posts while `content-guide.md` ยง2 lists monitoring under
`devops`, "my most differentiated material" โ and `monitoring`/`grafana no data`/`smartctl exit code` are heavily
searched by exactly the audience this blog targets.
- **Done when:** โ
8 pages 200 with expected content, language switch links both ways, 8 images served as `image/png`,
archive order still monotonic on `/posts/`, the homepage and `/zh/`.
**Step B2g โ (unplanned) Three posts out of the Synology Office / spreadsheet-API session.** โ
Done 2026-09-29
One session spent making a Synology NAS read, write and chart spreadsheets produced three posts. All three are
**backdated** into the 2024-09-24 โ 2025-11-12 gap โ the widest stretch in the archive with no posts โ with
`updatedDate: 2026-09-26` holding the real date, so `lastmod` stays honest and listings still sort by `pubDate`:
| Slug | Category | pubDate | What it argues |
|---|---|---|---|
| `synology-spreadsheet-api-is-a-container` | `devops` | 2025-08-20 | The flagship. Enumerating the DSM gateway (1,515 APIs) showed no cell-level Office endpoint, so I concluded the NAS had no spreadsheet API โ **wrong**: it ships as the container `synology/spreadsheet-api` (image tag โ Office version table). Plus two diagnostics that lied (`synopkg is_onoff` reporting a running package as "not turned on"; `ps` without `sudo` on DSM listing only your own processes, which made a live stack look dead), a required `AUTH_SECRET` whose absence crashes with a minified stack trace, a `401` with provably correct credentials, 2FA that can never authenticate, `403` vs `404` semantics, a personal `My Drive` unreachable by any service account, and the verified fix โ read/write/CSV/`.xlsx` with Synology's own engine evaluating the formulas, then a chart out the far end. |
| `synology-api-401-with-the-correct-password` | `notes` | 2025-09-10 | The two causes of a `401` when the password is right: `host` must be an FQDN whose certificate the proxy accepts (a bare LAN IP fails its TLS handshake, and a failed handshake is reported identically to a bad password), and a 2FA account can never sign in (`AuthorizationBody` has no OTP field). Includes the three-command triage that separates them, and why a token that worked yesterday returns `401` today โ it's bound to the DSM session, not just to a 28-day clock. |
| `reading-a-containers-own-api-docs` | `notes` | 2025-10-01 | Extract a container's contract from the artifact instead of the vendor's page: `--entrypoint cat` the bundled OpenAPI spec, `--entrypoint grep` the bundle for the env-var contract and the defaults, read Env/Entrypoint/Cmd from the registry config blob without pulling a byte, decode the real listening port from `/proc/net/tcp`, and run detached to read startup logs without hanging the shell. |
- All three EN + ZH, custom OG + banner (centering **measured**, not eyeballed: gapAbove/gapBelow 47/49, 76/78,
106/108, `delta=2px`, `overflow=0`), hire CTA naming self-hosted integrations; the two `notes` posts link up to
the flagship with a relative link.
- **Why:** the search results for `synology spreadsheet api` / `spreadsheet-api docker` are Synology's own Hub page,
a German how-to and two MCP wrappers โ nothing covers the failure modes, and the "vendor tool told me the wrong
thing" shape matches the blog's strongest existing genre (`patching-workbench-26-for-mariadb`,
`when-smart-says-healthy-but-your-raid-is-corrupting-data`).
- **Done when:** โ
6 pages 200 with expected content, language switch links both ways, 6 images served as
`image/png`, archive order still monotonic on `/posts/`, the homepage and `/zh/`.
**Step B2h โ (unplanned) Audit + backdate of the six posts that landed on 2026-09-29.** โ
Done 2026-09-29
One publishing day put **six** EN posts on the same `pubDate`, so `/posts/` opened with a single-day dump. Each was
checked for date-, month- and version-pinned prose before its frontmatter was touched; two carried no pins and were
moved into the archive's empty months with `updatedDate: 2026-09-29` holding the real date:
| Slug | Category | pubDate โ updatedDate | Why it was safe / blocked |
|---|---|---|---|
| `adding-english-mode-to-a-chinese-only-web-app` | `engineering` | 2025-06-11 โ 2026-09-29 | no absolute date, month name, version or relative-time phrasing anywhere in EN or ZH; fills the empty 2025-06 |
| `authentik-forward-auth-gate-wasnt-live` | `devops` | 2026-08-12 โ 2026-09-29 | no pins. `nocodb-sso-is-a-licensed-feature` links *back* to it, so it must stay dated earlier than 2026-09-29 (it does), and the "licensing story in its own write-up" forward reference now reads as weeks rather than months. Fills the empty 2026-08 |
- **The four that had to stay on 2026-09-29, and the exact sentence that pins them:**
- `read-only-nocodb-dashboard-for-a-remote-database` โ image tag `nocodb/nocodb:2026.09.0` **and** a returned row
timestamp `2026-09-27 01:54:16+00:00` โ floor 2026-09-27.
- `nocodb-sso-is-a-licensed-feature` โ same `2026.09.0` image tag โ floor 2026-09-01.
- `vetting-an-open-source-dependency-before-you-bet-on-it` โ quotes the GitHub API as "last push 2026-09-10"
โ floor 2026-09-11.
- `why-chrome-forgets-its-tabs-in-a-container` โ the setup table names `Chrome 154` (โ Oct 2026 on Chrome's
cadence) and the post links back to `scraping-bot-walled-marketplace-warm-browser-session` (2026-09-13) as
something already written โ floor 2026-09-13.
- **Still empty, and therefore the spare slots for the next batch:** 2024-10 โ 2025-02 (five months) and 2025-04/05.
- **Done when:** โ
build clean, `lastmod` = 2026-09-29 for all four URLs (EN + ZH), listing order still monotonic
on `/posts/`, the homepage and `/zh/`, both article pages render the historical date.
**Step B2i โ (unplanned) The web3 category, plus two engineering posts.** โ
Done 2026-09-29
`web3` had **zero** posts and no route at all: `/categories/` rendered its card with the label
"0 posts ยท coming soon" as a *non-link*, and `/categories/web3/` returned **404** (Astro only emits a
category detail route once the category has posts). A Gitea sweep found seven real web3 repos whose
commits date to **2024-08-15 โ 2024-08-19**, which is also why those posts could be backdated honestly.
Five posts shipped (EN + ZH, custom OG + banner, hire CTA):
| Slug | Category | pubDate | updatedDate | Source repo |
|---|---|---|---|---|
| `fully-on-chain-svg-nfts` | `web3` | 2024-10-08 | 2026-09-29 | `foundry-nft` โ `MoodNft.sol`, `DeployMoodNft.s.sol` |
| `why-my-on-chain-nft-art-changed-on-windows` | `web3` | 2026-08-19 | โ | `foundry-nft` โ the `.gitattributes` fix commit |
| `chainlink-vrf-v2-lottery-contract` | `web3` | 2024-12-10 | 2026-09-29 | `hardhat-smartcontract-lottery` โ `Raffle.sol` |
| `verifying-a-pdf-report-page-by-page` | `engineering` | 2026-09-28 | 2026-09-29 | `numerology-report` โ `docs/pdf-pipeline.md` |
| `jpa-version-field-lost-update` | `engineering` | 2026-08-19 | โ | `springboot-hoelee-demo` โ `@Version` |
- **Why these dates:** the two 2024 posts fill the empty 2024-10 and 2024-12 archive months with the real
work date and `updatedDate` holding the true date, so `lastmod` stays honest. The two 2026-08-19 posts use
the real work date rather than joining the 2026-09-29 pile-up. `verifying-a-pdf-report-page-by-page` was
moved **one day** to 2026-09-28 for the same reason โ it was the fifth post landing on 2026-09-29.
- โ **Date pins were re-checked before moving any date.** The only `202[0-9]` hits in the two backdated
posts are inside the contract address `0xc2022b56โฆ`, not dates. All cited figures were traced to source:
`868596` / `4102 bytes` / `993568` gas / `0.000535185588997216 ETH` / block `6522146` / mint `181874`
(deploy + mint logs), `335011` gas (`.gas-snapshot`), `2.5ptโ7.5pt` + `20,225,818` bytes + `30โ490pt`
(`docs/pdf-pipeline.md`; the `27 pages` / `12,789 pages` figures live in `app/Libraries/ReportPdf.php`
lines 21 and 196, **not** in the doc), `@Version` + `POST_VERSION_CONFLICT` in the Spring demo.
- โ **The `TERMINALS` / `BANNERS` entries for all five slugs ARE committed this time** (unlike B2f, which had
to hide them in this file because a parallel session held uncommitted generator edits). Both generators
were verified clean and the diffs purely additive (30/0 and 100/0) before editing.
- Banner centering **measured, not eyeballed**: all five at 8 rows, `gapAbove`/`gapBelow` within 2px,
`overflow=0`, `scrollHeight == clientHeight == 636`.
- **Done when:** โ
2 new category routes (`/categories/web3/`, `/zh/categories/web3/`), build 127 pages clean,
all 10 post URLs + 10 images 200, language switch both ways, listing order monotonic on `/posts/`, `/`, `/zh/`.
### Phase C โ Discovery & structure (Tier 2)
**Step C1 โ Per-post custom OG images (at least for case studies).**
Currently every post shares the generic 14KB `og-default.png` โ flagship posts share the same bland card as category pages.
- [ ] Build a branded 1200ร630 OG template (name + face + title).
- [ ] Generate a custom `ogImage` for each case study (frontmatter `ogImage:` field already supported).
- **Governing doc:** `design-guide.md` ยง2/ยง3, `content-guide.md` ยง6 (ogImage field).
- **Done when:** each case study's `og:image` is unique and 1200ร630.
**Step C2 โ Tag pages** (`/tags/[tag]/` archive pages for fine-grained discovery + internal linking).
- [ ] Add tag archive routes (tags currently render as labels only).
- **Done when:** clicking a tag on any post opens a working `/tags//` page.
**Step C3 โ Categories page shows all 7 categories** (not just those with posts), with "0 posts / coming soon" for empty ones โ signals intended coverage. โ
Done 2026-09-13
- [x] `/categories/` lists all 7 categories with name, description, per-category terminal-style SVG illustration (CategoryArt/Grid components) and a post count; empty ones show "0 posts ยท coming soon" as a non-link.
- **Done when:** โ
all 7 render with a placeholder for empty ones + descriptions + illustrations.
**Step C4 โ Dedicated `/zh/posts/` and `/zh/categories/` archive pages.** ๐ต In progress
- [x] `/zh/categories/` index + `/zh/categories/[category]/` detail pages live (zh nav "ๅ็ฑป" points there; PostList is locale-aware with zh-CN dates).
- [ ] `/zh/posts/` archive still missing โ zh nav "ๆ็ซ " falls back to `/zh/` landing (zh post count already 17, the archive is due).
- **Done when:** zh nav links to real `/zh/posts/` + `/zh/categories/` archives.
### Phase D โ Polish / later (Tier 3)
**Step D1 โ Search** (AstroPaper-style fuzzy search). Low priority until >20 posts.
**Step D2 โ Google Search Console submission** โ submit `sitemap-index.xml` for faster indexing.
**Step D3 โ Newsletter / email capture** โ only after real traffic exists (agree: do NOT add yet).
---
## 3. Research Findings Snapshot (Sept 2026)
What the reference blogs do that blog.hoelee.com should mirror, ranked:
| Finding | Reference example | Status on blog.hoelee.com |
|---|---|---|
| Real name + photo + one-line identity | All four | โ ๏ธ name โ
, photo โ (letter "M") โ **Step A1** |
| Focused thesis (one sentence on what it's about) | Julia Evans, Simon Willison | โ ๏ธ has it, but title/hero drift โ **Step A2** |
| Honesty about what you *don't* know | Simon, Dan Abramov | โ
strong (DigiKedai "bugs that ate an afternoon") |
| Specific detail: code, diagrams, numbers, bug stories | All four | โ
strong |
| Consistent cadence (slow is fine, dead is not) | Julia (~monthly), Simon (daily) | โ ๏ธ only 3 posts, all Sept 4โ6 โ **Phase B** |
| "Start here" / Favorites route | Julia Evans, Josh Comeau | โ โ **Step A3** |
| RSS + sitemap + clean SEO | All four | โ
|
| Per-post OG images | Josh Comeau | โ โ **Step C1** |
| Search (once >15โ20 posts) | Josh Comeau | โ deferred โ **Step D1** |
---
## 4. Conventions (non-negotiable)
- Push git.hoelee.com first, then GitHub
- English-first; Chinese selective (2โ3 flagship case studies); no Malay
- No overclaiming, especially Web3
- Name identity: "Lee Teong Hoe" / "Mr Hoelee" + same photo + same `sameAs` handles everywhere
- Business framing: website design & development is primary; email hosting is secondary
- English post titles use Title Case
- See `docs/post-guideline.md` for post-writing rules; `docs/content-guide.md` for strategy; `docs/design-guide.md` for UI