fix(i18n,404,nginx): broken-windows batch B2 from the external review
Deploy / build (push) Successful in 3m59s
Deploy / build (push) Successful in 3m59s
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"; <html lang> 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).
This commit is contained in:
1 parent
831d440661
commit
874c434576
6 files changed
+160
-6
No files matched your search
@@ -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/<slug>/`, 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.
|
||||
|
||||
@@ -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 `<html lang>` on ZH pages → `zh-Hans`.
|
||||
- **No 404 page** → `src/pages/404.astro` (status stays 404, branded page with the search overlay, archive links and the 3 newest posts).
|
||||
- **JSON-LD `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** → `<picture>` 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.
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang} data-theme="dark">
|
||||
<html lang={isZh ? 'zh-Hans' : lang} data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -87,8 +87,8 @@ const nav = isZh
|
||||
<meta property="og:image" content={ogImageUrl} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content={SITE.locale} />
|
||||
{lang === 'zh' && <meta property="og:locale:alternate" content="en_US" />}
|
||||
<meta property="og:locale" content={isZh ? 'zh_CN' : SITE.locale} />
|
||||
<meta property="og:locale:alternate" content={isZh ? 'en_US' : 'zh_CN'} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
@@ -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
|
||||
<header class="site-header">
|
||||
<nav class="nav" aria-label="Main">
|
||||
<a class="brand" href="/">Mr<span class="dot"> Hoelee</span></a>
|
||||
<a href={isZh ? '/zh/' : '/posts/'}>{nav.posts}</a>
|
||||
<a href={isZh ? '/zh/posts/' : '/posts/'}>{nav.posts}</a>
|
||||
<a href={isZh ? '/zh/categories/' : '/categories/'}>{nav.categories}</a>
|
||||
<a href={isZh ? '/zh/' : '/about/'}>{nav.about}</a>
|
||||
<a href={isZh ? '/zh/about/' : '/about/'}>{nav.about}</a>
|
||||
<a href="/rss.xml" title={nav.rss}>{nav.rss}</a>
|
||||
<button class="search-toggle" id="search-toggle" aria-label={isZh ? '搜索' : 'Search'}>⌕</button>
|
||||
<a class="lang-switch" href={resolvedSwitchHref} aria-label={isZh ? 'Switch to English' : '切换到中文'}>{switchLabel}</a>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../lib/lang';
|
||||
import { sortForListing } from '../lib/sort';
|
||||
|
||||
// Static 404. nginx serves it with `error_page 404 /404.html`, so the status code
|
||||
// stays 404 (crawlers do not index it) while the visitor gets a way forward.
|
||||
// Before this existed, a broken link showed nginx's bare default page.
|
||||
const latest = sortForListing(await getCollection('posts', isEn)).slice(0, 3);
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="404 — Page Not Found — Mr Hoelee"
|
||||
description="That URL does not exist on this site. Search the posts or browse the archive."
|
||||
>
|
||||
<h1>404 — container exited (code 1)</h1>
|
||||
<p>
|
||||
There is no page at this address. It may have been renamed, or the link that sent
|
||||
you here may be wrong. Nothing is broken on your side.
|
||||
</p>
|
||||
|
||||
<p class="notfound-actions">
|
||||
<button id="search-cta" class="notfound-btn" type="button">⌕ Search the posts</button>
|
||||
<a href="/posts/">Browse all posts</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="/categories/">Categories</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="/zh/">中文</a>
|
||||
</p>
|
||||
|
||||
<h2>Recent posts</h2>
|
||||
<ul class="post-list">
|
||||
{latest.map((p) => (
|
||||
<li class="post-row notfound-row">
|
||||
<div class="body">
|
||||
<a href={`/posts/${p.slug}/`}><h2>{p.data.title}</h2></a>
|
||||
<div class="meta">
|
||||
<time datetime={p.data.pubDate.toISOString()}>{fmt(p.data.pubDate)}</time>
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={`/categories/${p.data.category}/`}>{p.data.category}</a>
|
||||
</div>
|
||||
<p>{p.data.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<script>
|
||||
// The search overlay is mounted on every page by BaseLayout; its trigger is the
|
||||
// ⌕ button in the header (also bound to Ctrl/Cmd+K there). Route this page's
|
||||
// button to the same overlay instead of growing a second search implementation.
|
||||
const cta = document.getElementById('search-cta');
|
||||
cta?.addEventListener('click', () => {
|
||||
const toggle = document.getElementById('search-toggle');
|
||||
if (toggle) toggle.click();
|
||||
});
|
||||
</script>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isZh } from '../../../lib/lang';
|
||||
import { sortForListing } from '../../../lib/sort';
|
||||
import { SITE } from '../../../config';
|
||||
|
||||
// The ZH archive. Until 2026-10-16 the ZH nav pointed "文章/关于" at /zh/ and this
|
||||
// route did not exist at all: /posts/zh/ (a directory with no index) answered 403
|
||||
// from nginx, and /zh/posts/ 404'd. Both are fixed here + in the nginx conf.
|
||||
const posts = sortForListing(await getCollection('posts', isZh));
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="文章 — Mr Hoelee"
|
||||
description="Hoelee 技术博客的全部中文文章。"
|
||||
lang="zh"
|
||||
switchHref="/posts/"
|
||||
altLocaleUrl={`${SITE.url}/posts/`}
|
||||
>
|
||||
<h1>文章</h1>
|
||||
{posts.length === 0 ? (
|
||||
<p><em>中文文章即将发布,敬请期待。</em></p>
|
||||
) : (
|
||||
<PostList posts={posts} lang="zh" />
|
||||
)}
|
||||
</BaseLayout>
|
||||
@@ -329,6 +329,26 @@ pre code {
|
||||
}
|
||||
.related-card time { font-size: 0.78rem; margin-top: auto; padding-top: 0.4rem; }
|
||||
|
||||
/* ---------- 404 ---------- */
|
||||
.notfound-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
.notfound-btn {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notfound-btn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.notfound-row .body { padding-block: 0.6rem; }
|
||||
|
||||
/* ---------- Post list ---------- */
|
||||
.post-list { list-style: none; padding: 0; margin: 0; }
|
||||
.post-list li {
|
||||
|
||||
Reference in new issue
Block a user