feat(structure): compact archive + tag pages + hero/featured, and fix site search
Deploy / build (push) Failing after 13m52s
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:
1 parent
8619e48271
commit
f4008d9e8b
15 files changed
+669
-60
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user