98 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.
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
- ✅ B4 identity & distribution batch, 2026-10-16 (fourth batch) — the photo and the About rewrite:
- Real author photo (source
Professional Portrait with Blue Gradient Background.png, 1254×1254, kept untouched inPictures/with a converted JPG master beside it). Two derivatives, WebP first with a JPEG fallback:lee-teong-hoe-640(42 KB JPG / 26 KB WebP) and-128(3 KB / 2 KB). Appears in the About hero, the author card on every post, the home hero (both languages) and JSON-LDPerson.image— the node search engines use to bind name to face. - About page 1,024 → 7,357 characters, in five sections: identity + photo, "By the numbers" (162 containers / 3 hosts / 71 essays / 0 hosting companies between me and the servers), the real tech stack in four columns, the Hoelee Enterprise block (website design and development is the main business, email hosting secondary) and a contact split between hiring and business (WhatsApp for enquiries). Chinese twin rewritten to match.
- The headshot's dark-blue background sat close to the page background, so the photo/ghero/avatar get a faint brand ring + shadow (measured: the photo reads as a deliberate element instead of a floating head).
/llms.txtgenerated from the collection at build time (48 KB, 142 post entries + section links) so LLM crawlers and answer engines can cite accurately;robots.txtnow points at it.- Still open: the main site (
www.hoelee.com) counter says12+ Experts/100+ Years Of Experiencewhile the blog says he runs the stack solo — needs the owner's real numbers; and cross-posting to dev.to/Hashnode needs his accounts.
- Real author photo (source
- ✅ B3 structure batch, 2026-10-16 (third batch from the external design review) — all numbers measured in a real browser (CDP), not by eye:
- Found and fixed a real bug: site search never worked.
/pagefind/pagefind.jsis an ES module (export { search, debouncedSearch, … }) that defines nowindow.Pagefind, butSearch.astroloaded it with a classic<script src>and readwindow.Pagefind→undefined→ every query in the overlay was answered "Search is unavailable." Live-verified before the fix. Loader is now a dynamicimport(); the specifier must be a variable (const url = '…'; import(/* @vite-ignore */ url)) because Rollup resolves a literal path at build time and fails on a file that only exists indist/. After: query "docker" → 12 hits, first = the Docker OOM post; ZH "容器" also returns hits. - Archive
/posts/+/zh/posts/: 18,220px → 3,788px (71 rows at 44px, grouped by year) with a client-side filter — title/tag/description text search + category chips + 14 tag chips, zero network requests (tested: "nginx" → 6 rows, chipdevops→ 28 rows, reset → 71). - Tag pages: 235 distinct tags measured (the earlier brief said 74 — wrong), 156 of them used exactly once; pages are generated only for tags with ≥ 3 posts (40 tags → 80 pages, build 165 → 245 pages). Tag pills on posts and listings link only when a page exists; one-off tags stay plain text.
/tags/docker/→ 25 posts. - Homepage + ZH homepage: hero (288px desktop) with the real scale claim (162 containers / 3 hosts), 3 CTAs (case studies ↓ / hiring profile / SME work), Featured 4 fixed slugs (scale → automation → observability → a delivered business result) and Latest trimmed 10 → 6.
- Long code blocks collapse at >420px with an "Expand (N lines) ↓" button and a gradient mask; the copy button still copies the full block (verified on
how-i-host-this-blog: 1010px block). - Search is now visible (nav pill "⌕ Search /" plus
/as a shortcut next to the existing Ctrl/Cmd+K) and the zero-result screen offers topic chips + the archive instead of a dead end (verified with a query that matches nothing). - ZH nav bug fixed in passing: the ZH hero linked "关于我" to the English
/about/.
- Found and fixed a real bug: site search never worked.
- ✅ B2 broken-windows batch, 2026-10-16 (second batch from the external design review) — every item verified before/after with
curl:/posts/zh/answered 403 (a directory with no index) → nginx now301s it to the new Chinese archive./zh/posts/did not exist (404) → newsrc/pages/zh/posts/index.astro(build went 163 → 165 pages).- ZH nav pointed "关于" at
/zh/→ now/zh/about/, and "文章" →/zh/posts/. og:localewasenon every Chinese page → nowzh_CN, withog:locale:alternateon both languages (EN pages advertisezh_CNtoo).hreflangvaluezh→zh-Hans, and<html lang>on ZH pages →zh-Hans.- No 404 page →
src/pages/404.astro(status stays 404, branded page with the search overlay, archive links and the 3 newest posts). - JSON-LD
PersongainedworksFor(Hoelee Enterprise +www.hoelee.com);imagestill absent on purpose until the author photo exists. - nginx config moved from the stock image file to a host bind mount at
/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf(Portainer DSM stack 247, endpoint 3), validated in a throwaway container before the redeploy; the conf also sets/_astro/*toimmutable1-year and HTML tomust-revalidate. Details inops-runbook.md. - Still open for the owner (CF token limits, see below): zone HSTS is
max-age=0and HTML is not edge-cached — both are Cloudflare settings my token cannot write. The deploy workflow already purges the edge after each deploy once a Cache Rule exists.
- ✅ B1 image batch, 2026-10-16 (first batch from the external design review) — measured with a real browser before/after, not by eye:
- Article banner →
<picture>with WebP q78 + PNG fallback: 70 banners 6.02 MB → 2.52 MB (42%). The PNG stays forog:image(social scrapers do not reliably render WebP cards). JPEG was measured worse than PNG on this art (q85 = 129%). - Related-post cards on a post page → text-only (category badge + title + date + read time). They used to load four 1200×630 PNGs (183 KB) to display 526×276 where the art's code is illegible anyway.
- List thumbnails (home + archive) →
/og/thumb/<name>-{400,800}.webpwithsrcset/sizes(400w 5.8 KB, 800w 16.1 KB). Homepage thumbnails ~450 KB → 62 KB; archive (71 rows) ~3.2 MB → 436 KB. They were fetching full 1200×630 PNGs for a 398px slot. - Desktop banner height capped (720px, centred): 67% → 45% of a 900px viewport, art intact rather than cropped.
- Measured post page: images 277 KB → 38 KB, height 5,758 → 5,065px desktop / 9,423 → 8,807px mobile. Script:
scripts/optimize-images.py; format policy:design-guide.md§3; runbook step inops-runbook.md.
- Article banner →
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
<head>(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: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) |
| 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. 🔵 dev.to half automated 2026-10-17; LinkedIn still manual (report §9 "Month 2+ — promote": never executed)
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 = 69 ranked titles / every live post except three —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) and the held-for-review post — at one per day ≈ 2.3 months.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. - 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. ✅ 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. 🔵 Cache Rule live 2026-10-17 and measured working; the HSTS half is still open
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
max-age=0; includeSubDomains; preload(re-measured 2026-10-17) — the decision (12 months,includeSubDomains, preload OFF) is still an owner action.max-age=0makes thepreloaddirective inert, so nothing is broken today; the zone setting is simply not doing anything yet. - ✅ 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 ⬜.
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 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
avatarletter with the photo in: nav/brand (optional), author card (About + every post),ProfilePageJSON-LDimage. - Add the photo to
og-default.pngtemplate 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;
curlshows no 404 for the asset.
Step A2 — Align the homepage title & hero framing. ✅ Done 2026-10-18
- Pick one line: "Full-Stack Developer & DevOps Engineer" aligned across
SITE.title,SITE.description, homepage hero<h1>, and JSON-LDjobTitle. - 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
/favoritesor/start-herepage (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.
- Write
src/content/posts/self-hosting-mem0.md(categorycase-studies). - Write Chinese twin
src/content/posts/zh/self-hosting-mem0.md(same filename → auto language-switch). - 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.
- 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).
- "Replacing RDPGuard With IPBan: The Traps Nobody Documents" (EN + ZH,
devops, 2026-09-19) — the uninstaller that unbans 12 attackers,--install-servicedoesn't exist in v4.1.0,ExpireTimevsBanTime. Both images custom. - "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 frommajor >= 8being an invalid MySQL-vs-MariaDB test. Both images custom. - "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-editedPreferences,RestoreOnStartuppolicy all verified failing); the 60-second snapshot keeper that fixed it, plus the staleSingleton*andcustom-cont-init.dpermission traps. Both images custom. - "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; addsskip_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, commite7e4ee5. - "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:
- Flip
draft: true→draft: falsein bothsrc/content/posts/<slug>.mdandsrc/content/posts/zh/<slug>.md. - Set the real
pubDate(currently2026-09-20, the draft date) in both files. - Generate its images:
node scripts/og-gen/generate.mjs <slug>andnode scripts/banner-gen/generate.mjs <slug>(add aTERMINALS[slug]/BANNERS[slug]entry first for the custom panel). npm run build, commit,git push origin main.- Verify both URLs return 200 and the language switcher links them.
- Why these two:
engineeringhad only 1 post andtutorialsonly 1 — the blog was ~alldevops/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:
engineeringwas 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-buyandhow-i-vetted-20-vps-providers-with-parallel-subagents— those cover vendors you pay, this covers code you depend on.devopsis 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 tasksuccess. - ⚠ 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;
updatedDatekeepslastmod/dateModifiedhonest (post-guideline backdating rule). Neither post carries a date-, month- or version-pinned sentence — verified withgrep -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
/cv301→404 chain) remain unstarted;why-your-cdp-clicks-silently-failcounts 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 theTERMINALS/BANNERSentries for these two slugs were applied through throwawaygenerate.local.mjscopies (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:
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 tasksuccess, andgit statusclean 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 percontent-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.mdbackdating rule). - Why: the blog had zero Prometheus/Grafana/monitoring posts while
content-guide.md§2 lists monitoring underdevops, "my most differentiated material" — andmonitoring/grafana no data/smartctl exit codeare 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 twonotesposts link up to the flagship with a relative link. - Why: the search results for
synology spreadsheet api/spreadsheet-api dockerare 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 tagnocodb/nocodb:2026.09.0and a returned row timestamp2026-09-27 01:54:16+00:00→ floor 2026-09-27.nocodb-sso-is-a-licensed-feature— same2026.09.0image 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 namesChrome 154(≈ Oct 2026 on Chrome's cadence) and the post links back toscraping-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
updatedDateholding the true date, solastmodstays 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-pagewas 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 address0xc2022b56…, not dates. All cited figures were traced to source:868596/4102 bytes/993568gas /0.000535185588997216 ETH/ block6522146/ mint181874(deploy + mint logs),335011gas (.gas-snapshot),2.5pt→7.5pt+20,225,818bytes +30–490pt(docs/pdf-pipeline.md; the27 pages/12,789 pagesfigures live inapp/Libraries/ReportPdf.phplines 21 and 196, not in the doc),@Version+POST_VERSION_CONFLICTin the Spring demo. - ⚠ The
TERMINALS/BANNERSentries 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/gapBelowwithin 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/.
Step B2j — (unplanned) One post out of the analytics/SSO session, plus the tracker wired into three sites. ✅ Done 2026-09-30
The analytics session (Step E1) produced one flagship post and the material to justify it:
| Slug | Category | pubDate | Commit | Live |
|---|---|---|---|---|
one-hostname-public-tracker-sso-dashboard |
devops |
2026-09-30 | 54d8cce |
EN + ZH + OG + banner all 200 |
- Angle: "public ingest, private dashboard" on one hostname — the authentik proxy provider +
skip_path_regexpattern, written up as the correct counterpart toauthentik-forward-auth-gate-wasnt-live(which documented the gate that was never live). The two posts cross-reference the same instance from opposite directions, which is exactly the compounding an authentik series is supposed to do. - Two traps are the post's core value (both cost real time in this session): (1) cloning a working
provider that used
mode=forward_singleleavesinternal_hostempty, so the SSO chain is textbook-correct while every app path 404s — the tell isx-powered-by: authentikon the 404; (2) the embedded outpost needs 1–2 minutes to pick up a provider change and answers302 → /flows/-/default/authentication/plus 404s until it does, which reads as "the wiring is broken". - Also in the post: why nginx basic auth can never gate this app (the browser carries one
Authorizationheader, so the app's own bearer token displaces Basic and every API call 401s), the honest trade-offs (double login — no OIDC in the app; dashboard is as available as the outpost; one LAN break-glass port), and the public-internet verification table (includingPOST /api/sendreturning an app-level 400 rather than a gate 403 — proof the request reaches the app). - Generators committed with the post (both clean, diffs additive-only 5/0 and 21/0): 3-line
TERMINALSentry, 8-rowBANNERSentry with the 5-step flow. - Verified: build clean (129 pages, Pagefind 2 languages), 14 content assertions on the deployed pages (framing, both traps, CTA, language switch both ways, tracker tag, og + banner), listing order monotonic with the new post newest (2026-09-30), Gitea Actions run 122 success, EN/ZH/OG/banner 200 within a minute.
- ⚠ Not done: no synthetic pageview this time — the real-Chrome CDP window (9222) was closed by the time the post shipped, and the house rule is to verify analytics with a real browser rather than a headless one. The page carries the tracker (asserted in the HTML), so the first real visit is what will show up.
Tracker now wired into three sites (E1 progress, 2026-09-30) — the analytics deployment is no longer "installed, not collecting":
| Site | website ID | Where the snippet lives | Deploy path |
|---|---|---|---|
blog.hoelee.com |
1817d8f6-0e49-4b9a-a50d-307e4f2962c7 |
src/layouts/BaseLayout.astro (EN + ZH share it) |
Gitea Actions, ~1 min |
www.digikedai.com |
61b57143-f3f4-4353-9ede-9c615146de84 |
dsm-resource-management/www/src/layouts/Base.astro |
npm run build + ./deploy.sh (CF Pages) |
www.sifumail.com |
9d76b8aa-486a-4e4a-b344-f1f93e9ba292 |
all 4 pages' <head> (static site, no shared template) |
scp to the VPS docroot /docker/web/www.sifumail.com/public/ |
⚠ Verification pitfall (cost me a false alarm): Umami drops bot/headless user agents. The same payload
returns 200 {"beep":"boop"} and stores nothing from HeadlessChrome, but 200 {"cache":"…"} and stores
from a normal Chrome UA — so "0 pageviews after a headless test" is not a bug. Verify with a real browser.
Step B2k — (unplanned) Two posts out of the reader/book.hoelee.com session: the upstream rewrite, and the front-door pattern. ✅ Done 2026-10-06
| Slug | Category | pubDate | Live |
|---|---|---|---|
the-upstream-was-deleted-then-came-back-rewritten |
devops |
2026-10-06 | EN + ZH + OG + banner all 200 |
putting-a-front-door-on-an-app-you-cant-modify |
devops |
2025-06-24 (backdated) | EN + ZH + OG + banner all 200 |
- Why these two, from the reader's own setup (stack 136) rather than a generic idea: the existing coverage of that
project was the translation post (
adding-english-mode-…) and the TTS post. The untouched material was (a) the dependency's death-and-resurrection and (b) the gateway/front-door packaging pattern. - ⚠ The upstream facts changed while the skill still said "dead". Re-verified live 2026-10-06:
github.com/hectorqin/readeris alive — 11,036★ / 5,471 forks / AGPL-3.0 / default branchmain, last push 2026-10-02 — but it is a from-scratch rewrite: the git history was re-initialised 2026-09-16 (chore: 初始化仓库) and holds 233 commits; Kotlin/Spring+Vert.x → TypeScript/Node, port 8080 → 5888,/storage/dataJSON →/dataSQLite, env config → admin-UI settings in the DB, image moved tocnb.cool/hectorqin/reader:main(branch/commit tags, deliberately no:latest), 0 releases / 0 tags; Docker Hubhectorqin/readerstill 404. Upstream's own P0 notes admit the image-pull + container upgrade drill was never run and legacy-data auto-migration is unimplemented. The running deployment is unchanged (liangnianzhi/reader.hectorqin:latest-20250525). Thehectorqin-readerskill was corrected in this session — a future session must not repeat "upstream is dead, there is no upgrade". - Post 1 writes that up as "dead vs rewriting" with the reusable checks (repository health ≠ artifact health; no tags ⇒ every upgrade is a SHA; mirror source and image; compare the shape before the features; read the maintainer's "not done yet" list; AGPL-3.0 if you run it for paying users).
- Post 2 is the gateway pattern: a second
nginx:alpinecontainer owning the public port, the request-timeresolver 127.0.0.11(so the front page survives the app being down),client_max_body_sizepreserved for uploads, why a proxy body rewrite cannot touch a client-rendered SPA, the "reload → back to the front door" injection with itssessionStorage.gatePassconsume-step, the app's own CSS hook for whitelabelling, and the silent-failure warning (the injections depend on the literals<html lang="en">and</head>). - ⚠ Backdating rule applied to post 2 only. Post 1 is date-pinned (its facts are Sept–Oct 2026) so it ships on the
real date. Post 2 went into the empty 2025-06-25 → 2025-07-07 window at
2025-06-24,updatedDate: 2026-10-06solastmodstays honest — and it is constrained to link backwards only (it cross-linksadding-english-mode-…, dated 2025-06-11); a forward link to a later-dated post would contradict the backdate. - Generators committed with the posts (both were clean,
deletions: 0,node --checkOK) viascripts/append_generator_entries.py: 4-lineTERMINALS+ 8-rowBANNERSper slug. - Verified after deploy: build clean (139 pages, Pagefind 2 languages, no duplicate-id warning);
measure_og_banner.pyPASS on both (ogrows=1 overflow=0 missingHash=0; banner 8 rows,delta=2,overflow=0); 8 live URLs 200 with the right content types; sitemap 139 URLs, all four new entrieslastmod 2026-10-06with hreflang alternates; EN and ZH listing rows monotonic with the new posts at #2 (2026-10-06) and #54 (2025-06-24, between 2025-07-08 and 2025-06-11); language switch links both ways; CTA links present (Cloudflare rewrites themailto:toemail-protection— expected). Commit378d55c, pushed Gitea + GitHub, both remotes 0/0; follow-upef3f520sharpened the wipe evidence after checking the actual repo (not just the API):masteris a single commit dated 2026-06-22 whose README is the worddeleted, sharing no common ancestor withmain— the API'screated_atstill reads 2021-08-13 and would have misled a reader who trusted it. - Upstream source mirrored to Gitea (private) the same session:
hoelee/reader-2026-rewrite— full clone ofgithub.com/hectorqin/reader(main@5c59cae, plus the 1-commitmaster), pushed withgit push --mirror. ⚠ Gitea's hook rejectsrefs/pull/*("hook declined") — that is expected and harmless; branches and the whole object history land. A future session should re-push from a freshgit clone --mirrorto refresh it, or switch the repo to a Gitea pull mirror (the existinghoelee/readeruses the 8h auto-sync mirror feature). - ⚠ Reminder for the next session: the assertion method matters — hrefs must be checked against raw HTML, not the
tag-stripped text (a first pass reported the CTA and cross-links "missing" because tag-stripping removes
href).
Step B2l — (unplanned) Two posts written from the 2026-10-09 → 10-16 sessions. ✅ Published 2026-10-16 (7b001dc)
Both were written, built and image-complete, then held for the user's read and published the same day on his
go-ahead: 8 URLs (EN / ZH / og / banner × 2) verified 200, /posts/ order still monotonic, lastmod =
2026-10-16 on both. A third post written in the same batch (the-dialog-opened-in-7-seconds-my-script-waited-5,
category ai) was dropped and deleted — see the note under the table.
⚠ The 7b001dc message claims "build 159 pages"; the real count was 153 — both posts had draft: false from the
start, so publishing added no pages. The number in that message is wrong; the deploy itself was verified by URL.
| Slug | Category | Proposed pubDate |
Source session (Hermes id) | What it argues |
|---|---|---|---|---|
calibrating-intel-quick-sync-hevc-quality |
tutorials |
2026-10-14 | 20261014_132458_102ffe |
The full QSV calibration: ICQ 18–22 inflate thin sources (size gate 176.8/141.8/107.6%), the usable range starts at 24, the quality cliff sits between 28 and 30, excerpts rank quality but never speed (fast == medium byte-for-byte, 168,435,266 B), the four traps (/lib mount, rgb24 rate control, SKIP already HEVC hvc1, one QSV job at a time) |
docker-reported-exit-0-the-oom-killer-disagreed |
devops |
2026-10-16 | 20261016_034103_770729 |
"Container stopped unexpectedly" while docker inspect reports ExitCode 0 / OOMKilled=false — the truth was 6 cgroup OOM kills in dmesg (uptime stamps matching FinishedAt), driven by a crawler flood (25,976 IPs / 50k lines, 33,636×403 + 16,082×429); fix = 512M → 2g + memswap_limit: 2g, labelled a stopgap; plus the "my own bare curl got 403" verification trap |
-
Deleted (do not resurrect):
the-dialog-opened-in-7-seconds-my-script-waited-5(the auto-LinkedIn composer race). Written 2026-10-16, briefly parked as a draft, then deleted outright on the user's instruction in3e354cc: both.mdfilesgit rm'd and itsTERMINALS/BANNERSentries removed from both generators (pure deletions — 0 added, 17 + 5 removed,node --checkOK, 0 references left to the slug, the other three intact). Do not bring this topic back unless the user asks for it. -
⚠ Backdating verdict for this batch (do not re-litigate): neither held post may carry a historical date.
docker-reported-exit-0-…: its evidence is dated —Oct 12,Oct 16, the times 02:45 / 02:58 / 03:39 / 05:33 / 07:37 / 07:57, "two weeks of uptime", "18 minutes after the restart". Any older date makes the post contradict its own table. Ships on the real date (2026-10-16),updatedDate= the day it is actually pushed.calibrating-intel-quick-sync-hevc-quality: the prose carries no calendar date, but two checkable pins fix it to 2026-10-14: (a) it names ffmpeg 9.0.2, and ffmpeg.org lists 9.0.2 as released 2026-09-18 (9.0 on 2026-08-04), so nothing before that date is honest; (b) the premise — a shipping QSV tier whose defaults mean "uncalibrated" — only existed from the tier landing (Batch 4b-1, 2026-10-14 11:41) to the calibration that evening (Batch 4b-2). KeeppubDate2026-10-14,updatedDate= the real push day.- Deep backdating (2024/2025 archive gaps) therefore needs evergreen posts only — see B2m for which queued topics qualify.
-
State of each: EN + ZH written;
rewrap_post.py→wrap_lists.pyapplied to the EN files (ZH untouched, one paragraph per line); OG + banner generated and measured (measure_og_banner.py→ PASS on all four pairs);npm run buildclean (155 pages, no duplicate-id warning); all 8dist/pages verified locally (language switch both ways, content assertions from whitespace-normalised tag-stripped text, listing order monotonic on/posts/,/and/zh/). -
⚠ The generators already carry all four
TERMINALS/BANNERSentry sets — the three for this batch's slugs (two held + one parked) rode into2295c0ftogether with the authentik entry (git stages whole files). They are inert until a slug is actually generated, so publishing each post needs only its two.mdfiles plus its two PNGs. -
To publish one: confirm the real
pubDate(date, and the newest<time>on the live/posts/), thengit addthe four explicit paths (post EN/ZH + og + banner) and pushorigin main; verify the four URLs 200.
Step B2n — (unplanned) The silent-JavaScript-failure post, backdated into the 2025-02 gap. 🔵 held for the read
| Slug | Category | pubDate |
updatedDate |
Source |
|---|---|---|---|---|
two-ways-a-javascript-file-stops-working-and-looks-fine |
engineering |
2025-02-20 | 2026-10-16 | hevc-transcode docs/OPERATIONS.md §10.33 |
- What it argues: a parameter named
tshadowing the i18nt()function, and alet LANGdeclared after its first call (TDZ) — two unrelated one-line causes that each aborted the whole script while the page kept rendering the inline default-language text, so nothing looked wrong. The fix is asserting on effects in a real browser (typeof t === 'function', every inline handler resolves — 219 attributes / 217 named calls), plus a pseudo-locale (?lang=pseudo, strings stretched 1.6×) that catches untranslated nodes and overflow. - Why the 2025-02-20 date: the prose carries no calendar date and no checkable version pin, which is the
condition for backdating (see B2m's note). It fills the empty stretch between
why-your-cdp-clicks-silently-fail(2025-03-19) andchainlink-vrf-v2-lottery-contract(2024-12-10) — verified in the built feed: the post lands at position 64 of 67, monotonic, withlastmod= 2026-10-16 fromupdatedDate. - ⚠ Consequence of the deep date: no internal links. Every post it would naturally reference is dated later, and
a backdated post may only link backwards — so it ships as a stand-alone page. If the cross-links are worth more
than the gap (e.g. to
it-reported-success-nothing-had-changed, 2026-10-08, the same "trust the signal, not the intent" theme), move the date into the recent window instead; that is a one-line frontmatter change in two files. - State: EN + ZH written (
rewrap_post.py→wrap_lists.pyon EN only), og + banner generated and measured (ogrows=1 overflow=0; banner 8 rowsdelta=2 overflow=0), build clean at 155 pages, bothdist/pages asserted locally (language switch both ways, content, date 2025-02-20, sitemap hreflang pair). Not committed, not pushed — the working tree also carries the two generator entries for this slug (additive: 5 / 17 lines, deletions 0).
Step B2o — (unplanned) The four surviving candidates, written and dated. ✅ Published 2026-10-16 (08fa7a9)
| Slug | Category | pubDate |
Source |
|---|---|---|---|
my-backup-was-using-someone-elses-api-quota |
notes |
2026-10-15 | Hermes 20261015_170438_0e4312 |
space-reclamation-wasnt-stuck-it-was-waiting |
devops |
2026-10-14 | Hermes 20261014_203035_c6b198 |
adding-a-domain-to-someone-elses-mail-server |
notes |
2025-05-06 | Hermes 20261014_092537_4759b3 |
embedding-fonts-so-a-pdf-looks-the-same-everywhere |
engineering |
2025-01-22 | numerology-report docs/v2/FONTS.md + Hermes 20261011_085128_453ab1 |
- What each argues. (1) A 1 TB restic seed stalled at zero bytes because rclone's shared OAuth client was
rate-limited (
consumer: projects/202264815644,quota_limit_value: 840000) — own the client, Desktop-app type, Publish the app or the refresh token dies every 7 days, and the callback accepts exactly one request; the old side gave back 1.012 TiB → 3.800 GiB while rclone reporteditemNotFoundafter finishing. (2) The DSM Storage Manager card readsrunning24×7 by design (run_all_time,schedule_table168×1) — free space actually came back +69.0 / +46.8 GiB at the 09:29/09:34 snapshot rotations while the recycle bin held 5 GiB and the afternoon wrote −47.2 GiB; retention 1 means a delete frees only at tomorrow's rotation. (3) Inbound needs four records and worked first try; outbound hit554 5.7.1(no PTR, 4 RBLs clean) and the relay hit550-From header sender domain not verified; PTR is one per IP, not per domain, forwarding needs Sieve and still cannot leave the box, so the design that survives is a receiving-side POP pull — verified now: forwardmail.hoelee.com → 41.216.187.102resolves, the reverse lookup for that IP still returns nothing. (4) Four families / eight static faces embedded: static only because the PDF engine's parser readsglyf/locaand ignoresfvar(variable fonts resolve to Thin 100 / ExtraLight 200),font-weight: 600rewritten toboldfor the PDF side only, GB2312 ∪ Big5 subsetting silently cut real name characters (犇 骉 頔 祎 廸 玧 祾 赟 堃), and a glyph lint that fails the build. - Dates and their justification. The two 2026 posts are pinned by their own evidence (the seed's quota errors and
today's samples) and ship on their real work dates; 2026-10-14 now carries two posts (this one and the QSV
calibration) — within the E7 limit of ~3–5. The two 2025 dates are backdating: both have no calendar date, no
quoted log timestamp and no checkable version pin in the prose (verified: the mail post's only month reference was
removed, the fonts post cites no engine version). They fill 2025-05 (between 2025-03-19 and 2025-06-11) and
2025-01 (between 2024-12-10 and 2025-02-20) — both confirmed in the built feed, monotonic, at positions 64 and 67.
updatedDate= 2026-10-16 on all four, so everylastmodis the real day. - State: EN + ZH written (
rewrap_post.py→wrap_lists.pyon EN only, ZH one paragraph per line); og + banner generated and measured for all four (rows=1 overflow=0, banner 8 rowsdelta=2 overflow=0); build clean at 163 pages; 8dist/pages asserted (language switch both ways, dates, content, sitemap hreflang, images). - ✅ Published the same day on the user's go-ahead (
08fa7a9, 18 files): all 16 live URLs verified 200 (4 posts × EN / ZH / og / banner, correctcontent_type), live feed monotonic at positions #2 (2026-10-15), #3–#4 (2026-10-14), #65 (2025-05-06) and #68 (2025-01-22), sitemap 163 URLs withlastmod2026-10-16 on all four EN/ZH pairs, and the two ZH pages carry the correct Chinese<title>+ self-canonical + hreflang pair. Note for the next session: the EN and ZH pages ofspace-reclamation-…happen to be byte-length identical (20492 B) while differing in content — that is a coincidence, not a broken route (md5 differs, ZH contains 空间回收 and not the English title). The pre-existingog:locale = enon every ZH page is Step E5, untouched here. - Internal links: the two recent posts link backwards (to
running-production-infrastructure-solo, 10-06;it-reported-success-nothing-had-changed, 10-08;your-disk-full-alert-is-lying, 06-24). The two backdated posts carry no prose links — every post they would naturally cite is dated later, and a backdated post may only link backwards. Their "related posts" rows are the theme's automatic ones, which every backdated post already has.
Step B2p — (unplanned) The commit-churn post, held for the read (written 2026-10-17 04:00). 🔵
| Slug | Category | pubDate |
Source |
|---|---|---|---|
why-500000-lines-of-commit-churn-only-left-230000-in-production |
engineering |
2026-10-17 | Hermes session 2026-10-17 |
- What it argues: thirty days of autonomous-agent work across five self-hosted Gitea repos = 865 commits, 518,233 additions, 130,892 deletions — and production kept 235,000 lines. Verification gates and pruning, not generation speed, are what survive.
- State (all of it measured 2026-10-17, not carried over from the writing session): EN + ZH written;
og + banner generated and measured (
scripts/measure_og_banner.py→ OGrows=1 overflow=0, banner 7 rowsdelta=2 overflow=0); local build clean at 250index.htmlpages with bothdist/pages present; the generator changes are purely additive (git diff --numstat8/0 and 18/0,node --checkOK on both). Not committed, not pushed — four files (EN + ZH.md, og + banner PNG, plus the generator delta) sit in the working tree on purpose: publication gate, the owner has not read it. - ⚠ This entry is the record of that hold. Without it the next session reads the untracked files as someone else's in-flight work (or as already-published) — the same failure the skill warns about.
- Preview for the owner:
cd dist && python -m http.server 8899 --bind 127.0.0.1, then/posts/why-500000-lines-of-commit-churn-only-left-230000-in-production/and its/posts/zh/…twin. - To publish on his go-ahead: confirm the real date (
date+ the newest<time>on the live/posts/), thengit addthe four explicit paths, commit, pushorigin mainandgithub main, verify the four URLs 200 and the/posts/order still monotonic. TheTERMINALS/BANNERSentries ride along (whole-file staging) and stay inert until that slug is generated. - ⚠ Kill the 8899 preview server once he has read it — a wedged
python -m http.serverfrom the writing session was still holding the port on 2026-10-17 and served an empty response.
Step B2m — The next-session topic queue (user decision 2026-10-16). ⬜
From the same sweep, explicitly not to be written: the Photoshop "closes every five minutes" post (#6 in the sweep) and the Shopee abnormal-status / voucher-card post (#12). Everything below is queued, in the user's requested order of interest, each with its source session:
⛔ Dropped by the user on 2026-10-16 — do not write these, do not publish them. Of the seven candidates below, only #2 (rclone/Drive), #4 (NAS space reclamation), #6 (mail domain/PTR) and #7 (embedded fonts) survive; they are written and dated in Step B2o. The other three are dead: #1 The Packaged App 404'd Its Own CSS, #3 The UI Said "Idle" / the savings ledger and #5 The Scan Failed Because an HTML Error Page Was Parsed as JSON. The same instruction killed the four candidates that surfaced later the same day and were never entered here — the pause-button hole (
pause_nowonly killing whenself.running and self.pid), the ten rows that passed a gate that did not exist yet, one-file-one-worker, and the empty-path 400. Do not resurrect any of them without a fresh request from the user.
Which of these can carry a deep historical date (the user's 2026-10-16 question). Deep backdating needs a post whose facts are undated — no calendar date, no quoted log timestamp, and no version whose release date is checkable. In this queue that is only two: the mail-server/PTR post (concepts, no events) and the PDF font-embedding post (four font families, no dates). Both fit the big empty stretches — 2024-09-25 → 2025-03-18 (six months) and the near-empty 2025-07 → 2025-12 — with
updatedDate= the real date. The other five are recent-window only: the NAS memory and idle-guard posts quote timestamped samples, the rclone one rests on a dated quota event, the scan failure and the router-401 posts both quote a runtime/version state.
| Topic | Category | Source | The hook |
|---|---|---|---|
| Moving a 1.5 TB cloud backup OneDrive → Google with rclone | notes |
20261015_170438_0e4312 |
seed stalled at 0 bytes/90 s with 159 rateLimitExceeded; gcloud holds only a TTS service account so the console is the critical path; the credential must be a Desktop app client for rclone's redirect |
| The UI said "idle", the memory guard had refused 83 times | devops |
20261015_232854_2d9fc2 |
lanes blocked 83× (34-minute waits) reported as idle; and the "savings" ledger lying — SUM(size − out_size) with rounded estimates, 632 of 688 rows size ≈ out_size, lifetime total fell 37.0 → 12.46 GB while the row count grew 591 → 670 |
| Your NAS didn't hang — 121 GiB came back while you weren't looking | devops |
20261014_203035_c6b198 |
5-minute free-space samples: two snapshot rotations +69.0 / +46.8 GiB, recycle bin held only 5 GiB, then −47 GiB of new writes; regrowth + snapshot retention ≠ a stuck job |
| The scan failed because an HTML error page was parsed as JSON | devops |
20261015_200429_37ec35 |
SyntaxError: Unexpected token '<'; the real cause was a CJK path printed to a GBK console → UnicodeEncodeError killed 3 jobs at their own first log line |
| Adding a domain to someone else's mail server | notes |
20261014_092537_4759b3 |
PTR is per IP, not per domain; forward-confirmed rDNS uses postfix's own HELO name; a new domain still needs MX/SPF/DKIM/DMARC + a verified SMTP2GO sender; forwarding still dies on p=reject |
The router that 401s /v1/models and serves chat completions with no key |
ai |
20261015_210913_47cca4 |
two Google-login connections (7 + 11 live models), REQUIRE_API_KEY=false with an empty key table, a dead gemini-3.1-pro alias |
| Embedding fonts so a PDF looks the same on any machine | engineering |
20261011_085128_453ab1 |
four families + weights fixed in-repo instead of trusting the server or the reader's machine ⚠ pull the final implementation detail from the session/docs first — the session moved on mid-topic |
| Two ways a JS file stops working silently | engineering |
hevc-transcode docs/OPERATIONS.md §10.33 |
a parameter named t shadowing the i18n t() function, and a let LANG declared after its first use (TDZ) — both abort the script while the page keeps rendering the old text, so it looks like nothing happened; the fix is asserting on effects (typeof t === 'function', key count > 0, ?lang=en really swaps), never on intent. No date or version pins — also a candidate for an archive gap. |
| The packaged app 404'd its own CSS | devops |
hevc-transcode docs/OPERATIONS.md §10.31 |
package-data listed only web/*.html, so the installed copy shipped without app.css / app.js / the i18n packs and the page 404'd its own assets (only a local checkout worked); plus the deploy-order trap — a running process reads the installed copy, so the order must be "install the Python half → restart at an item boundary → swap the page". |
Housekeeping (not posts): E1 CF Web Analytics + GSC sitemap, E3 syndicate 2–3 flagships, A1 author photo, E8 og-default.png face + the one banner-less post |
— | — | these still outrank writing anything new, per the 2026-09-29 audit |
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
/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
/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 Disallowed 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 (✅ 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)
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.
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