Deploy / build (push) Successful in 33s
Review pass on the 041471d delivery, measured rather than eyeballed.
/posts/ on a phone put its first archive row 2.36 screens down (featured block
1235 px + 435 px of controls = row at 1996 px at 390x844), so a reader who came
to browse had to scroll past three stacked 16:9 cards. Below 700 px the cards are
now horizontal — thumb 38 %, excerpt dropped, title clamped to three lines — and
the block header carries `All 91 posts ↓` into #archive-list (with scroll-margin
for the sticky header), which doubles as the bypass-block affordance. Block
1235 → 523 px, card 392 → 153 px, first row 1996 → 1309 px.
Two new small-text colours measured under WCAG AA (.feat-min 3.04:1,
.archive-status 3.67:1 at ~12 px) move to --muted → 5.78:1 and 6.99:1. The
featured heading went with them; pre-existing --faint dates (3.67:1) were left
alone rather than restyling 91 rows.
/categories/ still showed the old Gemini case-studies.webp while the category
page had the new topology banner — one category, two art languages. The tile now
uses case-studies-banner-1200.webp (14 KB) letterboxed on #0b1613: a 16:9 centre
crop sliced the outer host panels in half, the letterbox was measured seamless.
`6min read` was emitted without its space (template-literal fix → `6 min read`,
`6 分钟`, verified in dist/). Both /posts/ ledes stopped claiming the page is
purely newest-first.
probe-layout: 72/72 viewport checks, collapse still 425→0→425 (1280) and
523→0→523 (390) in EN and ZH.
503 lines
58 KiB
Markdown
503 lines
58 KiB
Markdown
# 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")
|
||
- ✅ Header brand = logo mark + real name (2026-10-26): `Lee Teong Hoe` in the header, preceded by the
|
||
three-colour brand mark (`/images/hoelee-mark-96.png`, 96 px rasterised from the brand vector so it stays
|
||
sharp at 26 CSS px). On ≤600 px the header is **two rows** — brand + search/lang/theme on row 1, the four
|
||
section links on row 2 (`ul.nav-links` takes `flex-basis: 100%`) — measured 320/360/390/414/600/768/1280:
|
||
`scrollWidth == innerWidth` at every width, nothing clipped. Same favicon set as learning.hoelee.com.
|
||
- ✅ 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]/`)
|
||
- ✅ `/posts/` hybrid archive + case-study portfolio (2026-10-27): fixed-slug flagship cards,
|
||
collapse-on-filter, portfolio hero, action links, 44 px tap targets — see §4e
|
||
- ✅ 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.
|
||
- ✅ **B8 Consultant Review R4 & Blueprints Architecture** (2026-10-27) —
|
||
- Stream separation: Homepage splits into 6 engineering posts + bottom GPU & Multimodal Lab strip.
|
||
- Top navigation: `Blueprints | Case Studies | All Posts | Work With Me` (ZH: `系统蓝图 | 工程案例 | 全部文章 | 关于与合作`).
|
||
- Hero CTA: Malaysian SME button anchors directly to `/about/#sme` (ZH: `/zh/about/#sme`).
|
||
- Dual-track `/about/` retains canonical URL, redirects `/work-with-me/` via 301.
|
||
- Three System Blueprints (`/blueprints/` and `/zh/blueprints/`):
|
||
- `self-hosted-infrastructure` (162 Containers, 4 Layers)
|
||
- `ai-agent-automation` (Deterministic Workflows + GPU Inference, 4 Layers)
|
||
- `sme-digital-suite` (High-Performance Web, SifuMail, Entitlements, 4 Layers)
|
||
- 94 post files mounted to the 12 Blueprint Layers across EN and ZH with in-article breadcrumbs.
|
||
- Schema.org `TechArticle` enrichment with `dependencies` and `audience`.
|
||
|
||
---
|
||
|
||
## 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 I — Three video & browser automation posts (2026-10-22)
|
||
- Added 3 posts in EN + ZH (6 files) backdated into the historical gaps of the archive with `updatedDate: 2026-10-22`:
|
||
- `how-i-built-an-automated-music-video-pipeline` (category `case-studies`, pubDate 2025-04-18)
|
||
- `uploading-large-videos-to-youtube-via-cdp` (category `devops`, pubDate 2025-08-22)
|
||
- `debugging-remotion-video-jitter` (category `engineering`, pubDate 2025-11-26)
|
||
- Full art suite rendered via `render_from_art.mjs` (banner jpg+webp, EN/ZH OG cards, 400/800 thumbs).
|
||
- All 158 published posts pass `scripts/check-assets.mjs` and `npm run build` cleanly (271 pages indexed).
|
||
|
||
### 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.
|
||
|
||
---
|
||
## 4d. Consultant review of the three video posts — 2026-10-22
|
||
|
||
A paid reviewer read `how-i-built-an-automated-music-video-pipeline`, `uploading-large-videos-to-youtube-via-cdp` and
|
||
`debugging-remotion-video-jitter` (EN + ZH). Verdict, receipts, number audit and the open items:
|
||
**`docs/consultant-review-2026-10-22-3-video-posts.md`**.
|
||
|
||
- **Live (Gitea Actions 292 / 293 / 295 success, six pages 200, artwork md5 matches, `check-assets.mjs` 162/162 OK):**
|
||
`f32ae90` did the fact corrections — "three minutes" → "a few minutes" + the 218 s scale, microsecond-accurate →
|
||
frame-accurate (33.3 ms at 30 fps), "38% speedup" → 38.0 % less render time / 1.61× throughput, the absolute
|
||
"Zero Jitter" / "Zero Light Flashing" claims scoped to the clip, `0o666` → `0o644`, the memory claim scoped to the
|
||
Chrome container, **TL;DR + inline cross-links on all three** (the trio had zero internal links), regenerated artwork.
|
||
`04a1082` (parallel session) added the end-to-end timing table, the benchmark table, the layer-compositing maths and the
|
||
upload-method comparison. `cf4504e` unified the render figure to **38.0 %** everywhere — the table derives it from 218.3 s.
|
||
- **The reviewer got one item wrong:** the "`ti.mode = 0o 666` syntax error" does not exist. Source and live HTML both read
|
||
`0o666`; the live page splits the token across two highlight spans and his text extraction inserted a space. `0o666` is
|
||
legal Python, so the `0o644` change is permission hygiene, not a bug fix.
|
||
- **Owner queue (nothing else blocks):**
|
||
- [ ] Confirm the provenance of the four stage timings in the pipeline post's timing table (14.2 / 2.1 / 32.4 / 48.6 s).
|
||
They have **no measurement trail in this repo** and the reviewer's whole complaint is about unmeasured numbers — either
|
||
confirm them as measured or relabel them as estimates. The render (218.3 s) and staging (1.89 s) figures do have a base.
|
||
- [ ] Provide the before/after jitter + dark-flash source clips so the comparison GIF can be cut and embedded (the one
|
||
reviewer item that needs an asset only the owner has).
|
||
- [ ] Decide which session owns edits to these three posts. On 2026-10-22 two sessions edited the same files and one
|
||
session's paragraphs rode into the other's commit — content survived, but the history stopped attributing correctly.
|
||
|
||
---
|
||
## 4e. Live re-check — 2026-10-27 (hybrid archive + case-study portfolio)
|
||
|
||
Delivered the four work orders in `docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md`.
|
||
Measured, not assumed — `node scripts/probe-layout.mjs` drives a headless Chrome over CDP and
|
||
reports **72/72** page×viewport checks with `scrollWidth == clientWidth`, plus the collapse test.
|
||
|
||
- **`/posts/` and `/zh/posts/` are hybrid, not paginated.** Three fixed-slug flagship cards
|
||
(`src/lib/featured.ts`, 3 on the archive, 4 on the home page) sit above the complete
|
||
year/month archive, which still lists every post and keeps `Ctrl+F` working. Typing in the
|
||
filter or clicking a category chip collapses the flagship block (`inert`, height 0,
|
||
`data-collapsed="true"`) and the status line switches from `All 91 posts` to
|
||
`Showing 27 of 91 posts`; clearing restores it. Verified at 1280 and 390 in both languages.
|
||
- **`/categories/case-studies/` is a portfolio.** `src/components/PortfolioHero.astro` — copy left,
|
||
a 21:9 architecture banner right (2400×1029, 33 KB WebP + a 1200 px variant, generated
|
||
deterministically by `scripts/gen-case-studies-banner.mjs`; pure geometry, no baked text).
|
||
Ordering comes from `src/lib/portfolio.ts` (`CASE_STUDY_FLAGSHIPS`, narrative order
|
||
scale → local AI → automation → shipped commercial result), not from `pubDate`, so publishing
|
||
a new post cannot rewrite the story. Every card carries a measured pill (container counts, VRAM,
|
||
cron counts — all read from the post body, never invented).
|
||
- **One shipped narrative.** `lib/featured.ts` and `lib/portfolio.ts` share `pickFeatured()`, so the
|
||
home page, the archive and the portfolio can never disagree about which write-ups are flagship.
|
||
- **Listing rows name their action**: `Read article →` / `Read case study →`
|
||
(`阅读文章 →` / `阅读案例复盘 →`), as a redundant link kept out of the tab order (`tabindex="-1"`,
|
||
one keyboard stop per card). Mobile title links get a 44 px minimum tap height.
|
||
- **ZH leak fixed at the source.** `Archive.astro` printed raw category slugs in the row tail and in
|
||
the category chips; both now go through `categoryLabel(category, lang)` (运维架构 / 工程案例 / …).
|
||
- **Smaller craft fixes found while reviewing screenshots**: the two chip strips were unlabelled and
|
||
read as one list with a duplicated `devops` (category 30 vs topic 6) → labelled *Category* / *Topic*,
|
||
stacked above the chips below 700 px; rows now cap tags at four plus a `+N` chip so card rhythm
|
||
stops breaking; the nav gained `aria-current="page"` (it had no active state at all).
|
||
- **Docs:** `scripts/probe-layout.mjs` is committed as the repeatable gate for this class of bug.
|
||
|
||
### 4e-bis. Review pass on the delivery (same day) — what measurement changed
|
||
|
||
An independent read of the shipped pages turned up four things the first pass missed. Fixed, remeasured:
|
||
|
||
- **Mobile archive was 2.4 screens away from its own list.** At 390×844 the featured block ran
|
||
688→1923 px (3 × 392 px stacked cards) plus 435 px of controls, so the first `2026` row sat at
|
||
**1996 px = 2.36 screens** — a reader who came to browse had to scroll past everything.
|
||
Below 700 px each featured card is now horizontal (thumb 38 %, excerpt dropped, title clamped to
|
||
three lines): card 392 → **153 px**, block 1235 → **523 px**, first row at **1309 px = 1.55
|
||
screens**. The block header gained `All 91 posts ↓` (→ `#archive-list`, `scroll-margin-top`
|
||
clears the sticky header), which doubles as the bypass-block affordance.
|
||
- **Two new small-text colours failed WCAG AA.** Measured against their own backgrounds:
|
||
`.feat-min` **3.04:1** and `.archive-status` **3.67:1** (`--faint`), where AA wants 4.5:1 under
|
||
18.66 px. Both moved to `--muted` → **5.78:1** and **6.99:1**; the featured block's own heading
|
||
moved with them. Pre-existing `--faint` dates (3.67:1) were left alone rather than restyling 91 rows.
|
||
- **One category, two art languages.** `/categories/` still showed the old Gemini
|
||
`/categories/case-studies.webp` while the category page had the new 21:9 topology banner.
|
||
The tile now points at `case-studies-banner-1200.webp` (14 KB) and letterboxes it
|
||
(`object-fit: contain` on `#0b1613`, measured seamless) instead of centre-cropping — a 16:9 crop
|
||
sliced the outer host panels in half.
|
||
- **`6min read` had no space.** `{f.minutes} {labels.minutes}` inside the nested `feat-cta` span is
|
||
emitted without the separating space (the same pattern elsewhere in the repo *does* keep it, so
|
||
this is compiler-adjacent, not universal). Replaced with a template literal → `6 min read` /
|
||
`6 分钟`, verified in `dist/`.
|
||
|
||
`/posts/` and `/zh/posts/` ledes no longer claim the page is purely newest-first.
|
||
|
||
## 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
|