Files
hoelee-blog/docs/project-state.md
T
hoelee 0dae8b0daa
Deploy / build (push) Successful in 34s
fix(art): give both 2026-10-20 posts their artwork, and gate the build on it
The YuE2 and subtitles posts shipped text-only: render_from_art.mjs only READS
ogImage/banner, and after the 2026-10-19 bulk migration no script had to write
them, so both pages advertised /og-default.jpg and rendered no hero banner.

- new metaphors (in build_captions.py, frozen here via sync_prompts_catalog.py),
  captions, art-meta evidence, banner plan and the five derived files per slug
- frontmatter: ogImage + banner in EN and ZH for both posts
- scripts/check-assets.mjs is now step 1 of npm run build, so CI fails when a
  published post lacks a language-correct og card, banner, banner .webp or
  400/800 list thumb. It reported 16 problems on the unfixed tree, all here.
2026-10-20 23:37:52 +08:00

392 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project State & Enhancement Backlog
Living list of what's done and what's next for blog.hoelee.com. Work through these **one by one** — don't batch unrelated changes. Check off items as they land.
**Status key:** ✅ done · 🔵 in progress · ⬜ not started
> **How to resume the project:** start at the top-most ⬜ item in the **Execution Plan** (§2) below and work downward. Each step is self-contained, has a "done when" criterion, and references the governing doc. Don't jump ahead — earlier steps unlock later ones.
>
> **Slimmed 2026-10-19:** the completed-phase logs (Phases A–D and F, plus the §1 batch detail) moved
> **verbatim** to `docs/handoffs/project-state-history.md`. What stays here is the live queue plus one
> summary line per finished phase. As of 2026-10-19 the only open ⬜ items in the queue are **owner
> actions** (Cloudflare Web Analytics, Search Console) — engineering has no unstarted step left.
---
## 1. Done (foundation)
- ✅ Astro 5 static + Markdown, Gitea Actions CI/CD → nginx → Cloudflare (deployed)
- ✅ Design system: hoelee.com brand palette, Inter + JetBrains Mono, light/dark, sticky nav, code copy button
- ✅ Brand "Mr Hoelee" (replaced "hoelee.dev")
- ✅ Author card + `Person`/`ProfilePage` JSON-LD (E-E-A-T), article meta, reading time, related posts
- ✅ Full SEO: canonical, Open Graph (+dims), twitter:card, favicon (all sizes), RSS + sitemap
- ✅ Category pages (`/categories/`, `/categories/[category]/`)
- ✅ Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder)
- ✅ Language switcher in nav — links to the **same post** in the other language (auto-matches by `zh/` prefix)
- ✅ Locale-aware nav labels (EN/ZH)
- ✅ Dark mode default (light opt-in via toggle)
- ✅ Case study "How I Host This Blog"
- ✅ Chinese translation of the case study (`posts/zh/how-i-host-this-blog.md`)
- ✅ hello-world intro post
- ✅ Case study "How I Built the DigiKedai Telegram AI Bot" + Chinese twin (`posts/zh/how-i-built-the-digikedai-telegram-bot/`)
- ✅ Business framing corrected (website design & development = primary; email hosting = secondary)
- ✅ Knowledge guides in `docs/` + README index + `hoelee-blog` skill
- ✅ Both repos public (Gitea + GitHub) with title/description/homepage/topics + `v1.0.0` release
- ✅ **Live re-audit of the Sept research recorded (§2 Phase E + §4), 2026-09-29** — 53 posts × 2 languages, 127 sitemap URLs, 133/133 internal links 200
**Batch index** — full logs for every batch (measurements, commits, traps) are in
`docs/handoffs/project-state-history.md`.
- ✅ **B1 image batch** (2026-10-16) — article banner → `<picture>` WebP (70 banners 6.02 MB → 2.52 MB); related cards went text-only; list thumbs got `srcset` 400/800; desktop banner height capped.
- ✅ **B2 broken windows** (2026-10-16) — `/posts/zh/` 403 → 301; new `/zh/posts/`; ZH nav fixed; `og:locale` `en` → `zh_CN`; `hreflang`/`<html lang>` → `zh-Hans`; a real 404 page; nginx config → host bind mount (DSM stack 247).
- ✅ **B3 structure** (2026-10-16) — **site search had never worked** (the Pagefind ES module was read as `window.Pagefind`); archive 18,220 → 3,788 px with a client-side filter; tag pages (235 tags measured, 156 used once); EN/ZH hero + nav fixes.
- ✅ **B4 identity & distribution** (2026-10-16) — real author photo in all five surfaces + JSON-LD `Person.image`; About 1,024 → 7,357 chars; `/llms.txt`.
- ✅ **B5 baseline & hygiene** (2026-10-18, `3f63150`) — `/og-default.jpg` 211 KB → 45 KB carrying the headshot; banner-404 sweep to 72/72 clean; CJK system font stack; A2 branding alignment.
- ✅ **B6 visual redesign + Ideogram 4 pipeline** (2026-10-18) — the consultant's hybrid "one art, four targets" template; bento theme tokens (a hardcoded dark scrim measured 1.96:1 text contrast in the light theme → 6.10). Phase F is its rollout.
- ✅ **B7 review-response** (2026-10-19, `5941e85` + `5ec321a`) — About counter derived from the collection (71 → **72**); reading time is CJK-aware (ZH **1 min → 5 分钟**); ZH wording fixes; `hello-world`'s stale language policy and container count; hero leads with the capability; featured cards got the `srcset` they lacked; **48/48** mobile-overflow combinations at 0 px.
---
## 2. Execution Plan (work top → bottom, one step at a time)
**The live queue is Phase E.** Phases A–D and F are done; their logs moved to
`docs/handoffs/project-state-history.md` on 2026-10-19. What stays below is one summary line per completed
phase so "is this done?" is still answerable here without opening the archive.
### Phase E — Distribution & visibility (added 2026-09-29 from the live re-audit — DO THESE FIRST)
The Sept research assumed the constraint was identity + content. Measured on 2026-09-29 that is no longer true:
53 posts live in both languages, 127 sitemap URLs, and all **133 internal links** across the home, archive,
category, about and ZH pages return **200**. The site is built and healthy — almost nobody can find it, and there
is no instrumentation to tell whether anyone does. Full evidence table in §4.
**Step E1 — Analytics + the search-performance loop.** ⬜
There is **no analytics of any kind**: home, post, about and ZH pages were grepped for GA/GTM, Plausible, Umami,
Matomo, Clarity, PostHog and Cloudflare's `beacon.min.js` — zero hits. Without it, "did this post get read" is
unanswerable, and the job-hunt thesis can't be verified. Decisions (2026-09-29):
| Option | Verdict |
|---|---|
| **Cloudflare Web Analytics** | ✅ **Primary.** Free with no event/traffic cap, **cookieless** (no consent banner, no CLS cost), one snippet in the base layout — and it reports Core Web Vitals field data, which the Sept report's CWV item otherwise has no way to check. |
| **Google Search Console** | ✅ **Not optional, and half-done already.** `hoelee.com` carries a `google-site-verification=…` TXT record, so a **domain property** already covers `blog.hoelee.com`. Unconfirmed: whether `https://blog.hoelee.com/sitemap-index.xml` was ever submitted. Only the GSC UI can answer that. |
| **Umami** (self-hosted on DSM — MIT, ~1 M events/month free cloud tier) | 🔵 Optional later, only if per-event funnels or full data ownership are wanted. |
| GA4 / Plausible CE | ❌ Skip — GA4 brings weight + a consent banner; Plausible CE needs Postgres **and** ClickHouse. |
- [ ] Enable CF Web Analytics for the zone in the Cloudflare dashboard. ⚠ There is **no repo change to make** — Cloudflare injects its own `cloudflareinsights` beacon once the site is added, so this is an owner action, not a layout edit. Re-check with `curl -s https://blog.hoelee.com/ | grep -c cloudflareinsights` → **0 = still not enabled** (measured 2026-10-19).
- [ ] Confirm the GSC domain property covers the blog, submit `sitemap-index.xml` (it serves **200** — measured 2026-10-19), and re-check "Discovered / Indexed" counts ~a week later.
- **Done when:** a pageview from a second device shows up in CF Web Analytics, and GSC lists the sitemap as Submitted with the post URLs Discovered.
- **Governing doc:** `seo-reference.md`.
**E1 progress — self-hosted Umami deployed 2026-09-29 ✅ (deploy only; not yet public, not yet wired to the blog)**
| Item | State |
|---|---|
| Stack | DSM Portainer **stack 284 `umami`** (endpoint 2), container `umami`, host port **5410 → 3000**, network `bridge_hoelee` |
| Image | `ghcr.io/umami-software/umami:postgresql-latest` → resolved **v3.4.0** (Node 22.23.2). ⚠ v3 publishes versioned tags on `docker.umami.is`, but ghcr only carries rolling tags (`postgresql-v*` stops at 2.16) — record the running version before any upgrade or there is no tag to roll back to |
| Database | **Reused the shared instance**: stack 139 `postgres` → `postgres-server` (PG 17.10, `Etc/UTC`). New role + DB `umami` (login only, **not** superuser). `CREATE EXTENSION pgcrypto` works because PG 13+ treats it as trusted. ⚠ Data lives in `/volume1/docker/postgres-server/data` next to authentik / n8n / tubesync / crowdsec — same fate if that volume is restored |
| Env | `DATABASE_URL`, `APP_SECRET`, `TWO_FACTOR_ENCRYPTION_KEY`, `CLIENT_IP_HEADER=x-forwarded-for`, `DISABLE_TELEMETRY=1`, `DISABLE_UPDATES=1`, `MCP_ENABLED=1`, `TZ=Asia/Kuala_Lumpur`. No `cpus:` (DSM has no CFS quota) |
| Verified on LAN | `/api/heartbeat` → `{"ok":true}` (first hit 6.1 s cold, then 66 ms), `/login` 200, container `healthy`, prisma migrations created **16 tables** |
| Ops notes | `/volume1/docker/umami/README.md` |
| **Public URL** | **https://stats.hoelee.com** live 2026-09-29 — DNS **A → 180.73.9.11** (the user's usual path, **not** through Cloudflare) → router 443 → DSM nginx reverse proxy (`ReverseProxy.json` key `8df2ab4d-ff73-47a0-b5ad-5a09957e6402`, frontend `stats.hoelee.com:443` → **`localhost:10000`** = the authentik outpost since the SSO cut-over) → `http://umami:3000`. Cert = Let's Encrypt `CN=stats.hoelee.com`, 2026-09-28 → 2026-12-27; `http://` → 308. Public allowlist via `skip_path_regex` only: `/script.js` 200, `POST /api/send` 200/400, `/api/heartbeat` 200; every dashboard path 302 → `auth.hoelee.com` |
| **Client IP fix** | ⚠ Because traffic does **not** pass Cloudflare, `CLIENT_IP_HEADER` was changed `cf-connecting-ip` → **`x-forwarded-for`** (what DSM nginx sets) on 2026-09-29, stack re-PUT and verified in the running container. Without it every visit would be attributed to the proxy. Consequence of no CF: no WAF/rate-limit/bot protection on this hostname |
| ✅ **Hardening (A+B) done 2026-09-29** | (A) Default `admin`/`umami` replaced with a random 20-char password (`C:\Users\hoelee\.secrets\umami-admin.txt`) and `APP_SECRET` rotated → every previously issued session invalidated. Verified: old password **401**, new password **200**, old token **401**. (B) New stack **285 `umami-gateway`** (`nginx:1.29-alpine`, holds host port **5410** = what the DSM vhost targets) — public allowlist is exactly `/script.js`, `/api/send`, `/api/heartbeat`; **everything else 403**. Umami itself no longer publishes a public port; the dashboard moved to **LAN-only `192.168.1.1:5411`** (stack 284 republished `5411:3000`), which is unreachable from the internet — verified from the VPS: TCP 5411 closed/filtered, TCP 443 open. ⚠ **Superseded later the same day by the SSO gate below** — the DSM vhost now points at the outpost, so stack 285 is no longer in the public path but is **left running as the rollback** |
| ⚠ Gate design constraint found | **HTTP basic auth cannot be used here**: the Umami front end sends `Authorization: Bearer <jwt>` on its own API calls, and a browser sends only one `Authorization` header — Bearer replaces Basic, so a basic-auth gate 401s every API call and the dashboard breaks. If a *remote* dashboard is ever wanted, **one hostname is still enough** — either rely on Umami's own login (+ enable 2FA) or put cookie auth (authentik forward-auth / oauth2-proxy) on this same hostname with skip-paths for the tracker (the `traefik-login-numerology` pattern). A second hostname (tracker vs admin) is the PostHog/Sentry-scale ingest-vs-app split, not a requirement. `stats-admin.hoelee.com` was only a hypothetical and was never created (NXDOMAIN verified) |
| End-to-end tracker verified | A real pageview POSTed through the **public** gate was recorded: 1 pageview / 1 visitor, with `country=MY, region=MY-07, city=George Town`, browser chrome, os Windows 10 — proving the `X-Forwarded-For` chain (DSM nginx → gate → Umami) works and geo needs no Cloudflare headers. The temporary test website was deleted afterwards (website list back to 0) |
| Still open | (a) CF Web Analytics still not enabled (independent of Umami — it is the zero-code option and would also give Core Web Vitals field data). (b) GSC sitemap submission still unconfirmed — only the GSC UI answers it. (c) ✅ **Resolved 2026-09-30** — the tracker is wired into three sites (see Step B2j) and pageviews are landing with real geo + referrer; "recording" is no longer in doubt, only the CF/GSC half of E1 remains |
| Dashboard login | **Two ways in** (since the SSO cut-over): <https://stats.hoelee.com> — authentik SSO first, then the app's own login — or the LAN break-glass <http://192.168.1.1:5411>. Credentials: `admin` + the random password in `C:\Users\hoelee\.secrets\umami-admin.txt` (agent-set 2026-09-29; change it in Settings → Profile if you prefer). Per-account 2FA is available (`TWO_FACTOR_ENCRYPTION_KEY` already set) |
**E1 progress — SSO gate wired 2026-09-29 ✅ (authentik `forward-auth`-style proxy in front of the dashboard; tracker stays public)**
The user asked for the dashboard to be reachable remotely **without** publishing an Umami login page, i.e. one hostname, SSO on the dashboard, tracker open. Built on the existing authentik instance (`auth.hoelee.com`, 2026.8.2) instead of a second container:
| Item | State |
|---|---|
| authentik objects | proxy provider **pk 64 `Provider Proxy Stats`** (`mode=proxy`, `external_host=https://stats.hoelee.com`, `internal_host=http://umami:3000`, `skip_path_regex` = `^/script\.js$` + `^/api/send` + `^/api/heartbeat$` + `^/mcp(/|$)`) + application **`umami-stats`** (launch URL + 512×512 logo, see below). Attached to the **embedded outpost** `e16274ac-a3ad-4c21-bb16-4a5d32570bc6` (20th provider) |
| 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(/|$)` appended to the provider's `skip_path_regex` (done via `ak shell` ORM — the `hermes3` API token was dead by then, and the ORM write is the same model path the API uses). Verified: `POST https://stats.hoelee.com/mcp` with `Authorization: Bearer umami_…` + `Accept: application/json, text/event-stream` → **200**, `Content-Type: text/event-stream`, chunked, `x-powered-by: authentik`, `tools/list` returns the tool table, `tools/call list_websites` → `count: 0`; **no key → 401** (`Missing bearer API key`, i.e. the request reaches Umami, not the SSO gate); dashboard paths still 302, tracker paths unchanged. ⚠ `x-api-key:` does **not** work for Umami's API/MCP — only `Authorization: Bearer` |
| **Tracker wired into the two sites (2026-09-29) ✅** | Website records created: `blog.hoelee.com` → `1817d8f6-0e49-4b9a-a50d-307e4f2962c7`, `www.digikedai.com` → `61b57143-f3f4-4353-9ede-9c615146de84`. Snippet added to `blog/src/layouts/BaseLayout.astro` (commit **de17bea**, pushed Gitea+GitHub, Gitea Actions → live in ~1 min; covers EN **and** ZH since both use the layout) and to `dsm-resource-management/www/src/layouts/Base.astro` (CF Pages via `./deploy.sh`, deployment `4d3d6aa6.digikedai.pages.dev`). Both tags carry `is:inline` (Astro would otherwise process the script) + `data-domains` so local dev never reports. Verified live HTML on both domains contains the `data-website-id`; verified end-to-end with a **real Chrome** (Canary CDP 9222): blog `/` + `/zh/` → 4 pageviews/2 visitors, digikedai `/` + `/products/` → 2 pageviews/1 visitor, geo `MY / MY-07 / George Town`, chrome/Windows 10; then `POST /api/websites/<id>/reset` cleared the test rows (both back to 0) |
| ⚠ **Verification pitfall: headless browsers are ignored** | Umami drops bot/headless user agents — `HeadlessChrome/151` (DSM browserless) → `200 {"beep":"boop"}` and **nothing stored**; a normal Chrome UA → `200 {"cache":…}` and stored. So `beep boop` = *ignored*, not success, and an empty dashboard after a headless test is not a bug. Verify tracking with a **real** browser (Canary CDP 9222: `curl -X PUT "http://127.0.0.1:9222/json/new?<url>"`), or at least a normal-UA curl |
| Not done yet | CF Web Analytics beacon + GSC `sitemap-index.xml` submission (both independent of Umami) |
| **Re-measured 2026-10-18** | Live homepage carries **exactly one** tracker tag (`stats.hoelee.com/script.js`, `data-website-id=1817d8f6…`) and **zero** `cloudflareinsights` references. Enabling CF Web Analytics is an **owner dashboard action** — there is no API token on this host with account scope (the deploy token purges only, and `~/.secrets/cloudflare-stats-token.txt` is still an unfilled placeholder), and CF injects its beacon itself once the site is added, so there is **no repo change to make**. GSC submission likewise only answers in its own UI. |
| Known trade-off (honest) | (1) SSO protects the *dashboard route*, but Umami has **no OIDC**, so after SSO the user still sees **Umami's own login page** — double login, expected, one-click after the first time (2FA is available per-account in Settings → Profile; `TWO_FACTOR_ENCRYPTION_KEY` already in the stack env). (2) `<http://192.168.1.1:5411>` remains a **LAN break-glass path that bypasses SSO** (still needs Umami credentials; verified closed from the internet) — delete/close it if that is not wanted. (3) If the authentik outpost goes down, the dashboard goes down with it (tracker too — but the site keeps loading, the tracker fails silently) |
| Rollback | Start the gate again (`sudo /usr/local/bin/docker start umami-gateway`, or start stack 285 in Portainer) → re-point the vhost + `ReverseProxy.json` to `5410` → reload nginx. Backups of both files sit next to the originals. (The gate container is now **`Exited (0)`** — it was stopped once the outpost took over, so port 5410 is free; the stack is kept as the rollback asset) |
| Note | The `hermes3` authentik API token expired mid-session (DB says `expires 2026-09-28 22:17:08Z`); the leftover self-test account was removed through authentik's own ORM (`ak shell`), so no admin-group test user is left behind |
**Step E2 — Wire the two sites together (www.hoelee.com → blog).** ⏸ Recorded and deferred — user decision 2026-10-17:
*先记录 www.hoelee.com 过后再改* (record it now, revisit the main site later)
Measured: **all six pages of www.hoelee.com** (home, `/zh-hans/`, `/about-mrhoelee/`, T&C, support, privacy) contain
**zero** references to `blog.hoelee.com`, while the blog links out to www.hoelee.com. The older, more established
domain passes no authority and offers no click-path to the portfolio — one-way.
⚠ **User decision 2026-09-29: www.hoelee.com needs a full overhaul; do NOT patch it piecemeal now.** Fold this link
into that overhaul (together with the v2.1 audit finding that its agency framing conflicts with the job hunt).
Tracked here so it isn't lost.
**Step E3 — Syndicate 2–3 flagship posts.** ✅ Both halves automated (dev.to 2026-10-17, LinkedIn 2026-10-16; measured 2026-10-18)
`seo-reference.md` already carries the syndication policy (cross-post with canonical back to the original domain);
until 2026-10-17 **nothing had ever been syndicated**, so all 72 posts existed only for people who already knew
the URL — the daily dev.to drip below is the first distribution channel outside the site.
- [x] ✅ **dev.to cross-posting is automated (2026-10-17).** `scripts/devto-crosspost.py` in the Hermes scripts dir
(`C:\Users\hoelee\AppData\Local\hermes\scripts\`) publishes **one queued post per day** with `canonical_url`
pointing at the blog original, from a Hermes **no_agent cron** (`devto daily crosspost`, id `ce8fc8bd23e6`,
`25 12 * * *`, deliver Telegram). Queue = **70 ranked titles / every live post except two** — `hello-world` (blog intro page),
`how-i-made-my-own-songs-with-suno-ai` (8 `<audio>` players + a lyric `<script>` that dev.to's sanitiser strips)
— at **one per day ≈ 2.3 months**. The post that had been held for review joined the queue on top on 2026-10-18,
the day it published. `devto-state.json` makes 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 says `draft: true` or 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 `--validate` re-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_url` matches the blog post in the API read-back **and** in the live page's `<link rel="canonical">`,
tags `ai, python, showdev, automation`, cover image re-hosted by dev.to (so the banner landed).
⚠ Quirks written into the `hoelee-blog` skill: 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.
- [x] ✅ **LinkedIn half is automated too (installed 2026-10-16, measured 2026-10-18).** Hermes no_agent cron
`linkedin-daily-post` (id `35f8985f609a`, `30 12 * * *`) runs `scripts/publish_next.py --commit` in
`D:/dev/hoelee-career-agent` and stays **silent unless it published / failed / ran dry** — silence is not proof
of work, so read `cron/output/35f8985f609a/*.md`: **5 posts published** as of 2026-10-18 12:30 (today's was
`read-only-nocodb-dashboard-for-a-remote-database`). Its "already published" ledger is NocoDB
`activity_events`, not a local file. So the old manual criterion is superseded: what remains is **feeding the
approved-draft queue**, not clicking share.
- **Done when:** ✅ each dev.to copy is live with the canonical set (2 of 69 so far, one per day), and the LinkedIn
drip is publishing (5 measured, daily 12:30).
**Step E4 — Chinese-side routes and nav are broken.** ✅ Done 2026-10-16 (batch B2), re-verified live 2026-10-17
Live ZH pages: the nav renders 「文章」→ `/zh/` (fallback; `/zh/posts/` **404s**) and 「关于」→ `/zh/` — i.e. the
Chinese nav's *About* link is wrong even though `/zh/about/` itself returns 200 and is in the sitemap. `grep
'/zh/about/'` finds **no inbound link anywhere on the site**, so the ZH About page is orphaned.
⚠ Also measured 2026-10-16 while publishing four posts: **`/posts/zh/` returns 403** (not 404) while `/zh/` and every
`/posts/zh/<slug>/` return 200 — there is no ZH post-archive route, so that path is answered by something else
entirely (CDN/nginx, not the static build). Not a regression from the publish; it is this same missing route.
- [x] ✅ Built in batch B2 (`src/pages/zh/posts/index.astro`); nginx `301`s `/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_CN` with `og:locale:alternate=en_US`.
**Step E6 — Cloudflare edge HTML caching.** ✅ Done 2026-10-17 (Cache Rule + purge + freshness proven); HSTS waived by the owner 2026-10-18
A post page used to return `cf-cache-status: DYNAMIC`. A static site behind CF should be edge-cached — lower TTFB and
less origin traffic through the tunnel.
- [x] ✅ **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 page
`MISS → 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-shot `MISS`es are NOT evidence the rule is broken.** Cloudflare's edge cache is per data centre
(`CF-RAY` showed NRT then SIN inside the same minute), so `MISS` twice in a row only means two caches were read from.
Judge caching with **≥ 8 sequential requests** (and read `age`), never with two.
- [x] ✅ **The deploy-side purge is proven live, not assumed.** The Gitea Actions run for `112cf7d` (task 252 / run 11313,
job 22965) logs `Purging zone *** … {"success":true,"errors":[],"messages":[],"result":{"id":"…"}}` — `CF_TOKEN` and
`CF_ZONE_ID` are set and the API call succeeded, so a publish is not stuck behind the 2 h Edge TTL.
- [x] ~~HSTS is still `max-age=0; includeSubDomains; preload`~~ — **waived by the owner 2026-10-18** (「HSTS 无视吧,没有关系的」). Re-measured the same day: still `max-age=0; includeSubDomains; preload`. Nothing is broken by it (`max-age=0` makes `preload` inert, and Cloudflare's "Always Use HTTPS" already covers the http→https hop), so this is a deliberate no-action, not a gap. **Do not re-open it** unless the owner asks.
- [x] ✅ **Freshness proven end-to-end, 2026-10-17.** Before the push, `/posts/` was a `HIT` still serving
`last-modified 14:35:23`; **67 s after** the push the same URL served `last-modified 14:56:41` with `age=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 the **`last-modified` header** before/after a push (nginx stamps it at deploy time) —
`cf-cache-status: HIT` alone can be a 2-hour-old build, which is exactly the trap this test exists to catch.
- **Done when:** a repeat request shows `cf-cache-status: HIT` ✅ **and** a deploy goes live within ~1 minute ✅
(67 s measured) **and** HSTS carries the decided value ⏸ **waived 2026-10-18 by the owner** — the step is closed.
**Step E7 — Content-volume guardrail.** ⬜ ongoing
53 posts shipped in ~4 weeks (30 dated 2026-09). The volume itself is the "content-farm" signal `content-guide.md`
§7 warns about, even though every post traces to real logs/commits. Two rules:
- Keep the existing per-post "every claim traces to a source file or log" check — do not relax it for volume.
- **Never stack more than ~3–5 posts on one `pubDate`** (worst day so far: 2026-09-18 with 5). Spreading a batch by
backdating into archive gaps is the sanctioned method.
**Step E9 — Topic queue after the 2026-10-20 sweep.** ✅ Shipped 2026-10-20
| Topic | Slug | Category | Status |
|---|---|---|---|
| Local 1080p AI video on a 12 GB card (Wan 2.2 + SeedVR2, measured) | `running-ai-video-locally-on-a-12gb-gpu` | ai | **published EN+ZH 2026-10-20** (commits 3efeafc…df4e193) |
| SeedVR2 3B crashes ComfyUI at >41 frames (native access violation) | — | `notes` | optional short note; the war story is already in the published post |
| Draft → final → upscale batch runner (`make_short.py`) | — | `ai` | not written; the pipeline commands are in the published post |
No topics were killed by the user in this sweep.
**Step E8 — Small hygiene backlog.** ✅ Done 2026-10-18
- [x] ✅ `scraping-bot-walled-marketplace-warm-browser-session`: banner generated + optimized (`public/banners/...{png,webp}`), frontmatter wired in EN+ZH twins. Measured with `measure_og_banner.py` → **PASS** (banner 8 rows, `delta=2`, `overflow=0`).
- [x] ✅ `authentik-css-greater-than-bug` (the `>` bug) — **published 2026-10-16 (`2295c0f`)**: `draft: true → false` in EN + ZH, `pubDate` kept at 2026-09-20 with **`updatedDate: 2026-10-16`** so `lastmod` stays honest, OG + banner generated (og `rows=1 overflow=0`; banner `delta=2 overflow=0`), and the CTA's displayed phone corrected (`011-797 2969` → `+60 12-797 2969`). Verified live: EN/ZH/og/banner all **200**, language switch both ways, `/posts/` order still monotonic. The draft bank is now empty.
- [x] ✅ `og-default` image & harness — **delivered & integrated 2026-10-18**:
- Layout delivered from outside (`hoelee-og-delivery`) with author photo (`lee-teong-hoe-640.jpg`), brand mark, real name, and full identity. Kept in repo as `scripts/og-gen/template-default.html` + `og-default-specs.md`.
- Generator rewritten: `scripts/og-gen/generate-default.mjs` runs Chrome headless, probes DOM via `--dump-dom` (6 automated checks: overflow=0, margin>=64, headline>=48, bottom clear>=64, retired wordmark absent, identity lines present).
- Format optimized to **JPEG** (45 KB, quality 85 mozjpeg) as the primary site-wide share card (`/og-default.jpg`), reducing weight by 4.7× vs unoptimized PNG (211 KB). PNG fallback is also rendered and kept beside it for backward compatibility.
- Linked across `BaseLayout.astro`, `PostList.astro`, and `src/pages/posts/[...slug].astro`.
- **Done when:** ✅ no page references a 404 image, the draft bank is empty, and the default share card matches the A1 photo.
**Step E9 — Fonts tuned for aesthetic reading (EN & ZH).** ✅ Done 2026-10-18
`src/styles/global.css` typography modernized with an intentional, zero-network-cost, highly-legible system font stack:
- Sans / Body: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif`
- English: modern native system UI (San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android) with excellent kerning and clarity.
- Chinese (Hans): explicit priority to macOS/iOS PingFang SC (冬青黑体/苹方), followed by Windows Microsoft YaHei (微软雅黑) and Linux fallback, eliminating jagged default CJK rendering.
- Code / Mono: `'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, Menlo, Monaco, monospace`
- Crisp ligatures and distinct 0/O/1/l/I glyphs for technical code snippets.
- Zero webfont transfer overhead, 0 ms FOUT/FOIT layout shifts, fully aligned with the blog's ultra-fast static philosophy.
### Phase F — Artwork rollout: v2 "one art, four targets" (DONE 2026-10-19)
- ✅ All 72 base frames rendered (1360×768) and OCR-gated; `art-meta.json` carries **72 hand-written evidence lines** (`scripts/og-gen/build_art_meta.py` regenerates them and aborts on any slug that lacks one).
- ✅ 288 frontmatter fields migrated (`/og/<slug>.jpg`, ZH `/og/zh-<slug>.jpg`, `banner: /banners/<slug>.jpg`); the 144 v1 PNGs deleted; `PostList.astro` + `[...slug].astro` fixed for the new extensions.
- ✅ Live: EN/ZH cards, banner WebP and both thumbs 200; v1 `.png` 404; build **251 pages**, 0 missing image refs.
- Re-surveyed 2026-10-19: 72/72 banners 1600×900 WebP · 72/72 `-800` thumbs 800×420 · 72/72 `-400` thumbs 400×210 · 144/144 share cards 1200×630 JPEG.
- Open, and **not** engineering: the round-5 consultant questions ("no text" acceptance line, banner framing, dark art on the light theme) — see the phase log.
- ⚠ Overwriting art in place keeps the same URLs, so a returning reader sees the old picture from their own cache; hard-refresh before judging a restyle batch.
### Phase G — Related-posts photo grid (2026-10-20)
- ✅ `Related Posts` / `相关文章` went from 4 text-only cards to **6 cards carrying each post's own share card**: `ogImage` per post means the EN page shows `/og/<slug>.jpg` and the ZH page `/og/zh-<slug>.jpg` — no cross-language art. Measured in the built HTML: 6 `<img class="related-thumb">`, all 6 files 200, ZH page resolves all 6 to `zh-` files.
- Layout: **2 across on desktop**, **1 across on phones** (≤600px), where only the **first 4** cards are shown (`.related-extra` hidden, `i >= 4` in `[...slug].astro`). The 3-across version was tried first and reverted same day — two-up at 1200px keeps the baked-in OG type readable.
- Verified with headless Chrome by injecting the built `<section class="related">` + built CSS into a probe at 1264 / 884 / 500 px: `gridTemplateColumns` = 3 / 2 / 2 columns, visible cards 6 / 6 / 4, `scrollWidth == clientWidth` everywhere (no overflow), 6/6 images decoded.
- ⚠ Headless `--window-size=390` is silently clamped to **500** on Windows — the right-hand column looks clipped in the screenshot, but that is the capture, not the layout. Verify <500px by putting the probe in a 390px `<iframe>` and screenshotting that.
- ⚠ `--virtual-time-budget` can expire before lazy images paint (5 of 6 cards came out as empty `var(--border)` rectangles at budget 6000). Use ≥20000 before declaring an image broken.
- **Tag pages now carry the same chrome as `/posts/`** (`/tags/<tag>/` and `/zh/tags/<tag>/` used to pass `chrome={false}` and show only a count line): the title/tag filter input, the category chips and the tag-chip strip. Category chips and the row filter stay scoped to the posts on the page, so their counts match the rows; the tag strip is built from the whole language collection (`chipPosts`) because those chips *navigate* — page-scoped counts there would lie — and the tag being viewed gets `is-active` + `aria-current`. Measured at 1264/500px: gaps 8px/22px between bar → category chips → tag chips → list, `scrollWidth < clientWidth`, 14 tag chips, 4 category chips on /tags/docker/.
- Bytes: 6 × ~65 KB JPEG ≈ 390 KB, `loading="lazy"` and below the fold. The `/og/thumb/` WebPs (5.8 / 16.1 KB) are keyed by base slug with **no ZH variant**, so they cannot be used here without first emitting `/og/thumb/zh-<slug>-{400,800}.webp`.
### Phase H — Two posts shipped without art (2026-10-20), and the gate that stops it recurring
- ⚠ **Cause**: the two posts from 2026-10-20 (`yue2-on-a-12gb-gpu-songs-that-lost-their-endings`, `generating-subtitles-for-songs-without-timestamps`, EN+ZH) were published text-complete only. `render_from_art.mjs` **reads** `ogImage`/`banner` but nothing **writes** them, and after the 2026-10-19 bulk migration no script had to — so both pages advertised `/og-default.jpg` (generic card in every share preview and in the new related grid) and rendered no hero banner. The pipeline itself was fine and up to date: `python scripts/og-gen/sync_prompts_catalog.py --check` named exactly these two slugs (`missing=[...]`, 73 metaphors vs 75 posts), which is the check nobody ran before pushing.
- ✅ Art generated with the current one-command flow (`C:/AI/ideogram4/post_art.py --slug … --metaphor … --evidence …`), same v2 semantic style as the other 73: no-text 1360×768 frame → 1600×900 banner (jpg+webp) → EN card → ZH card → 400/800 thumbs.
- ✅ **New gate**: `scripts/check-assets.mjs` runs as step 1 of `npm run build`, so a post that is text-complete but not image-complete now fails the Gitea Actions build instead of reaching nginx. It checks, per published post: `ogImage` present and language-correct (EN `/og/<slug>.jpg`, ZH `/og/zh-<slug>.jpg`), `banner` present (`/banners/<slug>.jpg`, shared by both languages), and the existence of banner `.jpg` + `.webp` + `-400` + `-800` thumbs. First run against the unfixed tree reported 16 problems, all on these two slugs and nothing else — it catches this bug class and is silent on the other 73 posts.
- Frontmatter for the two posts now carries all four fields; nothing else about the posts changed.
### Phase A — Identity (DONE)
- ✅ Real headshot in the About hero, the author card on every post, the home hero (EN + ZH) and JSON-LD `Person.image`; no 404 for any derivative.
- ✅ One branding line ("Full-Stack Developer & DevOps Engineer") aligned across `SITE.title`, meta description, JSON-LD and the homepage hero.
- ✅ Homepage `Featured` = a 2×2 bento grid over four pinned flagship slugs (fixed slugs, not "newest 4").
- Deliberately deferred: a separate `/start-here` page — the homepage strip was the immediate win.
### Phase B — Content (the long game; all batches B1–B7 landed)
- ✅ **72 EN + 72 ZH** published posts (live figure; measured 2026-10-19), 0 drafts, every post has its twin.
- Still open: the two gotchas nobody has written yet — *Site-to-site OpenVPN behind CGNAT* and *Fixing the WordPress /cv 301→404 chain*. Both were on the old list and neither slug exists in `src/content/posts/`.
- The per-session topic queue is **not** kept here any more: it is re-derived from the Hermes session store each time (rules in the `hoelee-blog` skill, *Sourcing the next post*).
### Phase C — Discovery & structure (DONE) · Phase D — Polish (DONE / declined)
- ✅ **C1** per-post OG images — now `/og/<slug>.jpg` + `/og/zh-<slug>.jpg` (B6b); the shared default card backs the homepage and category pages.
- ✅ **C2 tag pages** — live at `/tags/<tag>/` above the post threshold (measured 2026-10-19: `/tags/docker/` 200). ⚠ The old `[ ]` here was stale — B3 shipped them.
- ✅ **C3** `/categories/` lists all 7 with descriptions and SVG art; an empty category renders "0 posts · coming soon" and is deliberately **not** a link (so its route 404s until its first post).
- ✅ **C4** `/zh/posts/` and `/zh/categories/` both exist (B2), and the ZH nav points at them. ⚠ The old "still missing" line was stale since 2026-10-16.
- ✅ **D1** search (Pagefind; the loader bug is B3). 🔵 **D2** GSC submission → merged into Step E1, owner-gated. ⛔ **D3** newsletter/email capture — deliberately not doing until there is real traffic.
---
## 3. Research Findings Snapshot (Sept 2026)
What the reference blogs do that blog.hoelee.com should mirror, ranked:
| Finding | Reference example | Status on blog.hoelee.com |
|---|---|---|
| Real name + photo + one-line identity | All four | ⚠️ name ✅, photo ❌ (letter "M") — **Step A1** |
| Focused thesis (one sentence on what it's about) | Julia Evans, Simon Willison | ⚠️ has it, but title/hero drift — **Step A2** |
| Honesty about what you *don't* know | Simon, Dan Abramov | ✅ strong (DigiKedai "bugs that ate an afternoon") |
| Specific detail: code, diagrams, numbers, bug stories | All four | ✅ strong |
| Consistent cadence (slow is fine, dead is not) | Julia (~monthly), Simon (daily) | ⚠️ only 3 posts, all Sept 4–6 — **Phase B** |
| "Start here" / Favorites route | Julia Evans, Josh Comeau | ❌ — **Step A3** |
| RSS + sitemap + clean SEO | All four | ✅ |
| Per-post OG images | Josh Comeau | ✅ done (verify 2026-09-29 — §4) |
| Search (once >15–20 posts) | Josh Comeau | ✅ done — Pagefind live (§4) |
---
## 4. Live Audit Snapshot — 2026-09-29 (measured, not assumed)
Method: `curl` from the Windows host (direct curl to blog.hoelee.com **worked** on this date — the DSM fallback was
not needed), cross-checked against the local repo at `D:/dev/hoelee-blog`.
| Check | Measured result |
|---|---|
| Posts | **53 published EN + 53 ZH** (54 files each, 1 draft) — every published post has a ZH twin, no orphans either way |
| Sitemap | `sitemap-index.xml` → `sitemap-0.xml`, **127 URLs** (53 posts + 53 ZH + 19 landing/category + `/posts/`), `lastmod` honest; 254 `xhtml:link` hreflang alternates |
| Internal links | 133 unique internal links across home / `/posts/` / `/categories/` / about / ZH pages → **133 × 200, zero 404s** |
| `robots.txt` | Pure ASCII, allows all crawlers (search **and** AI), `Disallow: /pagefind/`, declares the sitemap |
| Search | **Pagefind live** — `/pagefind/pagefind.js` 200, nav search toggle on both locales (**D1 done**) |
| Per-post OG | Every post serves `/og/<slug>.png`; **1 post has no banner** (`scraping-bot-walled-…`) |
| Analytics | **None at all.** No GA/GTM, Plausible, Umami, Matomo, Clarity, PostHog or CF `beacon.min.js` on home, post, about or ZH pages → **E1** |
| Google Search Console | `hoelee.com` TXT `google-site-verification=g9jeE8…` exists ⇒ a **domain property** already covers the blog. Sitemap submission unverified → **E1** |
| www.hoelee.com | 6 pages, **0 references to `blog.hoelee.com`** (one-way linking) → **E2 (deferred)**. Also `/cv` is now `301 → cloud.hoelee.com` Drive share (GET 200) — the v2.1 "broken /cv" finding is **FIXED**; note `HEAD` still returns 404 (a Pretty Link quirk), so don't re-diagnose it from a HEAD |
| Author identity | Author card is still the letter avatar `<div class="avatar">M</div>` on About **and every post**; no photo anywhere on the site → **A1** |
| ZH nav | 「文章」→ `/zh/` (no `/zh/posts/` route: **404**) and 「关于」→ `/zh/` — while `/zh/about/` is 200 but **orphaned** (zero inbound links) → **E4 / C4** |
| `og:locale` | ZH pages emit `en` + alternate `en_US` → **E5** |
| Homepage | No featured/start-here strip (**A3**). `<title>` says "engineering, DevOps & self-hosting" while the hero says "full-stack developer and DevOps engineer" (**A2**, cosmetic — lowest priority) |
| Caching | `cf-cache-status: DYNAMIC` on a post page (✅ **now HIT** — owner's Cache Rule, measured 2026-10-17, §4b); `strict-transport-security: max-age=0` → **E6** |
| Tags | Tags render as plain labels (not links), so `/tags/<tag>/` 404s cause **no broken links** — C2 is a missed-discovery item, not a bug |
| Drafts | Exactly one: `authentik-css-greater-than-bug` (EN + ZH) → **E8** |
| Cadence | 30 posts dated 2026-09; worst single-day stack = **5** (2026-09-18) — the backdating work held, no single-day dump at the top of the feed |
| What did NOT change | The Sept report's platform verdict (Astro static) and its theme advice (AstroPaper) — the platform call still holds; the theme pick is moot because the custom theme already shipped and works |
---
## 4b. Live re-check — 2026-10-17 (after batches B1–B4)
**Appended 2026-10-18 (measured after the B6 front-end deploy and the commit-churn publish):**
- `/` and `/zh/` serve the bento featured grid (`featured-bento-grid`, 4 cards, OG thumbs `naturalWidth 800`); the light theme's pill contrast is fixed (1.96 → 6.10); `last-modified` moved within 67 s of the push, so the CF purge still works.
- The commit-churn post is live in both languages (`pubDate 2026-10-18`), sitemap now **250 URLs**, both new URLs carry `lastmod 2026-10-18` + 2 hreflang alternates, RSS contains it, `/posts/` order monotonic with it on top.
- Cron facts at this moment: `devto daily crosspost` published 2 of a 70-deep queue, `linkedin-daily-post` has published **5** (silent unless it publishes — read `cron/output/35f8985f609a/*.md`, not `last_status`).
...
- **B6 Visual Redesign Milestone reached (2026-10-18)**:
- Ideogram 4 pipeline running locally (`1360x768`, ar 16:9).
- Outside consultant reviewed Round 1..4. Frosted Bento layout + blueprint grid + headline auto-fitting (`FIT <n>px`) verified on real Chrome Headless.
- Delivery package Round 4 zipped (`delivery-for-consultant-round4.zip`, 2.4 MB).
- Ready for the 72-post batch run — **blocked on**: the consultant's answers to the 3 typography questions, the `og:image` format decision (measured: 558 KB PNG → 84 KB JPEG q85), a GPU window for the ~70 missing base-art frames, and the promised OCR pseudo-text gate.
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.conf` on 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-blog` read-write, then `nginx -t` before `nginx -s reload`.
---
## 4c. Live re-check — 2026-10-19 (after batches B5–B7, measured this session)
- **Posts**: 72 EN + 72 ZH on disk, 0 drafts; build **251 pages**; homepage renders `72 posts in English and 中文`, the About pages now derive the same number.
- **Mobile**: 12 routes × 4 widths (360 / 390 / 414 / 768) = **48/48 with `scrollWidth − clientWidth == 0`**.
- **Art**: 72/72 banners 1600×900 WebP; 72/72 `-800` thumbs 800×420; 72/72 `-400` thumbs 400×210; 144/144 share cards 1200×630 JPEG. Featured bento cards now carry `srcset` (400w/800w) and pick the 400 file at DPR 1.
- **Reading time**: EN `5 min read` vs ZH `5 分钟` on the same post (was EN 5 / ZH 1 before the CJK fix).
- **Not enabled** (still owner-gated, re-measured today): Cloudflare Web Analytics — `grep -c cloudflareinsights` on the homepage returns **0**. `sitemap-index.xml`, `llms.txt` and `robots.txt` all serve 200.
- **Remotes**: Gitea and GitHub both at `5ec321a`, 0 divergence.
---
## 5. Conventions (non-negotiable)
- Push git.hoelee.com first, then GitHub
- English-first; **Chinese for every post** (same filename in `posts/zh/` — `post-guideline.md` §8 wins over the older "selective" wording); no Malay
- No overclaiming, especially Web3
- Name identity: "Lee Teong Hoe" / "Mr Hoelee" + same photo + same `sameAs` handles everywhere
- Business framing: website design & development is primary; email hosting is secondary
- English post titles use Title Case
- See `docs/post-guideline.md` for post-writing rules; `docs/content-guide.md` for strategy; `docs/design-guide.md` for UI