# 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 - βœ… **Live re-audit of the Sept research recorded (Β§2 Phase E + Β§4), 2026-09-29** β€” 53 posts Γ— 2 languages, 127 sitemap URLs, 133/133 internal links 200 --- ## 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. > > **⚠ Re-checked against the live site 2026-09-29 (Β§4).** Parts of Phase A/C/D are stale β€” **C1, D1 and B2b are DONE.** The measured bottleneck is no longer content; it is **distribution / visibility**. Per user decision on 2026-09-29, **Phase E is inserted at the TOP of the queue β€” start there**, then work the still-open A/C items. ### Phase E β€” Distribution & visibility (added 2026-09-29 from the live re-audit β€” DO THESE FIRST) The Sept research assumed the constraint was identity + content. Measured on 2026-09-29 that is no longer true: 53 posts live in both languages, 127 sitemap URLs, and all **133 internal links** across the home, archive, category, about and ZH pages return **200**. The site is built and healthy β€” almost nobody can find it, and there is no instrumentation to tell whether anyone does. Full evidence table in Β§4. **Step E1 β€” Analytics + the search-performance loop.** ⬜ There is **no analytics of any kind**: home, post, about and ZH pages were grepped for GA/GTM, Plausible, Umami, Matomo, Clarity, PostHog and Cloudflare's `beacon.min.js` β€” zero hits. Without it, "did this post get read" is unanswerable, and the job-hunt thesis can't be verified. Decisions (2026-09-29): | Option | Verdict | |---|---| | **Cloudflare Web Analytics** | βœ… **Primary.** Free with no event/traffic cap, **cookieless** (no consent banner, no CLS cost), one snippet in the base layout β€” and it reports Core Web Vitals field data, which the Sept report's CWV item otherwise has no way to check. | | **Google Search Console** | βœ… **Not optional, and half-done already.** `hoelee.com` carries a `google-site-verification=…` TXT record, so a **domain property** already covers `blog.hoelee.com`. Unconfirmed: whether `https://blog.hoelee.com/sitemap-index.xml` was ever submitted. Only the GSC UI can answer that. | | **Umami** (self-hosted on DSM β€” MIT, ~1 M events/month free cloud tier) | πŸ”΅ Optional later, only if per-event funnels or full data ownership are wanted. | | GA4 / Plausible CE | ❌ Skip β€” GA4 brings weight + a consent banner; Plausible CE needs Postgres **and** ClickHouse. | - [ ] Enable CF Web Analytics for the zone and add the beacon to the base layout's `` (belt-and-braces vs CF auto-injection). - [ ] Confirm the GSC domain property covers the blog, submit `sitemap-index.xml`, and re-check "Discovered / Indexed" counts ~a week later. - **Done when:** a pageview from a second device shows up in CF Web Analytics, and GSC lists the sitemap as Submitted with the post URLs Discovered. - **Governing doc:** `seo-reference.md`. **E1 progress β€” self-hosted Umami deployed 2026-09-29 βœ… (deploy only; not yet public, not yet wired to the blog)** | Item | State | |---|---| | Stack | DSM Portainer **stack 284 `umami`** (endpoint 2), container `umami`, host port **5410 β†’ 3000**, network `bridge_hoelee` | | Image | `ghcr.io/umami-software/umami:postgresql-latest` β†’ resolved **v3.4.0** (Node 22.23.2). ⚠ v3 publishes versioned tags on `docker.umami.is`, but ghcr only carries rolling tags (`postgresql-v*` stops at 2.16) β€” record the running version before any upgrade or there is no tag to roll back to | | Database | **Reused the shared instance**: stack 139 `postgres` β†’ `postgres-server` (PG 17.10, `Etc/UTC`). New role + DB `umami` (login only, **not** superuser). `CREATE EXTENSION pgcrypto` works because PG 13+ treats it as trusted. ⚠ Data lives in `/volume1/docker/postgres-server/data` next to authentik / n8n / tubesync / crowdsec β€” same fate if that volume is restored | | Env | `DATABASE_URL`, `APP_SECRET`, `TWO_FACTOR_ENCRYPTION_KEY`, `CLIENT_IP_HEADER=x-forwarded-for`, `DISABLE_TELEMETRY=1`, `DISABLE_UPDATES=1`, `MCP_ENABLED=1`, `TZ=Asia/Kuala_Lumpur`. No `cpus:` (DSM has no CFS quota) | | Verified on LAN | `/api/heartbeat` β†’ `{"ok":true}` (first hit 6.1 s cold, then 66 ms), `/login` 200, container `healthy`, prisma migrations created **16 tables** | | Ops notes | `/volume1/docker/umami/README.md` | | **Public URL** | **https://stats.hoelee.com** live 2026-09-29 β€” DNS **A β†’ 180.73.9.11** (the user's usual path, **not** through Cloudflare) β†’ router 443 β†’ DSM nginx reverse proxy (`ReverseProxy.json` key `8df2ab4d-ff73-47a0-b5ad-5a09957e6402`, frontend `stats.hoelee.com:443` β†’ `localhost:5410`) β†’ container. Cert = Let's Encrypt `CN=stats.hoelee.com`, 2026-09-28 β†’ 2026-12-27; `http://` β†’ 308. Public checks: `/api/heartbeat` 200 `{"ok":true}`, `/login` 200, `/script.js` 200, `/api/send` with a bogus id β†’ 400 `Website not found` | | **Client IP fix** | ⚠ Because traffic does **not** pass Cloudflare, `CLIENT_IP_HEADER` was changed `cf-connecting-ip` β†’ **`x-forwarded-for`** (what DSM nginx sets) on 2026-09-29, stack re-PUT and verified in the running container. Without it every visit would be attributed to the proxy. Consequence of no CF: no WAF/rate-limit/bot protection on this hostname | | ⚠ **Open security item** | The Umami dashboard is **publicly reachable with the default `admin` / `umami` credentials** β€” verified 2026-09-29 (`POST /api/auth/login` β†’ 200 + token). New subdomains appear in Certificate Transparency logs within minutes, so this needs closing now: (a) change the password, and/or (b) gate it β€” public router for `/script.js` + `/api/send` + `/api/heartbeat`, everything else behind basicAuth/authentik (recipe in the stack README) | | Still open | (a) CF Web Analytics still not enabled (independent of Umami). (b) GSC sitemap submission still unconfirmed. (c) The blog's tracker snippet is **not** wired yet β€” no data is collected until a website record exists and the snippet is in the base layout. | | Dashboard login | default `admin` / `umami` β€” change on first login (agent does not hold this password) | **Step E2 β€” Wire the two sites together (www.hoelee.com β†’ blog).** ⏸ Deferred by user 2026-09-29 Measured: **all six pages of www.hoelee.com** (home, `/zh-hans/`, `/about-mrhoelee/`, T&C, support, privacy) contain **zero** references to `blog.hoelee.com`, while the blog links out to www.hoelee.com. The older, more established domain passes no authority and offers no click-path to the portfolio β€” one-way. ⚠ **User decision 2026-09-29: www.hoelee.com needs a full overhaul; do NOT patch it piecemeal now.** Fold this link into that overhaul (together with the v2.1 audit finding that its agency framing conflicts with the job hunt). Tracked here so it isn't lost. **Step E3 β€” Syndicate 2–3 flagship posts.** ⬜ (report Β§9 "Month 2+ β€” promote": never executed) `seo-reference.md` already carries the syndication policy (cross-post with canonical back to the original domain); nothing has ever been syndicated, so the 53 posts only exist for people who already know the URL. - [ ] Cross-post to dev.to with `` pointing at the original: `how-i-built-the-digikedai-telegram-bot`, `one-prometheus-for-unraid-synology-and-a-vps`, `self-hosting-mem0`. - [ ] One LinkedIn share per flagship (same photo + name identity as the blog). - **Done when:** each dev.to copy is live with the canonical set, and each has one LinkedIn post. **Step E4 β€” Chinese-side routes and nav are broken.** ⬜ (extends Step C4) Live ZH pages: the nav renders γ€Œζ–‡η« γ€β†’ `/zh/` (fallback; `/zh/posts/` **404s**) and γ€Œε…³δΊŽγ€β†’ `/zh/` β€” i.e. the Chinese nav's *About* link is wrong even though `/zh/about/` itself returns 200 and is in the sitemap. `grep '/zh/about/'` finds **no inbound link anywhere on the site**, so the ZH About page is orphaned. - [ ] Build the `/zh/posts/` archive; point γ€Œζ–‡η« γ€ at it and γ€Œε…³δΊŽγ€ at `/zh/about/`. - **Done when:** `/zh/posts/` 200, both ZH nav labels point at the right routes, and `/zh/about/` is reachable by clicking from `/zh/`. **Step E5 β€” `og:locale` is wrong on all 53 Chinese pages.** ⬜ ZH post pages emit `` with `og:locale:alternate = en_US`. - **Done when:** ZH pages emit `zh_CN` (`en_US` as the alternate), EN pages unchanged. **Step E6 β€” Cloudflare is not caching the HTML.** ⬜ A post page returns `cf-cache-status: DYNAMIC` and `strict-transport-security: max-age=0`. A static site behind CF should be edge-cached β€” lower TTFB and less origin traffic through the tunnel. - [ ] Add a cache rule for `blog.hoelee.com` (cache HTML, honour origin `last-modified`) and take a decision on HSTS. - **Done when:** a repeat request shows `cf-cache-status: HIT` **and** a deploy still goes live within ~1 minute. **Step E7 β€” Content-volume guardrail.** ⬜ ongoing 53 posts shipped in ~4 weeks (30 dated 2026-09). The volume itself is the "content-farm" signal `content-guide.md` Β§7 warns about, even though every post traces to real logs/commits. Two rules: - Keep the existing per-post "every claim traces to a source file or log" check β€” do not relax it for volume. - **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.** ⬜ - [ ] `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. - [ ] `authentik-css-greater-than-bug` (the `>` bug) is complete in EN + ZH but still `draft: true` since 2026-09-20 β€” the last item in the draft bank; publish it with images. - [ ] `og-default.png` has **no face**; the homepage and category pages share it. Regenerate after Step A1 (same source asset). - **Done when:** no page references a 404 image, the draft bank is empty, and the default share card matches the A1 photo. ### 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 `<title>`/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.** βœ… Both published 2026-09-27 (EN + ZH, with OG + banner, verified 200 on 2026-09-29) β€” `migrating-codeigniter-iis-to-openlitespeed` and `upgrading-codeigniter-46-to-47`. The publish recipe below stays valid for the next draft. Remaining draft: `authentik-css-greater-than-bug` (**E8**). 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/<slug>.md` and `src/content/posts/zh/<slug>.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 <slug>` and `node scripts/banner-gen/generate.mjs <slug>` (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 `<html lang="en">` 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'] = ` <div class="line"><span class="prompt">$</span><span class="cmd">python voucher_watch.py --auto</span></div> <div class="line"><span class="prompt"> </span><span class="err">Confirm clicked Β· voucher not in the list β€” the list was 10 min stale</span></div> <div class="line"><span class="prompt"> </span><span class="cmd">mode=propose</span><span class="fix">β†’ waiting for price confirmation βœ“</span></div>`; TERMINALS['why-your-cdp-clicks-silently-fail'] = ` <div class="line"><span class="prompt">$</span><span class="cmd">cdp click '.picker-item input'</span></div> <div class="line"><span class="prompt"> </span><span class="err">clicked @591,361 Β· nothing happened Β· document.hidden=true</span></div> <div class="line"><span class="prompt"> </span><span class="cmd">bringToFront + dispatch MouseEvent</span><span class="fix">β†’ picker opens βœ“</span></div>`; 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.** βœ… Done (verified live 2026-09-29) Every published post serves its own `/og/<slug>.png` (1200Γ—630) generated by `scripts/og-gen/generate.mjs`, with the category chip derived from frontmatter. Two leftovers only: the shared `og-default.png` still backs the homepage and category pages (no face β€” folded into **A1 / E8**), and one post's banner 404s (**E8**). **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/<tag>/` 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. ⚠ Live-measured 2026-09-29: the ZH nav's γ€Œε…³δΊŽγ€ is **also** wrong (points at `/zh/`, not `/zh/about/`), and `/zh/about/` has **zero inbound links** β€” see **Step E4**, which folds into this one. - **Done when:** zh nav links to real `/zh/posts/` + `/zh/categories/` archives. ### Phase D β€” Polish / later (Tier 3) **Step D1 β€” Search.** βœ… Done (verified live 2026-09-29) Pagefind is shipped: `/pagefind/pagefind.js` β†’ 200, a search toggle in the nav on both locales, and the shards are correctly `Disallow`ed in `robots.txt`. (Originally deferred until >20 posts.) **Step D2 β€” Google Search Console submission.** β†’ merged into **Step E1** (2026-09-29). A `google-site-verification` TXT record already exists on `hoelee.com`, so the GSC domain property exists; what is unconfirmed is whether the blog's sitemap was ever submitted. **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 | βœ… done (verify 2026-09-29 β€” Β§4) | | Search (once >15–20 posts) | Josh Comeau | βœ… done β€” Pagefind live (Β§4) | --- ## 4. Live Audit Snapshot β€” 2026-09-29 (measured, not assumed) Method: `curl` from the Windows host (direct curl to blog.hoelee.com **worked** on this date β€” the DSM fallback was not needed), cross-checked against the local repo at `D:/dev/hoelee-blog`. | Check | Measured result | |---|---| | Posts | **53 published EN + 53 ZH** (54 files each, 1 draft) β€” every published post has a ZH twin, no orphans either way | | Sitemap | `sitemap-index.xml` β†’ `sitemap-0.xml`, **127 URLs** (53 posts + 53 ZH + 19 landing/category + `/posts/`), `lastmod` honest; 254 `xhtml:link` hreflang alternates | | Internal links | 133 unique internal links across home / `/posts/` / `/categories/` / about / ZH pages β†’ **133 Γ— 200, zero 404s** | | `robots.txt` | Pure ASCII, allows all crawlers (search **and** AI), `Disallow: /pagefind/`, declares the sitemap | | Search | **Pagefind live** β€” `/pagefind/pagefind.js` 200, nav search toggle on both locales (**D1 done**) | | Per-post OG | Every post serves `/og/<slug>.png`; **1 post has no banner** (`scraping-bot-walled-…`) | | Analytics | **None at all.** No GA/GTM, Plausible, Umami, Matomo, Clarity, PostHog or CF `beacon.min.js` on home, post, about or ZH pages β†’ **E1** | | Google Search Console | `hoelee.com` TXT `google-site-verification=g9jeE8…` exists β‡’ a **domain property** already covers the blog. Sitemap submission unverified β†’ **E1** | | www.hoelee.com | 6 pages, **0 references to `blog.hoelee.com`** (one-way linking) β†’ **E2 (deferred)**. Also `/cv` is now `301 β†’ cloud.hoelee.com` Drive share (GET 200) β€” the v2.1 "broken /cv" finding is **FIXED**; note `HEAD` still returns 404 (a Pretty Link quirk), so don't re-diagnose it from a HEAD | | Author identity | Author card is still the letter avatar `<div class="avatar">M</div>` on About **and every post**; no photo anywhere on the site β†’ **A1** | | ZH nav | γ€Œζ–‡η« γ€β†’ `/zh/` (no `/zh/posts/` route: **404**) and γ€Œε…³δΊŽγ€β†’ `/zh/` β€” while `/zh/about/` is 200 but **orphaned** (zero inbound links) β†’ **E4 / C4** | | `og:locale` | ZH pages emit `en` + alternate `en_US` β†’ **E5** | | Homepage | No featured/start-here strip (**A3**). `<title>` says "engineering, DevOps & self-hosting" while the hero says "full-stack developer and DevOps engineer" (**A2**, cosmetic β€” lowest priority) | | Caching | `cf-cache-status: DYNAMIC` on a post page; `strict-transport-security: max-age=0` β†’ **E6** | | Tags | Tags render as plain labels (not links), so `/tags/<tag>/` 404s cause **no broken links** β€” C2 is a missed-discovery item, not a bug | | Drafts | Exactly one: `authentik-css-greater-than-bug` (EN + ZH) β†’ **E8** | | Cadence | 30 posts dated 2026-09; worst single-day stack = **5** (2026-09-18) β€” the backdating work held, no single-day dump at the top of the feed | | What did NOT change | The Sept report's platform verdict (Astro static) and its theme advice (AstroPaper) β€” the platform call still holds; the theme pick is moot because the custom theme already shipped and works | --- ## 5. Conventions (non-negotiable) - Push git.hoelee.com first, then GitHub - English-first; **Chinese for every post** (same filename in `posts/zh/` β€” `post-guideline.md` Β§8 wins over the older "selective" wording); 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