From a0b238bc98c0211a71221e98eb1fa89ae7956ca5 Mon Sep 17 00:00:00 2001 From: hoelee Date: Sun, 6 Sep 2026 06:21:28 +0800 Subject: [PATCH] feat: design-system upgrade + brand to 'Mr Hoelee' - Extract CSS into src/styles/global.css with design tokens (hoelee.com brand palette) - Self-hosted Inter + JetBrains Mono via @fontsource - Sticky nav + manual light/dark toggle + copy-button code blocks - Author card + Person JSON-LD (E-E-A-T), article meta, related posts, reading time - Full SEO: canonical, OG (with dims), twitter:card, favicon (all sizes), RSS + sitemap - New /categories/ and /categories/[category]/ pages - Add docs/ knowledge guides (content, design, seo, ops) + README index - Brand name: hoelee.dev -> Mr Hoelee --- README.md | 41 +++- astro.config.mjs | 2 + docs/content-guide.md | 105 +++++++++ docs/design-guide.md | 72 ++++++ docs/ops-runbook.md | 65 ++++++ docs/seo-reference.md | 64 +++++ package-lock.json | 230 ++++++++++++++++++ package.json | 4 + public/apple-touch-icon.png | Bin 0 -> 1348 bytes public/favicon-192x192.png | Bin 0 -> 1447 bytes public/favicon-32x32.png | Bin 0 -> 271 bytes public/favicon.ico | Bin 0 -> 180 bytes public/og-default.png | Bin 0 -> 14693 bytes src/components/AuthorCard.astro | 23 ++ src/components/PostList.astro | 29 +++ src/config.ts | 20 ++ src/layouts/BaseLayout.astro | 201 +++++++++++----- src/pages/about.astro | 34 ++- src/pages/categories/[category].astro | 22 ++ src/pages/categories/index.astro | 22 ++ src/pages/index.astro | 54 ++--- src/pages/posts/[slug].astro | 70 +++++- src/pages/posts/index.astro | 21 +- src/pages/rss.xml.ts | 22 ++ src/styles/global.css | 325 ++++++++++++++++++++++++++ 25 files changed, 1306 insertions(+), 120 deletions(-) create mode 100644 docs/content-guide.md create mode 100644 docs/design-guide.md create mode 100644 docs/ops-runbook.md create mode 100644 docs/seo-reference.md create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-192x192.png create mode 100644 public/favicon-32x32.png create mode 100644 public/favicon.ico create mode 100644 public/og-default.png create mode 100644 src/components/AuthorCard.astro create mode 100644 src/components/PostList.astro create mode 100644 src/config.ts create mode 100644 src/pages/categories/[category].astro create mode 100644 src/pages/categories/index.astro create mode 100644 src/pages/rss.xml.ts create mode 100644 src/styles/global.css 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 0000000000000000000000000000000000000000..2ec60575af68497f3b9a1708c748ba29f68968fb GIT binary patch literal 1348 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6VCD97aSW-L^Y(6hMp&uL@sH;> zr@!mpm896l+&Ql+A}ytUNl=%On}LrCwhq#P(X}nmbq0_peELc3YBsjJuQruDe zo`CEBJigfROJM!?3%h~RE@87?v{AT%HoG0|!^# zMD35-^~~sH*VId=o(B1E-?Amz%3Rs^pX%IM6Y8WUx7APNtJ9YK;WleV&bJfdVoiDh zTYYa8?A`Lz?`50z(yXsG`@^U1vRcwzwIpi(64Oh{(o1JXzC68tsqK8P-+W%rmvzgm zWpC0uad*3izpcVo-R@e2YirN#ck}nkuT!c0<0@zM%@KIO%Go7vpJra4U2CxE zYg+a1^!txw6S`BnCEUv&5VYfjD=9B&rBJD2;@^G;3HY7hOqsn@=pe_s|Y>&U)$gQ@+M zjJ9v9zMOjebE*9k4yy+ze=qgjzH;TX?_RgfP0M-Kwk%*aySHT8hRWuW!u-wd7o|9E zIk@lsa=`(@K4Sa&lkm|^kB@mdE<5KnUv|{Y1a{Ki{8MPhT=|WzDnyd)b#Pl84|u3x@%QBc+NUvWq$lA2&Ii>vT<#JpcsNSqz z@Z4B-X27yVLCO5Gd!EL&oOt_wlk$s(Ma!PeN=;kRCRoBye0Hl`6Q4)rY3WP>lQmRF sA-~u+|Id{Aa6nEfjAL-2qEF^cLVNn}NS=5CEFBpRPG z+B>mJ;+xvVo)7v<|6ZK4?aWRw{^v*UI&|BdFFt?sZ>_z-yYAh;=R809^WB`{_r>S6 zHc#*E;V>{=_=1zcRf!=&c~GKkjVh+p;`hCmWUtHre!c&;o^GYZvz`e;?v%Oq@j3xj zgU>22KUD@jUwLWG%%JMoS1zge=1pm9TY9kh(t!nAYPWCss{Zn}?xk?)CGp=yZ9jkP zXW%m08o4WTVM*@vl50JS|KB^(8T42s^OnfVEoCcz?|D@`dz)wWHP7tpp8MrjoZRx7 zkMYWlEn=BJc`r?Rmvj8w;?2%gE1aV?hEy&0uc}=8McTdfe`|Jw)Xad1d67D`TfAQF zH2Qze{?-z45yDg^DY8YF59md-w9vqwb~N&yM^J+5WpG z^Y62Y$5)w|RxCXKYpM9Xsb=pjK7CGfjQ+v7+V=jt(@qs{%wFCxew6cnljWt&g>9>( z=P+G8u#frDr}QP&^Q?cJt53+{Uw}ruy&&(_QOdd}T>p$+_`FZ-Ek5 z?BF|5T`d+O=&3}I zOZ>Bb`y-D-Vbh1RdOh;MczpBK-lLl#p_evjtx@$&j{n6h+boz4NHGptx#|44$rjF6*2UngA?iUdjLf literal 0 HcmV?d00001 diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..8cb062d35ddef1dc19517822d4f923ae9d92a3b4 GIT binary patch literal 271 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJYo0ESAr*6yuk`%gcLM7Fj|9&BvVwrp$xxkV3b zn4O;+v9*=YfAN4JO^vPX=~o_}q$Uo-_-qdQjdS?|51cu2;KLJB=H_TY=H^l{=EF;O z3ltXXvav-PT3Il#I2`0HI`aEG507`b@qxxB73Nx*g2#$Ye_v`hojvHq&i>~g^Ff0T z-{qUmo^S#3)ZK+mWgUDExP)GIlXYNAkjb59%wv!sRmyNULxjgb;)4bQgO{e(B;P|h R!9X7}c)I$ztaD0e0stS;T$2C* literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ffeb8464f5db6d8272c5b8e7383594e079748980 GIT binary patch literal 180 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 + +
+

{SITE.author}

+

{bio}

+ +
+ diff --git a/src/components/PostList.astro b/src/components/PostList.astro new file mode 100644 index 0000000..1ca16d9 --- /dev/null +++ b/src/components/PostList.astro @@ -0,0 +1,29 @@ +--- +import type { CollectionEntry } from 'astro:content'; + +interface Props { + posts: CollectionEntry<'posts'>[]; + limit?: number; +} + +const { posts, limit } = Astro.props; +const shown = limit ? posts.slice(0, limit) : posts; + +const fmt = (d: Date) => + d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); +--- + + 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) => )} + + )} + + + + + + + + + + + + + + -