44 KiB
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.
Slimmed 2026-10-19: the completed-phase logs (Phases A–D and F, plus the §1 batch detail) moved verbatim to
docs/handoffs/project-state-history.md. What stays here is the live queue plus one summary line per finished phase. As of 2026-10-19 the only open ⬜ items in the queue are owner actions (Cloudflare Web Analytics, Search Console) — engineering has no unstarted step left.
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/ProfilePageJSON-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 inposts/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-blogskill -
✅ Both repos public (Gitea + GitHub) with title/description/homepage/topics +
v1.0.0release -
✅ 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 Batch index — full logs for every batch (measurements, commits, traps) are in
docs/handoffs/project-state-history.md. -
✅ B1 image batch (2026-10-16) — article banner →
<picture>WebP (70 banners 6.02 MB → 2.52 MB); related cards went text-only; list thumbs gotsrcset400/800; desktop banner height capped. -
✅ B2 broken windows (2026-10-16) —
/posts/zh/403 → 301; new/zh/posts/; ZH nav fixed;og:localeen→zh_CN;hreflang/<html lang>→zh-Hans; a real 404 page; nginx config → host bind mount (DSM stack 247). -
✅ B3 structure (2026-10-16) — site search had never worked (the Pagefind ES module was read as
window.Pagefind); archive 18,220 → 3,788 px with a client-side filter; tag pages (235 tags measured, 156 used once); EN/ZH hero + nav fixes. -
✅ B4 identity & distribution (2026-10-16) — real author photo in all five surfaces + JSON-LD
Person.image; About 1,024 → 7,357 chars;/llms.txt. -
✅ B5 baseline & hygiene (2026-10-18,
3f63150) —/og-default.jpg211 KB → 45 KB carrying the headshot; banner-404 sweep to 72/72 clean; CJK system font stack; A2 branding alignment. -
✅ B6 visual redesign + Ideogram 4 pipeline (2026-10-18) — the consultant's hybrid "one art, four targets" template; bento theme tokens (a hardcoded dark scrim measured 1.96:1 text contrast in the light theme → 6.10). Phase F is its rollout.
-
✅ B7 review-response (2026-10-19,
5941e85+5ec321a) — About counter derived from the collection (71 → 72); reading time is CJK-aware (ZH 1 min → 5 分钟); ZH wording fixes;hello-world's stale language policy and container count; hero leads with the capability; featured cards got thesrcsetthey lacked; 48/48 mobile-overflow combinations at 0 px.
2. Execution Plan (work top → bottom, one step at a time)
The live queue is Phase E. Phases A–D and F are done; their logs moved to
docs/handoffs/project-state-history.md on 2026-10-19. What stays below is one summary line per completed
phase so "is this done?" is still answerable here without opening the archive.
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 in the Cloudflare dashboard. ⚠ There is no repo change to make — Cloudflare injects its own
cloudflareinsightsbeacon once the site is added, so this is an owner action, not a layout edit. Re-check withcurl -s https://blog.hoelee.com/ | grep -c cloudflareinsights→ 0 = still not enabled (measured 2026-10-19). - Confirm the GSC domain property covers the blog, submit
sitemap-index.xml(it serves 200 — measured 2026-10-19), 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:10000 = the authentik outpost since the SSO cut-over) → http://umami:3000. Cert = Let's Encrypt CN=stats.hoelee.com, 2026-09-28 → 2026-12-27; http:// → 308. Public allowlist via skip_path_regex only: /script.js 200, POST /api/send 200/400, /api/heartbeat 200; every dashboard path 302 → auth.hoelee.com |
| 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 |
| ✅ Hardening (A+B) done 2026-09-29 | (A) Default admin/umami replaced with a random 20-char password (C:\Users\hoelee\.secrets\umami-admin.txt) and APP_SECRET rotated → every previously issued session invalidated. Verified: old password 401, new password 200, old token 401. (B) New stack 285 umami-gateway (nginx:1.29-alpine, holds host port 5410 = what the DSM vhost targets) — public allowlist is exactly /script.js, /api/send, /api/heartbeat; everything else 403. Umami itself no longer publishes a public port; the dashboard moved to LAN-only 192.168.1.1:5411 (stack 284 republished 5411:3000), which is unreachable from the internet — verified from the VPS: TCP 5411 closed/filtered, TCP 443 open. ⚠ Superseded later the same day by the SSO gate below — the DSM vhost now points at the outpost, so stack 285 is no longer in the public path but is left running as the rollback |
| ⚠ Gate design constraint found | HTTP basic auth cannot be used here: the Umami front end sends Authorization: Bearer <jwt> on its own API calls, and a browser sends only one Authorization header — Bearer replaces Basic, so a basic-auth gate 401s every API call and the dashboard breaks. If a remote dashboard is ever wanted, one hostname is still enough — either rely on Umami's own login (+ enable 2FA) or put cookie auth (authentik forward-auth / oauth2-proxy) on this same hostname with skip-paths for the tracker (the traefik-login-numerology pattern). A second hostname (tracker vs admin) is the PostHog/Sentry-scale ingest-vs-app split, not a requirement. stats-admin.hoelee.com was only a hypothetical and was never created (NXDOMAIN verified) |
| End-to-end tracker verified | A real pageview POSTed through the public gate was recorded: 1 pageview / 1 visitor, with country=MY, region=MY-07, city=George Town, browser chrome, os Windows 10 — proving the X-Forwarded-For chain (DSM nginx → gate → Umami) works and geo needs no Cloudflare headers. The temporary test website was deleted afterwards (website list back to 0) |
| Still open | (a) CF Web Analytics still not enabled (independent of Umami — it is the zero-code option and would also give Core Web Vitals field data). (b) GSC sitemap submission still unconfirmed — only the GSC UI answers it. (c) ✅ Resolved 2026-09-30 — the tracker is wired into three sites (see Step B2j) and pageviews are landing with real geo + referrer; "recording" is no longer in doubt, only the CF/GSC half of E1 remains |
| Dashboard login | Two ways in (since the SSO cut-over): https://stats.hoelee.com — authentik SSO first, then the app's own login — or the LAN break-glass http://192.168.1.1:5411. Credentials: admin + the random password in C:\Users\hoelee\.secrets\umami-admin.txt (agent-set 2026-09-29; change it in Settings → Profile if you prefer). Per-account 2FA is available (TWO_FACTOR_ENCRYPTION_KEY already set) |
E1 progress — SSO gate wired 2026-09-29 ✅ (authentik forward-auth-style proxy in front of the dashboard; tracker stays public)
The user asked for the dashboard to be reachable remotely without publishing an Umami login page, i.e. one hostname, SSO on the dashboard, tracker open. Built on the existing authentik instance (auth.hoelee.com, 2026.8.2) instead of a second container:
| Item | State |
|---|---|
| authentik objects | proxy provider pk 64 Provider Proxy Stats (mode=proxy, external_host=https://stats.hoelee.com, internal_host=http://umami:3000, skip_path_regex = ^/script\.js$ + ^/api/send + ^/api/heartbeat$ + `^/mcp(/ |
| Per-app login flow | auth-stats (designation authentication, 4 stages: identification → password → mfa-validation → user-login), so the SSO page for this app is its own branded screen and not shared with the other 19 apps. Provider authentication_flow points at it |
| App icon | ⚠ In this build the application icon field is meta_icon (not icon — PATCHing icon silently no-ops). Uploaded with the house convention application-icons/umami.png via POST /admin/file/ (multipart, usage=media), then PATCH /core/applications/umami-stats/ {"meta_icon": "application-icons/umami.png"} → meta_icon_url /files/media/public/application-icons/umami.png verified 200 (21,989 B PNG) |
| DSM cut-over | vhost …c378795c2acb.w3conf §2731 proxy_pass http://localhost:5410 → http://localhost:10000 (the outpost) + ReverseProxy.json "port" : 5410 → 10000; backups *.bak-20260929-sso-stats-sso. Both edits asserted unique first (grep -c 5410 = 1 in each file), nginx -t clean, reloaded |
| ⚠ Pitfall found | mode=proxy is required here, forward_single breaks it: forward_single leaves internal_host empty, so the outpost has no upstream — the SSO redirect chain looks perfectly healthy while every app path silently 404s (/script.js 404 with x-powered-by: authentik). proxy + internal_host=http://umami:3000 is also what HA (pk 6) / NocoDB (pk 62) use |
| ⚠ Other pitfall | After any provider change the embedded outpost needs ~1–2 min to pick up the new config; before that, / answers 302 → /flows/-/default/authentication/ and app paths 404. Judging the wiring before that window expires gives a false "it's broken" |
| Verified from the public internet (2026-09-29) | anonymous / → 302 → /outpost.goauthentik.io/start → auth.hoelee.com/if/flow/auth-stats/… → 200 (same shape as the working auth-mysql chain); /login, /api/websites → 302 gated; /script.js 200, /api/heartbeat 200, POST /api/send 400 app-level |
| End-to-end tracker through the SSO path | temp website + 2 pageviews POSTed to https://stats.hoelee.com/api/send → recorded 2 pageviews / 1 visitor / 1 session, country=MY, region=MY-07, city=George Town, referrer metric google.com → the outpost does not break X-Forwarded-For, geo still works; temp website deleted |
| MCP over the public URL (added 2026-09-29, same day) | `^/mcp(/ |
| Tracker wired into the two sites (2026-09-29) ✅ | Website records created: blog.hoelee.com → 1817d8f6-0e49-4b9a-a50d-307e4f2962c7, www.digikedai.com → 61b57143-f3f4-4353-9ede-9c615146de84. Snippet added to blog/src/layouts/BaseLayout.astro (commit de17bea, pushed Gitea+GitHub, Gitea Actions → live in ~1 min; covers EN and ZH since both use the layout) and to dsm-resource-management/www/src/layouts/Base.astro (CF Pages via ./deploy.sh, deployment 4d3d6aa6.digikedai.pages.dev). Both tags carry is:inline (Astro would otherwise process the script) + data-domains so local dev never reports. Verified live HTML on both domains contains the data-website-id; verified end-to-end with a real Chrome (Canary CDP 9222): blog / + /zh/ → 4 pageviews/2 visitors, digikedai / + /products/ → 2 pageviews/1 visitor, geo MY / MY-07 / George Town, chrome/Windows 10; then POST /api/websites/<id>/reset cleared the test rows (both back to 0) |
| ⚠ Verification pitfall: headless browsers are ignored | Umami drops bot/headless user agents — HeadlessChrome/151 (DSM browserless) → 200 {"beep":"boop"} and nothing stored; a normal Chrome UA → 200 {"cache":…} and stored. So beep boop = ignored, not success, and an empty dashboard after a headless test is not a bug. Verify tracking with a real browser (Canary CDP 9222: curl -X PUT "http://127.0.0.1:9222/json/new?<url>"), or at least a normal-UA curl |
| Not done yet | CF Web Analytics beacon + GSC sitemap-index.xml submission (both independent of Umami) |
| Re-measured 2026-10-18 | Live homepage carries exactly one tracker tag (stats.hoelee.com/script.js, data-website-id=1817d8f6…) and zero cloudflareinsights references. Enabling CF Web Analytics is an owner dashboard action — there is no API token on this host with account scope (the deploy token purges only, and ~/.secrets/cloudflare-stats-token.txt is still an unfilled placeholder), and CF injects its beacon itself once the site is added, so there is no repo change to make. GSC submission likewise only answers in its own UI. |
| Known trade-off (honest) | (1) SSO protects the dashboard route, but Umami has no OIDC, so after SSO the user still sees Umami's own login page — double login, expected, one-click after the first time (2FA is available per-account in Settings → Profile; TWO_FACTOR_ENCRYPTION_KEY already in the stack env). (2) <http://192.168.1.1:5411> remains a LAN break-glass path that bypasses SSO (still needs Umami credentials; verified closed from the internet) — delete/close it if that is not wanted. (3) If the authentik outpost goes down, the dashboard goes down with it (tracker too — but the site keeps loading, the tracker fails silently) |
| Rollback | Start the gate again (sudo /usr/local/bin/docker start umami-gateway, or start stack 285 in Portainer) → re-point the vhost + ReverseProxy.json to 5410 → reload nginx. Backups of both files sit next to the originals. (The gate container is now Exited (0) — it was stopped once the outpost took over, so port 5410 is free; the stack is kept as the rollback asset) |
| Note | The hermes3 authentik API token expired mid-session (DB says expires 2026-09-28 22:17:08Z); the leftover self-test account was removed through authentik's own ORM (ak shell), so no admin-group test user is left behind |
Step E2 — Wire the two sites together (www.hoelee.com → blog). ⏸ Recorded and deferred — user decision 2026-10-17: 先记录 www.hoelee.com 过后再改 (record it now, revisit the main site later)
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. ✅ Both halves automated (dev.to 2026-10-17, LinkedIn 2026-10-16; measured 2026-10-18)
seo-reference.md already carries the syndication policy (cross-post with canonical back to the original domain);
until 2026-10-17 nothing had ever been syndicated, so all 72 posts existed only for people who already knew
the URL — the daily dev.to drip below is the first distribution channel outside the site.
- ✅ dev.to cross-posting is automated (2026-10-17).
scripts/devto-crosspost.pyin the Hermes scripts dir (C:\Users\hoelee\AppData\Local\hermes\scripts\) publishes one queued post per day withcanonical_urlpointing at the blog original, from a Hermes no_agent cron (devto daily crosspost, idce8fc8bd23e6,25 12 * * *, deliver Telegram). Queue = 70 ranked titles / every live post except two —hello-world(blog intro page),how-i-made-my-own-songs-with-suno-ai(8<audio>players + a lyric<script>that dev.to's sanitiser strips) — at one per day ≈ 2.3 months. The post that had been held for review joined the queue on top on 2026-10-18, the day it published.devto-state.jsonmakes it idempotent per slug; the API key lives in~/.secrets/devto-api-key.txt, never in the repo. Two guards added the same day: an entry is refused while its frontmatter still saysdraft: trueor its blog URL is not HTTP 200 (a transient 5xx after 3 retries aborts the whole run instead of skipping, so queue order is never reshuffled by a tunnel blip), and--validatere-checks all 69 entries read-only (last run: 69 entries, 1 published, 0 blocked). First article live and verified: https://dev.to/hoelee/how-i-built-the-digikedai-telegram-ai-bot-39of —canonical_urlmatches the blog post in the API read-back and in the live page's<link rel="canonical">, tagsai, python, showdev, automation, cover image re-hosted by dev.to (so the banner landed). ⚠ Quirks written into thehoelee-blogskill: the banner and the layout hero are frontmatter (a verbatim body copy ships imageless), relative links must be absolutised outside code fences only, and dev.to rejects unknown tags. - ✅ LinkedIn half is automated too (installed 2026-10-16, measured 2026-10-18). Hermes no_agent cron
linkedin-daily-post(id35f8985f609a,30 12 * * *) runsscripts/publish_next.py --commitinD:/dev/hoelee-career-agentand stays silent unless it published / failed / ran dry — silence is not proof of work, so readcron/output/35f8985f609a/*.md: 5 posts published as of 2026-10-18 12:30 (today's wasread-only-nocodb-dashboard-for-a-remote-database). Its "already published" ledger is NocoDBactivity_events, not a local file. So the old manual criterion is superseded: what remains is feeding the approved-draft queue, not clicking share. - Done when: ✅ each dev.to copy is live with the canonical set (2 of 69 so far, one per day), and the LinkedIn drip is publishing (5 measured, daily 12:30).
Step E4 — Chinese-side routes and nav are broken. ✅ Done 2026-10-16 (batch B2), re-verified live 2026-10-17
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.
⚠ Also measured 2026-10-16 while publishing four posts: /posts/zh/ returns 403 (not 404) while /zh/ and every
/posts/zh/<slug>/ return 200 — there is no ZH post-archive route, so that path is answered by something else
entirely (CDN/nginx, not the static build). Not a regression from the publish; it is this same missing route.
- ✅ Built in batch B2 (
src/pages/zh/posts/index.astro); nginx301s/posts/zh/→/zh/posts/; the ZH nav points 「文章」 →/zh/posts/and 「关于」 →/zh/about/. - Done when: ✅ measured 2026-10-17 —
/zh/posts/200 (74 KB, the ZH archive with every row),/zh/about/200,/posts/zh/301, and both ZH nav links resolve when clicked.
Step E5 — og:locale is wrong on all 53 Chinese pages. ✅ Done 2026-10-16 (batch B2), re-verified live 2026-10-17
ZH post pages emit <meta property="og:locale" content="en"> with og:locale:alternate = en_US.
- Done when: ✅ measured 2026-10-17 — a ZH post page emits
og:locale=zh_CNwithog:locale:alternate=en_US.
Step E6 — Cloudflare edge HTML caching. ✅ Done 2026-10-17 (Cache Rule + purge + freshness proven); HSTS waived by the owner 2026-10-18
A post page used to return cf-cache-status: DYNAMIC. A static site behind CF should be edge-cached — lower TTFB and
less origin traffic through the tunnel.
- ✅ Cache Rule in place (owner action, 2026-10-17: Edge TTL → Override origin → 2 hours; the dashboard offers no
30-minute preset, which is why 2 h was picked — freshness comes from the deploy purge, not from the TTL).
Measured the same day: 10 sequential requests to
/posts/→ 9 ×HIT/ 1 ×MISS, ages 7–49 s; a post pageMISS → HIT (age 1 s) → HIT (age 661 s), TTFB 0.26–0.31 s on HIT vs 0.55 s on the cold MISS. ⚠ Two single-shotMISSes are NOT evidence the rule is broken. Cloudflare's edge cache is per data centre (CF-RAYshowed NRT then SIN inside the same minute), soMISStwice in a row only means two caches were read from. Judge caching with ≥ 8 sequential requests (and readage), never with two. - ✅ The deploy-side purge is proven live, not assumed. The Gitea Actions run for
112cf7d(task 252 / run 11313, job 22965) logsPurging zone *** … {"success":true,"errors":[],"messages":[],"result":{"id":"…"}}—CF_TOKENandCF_ZONE_IDare set and the API call succeeded, so a publish is not stuck behind the 2 h Edge TTL. HSTS is still— waived by the owner 2026-10-18 (「HSTS 无视吧,没有关系的」). Re-measured the same day: stillmax-age=0; includeSubDomains; preloadmax-age=0; includeSubDomains; preload. Nothing is broken by it (max-age=0makespreloadinert, and Cloudflare's "Always Use HTTPS" already covers the http→https hop), so this is a deliberate no-action, not a gap. Do not re-open it unless the owner asks.- ✅ Freshness proven end-to-end, 2026-10-17. Before the push,
/posts/was aHITstill servinglast-modified 14:35:23; 67 s after the push the same URL servedlast-modified 14:56:41withage=0— the purge invalidated the 2 h-cached copy and the new build was live inside a minute, so the Edge TTL cannot hide a publish. ⚠ Recipe: compare thelast-modifiedheader before/after a push (nginx stamps it at deploy time) —cf-cache-status: HITalone can be a 2-hour-old build, which is exactly the trap this test exists to catch. - Done when: a repeat request shows
cf-cache-status: HIT✅ and a deploy goes live within ~1 minute ✅ (67 s measured) and HSTS carries the decided value ⏸ waived 2026-10-18 by the owner — the step is closed.
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. ✅ Done 2026-10-18
- ✅
scraping-bot-walled-marketplace-warm-browser-session: banner generated + optimized (public/banners/...{png,webp}), frontmatter wired in EN+ZH twins. Measured withmeasure_og_banner.py→ PASS (banner 8 rows,delta=2,overflow=0). - ✅
authentik-css-greater-than-bug(the>bug) — published 2026-10-16 (2295c0f):draft: true → falsein EN + ZH,pubDatekept at 2026-09-20 withupdatedDate: 2026-10-16solastmodstays honest, OG + banner generated (ogrows=1 overflow=0; bannerdelta=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-defaultimage & 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 asscripts/og-gen/template-default.html+og-default-specs.md. - Generator rewritten:
scripts/og-gen/generate-default.mjsruns 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, andsrc/pages/posts/[...slug].astro.
- Layout delivered from outside (
- Done when: ✅ no page references a 404 image, the draft bank is empty, and the default share card matches the A1 photo.
Step E9 — Fonts tuned for aesthetic reading (EN & ZH). ✅ Done 2026-10-18
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 F — Artwork rollout: v2 "one art, four targets" (DONE 2026-10-19)
- ✅ All 72 base frames rendered (1360×768) and OCR-gated;
art-meta.jsoncarries 72 hand-written evidence lines (scripts/og-gen/build_art_meta.pyregenerates them and aborts on any slug that lacks one). - ✅ 288 frontmatter fields migrated (
/og/<slug>.jpg, ZH/og/zh-<slug>.jpg,banner: /banners/<slug>.jpg); the 144 v1 PNGs deleted;PostList.astro+[...slug].astrofixed for the new extensions. - ✅ Live: EN/ZH cards, banner WebP and both thumbs 200; v1
.png404; build 251 pages, 0 missing image refs. - Re-surveyed 2026-10-19: 72/72 banners 1600×900 WebP · 72/72
-800thumbs 800×420 · 72/72-400thumbs 400×210 · 144/144 share cards 1200×630 JPEG. - Open, and not engineering: the round-5 consultant questions ("no text" acceptance line, banner framing, dark art on the light theme) — see the phase log.
- ⚠ Overwriting art in place keeps the same URLs, so a returning reader sees the old picture from their own cache; hard-refresh before judging a restyle batch.
Phase A — Identity (DONE)
- ✅ Real headshot in the About hero, the author card on every post, the home hero (EN + ZH) and JSON-LD
Person.image; no 404 for any derivative. - ✅ One branding line ("Full-Stack Developer & DevOps Engineer") aligned across
SITE.title, meta description, JSON-LD and the homepage hero. - ✅ Homepage
Featured= a 2×2 bento grid over four pinned flagship slugs (fixed slugs, not "newest 4"). - Deliberately deferred: a separate
/start-herepage — the homepage strip was the immediate win.
Phase B — Content (the long game; all batches B1–B7 landed)
- ✅ 72 EN + 72 ZH published posts (live figure; measured 2026-10-19), 0 drafts, every post has its twin.
- Still open: the two gotchas nobody has written yet — Site-to-site OpenVPN behind CGNAT and Fixing the WordPress /cv 301→404 chain. Both were on the old list and neither slug exists in
src/content/posts/. - The per-session topic queue is not kept here any more: it is re-derived from the Hermes session store each time (rules in the
hoelee-blogskill, Sourcing the next post).
Phase C — Discovery & structure (DONE) · Phase D — Polish (DONE / declined)
- ✅ C1 per-post OG images — now
/og/<slug>.jpg+/og/zh-<slug>.jpg(B6b); the shared default card backs the homepage and category pages. - ✅ C2 tag pages — live at
/tags/<tag>/above the post threshold (measured 2026-10-19:/tags/docker/200). ⚠ The old[ ]here was stale — B3 shipped them. - ✅ C3
/categories/lists all 7 with descriptions and SVG art; an empty category renders "0 posts · coming soon" and is deliberately not a link (so its route 404s until its first post). - ✅ C4
/zh/posts/and/zh/categories/both exist (B2), and the ZH nav points at them. ⚠ The old "still missing" line was stale since 2026-10-16. - ✅ D1 search (Pagefind; the loader bug is B3). 🔵 D2 GSC submission → merged into Step E1, owner-gated. ⛔ D3 newsletter/email capture — deliberately not doing until there is real traffic.
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 (✅ now HIT — owner's Cache Rule, measured 2026-10-17, §4b); 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 |
4b. Live re-check — 2026-10-17 (after batches B1–B4)
Appended 2026-10-18 (measured after the B6 front-end deploy and the commit-churn publish):
/and/zh/serve the bento featured grid (featured-bento-grid, 4 cards, OG thumbsnaturalWidth 800); the light theme's pill contrast is fixed (1.96 → 6.10);last-modifiedmoved within 67 s of the push, so the CF purge still works.- The commit-churn post is live in both languages (
pubDate 2026-10-18), sitemap now 250 URLs, both new URLs carrylastmod 2026-10-18+ 2 hreflang alternates, RSS contains it,/posts/order monotonic with it on top. - Cron facts at this moment:
devto daily crosspostpublished 2 of a 70-deep queue,linkedin-daily-posthas published 5 (silent unless it publishes — readcron/output/35f8985f609a/*.md, notlast_status).
...
- B6 Visual Redesign Milestone reached (2026-10-18):
- Ideogram 4 pipeline running locally (
1360x768, ar 16:9). - Outside consultant reviewed Round 1..4. Frosted Bento layout + blueprint grid + headline auto-fitting (
FIT <n>px) verified on real Chrome Headless. - Delivery package Round 4 zipped (
delivery-for-consultant-round4.zip, 2.4 MB). - Ready for the 72-post batch run — blocked on: the consultant's answers to the 3 typography questions, the
og:imageformat decision (measured: 558 KB PNG → 84 KB JPEG q85), a GPU window for the ~70 missing base-art frames, and the promised OCR pseudo-text gate.
- Ideogram 4 pipeline running locally (
Method: curl from the Windows host, the unRaid nginx access log read over the Portainer API (no SSH to that host),
and a DOM probe for the regenerated card. Spot values: /zh/posts/ 200 · /tags/docker/ 200 · /llms.txt 200
(48,386 B) · author photo live (lee-teong-hoe-640|128, WebP + JPEG) · no analytics beacon on any page ·
cf-cache-status: DYNAMIC + HSTS max-age=0 when the spot checks ran; the owner's Cache Rule landed later the same day, so see the table below for the re-measure.
| Check | Result |
|---|---|
| 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 |
| TTFB | origin 3–20 ms vs public 1.3–5.7 s before the edge cache (E6). After the owner's Cache Rule landed the same day: 0.10–0.58 s on cf-cache-status: HIT (cold MISS 0.55–0.74 s) |
Reminder the next session needs: the blog's nginx config is not in the repo — it is the host file
/mnt/user/docker/nginx-hoelee-blog/conf.d/default.confon unRaid, reachable through the DSM Portainer (endpoint 3, stack 247) because unRaid's root login refuses SSH keys. Read it from the running container (docker exec nginx-hoelee-blog cat /etc/nginx/conf.d/default.conf), write it with a helper container that bind-mounts/mnt/user/docker/nginx-hoelee-blogread-write, thennginx -tbeforenginx -s reload.
4c. Live re-check — 2026-10-19 (after batches B5–B7, measured this session)
- Posts: 72 EN + 72 ZH on disk, 0 drafts; build 251 pages; homepage renders
72 posts in English and 中文, the About pages now derive the same number. - Mobile: 12 routes × 4 widths (360 / 390 / 414 / 768) = 48/48 with
scrollWidth − clientWidth == 0. - Art: 72/72 banners 1600×900 WebP; 72/72
-800thumbs 800×420; 72/72-400thumbs 400×210; 144/144 share cards 1200×630 JPEG. Featured bento cards now carrysrcset(400w/800w) and pick the 400 file at DPR 1. - Reading time: EN
5 min readvs ZH5 分钟on the same post (was EN 5 / ZH 1 before the CJK fix). - Not enabled (still owner-gated, re-measured today): Cloudflare Web Analytics —
grep -c cloudflareinsightson the homepage returns 0.sitemap-index.xml,llms.txtandrobots.txtall serve 200. - Remotes: Gitea and GitHub both at
5ec321a, 0 divergence.
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
sameAshandles everywhere - Business framing: website design & development is primary; email hosting is secondary
- English post titles use Title Case
- See
docs/post-guideline.mdfor post-writing rules;docs/content-guide.mdfor strategy;docs/design-guide.mdfor UI