diff --git a/README.md b/README.md index f9201b8..6d8ea81 100644 --- a/README.md +++ b/README.md @@ -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//) 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/`` 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/.md` with frontmatter (see content guide). +1. Create `src/content/posts/.md` (English) or `src/content/posts/zh/.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. diff --git a/docs/content-guide.md b/docs/content-guide.md index 8c22f0e..4f08dd3 100644 --- a/docs/content-guide.md +++ b/docs/content-guide.md @@ -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//`). +- 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. diff --git a/docs/project-state.md b/docs/project-state.md new file mode 100644 index 0000000..00b45cb --- /dev/null +++ b/docs/project-state.md @@ -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 diff --git a/src/content.config.ts b/src/content.config.ts index e98f7a6..c94a30a 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), }), }); diff --git a/src/content/posts/hello-world-zh.md b/src/content/posts/hello-world-zh.md deleted file mode 100644 index 57364c9..0000000 --- a/src/content/posts/hello-world-zh.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "你好,世界 — 关于这个博客" -description: "这个博客的中文部分:技术文章、自托管经验、以及我在构建与学习中的记录。" -pubDate: 2026-09-06 -category: notes -tags: [intro] -lang: zh ---- - -这是博客的中文部分。我会在这里用中文分享技术文章和自托管的经验。 - -英文是主要语言,中文内容会选择性发布——通常是那些对中文读者更有价值的主题。 - -## 关于我 - -我是一名全栈开发者兼 DevOps 工程师,base 在马来西亚。我构建 Web 应用、 -自托管基础设施(约 140 个容器),并运营一个面向马来西亚中小企业的邮箱托管业务。 diff --git a/src/content/posts/hello-world.md b/src/content/posts/hello-world.md new file mode 100644 index 0000000..1549e0b --- /dev/null +++ b/src/content/posts/hello-world.md @@ -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/). diff --git a/src/content/posts/how-i-host-this-blog.md b/src/content/posts/how-i-host-this-blog.md index d4255fd..8250bbc 100644 --- a/src/content/posts/how-i-host-this-blog.md +++ b/src/content/posts/how-i-host-this-blog.md @@ -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. diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index e2de7a7..47d84d5 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -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' : '中文'; --- - + @@ -96,17 +102,20 @@ const ogImageUrl = new URL(ogImage, SITE.url).href; ], })} /> - + @@ -118,6 +127,7 @@ const ogImageUrl = new URL(ogImage, SITE.url).href; categories about rss + {switchLabel} diff --git a/src/lib/lang.ts b/src/lib/lang.ts new file mode 100644 index 0000000..f1f4de6 --- /dev/null +++ b/src/lib/lang.ts @@ -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'; diff --git a/src/pages/categories/[category].astro b/src/pages/categories/[category].astro index 58b0970..5158b34 100644 --- a/src/pages/categories/[category].astro +++ b/src/pages/categories/[category].astro @@ -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()); --- diff --git a/src/pages/categories/index.astro b/src/pages/categories/index.astro index 072fd6e..da1b202 100644 --- a/src/pages/categories/index.astro +++ b/src/pages/categories/index.astro @@ -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(); diff --git a/src/pages/index.astro b/src/pages/index.astro index 2f633d8..a5c06ee 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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()); --- diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[...slug].astro similarity index 84% rename from src/pages/posts/[slug].astro rename to src/pages/posts/[...slug].astro index 646ca16..2ff06ad 100644 --- a/src/pages/posts/[slug].astro +++ b/src/pages/posts/[...slug].astro @@ -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 0 && (