feat(structure): compact archive + tag pages + hero/featured, and fix site search
Deploy / build (push) Failing after 13m52s

Found a real bug while verifying the nav search: /pagefind/pagefind.js is an ES
module that defines no window.Pagefind, but Search.astro loaded it with a classic
<script src> and read window.Pagefind — so every query answered "Search is
unavailable." (confirmed live before the fix). Now a dynamic import; the specifier
must be a variable or Rollup resolves it at build time and the build fails.

Measured before/after in a real browser:

- archive /posts/ + /zh/posts/: 18,220px -> 3,788px (71 rows at 44px, grouped by
  year) with a client-side filter (title/tags/description text, category chips,
  14 tag chips, zero requests). Tested: "nginx" -> 6 rows, chip devops -> 28.
- tag pages for tags with >= 3 posts. Measured 235 distinct tags (an earlier brief
  claimed 74), 156 used exactly once — pages only where a topic exists: 40 tags ->
  80 pages, build 165 -> 245 pages. Pills link only when a page exists.
- homepage + ZH homepage: hero (288px) with the real scale claim, three CTAs, a
  fixed Featured 4 (scale -> automation -> observability -> a delivered result),
  Latest 10 -> 6. ZH hero had linked "关于我" to the English /about/.
- long code blocks collapse above 420px with an Expand button + gradient, copy
  button still copies the whole block (verified on the 1010px block).
- search is visible ("⌕ Search /" pill, "/" plus the existing Ctrl/Cmd+K) and the
  zero-result screen offers topic chips instead of a dead end.

Docs: project-state B3 entry, ops-runbook Pagefind gotcha.
This commit is contained in:
hoelee committed 2026-10-16 21:18:25 +08:00
1 parent 8619e48271
commit f4008d9e8b
15 files changed
+669 -60

No files matched your search

+8
View File
@@ -32,6 +32,14 @@ Markdown (src/content/posts) → Astro build (dist/) → Gitea Actions → act_r
## Build pitfalls (from prior Astro work — check these first)
- **Pagefind is an ES module and exposes no window global.** `dist/pagefind/pagefind.js`
ends with `export { search, debouncedSearch, … }`. Load it with a dynamic `import()`, never
a classic `<script src>` + `window.Pagefind` — that combination silently made every search
answer "Search is unavailable." for as long as the overlay existed. The specifier must be a
**variable** (`const url = '/pagefind/pagefind.js'; await import(/* @vite-ignore */ url)`)
because Rollup resolves a literal dynamic-import path at build time and fails on a file that
does not exist until after the search index is generated.
- **No JSX helper components in frontmatter** — inline JSX directly in `.map()` calls, or the build fails with `Expected ">" but found "class"`.
- **Import depth** — `src/pages/*.astro` use `../layouts/…`; subdirs use `../../`. Wrong level = `Could not resolve`.
- **Footer must render AFTER `<main>`** — a shared header+footer component renders footer above content otherwise.
+8
View File
@@ -28,6 +28,14 @@ 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
- ✅ **B3 structure batch, 2026-10-16 (third batch from the external design review)** — all numbers measured in a real browser (CDP), not by eye:
- **Found and fixed a real bug: site search never worked.** `/pagefind/pagefind.js` is an **ES module** (`export { search, debouncedSearch, … }`) that defines **no `window.Pagefind`**, but `Search.astro` loaded it with a classic `<script src>` and read `window.Pagefind` → `undefined` → every query in the overlay was answered **"Search is unavailable."** Live-verified before the fix. Loader is now a dynamic `import()`; the specifier must be a **variable** (`const url = '…'; import(/* @vite-ignore */ url)`) because Rollup resolves a literal path at build time and fails on a file that only exists in `dist/`. After: query "docker" → 12 hits, first = the Docker OOM post; ZH "容器" also returns hits.
- **Archive `/posts/` + `/zh/posts/`: 18,220px → 3,788px** (71 rows at 44px, grouped by year) with a client-side filter — title/tag/description text search + category chips + 14 tag chips, zero network requests (tested: "nginx" → 6 rows, chip `devops` → 28 rows, reset → 71).
- **Tag pages**: 235 distinct tags measured (the earlier brief said 74 — wrong), **156 of them used exactly once**; pages are generated only for tags with **≥ 3 posts** (40 tags → **80 pages**, build 165 → 245 pages). Tag pills on posts and listings link only when a page exists; one-off tags stay plain text. `/tags/docker/` → 25 posts.
- **Homepage + ZH homepage**: hero (288px desktop) with the real scale claim (162 containers / 3 hosts), 3 CTAs (case studies ↓ / hiring profile / SME work), **Featured 4** fixed slugs (scale → automation → observability → a delivered business result) and Latest trimmed 10 → 6.
- **Long code blocks collapse** at >420px with an "Expand (N lines) ↓" button and a gradient mask; the copy button still copies the full block (verified on `how-i-host-this-blog`: 1010px block).
- **Search is now visible** (nav pill "⌕ Search /" plus `/` as a shortcut next to the existing Ctrl/Cmd+K) and the zero-result screen offers topic chips + the archive instead of a dead end (verified with a query that matches nothing).
- ZH nav bug fixed in passing: the ZH hero linked "关于我" to the **English** `/about/`.
- ✅ **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).
+238
View File
@@ -0,0 +1,238 @@
---
import type { CollectionEntry } from 'astro:content';
import { tagCounts, tagHref } from '../lib/tags';
interface Props {
posts: CollectionEntry<'posts'>[];
lang?: 'en' | 'zh';
/** Filter bar + chips. Off on tag/collection pages, where the set is already narrow. */
chrome?: boolean;
}
const { posts, lang = 'en', chrome = true } = Astro.props;
const isZh = lang === 'zh';
const counts = tagCounts(posts);
const years = [...new Set(posts.map((p) => p.data.pubDate.getUTCFullYear()))].sort((a, b) => b - a);
const categories = [...new Set(posts.map((p) => p.data.category))]
.map((c) => ({ name: c, n: posts.filter((p) => p.data.category === c).length }))
.sort((a, b) => b.n - a.n);
// Only tags that have their own page become chips — a chip that leads nowhere is the
// same trap the old non-clickable pills were.
const chips = [...counts.entries()]
.filter(([, n]) => n >= 3)
.sort((a, b) => b[1] - a[1])
.slice(0, 14)
.map(([t, n]) => ({ tag: t, n }));
const shortDate = (d: Date) =>
`${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const yearOf = (d: Date) => d.getUTCFullYear();
const labels = isZh
? { filter: '按标题或标签筛选…', all: '全部', empty: '没有匹配的文章。', search: '全文搜索', count: '篇' }
: { filter: 'Filter by title or tag…', all: 'All', empty: 'No posts match.', search: 'Full-text search', count: 'posts' };
---
{chrome && (
<>
<div class="archive-bar">
<input
type="search"
id="archive-q"
class="archive-input"
placeholder={labels.filter}
autocomplete="off"
aria-label={labels.filter}
/>
<button class="archive-overlay-btn" id="archive-search" type="button">⌕ {labels.search}</button>
</div>
<div class="archive-chips" role="group" aria-label={isZh ? '按分类筛选' : 'Filter by category'}>
<button class="chip is-active" data-cat="" type="button">{labels.all} <span>{posts.length}</span></button>
{categories.map((c) => (
<button class="chip" data-cat={c.name} type="button">{c.name} <span>{c.n}</span></button>
))}
</div>
<div class="archive-chips archive-tags" role="group" aria-label={isZh ? '按标签筛选' : 'Filter by tag'}>
{chips.map((c) => (
<a class="chip tag-chip" href={tagHref(c.tag, lang)}>{c.tag} <span>{c.n}</span></a>
))}
</div>
</>
)}
{!chrome && <p class="archive-meta">{posts.length} {labels.count}</p>}
<ol class="archive" id="archive-list">
{years.map((y) => (
<li class="archive-year-block" data-year={y}>
<h2 class="archive-year">{y}</h2>
<ol class="archive-rows">
{posts.filter((p) => yearOf(p.data.pubDate) === y).map((p) => (
<li
class="archive-row"
data-cat={p.data.category}
data-tags={p.data.tags.join(' ')}
data-q={`${p.data.title} ${p.data.tags.join(' ')} ${p.data.description}`.toLowerCase()}
>
<time datetime={p.data.pubDate.toISOString()}>{shortDate(p.data.pubDate)}</time>
<a class="archive-title" href={`/posts/${p.slug}/`}>{p.data.title}</a>
<a class="archive-cat" href={`${isZh ? '/zh' : ''}/categories/${p.data.category}/`}>{p.data.category}</a>
</li>
))}
</ol>
</li>
))}
</ol>
<p class="archive-empty" id="archive-empty" hidden>{labels.empty}</p>
<style>
.archive-bar {
display: flex;
gap: 0.6rem;
align-items: center;
margin: 1.25rem 0 0.9rem;
flex-wrap: wrap;
}
.archive-input {
flex: 1 1 16rem;
min-width: 12rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--ink);
font: inherit;
font-size: 0.95rem;
padding: 0.6rem 0.8rem;
}
.archive-input:focus { outline: none; border-color: var(--brand); }
.archive-overlay-btn {
font: inherit;
font-size: 0.9rem;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.archive-overlay-btn:hover { color: var(--brand); border-color: var(--brand); }
.archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
.archive-tags { margin-bottom: 1.1rem; }
.chip {
font: inherit;
font-size: 0.78rem;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.22rem 0.6rem;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.chip span { opacity: 0.55; margin-left: 0.15rem; }
.chip:hover { color: var(--brand); border-color: var(--brand); }
.chip.is-active { color: var(--ink); border-color: var(--brand); }
.archive { list-style: none; padding: 0; margin: 0; }
.archive-year {
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
margin: 1.4rem 0 0.4rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
}
.archive-rows { list-style: none; padding: 0; margin: 0; }
.archive-row {
display: flex;
align-items: center;
gap: 0.8rem;
min-height: 44px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
}
.archive-row time {
flex: 0 0 3.2rem;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--faint);
}
.archive-title {
flex: 1 1 auto;
color: var(--ink);
text-decoration: none;
font-size: 0.95rem;
line-height: 1.35;
}
.archive-title:hover { color: var(--brand); }
.archive-cat {
flex: 0 0 auto;
font-size: 0.72rem;
color: var(--muted);
text-decoration: none;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.1em 0.55em;
}
.archive-cat:hover { color: var(--brand); border-color: var(--brand); }
.archive-empty { color: var(--muted); margin-top: 1.5rem; }
[hidden] { display: none !important; }
@media (max-width: 600px) {
.archive-row { flex-wrap: wrap; gap: 0.3rem 0.6rem; padding: 0.4rem 0; }
.archive-title { flex: 1 1 100%; order: 2; }
.archive-row time { flex: 0 0 auto; order: 1; }
.archive-cat { order: 1; }
}
</style>
<script>
// Client-side filter over an already-rendered list: zero network requests, works
// with the browser's own Ctrl+F, and keeps the archive one static page.
const q = document.getElementById('archive-q') as HTMLInputElement | null;
const list = document.getElementById('archive-list');
const empty = document.getElementById('archive-empty');
const chips = Array.from(document.querySelectorAll('.chip[data-cat]'));
const overlayBtn = document.getElementById('archive-search');
let activeCat = '';
overlayBtn?.addEventListener('click', () => {
document.getElementById('search-toggle')?.click();
});
function apply() {
const term = (q?.value ?? '').trim().toLowerCase();
const terms = term ? term.split(/\s+/) : [];
let shown = 0;
document.querySelectorAll('.archive-row').forEach((row) => {
const el = row as HTMLElement;
const cat = el.dataset.cat ?? '';
const hay = el.dataset.q ?? '';
const okCat = !activeCat || cat === activeCat;
const okTerm = terms.every((t) => hay.includes(t));
const ok = okCat && okTerm;
el.hidden = !ok;
if (ok) shown++;
});
document.querySelectorAll('.archive-year-block').forEach((block) => {
const el = block as HTMLElement;
const any = Array.from(el.querySelectorAll('.archive-row')).some((r) => !(r as HTMLElement).hidden);
el.hidden = !any;
});
if (empty) empty.hidden = shown !== 0;
}
q?.addEventListener('input', apply);
chips.forEach((chip) => {
chip.addEventListener('click', () => {
activeCat = (chip as HTMLElement).dataset.cat ?? '';
chips.forEach((c) => c.classList.toggle('is-active', c === chip));
apply();
});
});
</script>
+11 -1
View File
@@ -1,5 +1,8 @@
---
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import { postLang } from '../lib/lang';
import { tagCounts, tagLink } from '../lib/tags';
interface Props {
posts: CollectionEntry<'posts'>[];
@@ -24,6 +27,10 @@ const catHref = (cat: string) => `/${isZh ? 'zh/categories' : 'categories'}/${ca
const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.png';
// Tag pills only link when the tag has its own page (>= TAG_MIN_POSTS in this language).
const counts = tagCounts((await getCollection('posts', ({ data }) => !data.draft))
.filter((p) => postLang(p.slug) === lang));
// The row thumbnail is displayed at ~398 CSS px wide, so the list used to pull a
// 1200x630 PNG (~45 KB) per row for a 398px slot. Serve 400w/800w WebP derived from
// the same art instead (5.8 KB / 16.1 KB — see scripts/optimize-images.py).
@@ -64,7 +71,10 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) =>
)}
<span class="sep">·</span>
<a class="tag" href={catHref(post.data.category)}>{post.data.category}</a>
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
{post.data.tags.map((t) => {
const href = tagLink(t, counts, lang);
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
})}
</div>
<p>{post.data.description}</p>
</div>
+44 -15
View File
@@ -11,6 +11,13 @@ const placeholder =
lang === 'zh' ? '搜索文章…' : 'Search posts…';
const noResults =
lang === 'zh' ? '没有匹配的结果。' : 'No results.';
// Empty-search state: a dead end helps nobody. Offer the topic entry points that
// actually exist (tag pages are generated for tags with >= 3 posts).
const popular = ['docker', 'self-hosting', 'nginx', 'monitoring', 'automation'];
const popularHref = (t: string) => (lang === 'zh' ? `/zh/tags/${t}/` : `/tags/${t}/`);
const tryLabel = lang === 'zh' ? '试试这些主题:' : 'Try a topic:';
const archiveLabel = lang === 'zh' ? '浏览全部文章' : 'Browse all posts';
const archiveHref = lang === 'zh' ? '/zh/posts/' : '/posts/';
---
<div class="search-overlay" id="search-overlay" hidden>
@@ -28,6 +35,16 @@ const noResults =
</div>
<div class="search-results" id="search-results"></div>
</div>
<!-- Dead-end screen for a search with no hits: real links, rendered at build time. -->
<template id="search-empty-tpl">
<p class="excerpt" data-role="message">{noResults}</p>
<p class="excerpt" data-role="try">{tryLabel}</p>
<p class="search-empty-chips">
{popular.map((t) => <a class="chip" href={popularHref(t)}>{t}</a>)}
<a class="chip" href={archiveHref}>{archiveLabel}</a>
</p>
</template>
</div>
<style>
@@ -88,6 +105,17 @@ const noResults =
.search-result:hover { color: var(--brand); }
.search-result h3 { margin: 0 0 0.2rem; font-size: 1.05rem; }
.search-result .excerpt { color: var(--muted); font-size: 0.88rem; margin: 0; }
.search-empty-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.search-empty .chip {
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.22rem 0.6rem;
font-size: 0.78rem;
text-decoration: none;
}
.search-empty .chip:hover { color: var(--brand); border-color: var(--brand); }
.search-result mark {
background: color-mix(in srgb, var(--brand) 25%, transparent);
color: var(--ink);
@@ -140,17 +168,17 @@ const noResults =
async function loadPagefind() {
if (pagefind) return pagefind;
// Pagefind's UI/JS is copied into /pagefind/ post-build. Load it via a
// script tag (not a dynamic import) so Vite leaves the URL untouched.
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = '/pagefind/pagefind.js';
s.onload = () => resolve();
s.onerror = () => reject(new Error('pagefind load failed'));
document.head.appendChild(s);
});
const w = window as unknown as { Pagefind?: Pagefind };
pagefind = w.Pagefind;
// /pagefind/pagefind.js is copied into the build post-build. It is an **ES module**
// (`export { search, debouncedSearch, … }`) and defines NO window global, so loading
// it with a classic <script src> leaves window.Pagefind undefined and every search
// answered "Search is unavailable." — which is what happened between the search
// shipping and this fix. Import it as a module instead; @vite-ignore keeps Vite from
// trying to resolve a path that only exists in dist/.
// The specifier must be a VARIABLE: Rollup resolves a literal dynamic-import path
// at build time (and @vite-ignore does not stop it), which breaks the build because
// /pagefind/ only exists in dist/ after the index is generated.
const url = '/pagefind/pagefind.js';
pagefind = (await import(/* @vite-ignore */ url)) as unknown as Pagefind;
return pagefind;
}
@@ -169,10 +197,11 @@ const noResults =
const res = await search;
results.innerHTML = '';
if (!res.results.length) {
const empty = document.createElement('p');
empty.className = 'excerpt';
empty.textContent = 'No results.';
results.appendChild(empty);
// Clone the build-time template: the links cannot drift from the pages that
// actually exist (tag pages are only generated for tags with >= 3 posts).
results.innerHTML = '';
const tpl = document.getElementById('search-empty-tpl') as HTMLTemplateElement | null;
if (tpl) results.appendChild(tpl.content.cloneNode(true));
return;
}
for (const r of res.results.slice(0, 12)) {
+28 -1
View File
@@ -180,7 +180,9 @@ const nav = isZh
<a href={isZh ? '/zh/categories/' : '/categories/'}>{nav.categories}</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>
<button class="search-toggle" id="search-toggle" aria-label={isZh ? '搜索' : 'Search'}>
⌕ <span class="search-hint">{isZh ? '搜索' : 'Search'}</span><kbd class="search-key">/</kbd>
</button>
<a class="lang-switch" href={resolvedSwitchHref} aria-label={isZh ? 'Switch to English' : '切换到中文'}>{switchLabel}</a>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">◐</button>
</nav>
@@ -230,6 +232,31 @@ const nav = isZh
});
});
wrap.appendChild(b);
// Long code blocks: collapse anything taller than ~22 lines so the prose keeps
// flowing, with an explicit expand control (and the copy button still copies the
// FULL block, because the text is only hidden by CSS).
if (pre.scrollHeight > 420) {
wrap.classList.add('is-collapsed');
const ex = document.createElement('button');
ex.className = 'code-expand';
ex.textContent = `Expand (${Math.round(pre.scrollHeight / 22)} lines) ↓`;
ex.addEventListener('click', () => {
wrap.classList.remove('is-collapsed');
ex.remove();
});
wrap.appendChild(ex);
}
});
// "/" opens search (Ctrl/Cmd+K is bound in Search.astro) — kept off inputs.
document.addEventListener('keydown', (e) => {
const t = e.target as HTMLElement | null;
if (e.key === '/' && !e.ctrlKey && !e.metaKey &&
!(t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable))) {
e.preventDefault();
document.getElementById('search-toggle')?.click();
}
});
</script>
</body>
+43
View File
@@ -0,0 +1,43 @@
import type { CollectionEntry } from 'astro:content';
type Post = CollectionEntry<'posts'>;
/**
* Tag pages are generated only for tags that actually carry a topic.
*
* Measured 2026-10-16: 235 distinct tags across the posts, of which **156 appear
* exactly once** and 40 appear >= 3 times. Generating a page per tag would create
* 156 near-empty pages (thin content) and no navigation value; the threshold below
* is the line between "a topic" and "a word that appeared in one post".
*/
export const TAG_MIN_POSTS = 3;
/** Count how many posts carry each tag. */
export function tagCounts(posts: Post[]): Map<string, number> {
const counts = new Map<string, number>();
for (const p of posts) {
for (const t of p.data.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
}
return counts;
}
/** Tags that get their own page, most-used first (stable, no locale drift). */
export function tagPageNames(posts: Post[]): string[] {
return [...tagCounts(posts).entries()]
.filter(([, n]) => n >= TAG_MIN_POSTS)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([t]) => t);
}
/** Where a tag links to in a given language. */
export function tagHref(tag: string, lang: 'en' | 'zh'): string {
return lang === 'zh' ? `/zh/tags/${tag}/` : `/tags/${tag}/`;
}
/**
* Tag pills: a tag with a page becomes a link, a one-off tag stays plain text.
* Without this split the pills look identical whether or not they go anywhere.
*/
export function tagLink(tag: string, counts: Map<string, number>, lang: 'en' | 'zh'): string | null {
return (counts.get(tag) ?? 0) >= TAG_MIN_POSTS ? tagHref(tag, lang) : null;
}
+42 -14
View File
@@ -7,24 +7,51 @@ import { SITE } from '../config';
import { sortForListing } from '../lib/sort';
const posts = sortForListing(await getCollection('posts', isEn));
// Featured picks: fixed slugs, not "newest 4", so the front page stays stable after a
// publish and keeps answering "who is this and what has he actually run" (scale →
// automation → observability → a delivered business result). Falls back to the newest
// posts if a slug ever disappears.
const FEATURED_SLUGS = [
'running-production-infrastructure-solo',
'loop-engineering-without-a-coding-agent',
'one-prometheus-for-unraid-synology-and-a-vps',
'self-healing-digital-goods-entitlements',
];
const bySlug = new Map(posts.map((p) => [p.slug, p]));
const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(Boolean);
const featuredPosts = featured.length === 4 ? featured : posts.slice(0, 4);
---
<BaseLayout title={SITE.title} description={SITE.description} altLocaleUrl={`${SITE.url}/zh/`}>
<section>
<h1>Hi, I'm {SITE.author}.</h1>
<p>
A full-stack developer and DevOps engineer based in Malaysia. I build web
apps (PHP/CodeIgniter, Java/Spring, TypeScript/React), self-host a
~140-container homelab, and run a website design & development business
for Malaysian SMEs. I write here about what I learn and ship.
<section class="hero">
<h1>{SITE.author} — full-stack developer &amp; DevOps engineer</h1>
<p class="hero-sub">
I run <strong>162 containers across three self-managed hosts</strong> — client sites, a
mail platform, CI/CD, workflow automation and this blog — and I build web systems for
Malaysian SMEs. I write up what broke, what the evidence said, and how I fixed it.
</p>
<p>
<a href="/about/">More about me</a>
<span class="sep">·</span>
<a href="/posts/">Browse all posts</a>
<span class="sep">·</span>
<a href="/rss.xml">RSS</a>
<p class="hero-cta">
<a class="btn btn-primary" href="#featured">Read the engineering case studies ↓</a>
<a class="btn" href="/about/">Hiring? Profile &amp; stack →</a>
<a class="btn" href={SITE.website} target="_blank" rel="noopener">Malaysian SME? How I work ↗</a>
</p>
<p class="hero-note">
{posts.length} posts in English and 中文 · <a href="/rss.xml">RSS</a>
</p>
</section>
<section id="featured">
<h2>Featured</h2>
<div class="featured-grid">
{featuredPosts.map((p) => (
<a class="featured-card" href={`/posts/${p.slug}/`}>
<span class="featured-badge">{p.data.category}</span>
<h3>{p.data.title}</h3>
<p>{p.data.description}</p>
</a>
))}
</div>
</section>
<section>
@@ -32,7 +59,8 @@ const posts = sortForListing(await getCollection('posts', isEn));
{posts.length === 0 ? (
<p><em>No posts yet — check back soon.</em></p>
) : (
<PostList posts={posts} limit={10} />
<PostList posts={posts} limit={6} />
)}
<p class="archive-meta"><a href="/posts/">All {posts.length} posts</a> · <a href="/tags/docker/">tag pages</a></p>
</section>
</BaseLayout>
+7 -1
View File
@@ -3,6 +3,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthorCard from '../../components/AuthorCard.astro';
import { getCollection, render } from 'astro:content';
import { postLang } from '../../lib/lang';
import { tagCounts, tagLink } from '../../lib/tags';
import { SITE } from '../../config';
export async function getStaticPaths() {
@@ -29,6 +30,8 @@ const minutesOf = (body?: string) => Math.max(1, Math.round((body?.trim().split(
// Related posts: same language, same category or overlapping tags, excluding self
const all = await getCollection('posts', ({ data }) => !data.draft);
// Tag pills: a tag with its own page becomes a link, a one-off tag stays plain text.
const tagCount = tagCounts(all.filter((p) => postLang(p.slug) === lang));
const related = all
.filter((p) => p.slug !== post.slug && postLang(p.slug) === lang)
.map((p) => {
@@ -97,7 +100,10 @@ const jsonLd = {
<span>{minutes} min read</span>
<span class="sep">·</span>
<a class="tag" href={`/categories/${post.data.category}/`}>{post.data.category}</a>
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
{post.data.tags.map((t) => {
const href = tagLink(t, tagCount, lang);
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
})}
</div>
</header>
+12 -9
View File
@@ -1,24 +1,27 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro';
import Archive from '../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortForListing } from '../../lib/sort';
import { SITE } from '../../config';
// One static page for all posts: the filter (title/tags/category) runs client-side
// over this already-rendered list, so it costs no requests and Ctrl+F still works.
// Before 2026-10-16 this page rendered 71 full cards and stood 18,220px tall.
const posts = sortForListing(await getCollection('posts', isEn));
---
<BaseLayout
title="Posts — Mr Hoelee"
description="All posts on Mr Hoelee's blog."
switchHref="/zh/"
altLocaleUrl={`${SITE.url}/zh/`}
description={`All ${posts.length} posts on Mr Hoelee's blog — engineering, DevOps, self-hosting, AI automation.`}
switchHref="/zh/posts/"
altLocaleUrl={`${SITE.url}/zh/posts/`}
>
<h1>Posts</h1>
{posts.length === 0 ? (
<p><em>No posts yet.</em></p>
) : (
<PostList posts={posts} />
)}
<p class="archive-lead">
{posts.length} posts, newest first. Filter by title, tag or category below — or read a
whole topic through its <a href="/tags/docker/">tag page</a>.
</p>
<Archive posts={posts} lang="en" />
</BaseLayout>
+40
View File
@@ -0,0 +1,40 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Archive from '../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortForListing } from '../../lib/sort';
import { tagCounts, tagPageNames } from '../../lib/tags';
import { SITE } from '../../config';
// One page per tag that carries a topic (>= TAG_MIN_POSTS posts). Measured
// 2026-10-16: 235 distinct tags, 156 of them used exactly once — generating a page
// for those would be 156 near-empty pages, so the threshold is the line between a
// topic and a word that happened to appear in one post.
export async function getStaticPaths() {
const posts = sortForListing(await getCollection('posts', isEn));
const counts = tagCounts(posts);
return tagPageNames(posts).map((tag) => ({
params: { tag },
props: { tag, n: counts.get(tag) ?? 0, posts: posts.filter((p) => p.data.tags.includes(tag)) },
}));
}
const { tag, posts } = Astro.props as { tag: string; posts: any[] };
---
<BaseLayout
title={`${tag} — posts — Mr Hoelee`}
description={`${posts.length} posts tagged "${tag}" on Mr Hoelee's blog — engineering, DevOps and self-hosting write-ups.`}
switchHref={`/zh/tags/${tag}/`}
altLocaleUrl={`${SITE.url}/zh/tags/${tag}/`}
>
<h1>Posts tagged <span class="tag-name">{tag}</span></h1>
<p class="archive-lead">
{posts.length} posts, newest first.
<a href="/posts/">All posts</a>
<span class="sep">·</span>
<a href="/categories/">Categories</a>
</p>
<Archive posts={posts} lang="en" chrome={false} />
</BaseLayout>
+41 -12
View File
@@ -7,6 +7,19 @@ import { SITE } from '../../config';
import { sortForListing } from '../../lib/sort';
const posts = sortForListing(await getCollection('posts', isZh));
// Same four topics as the English front page, in their Chinese versions, so the two
// front pages tell the same story (scale → automation → observability → a delivered
// business result). Falls back to the newest posts if a slug ever disappears.
const FEATURED_SLUGS = [
'zh/running-production-infrastructure-solo',
'zh/loop-engineering-without-a-coding-agent',
'zh/one-prometheus-for-unraid-synology-and-a-vps',
'zh/self-healing-digital-goods-entitlements',
];
const bySlug = new Map(posts.map((p) => [p.slug, p]));
const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(Boolean);
const featuredPosts = featured.length === 4 ? featured : posts.slice(0, 4);
---
<BaseLayout
@@ -15,19 +28,34 @@ const posts = sortForListing(await getCollection('posts', isZh));
lang="zh"
altLocaleUrl={`${SITE.url}/`}
>
<section>
<h1>你好,我是 {SITE.author}。</h1>
<p>
一名base在马来西亚的全栈开发者兼 DevOps 工程师。我构建 Web 应用,
自托管一个约 140 个容器的 homelab,并运营 Hoelee Enterprise——一家面向
马来西亚中小企业的网站设计开发公司(邮箱托管只是副业,并非主业)。
这里是博客的中文部分。
<section class="hero">
<h1>{SITE.author} —— 全栈开发者兼 DevOps 工程师</h1>
<p class="hero-sub">
我独自运维 <strong>三台自管服务器上的 162 个容器</strong>(客户站点、邮件平台、
CI/CD、流程自动化与这个博客),并为马来西亚中小企业构建 Web 系统。这里写的是
「坏了什么、证据在哪、怎么修好」。
</p>
<p>
<a href="/">切换到英文</a>
<span class="sep">·</span>
<a href="/about/">关于我</a>
<p class="hero-cta">
<a class="btn btn-primary" href="#featured">先看工程案例 ↓</a>
<a class="btn" href="/zh/about/">招聘方:技术栈与履历 →</a>
<a class="btn" href={SITE.website} target="_blank" rel="noopener">中小企业:合作方式 ↗</a>
</p>
<p class="hero-note">
共 {posts.length} 篇中文文章 · <a href="/">English</a> · <a href="/rss.xml">RSS</a>
</p>
</section>
<section id="featured">
<h2>精选</h2>
<div class="featured-grid">
{featuredPosts.map((p) => (
<a class="featured-card" href={`/posts/${p.slug}/`}>
<span class="featured-badge">{p.data.category}</span>
<h3>{p.data.title}</h3>
<p>{p.data.description}</p>
</a>
))}
</div>
</section>
<section>
@@ -35,7 +63,8 @@ const posts = sortForListing(await getCollection('posts', isZh));
{posts.length === 0 ? (
<p><em>中文文章即将发布,敬请期待。</em></p>
) : (
<PostList posts={posts} lang="zh" />
<PostList posts={posts} lang="zh" limit={6} />
)}
<p class="archive-meta"><a href="/zh/posts/">全部 {posts.length} 篇</a></p>
</section>
</BaseLayout>
+7 -7
View File
@@ -1,6 +1,6 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostList from '../../../components/PostList.astro';
import Archive from '../../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { sortForListing } from '../../../lib/sort';
@@ -14,15 +14,15 @@ const posts = sortForListing(await getCollection('posts', isZh));
<BaseLayout
title="文章 — Mr Hoelee"
description="Hoelee 技术博客的全部中文文章。"
description={`Hoelee 技术博客的全部 ${posts.length} 篇中文文章:工程、DevOps、自托管与 AI 自动化。`}
lang="zh"
switchHref="/posts/"
altLocaleUrl={`${SITE.url}/posts/`}
>
<h1>文章</h1>
{posts.length === 0 ? (
<p><em>中文文章即将发布,敬请期待。</em></p>
) : (
<PostList posts={posts} lang="zh" />
)}
<p class="archive-lead">
共 {posts.length} 篇,按时间倒序。下方可按标题、标签或分类即时筛选,也可以走
<a href="/zh/tags/docker/">标签页</a> 把一个主题读完。
</p>
<Archive posts={posts} lang="zh" />
</BaseLayout>
+39
View File
@@ -0,0 +1,39 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import Archive from '../../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { sortForListing } from '../../../lib/sort';
import { tagCounts, tagPageNames } from '../../../lib/tags';
import { SITE } from '../../../config';
// Chinese twin of /tags/<tag>/ — same threshold, same tag names (the EN and ZH posts
// carry identical tag sets, so every language pair actually has both pages).
export async function getStaticPaths() {
const posts = sortForListing(await getCollection('posts', isZh));
const counts = tagCounts(posts);
return tagPageNames(posts).map((tag) => ({
params: { tag },
props: { tag, n: counts.get(tag) ?? 0, posts: posts.filter((p) => p.data.tags.includes(tag)) },
}));
}
const { tag, posts } = Astro.props as { tag: string; posts: any[] };
---
<BaseLayout
title={`${tag} — 文章 — Mr Hoelee`}
description={`标签「${tag}」下的 ${posts.length} 篇中文文章。`}
lang="zh"
switchHref={`/tags/${tag}/`}
altLocaleUrl={`${SITE.url}/tags/${tag}/`}
>
<h1>标签 <span class="tag-name">{tag}</span></h1>
<p class="archive-lead">
共 {posts.length} 篇,按时间倒序。
<a href="/zh/posts/">全部文章</a>
<span class="sep">·</span>
<a href="/zh/categories/">分类</a>
</p>
<Archive posts={posts} lang="zh" chrome={false} />
</BaseLayout>
+101
View File
@@ -139,6 +139,16 @@ body {
justify-content: center;
}
.search-toggle:hover { color: var(--brand); border-color: var(--brand); }
.search-hint { font-size: 0.85rem; margin-right: 0.35rem; }
.search-key {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--faint);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.25rem;
margin-left: 0.1rem;
}
.lang-switch {
color: var(--muted);
text-decoration: none;
@@ -329,6 +339,95 @@ pre code {
}
.related-card time { font-size: 0.78rem; margin-top: auto; padding-top: 0.4rem; }
/* ---------- Home hero ---------- */
.hero {
margin: 0.5rem 0 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.hero h1 { margin-bottom: 0.75rem; }
.hero-sub { font-size: 1.05rem; color: var(--muted); max-width: 62ch; }
.hero-cta {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin: 1.25rem 0 0.75rem;
}
.btn {
display: inline-block;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.85rem;
text-decoration: none;
}
.btn:hover { border-color: var(--brand); color: var(--brand); }
.btn-primary { border-color: var(--brand); color: var(--brand); }
.hero-note { font-size: 0.82rem; color: var(--faint); margin: 0; }
/* ---------- Featured ---------- */
.featured-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin: 1rem 0 2.5rem;
}
.featured-card {
display: block;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
color: var(--ink);
text-decoration: none;
}
.featured-card:hover { border-color: var(--brand); }
.featured-card h3 { margin: 0.5rem 0 0.4rem; font-size: 1.02rem; line-height: 1.35; }
.featured-card p { margin: 0; font-size: 0.88rem; color: var(--muted); }
.featured-badge {
display: inline-block;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.1em 0.6em;
}
/* ---------- Collapsible long code blocks ---------- */
.code-block { position: relative; }
.code-block.is-collapsed pre { max-height: 420px; overflow: hidden; }
.code-block.is-collapsed::after {
content: '';
position: absolute;
left: 0; right: 0;
bottom: 2.4rem;
height: 5rem;
pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--paper));
}
.code-expand {
display: block;
width: 100%;
margin-top: 0.35rem;
font: inherit;
font-size: 0.82rem;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35rem;
cursor: pointer;
}
.code-expand:hover { color: var(--brand); border-color: var(--brand); }
/* ---------- Archive ---------- */
.archive-lead, .archive-meta { color: var(--muted); font-size: 0.92rem; }
.archive-meta { margin: 0 0 1rem; }
/* ---------- 404 ---------- */
.notfound-actions {
display: flex;
@@ -541,5 +640,7 @@ a.category-card:hover { border-color: var(--brand); }
h1 { font-size: 1.7rem; }
h2 { font-size: 1.25rem; }
.related-grid { grid-template-columns: 1fr; }
.featured-grid { grid-template-columns: 1fr; }
.search-hint, .search-key { display: none; }
.author-card { flex-direction: column; align-items: flex-start; }
}