From 874c4345769755475b32f5be61218775ba441247 Mon Sep 17 00:00:00 2001 From: hoelee Date: Fri, 16 Oct 2026 20:50:26 +0800 Subject: [PATCH] fix(i18n,404,nginx): broken-windows batch B2 from the external review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All items measured before/after with curl, on the origin and through Cloudflare: - /posts/zh/ answered 403 (directory with no index) -> nginx 301 to the new archive - /zh/posts/ 404'd -> new src/pages/zh/posts/index.astro (build 163 -> 165 pages) - ZH nav pointed "关于" at /zh/ and "文章" at /zh/ -> /zh/about/ and /zh/posts/ - og:locale was "en" on every Chinese page -> zh_CN, plus og:locale:alternate on both languages (EN pages now advertise zh_CN as well) - hreflang "zh" -> "zh-Hans"; on ZH pages -> zh-Hans - no 404 page existed -> src/pages/404.astro, served by nginx with a 404 status so crawlers skip it, offering search + archive + the 3 newest posts - JSON-LD Person gained worksFor (Hoelee Enterprise); image still omitted until the author photo exists rather than pointing at a placeholder nginx: config moved from the stock image file to a host bind mount (/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf, Portainer stack 247 on endpoint 3), validated with nginx -t in a throwaway container before the redeploy. It also sets /_astro/* to immutable 1-year (hashed names) and HTML to must-revalidate; unhashed /og/*.png and /banners/*.png keep the default TTL on purpose. Note: the public HSTS and 16-day asset TTL come from Cloudflare zone settings, not from this nginx. Docs: ops-runbook.md (the nginx layer + why each rule), project-state.md (B2 entry and the two open Cloudflare items my token cannot write). --- docs/ops-runbook.md | 25 ++++++++++++++ docs/project-state.md | 11 ++++++ src/layouts/BaseLayout.astro | 21 ++++++++---- src/pages/404.astro | 61 ++++++++++++++++++++++++++++++++++ src/pages/zh/posts/index.astro | 28 ++++++++++++++++ src/styles/global.css | 20 +++++++++++ 6 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 src/pages/404.astro create mode 100644 src/pages/zh/posts/index.astro diff --git a/docs/ops-runbook.md b/docs/ops-runbook.md index c82f6fa..423542d 100644 --- a/docs/ops-runbook.md +++ b/docs/ops-runbook.md @@ -54,6 +54,31 @@ Overflow diagnosis (real numbers, not screenshot guessing): check `scrollWidth > --- +## nginx layer (unRaid container, Portainer stack 247) + +The container `nginx-hoelee-blog` (`nginx:1.29-alpine`, host port 8081) had a **stock +`default.conf`** until 2026-10-16, when the config became a bind mount: + +- `/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf` → `/etc/nginx/conf.d/default.conf:ro` +- Edits take effect after `docker exec nginx-hoelee-blog nginx -t && docker exec nginx-hoelee-blog nginx -s reload` + (no restart needed). Validate a candidate file first in a throwaway container: + `docker run --rm -v /mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro nginx:1.29-alpine nginx -t` +- Stack: Portainer **DSM instance** (endpoint 3 = unRaid), stack id **247**. Read/update via + `GET|PUT /api/stacks/247?endpointId=3`; PUT recreates the service (a few seconds of downtime). + +What the conf adds, and why: + +| Rule | Why | +|---|---| +| `location = /posts/zh/ { return 301 /zh/posts/; }` | Chinese posts live at `/posts/zh//`, so the bare directory had no index and answered **403**. The Chinese archive is `/zh/posts/`. | +| `error_page 404 /404.html;` + internal location | Astro builds `dist/404.html`. Without it, a broken link showed nginx's default page. Served with a 404 status so crawlers do not index it. | +| `location /_astro/` → `max-age=31536000, immutable` | Astro hashes these filenames, so they can never go stale. `/og/*.png` and `/banners/*.png` are **not** hashed and are regenerated in place, so they deliberately keep Cloudflare's default TTL. | +| HTML → `max-age=0, must-revalidate` | A deploy must be visible immediately; edge caching is controlled separately by the Cloudflare Cache Rule + the deploy purge step. | + +Header note: the public `Strict-Transport-Security` and the default 16-day asset +`Cache-Control` come from **Cloudflare zone settings**, not from this nginx (the origin +sends no such headers). See `docs/project-state.md` §1 B2 for the open CF items. + ## Hosting notes - Static output = trivial to serve from any nginx/OpenLiteSpeed dir. Cloudflare in front gives CDN + HTTPS + DDoS. diff --git a/docs/project-state.md b/docs/project-state.md index d6081c2..dceb142 100644 --- a/docs/project-state.md +++ b/docs/project-state.md @@ -28,6 +28,17 @@ Living list of what's done and what's next for blog.hoelee.com. Work through the - ✅ 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 +- ✅ **B2 broken-windows batch, 2026-10-16 (second batch from the external design review)** — every item verified before/after with `curl`: + - **`/posts/zh/` answered 403** (a directory with no index) → nginx now `301`s it to the new Chinese archive. + - **`/zh/posts/` did not exist** (404) → new `src/pages/zh/posts/index.astro` (build went 163 → 165 pages). + - **ZH nav pointed "关于" at `/zh/`** → now `/zh/about/`, and "文章" → `/zh/posts/`. + - **`og:locale` was `en` on every Chinese page** → now `zh_CN`, with `og:locale:alternate` on both languages (EN pages advertise `zh_CN` too). + - `hreflang` value `zh` → **`zh-Hans`**, and `` on ZH pages → `zh-Hans`. + - **No 404 page** → `src/pages/404.astro` (status stays 404, branded page with the search overlay, archive links and the 3 newest posts). + - **JSON-LD `Person`** gained `worksFor` (Hoelee Enterprise + `www.hoelee.com`); `image` still absent on purpose until the author photo exists. + - nginx config moved from the stock image file to a host bind mount at + `/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf` (Portainer DSM stack **247**, endpoint 3), validated in a throwaway container before the redeploy; the conf also sets `/_astro/*` to `immutable` 1-year and HTML to `must-revalidate`. Details in `ops-runbook.md`. + - Still open for the owner (CF token limits, see below): **zone HSTS is `max-age=0`** and **HTML is not edge-cached** — both are Cloudflare settings my token cannot write. The deploy workflow already purges the edge after each deploy once a Cache Rule exists. - ✅ **B1 image batch, 2026-10-16 (first batch from the external design review)** — measured with a real browser before/after, not by eye: - **Article banner** → `` with WebP q78 + PNG fallback: 70 banners 6.02 MB → **2.52 MB (42%)**. The PNG stays for `og:image` (social scrapers do not reliably render WebP cards). JPEG was measured *worse* than PNG on this art (q85 = 129%). - **Related-post cards** on a post page → **text-only** (category badge + title + date + read time). They used to load four 1200×630 PNGs (183 KB) to display 526×276 where the art's code is illegible anyway. diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index d2a66a4..ae54951 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -45,7 +45,7 @@ const ogImageUrl = new URL(ogImage, SITE.url).href; const hreflang = altLocaleUrl ? [ { hreflang: 'en', href: lang === 'en' ? canonical : altLocaleUrl }, - { hreflang: 'zh', href: lang === 'zh' ? canonical : altLocaleUrl }, + { hreflang: 'zh-Hans', href: lang === 'zh' ? canonical : altLocaleUrl }, { hreflang: 'x-default', href: lang === 'en' ? canonical : altLocaleUrl }, ] : []; @@ -63,7 +63,7 @@ const nav = isZh --- - + @@ -87,8 +87,8 @@ const nav = isZh - - {lang === 'zh' && } + + @@ -128,6 +128,15 @@ const nav = isZh email: SITE.email, sameAs: [SITE.linkedin, SITE.github, SITE.gitea, SITE.website].filter(Boolean), jobTitle: 'Full-stack Developer & DevOps Engineer', + // The consultancy that does the client work — ties the blog's author node to + // the business entity (and gives the hiring side a company reference). + worksFor: { + '@type': 'Organization', + name: 'Hoelee Enterprise', + url: SITE.website, + }, + // TODO(image): `image` stays absent until the author photo exists — see + // project-state §2 B4. A placeholder URL would be worse than no image. knowsAbout: [ 'Full-stack engineering', 'DevOps', 'Self-hosting', 'Docker', 'WordPress', 'AI automation', 'Web3', @@ -163,9 +172,9 @@ const nav = isZh