diff --git a/README.md b/README.md
index 7844c68..f9201b8 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,23 @@
My personal technical blog — where I write about what I build and learn.
-Built with [Astro](https://astro.build) + Markdown, deployed via self-hosted Gitea CI/CD to an nginx container on my unRaid server, served through Cloudflare.
+Built with [Astro](https://astro.build) 5 + Markdown, deployed via self-hosted Gitea CI/CD to an nginx container on my unRaid server, served through Cloudflare tunnel + CDN. Live at `blog.hoelee.com`.
+
+## Purpose (read this first)
+
+This blog exists so that when a recruiter, hiring manager, or freelance client Googles **"Lee Teong Hoe"**, they land on a stream of concrete technical work that proves I can build things — plus a clear, trustworthy picture of who I am. It backs the job hunt **and** the freelance funnel (Upwork / Fiverr / Codeable).
+
+- 67% of hiring managers check a candidate's online presence before deciding (Stack Overflow 2025).
+- SEO is a long game — while posts rank, the blog doubles as a portfolio and inbound-lead magnet.
+
+Every decision below optimizes for **credibility + discoverability + speed of shipping**, not monetization or vanity traffic.
## Stack
-- **Framework:** Astro 5 (static output)
+- **Framework:** Astro 5 (static output, zero runtime JS by default)
- **Content:** Markdown + MDX, versioned in git
-- **i18n:** English (default) + Chinese (`zh`)
-- **CI/CD:** Gitea Actions → `act_runner` on unRaid
+- **i18n:** English (default) + Chinese (`zh`) — English-first
+- **CI/CD:** Gitea Actions → `act_runner` on unRaid → nginx container
- **Host:** nginx on unRaid → Cloudflare tunnel → Cloudflare CDN
## Structure
@@ -24,14 +33,34 @@ src/
about.astro
layouts/
BaseLayout.astro
+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
```
+## Knowledge guides (my rules of thumb)
+
+| Doc | When to read |
+|---|---|
+| [`docs/content-guide.md`](docs/content-guide.md) | Before writing/planning any blog post — categories, post types, the "hard job → post" template, and the anti-patterns. |
+| [`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. |
+
## Write a post
-1. Create `src/content/posts/.md` with frontmatter.
+1. Create `src/content/posts/.md` with frontmatter (see content guide).
2. Push to `main`.
3. CI builds and deploys automatically.
## Categories
-engineering · devops · ai · web3 · tutorials · case-studies · notes
+`engineering` · `devops` · `ai` · `web3` · `tutorials` · `case-studies` · `notes`
+
+## Conventions
+
+- **Repo publishing order:** push to git.hoelee.com (Gitea) first, then GitHub. (My standing convention.)
+- **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/astro.config.mjs b/astro.config.mjs
index d87bcd5..5cd6211 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,4 +1,5 @@
import { defineConfig } from 'astro/config';
+import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({
@@ -11,4 +12,5 @@ export default defineConfig({
prefixDefaultLocale: false,
},
},
+ integrations: [sitemap()],
});
diff --git a/docs/content-guide.md b/docs/content-guide.md
new file mode 100644
index 0000000..8c22f0e
--- /dev/null
+++ b/docs/content-guide.md
@@ -0,0 +1,105 @@
+# Content Guide — what to write & what to avoid
+
+Read this **before** planning or writing any post on blog.hoelee.com. It encodes the positioning, taxonomy, post types, cadence, and anti-patterns so every piece of content serves the two goals: get hired, and win freelance clients.
+
+---
+
+## 1. The job of every post
+
+A post is not "content marketing". It is a **proof-of-expertise artifact**. After reading it, a recruiter or client should think: *"this person can actually do this, and I trust how they explain it."*
+
+Two hard rules flow from that:
+1. **Attach your identity to every post** — photo, bio, links. Anonymous content kills E-E-A-T and defeats the whole purpose.
+2. **Pitch to learners, not experts.** Most search traffic comes from people learning. Write down, not up.
+
+---
+
+## 2. Categories (stable — don't invent new ones casually)
+
+Use these 7 categories. Everything else is a free-form **tag** (`traefik`, `solidity`, `gitea`, `n8n`…).
+
+| Category | What goes in it |
+|---|---|
+| `engineering` | Java/Spring, PHP/CodeIgniter, React/TS, WordPress deep dives |
+| `devops` | Docker/Portainer, Traefik, Cloudflare tunnels, NAS, backups, monitoring — **my most differentiated material** |
+| `ai` | n8n workflows, Telegram bots, local LLM (LM Studio), mem0 memory stack, TTS |
+| `web3` | Solidity, Foundry/Hardhat, ERC-20/721, Chainlink — honestly framed |
+| `tutorials` | Beginner-facing how-tos |
+| `case-studies` | "How I built X" — the portfolio |
+| `notes` | Short low-friction entries: fixes, gotchas, link roundups |
+
+---
+
+## 3. Post types that work (ranked by hiring ROI)
+
+1. **"How I built X" case studies** — highest value. Recruiters read these. (DigiKedai bot, self-hosted mem0, mailcow email platform, NAS product delivery, SifuMail.)
+2. **"Hard problem → solution" tutorials** — e.g. "The Traefik forward-auth gotcha that cost me a day", "Site-to-site OpenVPN behind CGNAT".
+3. **Gotcha / debugging posts** — short, extremely Google-friendly, compound over time.
+4. **Tool roundups & comparisons** — I have genuine first-hand material (Astro vs WP, Gitea vs GitHub, Foundry vs Hardhat).
+5. **Config / recipe posts** — reusable Traefik/Docker/Pi-hole configs with explanation.
+
+---
+
+## 4. The "hard job done → public post" template
+
+When I finish a difficult piece of work, publish with this shape. It is simultaneously a tutorial, a case study, and a proof-of-expertise — the highest-ROI post type I can write.
+
+```
+① The problem → phrased as the searchable question a learner would type
+② What I tried & why it failed → the debugging story (this is what no one else can copy)
+③ The fix → runnable code/config, explained
+④ What I'd do differently → shows judgment, not just luck
+⑤ The result → one quantified outcome
+```
+
+---
+
+## 5. Cadence
+
+- **Realistic target:** 2 posts/month to start, then 1/week.
+- Sonmez playbook: Month 1–2 = 1/week → Month 3 = 2/week → Month 4+ = 1–2/week mixing 500-word tactical posts with 2,000+ word guides.
+- **Ship a `notes` entry anytime** I solve something — keeps the feed alive between deep posts.
+- Long guides bring search traffic; short posts keep me consistent.
+
+---
+
+## 6. Frontmatter shape (reference)
+
+```yaml
+---
+title: "How I built the DigiKedai Telegram bot"
+description: "A ~155-char meta description with the target keyword."
+pubDate: 2026-09-06
+category: case-studies
+tags: ["telegram", "n8n", "docker", "cloudflare"]
+ogImage: "/og/digikedai-bot.png" # 1200×630, custom per post
+draft: false
+lang: en
+---
+```
+
+---
+
+## 7. What to AVOID (anti-patterns)
+
+| Anti-pattern | Why it hurts |
+|---|---|
+| **Anonymous / no author identity** | Kills E-E-A-T; defeats the name-search strategy. Always show photo + bio + sameAs links. |
+| **Writing for experts, not learners** | Most search traffic is beginners. |
+| **AI-spam / content-farm filler** | Google aggressively demotes unedited AI churn. My first-hand case studies are the moat — never dilute them with filler. |
+| **Perfectionism before publishing** | Procrastination. Ship it, edit later. A dormant blog is worse than an imperfect one (mine was dormant Dec 2023 → now). |
+| **Reused logo as og:image** | Every post needs its own 1200×630 image, or shares look broken. |
+| **Orphaned / thin pages** | hoelee.com has ~400 orphaned image-attachment pages. Don't recreate — keep the sitemap clean. |
+| **Hosting on Medium/Dev.to as primary** | Platform risk + no SEO ownership. Use them for **syndication only** (cross-post + canonical back to my domain). |
+| **Overclaiming Web3 experience** | Be honest: "learning Foundry, deployed test ERC-20/721" is credible; "production DeFi engineer" is not yet. |
+| **Broken resume/CV link** | About page + author bio must link a *working* resume. Fix the source of truth. |
+| **Monetizing before audience** | No ads, sponsored posts, or ebooks yet. Build trust + traffic for a year first. |
+
+---
+
+## 8. i18n policy
+
+- **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).
+- Never let "I should translate this" block publishing an English post.
diff --git a/docs/design-guide.md b/docs/design-guide.md
new file mode 100644
index 0000000..c9484ba
--- /dev/null
+++ b/docs/design-guide.md
@@ -0,0 +1,72 @@
+# Design Guide — what the blog UI must have & must avoid
+
+Read this **before** touching theme, layout, typography, color, or SEO markup. Priority order is fixed: **reading experience → SEO → brand → everything else.**
+
+---
+
+## 1. Non-negotiables (reading experience first)
+
+| Rule | Spec |
+|---|---|
+| **Measure (line length)** | 65–75 characters per line. Single biggest readability win. |
+| **Body typography** | 16–17px, `line-height: 1.6–1.7`, a clean sans (Inter / system-ui / Source Sans). |
+| **Headings** | A character font (Plus Jakarta Sans / Space Grotesk) or keep Inter for minimalism. |
+| **Code** | Proper monospace (JetBrains Mono / Fira Code) + distinct background block + **syntax highlighting + copy button + language label**. Non-negotiable for technical posts. |
+| **Light + dark mode** | Both. Dev audience splits ~50/50. |
+| **Color** | 2-accent palette (primary + semantic green/red for correct/wrong in tutorials). Avoid pure black / pure white. |
+
+---
+
+## 2. Required components
+
+- **Sticky header** — name/logo + nav (Blog / Categories / About / RSS).
+- **Article header** — title, pub date, reading time, category + tags, author card (photo, 1–2 line bio, LinkedIn + GitHub + git.hoelee.com links).
+- **Author box / ProfilePage schema** on every post (see SEO below).
+- **Related posts** (3 cards) — internal linking + keeps readers on-site.
+- **RSS link** in footer — how the technical audience follows me.
+- **Custom og:image per post** — 1200×630, never the reused logo.
+
+---
+
+## 3. Responsive & media
+
+- **Mobile-first**: design 390px, then desktop 1280px.
+- **Astro Image** for automatic optimization/WebP (Core Web Vitals win).
+- **Declare `og:image:width/height` explicitly** — mis-declared dimensions get preview cards rejected by WhatsApp/Telegram/LinkedIn.
+- **Favicon**: reference all sizes (`.ico` + 32×32 + 192×192 PNG + apple-touch 180×180). "Favicon not showing" is usually browser cache, not a missing asset — `curl` the live files before "fixing".
+
+---
+
+## 4. Theme / stack decisions (settled)
+
+- **Astro 5 static** (already scaffolded). Rationale: TypeScript/React skill surface, true static output, low attack surface, first-class i18n routing, and the repo itself is a portfolio artifact.
+- **Theme base:** AstroPaper-style minimalism (light/dark, fuzzy search, RSS + sitemap defaults). Don't rebuild from scratch.
+- **Skip for v1:** WordPress-headless (two systems), Next.js SSR/ISR (heavier than needed), self-built blog engine (the classic time sink).
+
+---
+
+## 5. SEO checklist (2026 — two lanes)
+
+Optimize for **both** classic SEO *and* GEO (getting cited by Google AI Overviews / ChatGPT Search / Perplexity).
+
+- **E-E-A-T first:** real `Person`/`ProfilePage` schema around the author bio, with `sameAs` → LinkedIn, GitHub, git.hoelee.com.
+- **Per-post meta:** unique title (≤60 chars), description (≤155), canonical, og:image (1200×630) + `twitter:card`.
+- **Sitemap.xml + RSS + robots.txt** — generated automatically.
+- **Core Web Vitals:** static site → 90+ LCP/CLS/INP easy. Verify with headless-Chrome screenshot + Lighthouse on 390px **and** 1280px.
+- **Internal linking:** related-posts module + link pillar posts (case studies) from every tutorial.
+- **Question-shaped headings:** write H2/H3 as the actual question a learner types. Rewarded by Google *and* AI engines.
+- **Name consistency:** same name ("Lee Teong Hoe" / "Mr Hoelee") + photo + handles across blog, LinkedIn, GitHub, git.hoelee.com. Fragmented identity is the #1 personal-brand SEO killer.
+
+---
+
+## 6. What to AVOID in design
+
+| Anti-pattern | Why |
+|---|---|
+| Heavy JS / client-side rendering | Static output is the point — don't reintroduce runtime cost. |
+| Reused logo as og:image | Already an issue on hoelee.com. Custom per post. |
+| Anonymous/absent author card | Kills E-E-A-T. |
+| Wall-of-text without code blocks | Technical posts need formatted, copyable code. |
+| No RSS / sitemap | Both are free in Astro; omitting them is pure loss. |
+| Orphaned pages / thin content | hoelee.com already has ~400; keep this sitemap clean. |
+| Over-styled "agency look" | This is a personal engineering blog — credibility over flash. |
diff --git a/docs/ops-runbook.md b/docs/ops-runbook.md
new file mode 100644
index 0000000..8027191
--- /dev/null
+++ b/docs/ops-runbook.md
@@ -0,0 +1,65 @@
+# Ops & Deploy Runbook — blog.hoelee.com
+
+How this blog is built, deployed, and kept alive. Read when debugging CI/CD, deployment, or hosting.
+
+---
+
+## Pipeline
+
+```
+Markdown (src/content/posts) → Astro build (dist/) → Gitea Actions → act_runner on unRaid
+ → nginx container → Cloudflare tunnel → Cloudflare CDN → blog.hoelee.com
+```
+
+- **Repo:** `git.hoelee.com/hoelee/hoelee-blog` (push here first, then GitHub — standing convention).
+- **Runner:** `act_runner` on unRaid (verified working: Gitea 1.27.3 + runner 3.3.2, PAT-secret pipeline).
+
+---
+
+## Write & publish a post
+
+1. Create `src/content/posts/.md` with frontmatter (see `content-guide.md` §6).
+2. Push to `main`.
+3. CI builds `dist/` and deploys to the nginx container automatically.
+4. Verify live: `curl -I https://blog.hoelee.com/` → expect 200.
+
+---
+
+## Build pitfalls (from prior Astro work — check these first)
+
+- **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 ``** — a shared header+footer component renders footer above content otherwise.
+- **`npm install` scripts blocked** can leave esbuild's binary missing → check `npm warn install-scripts` at install time.
+
+---
+
+## Verify before shipping a layout change
+
+Headless-Chrome screenshot + vision check on **both** 390px and 1280px:
+
+```bash
+CHROME="/c/Program Files/Google/Chrome/Application/chrome.exe"
+"$CHROME" --headless --disable-gpu --window-size=1280,2400 \
+ --screenshot="$(cygpath -w $LOCALAPPDATA/Temp/blog.png)" \
+ --virtual-time-budget=4000 "http://localhost:4321/"
+```
+
+Overflow diagnosis (real numbers, not screenshot guessing): check `scrollWidth > innerWidth` via CDP `Runtime.evaluate`.
+
+---
+
+## Hosting notes
+
+- Static output = trivial to serve from any nginx/OpenLiteSpeed dir. Cloudflare in front gives CDN + HTTPS + DDoS.
+- Cloudflare Pages **git integration supports GitHub/GitLab only — not Gitea**. Current setup (Gitea → self-hosted runner → nginx) sidesteps this entirely and is the reason the pipeline is the way it is.
+- Full self-hosting also avoids Cloudflare Pages' 25MB/file, 20k-files limits — a decision already baked into this architecture.
+
+---
+
+## Health checks
+
+- `curl -I https://blog.hoelee.com/` → 200, correct `content-type`.
+- `curl https://blog.hoelee.com/sitemap.xml` → lists all published posts.
+- `curl https://blog.hoelee.com/rss.xml` → non-empty.
+- DNS: `blog.hoelee.com` resolves through Cloudflare (proxy enabled).
diff --git a/docs/seo-reference.md b/docs/seo-reference.md
new file mode 100644
index 0000000..44a1bd2
--- /dev/null
+++ b/docs/seo-reference.md
@@ -0,0 +1,64 @@
+# SEO & Metadata Reference — blog.hoelee.com
+
+Operational reference for the SEO/GEO decisions baked into this project. Read when adding posts, changing `` markup, or debugging search visibility.
+
+---
+
+## Two-lane strategy (2026)
+
+Optimize for **both**:
+1. **Classic SEO** — Google blue links (still the majority of name searches).
+2. **GEO (Generative Engine Optimization)** — getting cited by Google AI Overviews, ChatGPT Search, Perplexity. They pull from well-structured, question-answering content.
+
+---
+
+## Identity & E-E-A-T (do this first, once)
+
+The entire blog strategy is a **name-search play**, so identity must be airtight:
+
+- **One name everywhere:** "Lee Teong Hoe" / "Mr Hoelee" — pick one and keep it identical on blog, LinkedIn, GitHub, git.hoelee.com.
+- **One photo** across all profiles.
+- **`sameAs` handles** in the `Person`/`ProfilePage` schema → LinkedIn, GitHub, git.hoelee.com.
+- **`ProfilePage` schema** wrapping every post's author bio — how Google connects my writing to my professional identity.
+
+---
+
+## Per-post checklist
+
+- [ ] Unique title ≤ 60 chars (target keyword near front)
+- [ ] Meta description ≤ 155 chars
+- [ ] Canonical URL set
+- [ ] Custom `og:image` (1200×630) — **never** the reused logo
+- [ ] `og:image:width/height` declared (else WhatsApp/Telegram/LinkedIn reject the preview)
+- [ ] `twitter:card: summary_large_image`
+- [ ] Category + tags set
+- [ ] Author card present
+- [ ] Internal links to ≥1 pillar post
+
+---
+
+## Structural checklist (site-wide)
+
+- [ ] `sitemap.xml` + `robots.txt` + RSS — auto-generated by Astro
+- [ ] Core Web Vitals 90+ (verify Lighthouse on 390px **and** 1280px)
+- [ ] Question-shaped H2/H3 (the phrasing a learner actually searches)
+- [ ] Related-posts module (3 cards) on every post
+- [ ] No orphaned/thin pages (avoid the hoelee.com ~400-attachment mistake)
+
+---
+
+## Syndication policy
+
+- Primary home = **this domain** (own the SEO).
+- **Cross-post to Dev.to / Medium** with a `rel=canonical` pointing back to blog.hoelee.com — reach without platform lock-in.
+- Never make a third-party platform the primary host.
+
+---
+
+## The "hard job → post" GEO framing
+
+When writing, phrase the opening as the exact question someone types into an AI engine or Google:
+
+> "How do I set up site-to-site OpenVPN behind CGNAT?"
+
+Then answer it directly with clear H2/H3 headings. This is what both Google and the AI engines cite.
diff --git a/package-lock.json b/package-lock.json
index 3fca47d..7931a1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,10 @@
"name": "hoelee-blog",
"version": "0.1.0",
"dependencies": {
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
+ "@fontsource-variable/inter": "^5.3.0",
+ "@fontsource/jetbrains-mono": "^5.3.0",
"astro": "^5.5.0"
}
},
@@ -64,6 +68,45 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
+ "node_modules/@astrojs/rss": {
+ "version": "4.0.19",
+ "resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.19.tgz",
+ "integrity": "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-xml-parser": "^5.5.7",
+ "piccolore": "^0.1.3",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/rss/node_modules/zod": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
+ "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@astrojs/sitemap": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz",
+ "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==",
+ "license": "MIT",
+ "dependencies": {
+ "sitemap": "^9.0.0",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/sitemap/node_modules/zod": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
+ "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@astrojs/telemetry": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
@@ -566,6 +609,24 @@
"node": ">=18"
}
},
+ "node_modules/@fontsource-variable/inter": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
+ "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/jetbrains-mono": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
+ "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1105,6 +1166,18 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
+ "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -1618,6 +1691,24 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/sax": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
+ "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -1741,6 +1832,24 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/anynum": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2440,6 +2549,45 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
+ "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.6.2",
+ "xml-naming": "^0.3.0"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.11.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
+ "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^3.0.0",
+ "fast-xml-builder": "^1.2.0",
+ "is-unsafe": "^2.0.0",
+ "path-expression-matcher": "^1.6.2",
+ "strnum": "^2.4.2",
+ "xml-naming": "^0.3.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2808,6 +2956,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-unsafe": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
+ "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
@@ -3898,6 +4058,21 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/path-expression-matcher": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
+ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -4361,6 +4536,25 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
+ "node_modules/sitemap": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
+ "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^24.9.2",
+ "@types/sax": "^1.2.1",
+ "arg": "^5.0.0",
+ "sax": "^1.4.1"
+ },
+ "bin": {
+ "sitemap": "dist/esm/cli.js"
+ },
+ "engines": {
+ "node": ">=20.19.5",
+ "npm": ">=10.8.2"
+ }
+ },
"node_modules/smol-toml": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
@@ -4438,6 +4632,21 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/strnum": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
+ "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "anynum": "^1.0.1"
+ }
+ },
"node_modules/svgo": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
@@ -4595,6 +4804,12 @@
"node": ">=22.19.0"
}
},
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "license": "MIT"
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -5487,6 +5702,21 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/xml-naming": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
diff --git a/package.json b/package.json
index cb239dc..1d98aac 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,10 @@
"astro": "astro"
},
"dependencies": {
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
+ "@fontsource-variable/inter": "^5.3.0",
+ "@fontsource/jetbrains-mono": "^5.3.0",
"astro": "^5.5.0"
}
}
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..2ec6057
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-192x192.png b/public/favicon-192x192.png
new file mode 100644
index 0000000..0ceb9ac
Binary files /dev/null and b/public/favicon-192x192.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 0000000..8cb062d
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..ffeb846
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/og-default.png b/public/og-default.png
new file mode 100644
index 0000000..2fbf853
Binary files /dev/null and b/public/og-default.png differ
diff --git a/src/components/AuthorCard.astro b/src/components/AuthorCard.astro
new file mode 100644
index 0000000..43665b0
--- /dev/null
+++ b/src/components/AuthorCard.astro
@@ -0,0 +1,23 @@
+---
+import { SITE } from '../config';
+
+interface Props {
+ bio?: string;
+}
+
+const { bio = 'Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.' } = Astro.props;
+---
+
+
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..73a46ec
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,20 @@
+// Single source of truth for site identity — used by BaseLayout for
+// SEO/JSON-LD. Keep these EXACTLY consistent with LinkedIn / GitHub /
+// git.hoelee.com (name-identity is a personal-brand SEO factor).
+export const SITE = {
+ name: 'Mr Hoelee',
+ author: 'Lee Teong Hoe', // canonical name — same everywhere
+ handle: 'Mr Hoelee',
+ url: 'https://blog.hoelee.com',
+ title: 'Mr Hoelee — engineering, DevOps & self-hosting',
+ description:
+ 'Personal blog of Lee Teong Hoe (Mr Hoelee) — full-stack engineering, DevOps & self-hosting, AI automation, and Web3 experiments.',
+ locale: 'en',
+ lang: 'en',
+ email: 'me@hoelee.com',
+ linkedin: 'https://www.linkedin.com/in/hoelee',
+ github: 'https://github.com/hoelee',
+ gitea: 'https://git.hoelee.com/hoelee',
+ website: 'https://www.hoelee.com',
+ twitter: '', // add if you have one
+} as const;
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 71532e3..e2de7a7 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -1,75 +1,170 @@
---
+import '../styles/global.css';
+import '@fontsource-variable/inter';
+import '@fontsource/jetbrains-mono/400.css';
+import { SITE } from '../config';
+
interface Props {
title: string;
description?: string;
lang?: string;
+ ogImage?: string;
+ type?: 'website' | 'article';
+ article?: {
+ publishedTime: string;
+ modifiedTime?: string;
+ tags?: string[];
+ category?: string;
+ };
}
-const { title, description = 'Personal blog of Hoelee — engineering, DevOps, AI, and self-hosting.', lang = 'en' } = Astro.props;
+const {
+ title,
+ description = SITE.description,
+ lang = SITE.lang,
+ ogImage = '/og-default.png',
+ type = 'website',
+ article,
+} = Astro.props;
+
+const canonical = new URL(Astro.url.pathname, SITE.url).href;
+const ogImageUrl = new URL(ogImage, SITE.url).href;
---
-
+
-
+
{title}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {article && (
+ <>
+
+ {article.modifiedTime && }
+ {article.category && }
+ {article.tags?.map((t) => )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- hoelee.dev
- about
- posts
+
+
-
+
+
+
+
+
+
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 8fa5d53..0ce2966 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -1,23 +1,35 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
+import AuthorCard from '../components/AuthorCard.astro';
+import { SITE } from '../config';
---
-
+
About
- I'm Hoelee, a full-stack developer and DevOps engineer based in Malaysia.
- I build web applications (PHP/CodeIgniter, Java/Spring, JavaScript/TypeScript/React),
- self-host a ~140-container homelab, and run a small email-hosting business
- for Malaysian SMEs.
+ I'm {SITE.author} ({SITE.handle}), a full-stack developer and DevOps engineer
+ based in Malaysia. I build web applications — PHP/CodeIgniter, Java/Spring,
+ JavaScript/TypeScript/React — and self-host a ~140-container homelab
+ (Docker, Traefik, nginx, Cloudflare tunnels) that runs my own email-hosting
+ business for Malaysian SMEs.
- This blog is where I document what I build — from self-hosted CI/CD and
- Docker orchestration to Web3 experiments and AI automation. If you're a
- recruiter or a fellow builder, the posts here are my living portfolio.
+ This blog is my living portfolio: I document what I build, from self-hosted
+ CI/CD and Docker orchestration to Web3 experiments and AI automation. If
+ you're a recruiter, a client, or a fellow builder, the posts here are the
+ most honest picture of what I can do.
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
index ff1b775..2f633d8 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,35 +1,37 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
+import PostList from '../components/PostList.astro';
import { getCollection } from 'astro:content';
+import { SITE } from '../config';
-const posts = (await getCollection('posts', ({ data }) => !data.draft))
+const posts = (await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
-
-
hoelee.dev
-
- I'm Hoelee — a full-stack developer and DevOps engineer. I build web apps,
- self-host infrastructure, and automate things. Here I write about what I
- learn and ship.
-
+
+
+
Hi, I'm {SITE.author}.
+
+ 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 small email-hosting business for
+ Malaysian SMEs. I write here about what I learn and ship.
+