feat: locale routing (en flat, zh subfolder), language switcher, dark default
Deploy / build (push) Successful in 11s
Deploy / build (push) Successful in 11s
- Derive lang from folder (posts/zh/* -> zh, else en); remove lang: frontmatter - Change post route to [...slug] to capture nested zh paths - Add nav language switcher (EN <-> 中文) + /zh/ landing page - Default theme to dark (light opt-in via toggle) - Translate hello-world-zh placeholder into English intro post - Update case study: docker-compose sample + real install gotchas (commit history) - Add docs/project-state.md (Tier 1/2/3 backlog)
This commit is contained in:
@@ -26,18 +26,22 @@ Every decision below optimizes for **credibility + discoverability + speed of sh
|
||||
```
|
||||
src/
|
||||
content/
|
||||
posts/ # blog posts (en + zh)
|
||||
posts/ # English posts (flat)
|
||||
zh/ # Chinese posts (subfolder -> /posts/zh/<slug>/)
|
||||
pages/
|
||||
index.astro # homepage
|
||||
posts/[...slug].astro # post template
|
||||
about.astro
|
||||
layouts/
|
||||
BaseLayout.astro
|
||||
lib/
|
||||
lang.ts # derives en/zh from folder path
|
||||
docs/
|
||||
content-guide.md # what to write, what to avoid (READ before writing a post)
|
||||
design-guide.md # what the design must have, what to avoid (READ before touching UI)
|
||||
seo-reference.md # E-E-A-T / name identity, SEO + GEO checklist, syndication
|
||||
ops-runbook.md # pipeline, publish steps, build pitfalls, health checks
|
||||
project-state.md # enhancement backlog (Tier 1/2/3) — work through one by one
|
||||
```
|
||||
|
||||
## Knowledge guides (my rules of thumb)
|
||||
@@ -48,10 +52,11 @@ docs/
|
||||
| [`docs/design-guide.md`](docs/design-guide.md) | Before changing theme, layout, typography, color, or SEO markup. |
|
||||
| [`docs/seo-reference.md`](docs/seo-reference.md) | Before adding posts/`<head>` markup or debugging search visibility. |
|
||||
| [`docs/ops-runbook.md`](docs/ops-runbook.md) | Before debugging CI/CD, deployment, or hosting. |
|
||||
| [`docs/project-state.md`](docs/project-state.md) | To see what's next — the prioritized enhancement backlog. |
|
||||
|
||||
## Write a post
|
||||
|
||||
1. Create `src/content/posts/<slug>.md` with frontmatter (see content guide).
|
||||
1. Create `src/content/posts/<slug>.md` (English) or `src/content/posts/zh/<slug>.md` (Chinese) with frontmatter (see content guide — **no `lang:` field**, it's derived from the folder).
|
||||
2. Push to `main`.
|
||||
3. CI builds and deploys automatically.
|
||||
|
||||
@@ -62,5 +67,7 @@ docs/
|
||||
## Conventions
|
||||
|
||||
- **Repo publishing order:** push to git.hoelee.com (Gitea) first, then GitHub. (My standing convention.)
|
||||
- **Language:** English posts flat in `posts/`; Chinese posts in `posts/zh/`; no Malay.
|
||||
- **Theme:** defaults to dark mode (light is opt-in via the toggle).
|
||||
- **No overclaiming** — especially Web3 (learning projects, not production DeFi).
|
||||
- **Name identity is consistent everywhere:** "Lee Teong Hoe" / "Mr Hoelee" + same photo + same `sameAs` handles across blog, LinkedIn, GitHub, git.hoelee.com.
|
||||
|
||||
+10
-1
@@ -74,10 +74,13 @@ category: case-studies
|
||||
tags: ["telegram", "n8n", "docker", "cloudflare"]
|
||||
ogImage: "/og/digikedai-bot.png" # 1200×630, custom per post
|
||||
draft: false
|
||||
lang: en
|
||||
---
|
||||
```
|
||||
|
||||
**No `lang:` field** — language is derived from the file location:
|
||||
- `posts/hello-world.md` → English (`en`)
|
||||
- `posts/zh/hello-world.md` → Chinese (`zh`), served at `/posts/zh/hello-world/`
|
||||
|
||||
---
|
||||
|
||||
## 7. What to AVOID (anti-patterns)
|
||||
@@ -102,4 +105,10 @@ lang: en
|
||||
- **English is primary and non-negotiable** — the whole SEO strategy targets English queries.
|
||||
- **Chinese (zh):** translate only the 2–3 best case studies. Cheap differentiation, opens zh-SG/zh-MY search.
|
||||
- **Malay: skip for v1** — no dev-audience demand (the main site already has a dangling Malay config; don't repeat it).
|
||||
|
||||
### File layout (enforced by code)
|
||||
|
||||
- English posts: `src/content/posts/*.md` (flat, no subfolder).
|
||||
- Chinese posts: `src/content/posts/zh/*.md` (subfolder → `/posts/zh/<slug>/`).
|
||||
- Language is derived from the folder (see `src/lib/lang.ts`), so **do not** set a `lang:` field in frontmatter.
|
||||
- Never let "I should translate this" block publishing an English post.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Project State & Enhancement Backlog
|
||||
|
||||
Living list of what's done and what's next for blog.hoelee.com. Work through these **one by one** — don't batch unrelated changes. Check off items as they land.
|
||||
|
||||
**Status key:** ✅ done · 🔵 in progress · ⬜ not started
|
||||
|
||||
---
|
||||
|
||||
## Done (foundation)
|
||||
|
||||
- ✅ Astro 5 static + Markdown, Gitea Actions CI/CD → nginx → Cloudflare (deployed)
|
||||
- ✅ Design system: hoelee.com brand palette, Inter + JetBrains Mono, light/dark, sticky nav, code copy button
|
||||
- ✅ Brand "Mr Hoelee" (replaced "hoelee.dev")
|
||||
- ✅ Author card + `Person`/`ProfilePage` JSON-LD (E-E-A-T), article meta, reading time, related posts
|
||||
- ✅ Full SEO: canonical, Open Graph (+dims), twitter:card, favicon (all sizes), RSS + sitemap
|
||||
- ✅ Category pages (`/categories/`, `/categories/[category]/`)
|
||||
- ✅ Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder, no `lang:` frontmatter)
|
||||
- ✅ Language switcher in nav (EN ↔ 中文) + `/zh/` landing page
|
||||
- ✅ Dark mode default (light is opt-in via toggle)
|
||||
- ✅ Case study post "how-i-host-this-blog" updated with docker-compose sample + real installation gotchas (from commit history)
|
||||
- ✅ Knowledge guides in `docs/` (content, design, seo, ops) + README index + `hoelee-blog` skill
|
||||
- ⬜ **Make the Gitea repo public** — verified no secrets in source or history (PAT is a `${{ secrets.PAT }}` reference, not hardcoded). Recommended: yes, public — it's a portfolio artifact. Action: flip visibility in Gitea repo settings.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Content (80% of value; do this first)
|
||||
|
||||
- ⬜ **Write the 2 flagship case studies** — highest ROI, these are the portfolio:
|
||||
- ⬜ "How I built the DigiKedai Telegram AI bot" (DSM Docker + Cloudflare tunnel webhook + LiteLLM)
|
||||
- ⬜ "Self-hosting a mem0 memory stack" (API + LiteLLM + pgvector)
|
||||
- ⬜ **2–3 gotcha posts** from real debugging history (short, Google-friendly):
|
||||
- ⬜ "The Traefik forward-auth gotcha that cost me a day"
|
||||
- ⬜ "Site-to-site OpenVPN behind CGNAT"
|
||||
- ⬜ "Fixing the WordPress /cv 301→404 chain" (from own audit)
|
||||
- ⬜ **"Hard job → post" habit** — every solved problem becomes a `notes` entry the same week (template in `docs/content-guide.md` §4)
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Structural gaps
|
||||
|
||||
- ⬜ **Tag pages** — tags currently render as labels only; add `/tags/[tag]/` archive pages for fine-grained discovery + internal linking
|
||||
- ⬜ **Categories page shows all 7 categories** (not just those with posts) — signal intended coverage; show "0 posts / coming soon" for empty ones
|
||||
- ⬜ **Search** — AstroPaper-style fuzzy search (low priority until >20 posts)
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Polish / later
|
||||
|
||||
- ⬜ **Verify Chinese content split** — confirm `zh/` posts don't appear in EN feed (the `isEn` helper already filters; re-check when first zh post lands)
|
||||
- ⬜ **Google Search Console submission** — submit `sitemap-index.xml` for faster indexing
|
||||
- ⬜ **Newsletter / email capture** — only after real traffic exists
|
||||
|
||||
---
|
||||
|
||||
## Conventions (non-negotiable)
|
||||
|
||||
- Push git.hoelee.com first, then GitHub
|
||||
- English-first; Chinese selective (2–3 flagship case studies); no Malay
|
||||
- No overclaiming, especially Web3
|
||||
- Name identity: "Lee Teong Hoe" / "Mr Hoelee" + same photo + same `sameAs` handles everywhere
|
||||
@@ -18,7 +18,9 @@ const posts = defineCollection({
|
||||
]),
|
||||
tags: z.array(z.string()).default([]),
|
||||
draft: z.boolean().default(false),
|
||||
lang: z.enum(['en', 'zh']).default('en'),
|
||||
// lang is NOT set in frontmatter anymore — it's derived from the folder:
|
||||
// posts/hello-world.md -> en (flat)
|
||||
// posts/zh/hello-world.md -> zh (subfolder)
|
||||
ogImage: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: "你好,世界 — 关于这个博客"
|
||||
description: "这个博客的中文部分:技术文章、自托管经验、以及我在构建与学习中的记录。"
|
||||
pubDate: 2026-09-06
|
||||
category: notes
|
||||
tags: [intro]
|
||||
lang: zh
|
||||
---
|
||||
|
||||
这是博客的中文部分。我会在这里用中文分享技术文章和自托管的经验。
|
||||
|
||||
英文是主要语言,中文内容会选择性发布——通常是那些对中文读者更有价值的主题。
|
||||
|
||||
## 关于我
|
||||
|
||||
我是一名全栈开发者兼 DevOps 工程师,base 在马来西亚。我构建 Web 应用、
|
||||
自托管基础设施(约 140 个容器),并运营一个面向马来西亚中小企业的邮箱托管业务。
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: "Hello, world — about this blog"
|
||||
description: "What this blog is for: technical writing, self-hosting experience, and a record of what I build and learn."
|
||||
pubDate: 2026-09-06
|
||||
category: notes
|
||||
tags: [intro]
|
||||
---
|
||||
|
||||
Welcome. This is where I write about what I build and learn — mostly
|
||||
engineering, DevOps, and self-hosting, with some AI automation and Web3
|
||||
experiments mixed in.
|
||||
|
||||
## What to expect
|
||||
|
||||
English is the primary language here. I'll write selectively in Chinese
|
||||
(简体中文) for topics where a Chinese-speaking audience is better served —
|
||||
those posts will live under `/posts/zh/`.
|
||||
|
||||
Most posts will be one of a few shapes:
|
||||
|
||||
- **Case studies** — "how I built X", with the architecture and the mistakes.
|
||||
- **Tutorials** — a hard problem, what I tried, and the fix.
|
||||
- **Gotchas & notes** — short entries on the small things that cost me a day.
|
||||
|
||||
## About me
|
||||
|
||||
I'm Lee Teong Hoe (Mr Hoelee), a full-stack developer and DevOps engineer
|
||||
based in Malaysia. I build web applications, self-host a ~140-container
|
||||
homelab, and run an email-hosting business for Malaysian SMEs.
|
||||
|
||||
If you're a recruiter, a client, or a fellow builder — the posts here are my
|
||||
living portfolio. Start with the [latest posts](/posts/).
|
||||
@@ -4,16 +4,17 @@ description: "A walkthrough of the end-to-end pipeline that builds and serves th
|
||||
pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
|
||||
lang: en
|
||||
---
|
||||
|
||||
This blog is itself a project I built to demonstrate the kind of work I do.
|
||||
Here's the full pipeline, so the architecture is transparent.
|
||||
Here's the full pipeline, so the architecture is transparent. The source is
|
||||
public at [git.hoelee.com/hoelee/hoelee-blog](https://git.hoelee.com/hoelee/hoelee-blog)
|
||||
— read it alongside this post.
|
||||
|
||||
## The stack
|
||||
|
||||
- **Astro 5** — static site generated from Markdown.
|
||||
- **Git as the CMS** — every post is a `.md` file with YAML frontmatter, versioned in [git.hoelee.com](https://git.hoelee.com/hoelee/hoelee-blog).
|
||||
- **Git as the CMS** — every post is a `.md` file with YAML frontmatter, versioned in Gitea.
|
||||
- **Gitea Actions** — a self-hosted CI runner (`act_runner`) on my unRaid server builds the site on every push to `main`.
|
||||
- **nginx** — a dedicated container serves the static `dist/` output.
|
||||
- **Cloudflare** — the tunnel exposes it publicly, and the CDN caches everything.
|
||||
@@ -37,8 +38,102 @@ git push → Gitea webhook → act_runner picks up the job
|
||||
The whole thing runs on hardware in my homelab, which is exactly the point —
|
||||
this is a live demo of the DevOps work I describe elsewhere.
|
||||
|
||||
## The docker-compose setup
|
||||
|
||||
Here's the shape of the stack. This is the local/origin side (Gitea + the
|
||||
runner + nginx); Cloudflare handles public exposure separately via a tunnel,
|
||||
so nothing here needs a public IP or open ports.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — Gitea + act_runner + nginx (blog docroot)
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.27
|
||||
container_name: gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
volumes:
|
||||
- ./gitea-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "2222:22" # SSH (optional — I use HTTPS)
|
||||
restart: unless-stopped
|
||||
|
||||
act_runner:
|
||||
image: gitea/act_runner:0.2.13
|
||||
container_name: act_runner
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=http://gitea:3000
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock # runner spawns build jobs
|
||||
- ./runner-data:/data
|
||||
depends_on:
|
||||
- gitea
|
||||
restart: unless-stopped
|
||||
|
||||
nginx-blog:
|
||||
image: nginx:alpine
|
||||
container_name: nginx-blog
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro # astro build output lands here
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
The CI job (`deploy.yml`) builds `dist/` on the runner, then `cp -r dist/*`
|
||||
into the `./html` volume shared with the nginx container. Cloudflare's tunnel
|
||||
daemon (`cloudflared`) points at `nginx-blog` — that's the only thing exposed
|
||||
to the internet.
|
||||
|
||||
Two things worth calling out that cost me time:
|
||||
|
||||
1. **`act_runner` needs the Docker socket** (`/var/run/docker.sock`) mounted to
|
||||
spawn build jobs — without it, jobs sit queued forever.
|
||||
2. **The runner's `GITEA_RUNNER_REGISTRATION_TOKEN`** is a Gitea *Actions
|
||||
secret* (`${{ secrets.PAT }}` in the workflow), never a hardcoded value in
|
||||
the repo — so the repo stays safe to make public.
|
||||
|
||||
## The installation pain (real, from my commit history)
|
||||
|
||||
Getting this pipeline working was *not* smooth — the git history of this repo
|
||||
is a log of the gotchas. The four that cost real time:
|
||||
|
||||
1. **No `actions/checkout` on Gitea.** Gitea's `act_runner` does not ship
|
||||
GitHub's marketplace actions. My first workflow failed immediately — there
|
||||
was nothing to check out the repo. Fix: drop the `actions/checkout` step
|
||||
and clone manually:
|
||||
```yaml
|
||||
- name: Checkout
|
||||
run: |
|
||||
git config --global --add safe.directory '*'
|
||||
git clone --depth 1 "https://hoelee:${{ secrets.PAT }}@git.hoelee.com/hoelee/hoelee-blog.git" .
|
||||
```
|
||||
|
||||
2. **`GITHUB_TOKEN` injection doesn't authenticate the clone.** The default
|
||||
runner token wasn't enough to `git clone` a private repo, so jobs failed on
|
||||
checkout. Fix: create a dedicated **Personal Access Token** and reference it
|
||||
as `${{ secrets.PAT }}` — never hardcode it in the workflow (that's also why
|
||||
this repo is safe to make public).
|
||||
|
||||
3. **Gitea ↔ `act_runner` version mismatch.** I burned three commits
|
||||
("test gitea 1.24.7 + act_runner 0.2.13" → "gitea 1.25.5" → "gitea 1.27.3 +
|
||||
runner 3.3.2") before the runner would register and pick up jobs. The
|
||||
lesson: **match the runner to the Gitea major version** — a runner one major
|
||||
version behind a newer Gitea silently fails to register.
|
||||
|
||||
4. **`config_file` env for the runner.** The runner needed its config path
|
||||
passed explicitly before it would connect to the right instance.
|
||||
|
||||
Each of these was a "one-line fix after an hour of head-scratching" — which is
|
||||
exactly the kind of thing a blog post should save the next person from.
|
||||
|
||||
## Coming up
|
||||
|
||||
Future posts will cover the individual pieces in depth: the `act_runner`
|
||||
container setup, the Cloudflare cache rules, and the i18n routing for the
|
||||
Chinese side of this site.
|
||||
I'll write dedicated posts on the pieces that had the most hidden gotchas: the
|
||||
`act_runner` registration flow (versions + token), the Cloudflare tunnel +
|
||||
cache rules, and the locale routing (English flat, Chinese under `posts/zh/`)
|
||||
that this site now uses.
|
||||
|
||||
@@ -29,10 +29,16 @@ const {
|
||||
|
||||
const canonical = new URL(Astro.url.pathname, SITE.url).href;
|
||||
const ogImageUrl = new URL(ogImage, SITE.url).href;
|
||||
|
||||
// Language switcher: opposite of current page lang.
|
||||
// English pages link to /zh/ (Chinese landing); Chinese pages link back to /.
|
||||
const isZh = lang === 'zh';
|
||||
const switchHref = isZh ? '/' : '/zh/';
|
||||
const switchLabel = isZh ? 'EN' : '中文';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang} data-theme="light">
|
||||
<html lang={lang} data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -96,17 +102,20 @@ const ogImageUrl = new URL(ogImage, SITE.url).href;
|
||||
],
|
||||
})} />
|
||||
|
||||
<!-- Theme init (before paint, no FOUC) -->
|
||||
<!-- Theme init (before paint, no FOUC). Default = dark. -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || t === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
if (t === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
// stored 'dark', or no preference -> default to dark
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
@@ -118,6 +127,7 @@ const ogImageUrl = new URL(ogImage, SITE.url).href;
|
||||
<a href="/categories/">categories</a>
|
||||
<a href="/about/">about</a>
|
||||
<a href="/rss.xml" title="RSS feed">rss</a>
|
||||
<a class="lang-switch" href={switchHref} aria-label={isZh ? 'Switch to English' : '切换到中文'}>{switchLabel}</a>
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">◐</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
/**
|
||||
* Derive the post language from its slug path.
|
||||
* posts/hello-world.md -> 'en' (flat)
|
||||
* posts/zh/hello-world.md -> 'zh' (subfolder)
|
||||
* English posts stay flat; only Chinese posts use the zh/ subfolder.
|
||||
*/
|
||||
export function postLang(slug: string): 'en' | 'zh' {
|
||||
return slug.startsWith('zh/') ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
export type Post = CollectionEntry<'posts'>;
|
||||
|
||||
/** Filter helper: non-draft English posts (for EN listing pages). */
|
||||
export const isEn = (p: Post) => !p.data.draft && postLang(p.slug) === 'en';
|
||||
|
||||
/** Filter helper: non-draft Chinese posts (for ZH listing pages). */
|
||||
export const isZh = (p: Post) => !p.data.draft && postLang(p.slug) === 'zh';
|
||||
@@ -2,16 +2,17 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en');
|
||||
const posts = await getCollection('posts', isEn);
|
||||
const cats = [...new Set(posts.map((p) => p.data.category))];
|
||||
return cats.map((cat) => ({ params: { category: cat } }));
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en'))
|
||||
const posts = (await getCollection('posts', isEn))
|
||||
.filter((p) => p.data.category === category)
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
|
||||
const posts = await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en');
|
||||
const posts = await getCollection('posts', isEn);
|
||||
|
||||
const categories = [...new Set(posts.map((p) => p.data.category))].sort();
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import PostList from '../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../lib/lang';
|
||||
import { SITE } from '../config';
|
||||
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en'))
|
||||
const posts = (await getCollection('posts', isEn))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import AuthorCard from '../../components/AuthorCard.astro';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import { postLang } from '../../lib/lang';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', ({ data }) => !data.draft);
|
||||
@@ -14,17 +15,19 @@ export async function getStaticPaths() {
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
|
||||
const lang = postLang(post.slug);
|
||||
|
||||
// Reading time (~200 wpm)
|
||||
const words = post.body?.trim().split(/\s+/).length ?? 0;
|
||||
const minutes = Math.max(1, Math.round(words / 200));
|
||||
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
// Related posts: same category or overlapping tags, excluding self
|
||||
// Related posts: same language, same category or overlapping tags, excluding self
|
||||
const all = await getCollection('posts', ({ data }) => !data.draft);
|
||||
const related = all
|
||||
.filter((p) => p.slug !== post.slug && p.data.lang === post.data.lang)
|
||||
.filter((p) => p.slug !== post.slug && postLang(p.slug) === lang)
|
||||
.map((p) => {
|
||||
const catMatch = p.data.category === post.data.category ? 2 : 0;
|
||||
const tagOverlap = p.data.tags.filter((t) => post.data.tags.includes(t)).length;
|
||||
@@ -39,7 +42,7 @@ const related = all
|
||||
<BaseLayout
|
||||
title={`${post.data.title} — Mr Hoelee`}
|
||||
description={post.data.description}
|
||||
lang={post.data.lang}
|
||||
lang={lang}
|
||||
ogImage={post.data.ogImage ?? '/og-default.png'}
|
||||
type="article"
|
||||
article={{
|
||||
@@ -69,7 +72,7 @@ const related = all
|
||||
|
||||
{related.length > 0 && (
|
||||
<section class="related">
|
||||
<h2>Related posts</h2>
|
||||
<h2>{lang === 'zh' ? '相关文章' : 'Related posts'}</h2>
|
||||
<div class="related-grid">
|
||||
{related.map((p) => (
|
||||
<a class="related-card" href={`/posts/${p.slug}/`}>
|
||||
@@ -2,8 +2,9 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en'))
|
||||
const posts = (await getCollection('posts', isEn))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../lib/lang';
|
||||
import { SITE } from '../config';
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en'))
|
||||
const posts = (await getCollection('posts', isEn))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
return rss({
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isZh } from '../../lib/lang';
|
||||
import { SITE } from '../../config';
|
||||
|
||||
const posts = (await getCollection('posts', isZh))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${SITE.handle} — 技术博客`}
|
||||
description="Hoelee 的技术博客中文版:工程、DevOps、自托管、AI 自动化与 Web3 实验。"
|
||||
lang="zh"
|
||||
>
|
||||
<section>
|
||||
<h1>你好,我是 {SITE.author}。</h1>
|
||||
<p>
|
||||
一名base在马来西亚的全栈开发者兼 DevOps 工程师。我构建 Web 应用,
|
||||
自托管一个约 140 个容器的 homelab,并运营面向马来西亚中小企业的
|
||||
邮箱托管业务。这里是博客的中文部分。
|
||||
</p>
|
||||
<p>
|
||||
<a href="/">切换到英文</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="/about/">关于我</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>最新文章</h2>
|
||||
{posts.length === 0 ? (
|
||||
<p><em>中文文章即将发布,敬请期待。</em></p>
|
||||
) : (
|
||||
<PostList posts={posts} />
|
||||
)}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -123,6 +123,16 @@ body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.theme-toggle:hover { color: var(--brand); border-color: var(--brand); }
|
||||
.lang-switch {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.1rem 0.55rem;
|
||||
}
|
||||
.lang-switch:hover { color: var(--brand); border-color: var(--brand); }
|
||||
|
||||
/* ---------- Typography ---------- */
|
||||
h1, h2, h3, h4 {
|
||||
|
||||
Reference in New Issue
Block a user