- 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
This commit is contained in:
@@ -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/`<head>` 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/<slug>.md` with frontmatter.
|
||||
1. Create `src/content/posts/<slug>.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.
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
@@ -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/<slug>.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/<slug>` → 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 `<main>`** — 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).
|
||||
@@ -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 `<head>` 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.
|
||||
Generated
+230
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 271 B |
Binary file not shown.
|
After Width: | Height: | Size: 180 B |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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;
|
||||
---
|
||||
|
||||
<div class="author-card">
|
||||
<div class="avatar" aria-hidden="true">{SITE.handle.charAt(0)}</div>
|
||||
<div>
|
||||
<p class="who">{SITE.author}</p>
|
||||
<p class="bio">{bio}</p>
|
||||
<div class="links">
|
||||
<a href={SITE.linkedin} target="_blank" rel="noopener">LinkedIn</a>
|
||||
<a href={SITE.github} target="_blank" rel="noopener">GitHub</a>
|
||||
<a href={SITE.gitea} target="_blank" rel="noopener">Gitea</a>
|
||||
<a href={SITE.website} target="_blank" rel="noopener">hoelee.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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' });
|
||||
---
|
||||
|
||||
<ul class="post-list">
|
||||
{shown.map((post) => (
|
||||
<li>
|
||||
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
|
||||
<div class="meta">
|
||||
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={`/categories/${post.data.category}/`}>{post.data.category}</a>
|
||||
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
|
||||
</div>
|
||||
<p>{post.data.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -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: '[email protected]',
|
||||
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;
|
||||
+148
-53
@@ -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;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<html lang={lang} data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {
|
||||
--fg: #1a1a1a;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--bg: #ffffff;
|
||||
--code-bg: #f3f4f6;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #e5e7eb;
|
||||
--muted: #9ca3af;
|
||||
--accent: #60a5fa;
|
||||
--bg: #111827;
|
||||
--code-bg: #1f2937;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
}
|
||||
main { max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
|
||||
header.nav {
|
||||
max-width: 720px; margin: 0 auto; padding: 1.5rem 1.5rem 0;
|
||||
display: flex; gap: 1rem; align-items: baseline;
|
||||
}
|
||||
header.nav a { color: var(--muted); text-decoration: none; }
|
||||
header.nav a:hover { color: var(--accent); }
|
||||
header.nav .brand { font-weight: 700; color: var(--fg); margin-right: auto; }
|
||||
h1, h2, h3 { line-height: 1.25; }
|
||||
a { color: var(--accent); }
|
||||
article a { text-decoration: underline; }
|
||||
code { background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; }
|
||||
pre { background: var(--code-bg); padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
||||
pre code { background: none; padding: 0; }
|
||||
time { color: var(--muted); font-size: 0.9rem; }
|
||||
.post-list { list-style: none; padding: 0; }
|
||||
.post-list li { margin-bottom: 1.5rem; }
|
||||
.post-list h2 { margin: 0 0 0.25rem; }
|
||||
.tag { display: inline-block; background: var(--code-bg); color: var(--muted); padding: 0.1em 0.5em; border-radius: 999px; font-size: 0.8rem; margin-right: 0.4rem; }
|
||||
</style>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={SITE.name} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImageUrl} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content={SITE.locale} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl} />
|
||||
|
||||
<!-- Article-specific meta -->
|
||||
{article && (
|
||||
<>
|
||||
<meta property="article:published_time" content={article.publishedTime} />
|
||||
{article.modifiedTime && <meta property="article:modified_time" content={article.modifiedTime} />}
|
||||
{article.category && <meta property="article:section" content={article.category} />}
|
||||
{article.tags?.map((t) => <meta property="article:tag" content={t} />)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Fonts are self-hosted via @fontsource (imported in frontmatter) -->
|
||||
|
||||
<!-- RSS -->
|
||||
<link rel="alternate" type="application/rss+xml" title={`${SITE.name} — RSS`} href="/rss.xml" />
|
||||
|
||||
<!-- Person / ProfilePage JSON-LD (E-E-A-T backbone) -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: SITE.author,
|
||||
alternateName: SITE.handle,
|
||||
url: SITE.url,
|
||||
email: SITE.email,
|
||||
sameAs: [SITE.linkedin, SITE.github, SITE.gitea, SITE.website].filter(Boolean),
|
||||
jobTitle: 'Full-stack Developer & DevOps Engineer',
|
||||
knowsAbout: [
|
||||
'Full-stack engineering', 'DevOps', 'Self-hosting',
|
||||
'Docker', 'WordPress', 'AI automation', 'Web3',
|
||||
],
|
||||
})} />
|
||||
|
||||
<!-- Theme init (before paint, no FOUC) -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || t === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<a class="brand" href="/">hoelee.dev</a>
|
||||
<a href="/about/">about</a>
|
||||
<a href="/posts/">posts</a>
|
||||
<header class="site-header">
|
||||
<nav class="nav" aria-label="Main">
|
||||
<a class="brand" href="/">Mr<span class="dot"> Hoelee</span></a>
|
||||
<a href="/posts/">posts</a>
|
||||
<a href="/categories/">categories</a>
|
||||
<a href="/about/">about</a>
|
||||
<a href="/rss.xml" title="RSS feed">rss</a>
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">◐</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
<main class="wrap">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>© {new Date().getFullYear()} {SITE.author} · {SITE.handle}</span>
|
||||
<span>
|
||||
<a href={SITE.linkedin} target="_blank" rel="noopener">LinkedIn</a>
|
||||
<a href={SITE.github} target="_blank" rel="noopener">GitHub</a>
|
||||
<a href={SITE.gitea} target="_blank" rel="noopener">Gitea</a>
|
||||
<a href={SITE.website} target="_blank" rel="noopener">hoelee.com</a>
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Theme toggle + code copy buttons (tiny, no framework)
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
const cur = document.documentElement.getAttribute('data-theme');
|
||||
const next = cur === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('theme', next); } catch (e) {}
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('pre').forEach((pre) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'code-block';
|
||||
pre.parentNode.insertBefore(wrap, pre);
|
||||
wrap.appendChild(pre);
|
||||
const b = document.createElement('button');
|
||||
b.className = 'copy-btn';
|
||||
b.textContent = 'copy';
|
||||
b.addEventListener('click', () => {
|
||||
const text = pre.innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
b.textContent = 'copied';
|
||||
b.classList.add('copied');
|
||||
setTimeout(() => { b.textContent = 'copy'; b.classList.remove('copied'); }, 1500);
|
||||
});
|
||||
});
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+23
-11
@@ -1,23 +1,35 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import AuthorCard from '../components/AuthorCard.astro';
|
||||
import { SITE } from '../config';
|
||||
---
|
||||
|
||||
<BaseLayout title="About — hoelee.dev">
|
||||
<BaseLayout title="About — Mr Hoelee" description={`About ${SITE.author} (${SITE.handle}).`}>
|
||||
<h1>About</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<AuthorCard bio="Full-stack developer & DevOps engineer based in Malaysia. Open to remote / hybrid opportunities." />
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
<a href="https://www.hoelee.com" target="_blank" rel="noopener">www.hoelee.com</a>
|
||||
·
|
||||
<a href="https://git.hoelee.com/hoelee" target="_blank" rel="noopener">git.hoelee.com</a>
|
||||
<a href={`mailto:${SITE.email}`}>{SITE.email}</a>
|
||||
<span class="sep">·</span>
|
||||
<a href={SITE.linkedin} target="_blank" rel="noopener">LinkedIn</a>
|
||||
<span class="sep">·</span>
|
||||
<a href={SITE.gitea} target="_blank" rel="noopener">git.hoelee.com</a>
|
||||
<span class="sep">·</span>
|
||||
<a href={SITE.website} target="_blank" rel="noopener">www.hoelee.com</a>
|
||||
</p>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en');
|
||||
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'))
|
||||
.filter((p) => p.data.category === category)
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
|
||||
<BaseLayout title={`${category} — Mr Hoelee`} description={`Posts in the ${category} category.`}>
|
||||
<h1>Category: {category}</h1>
|
||||
<PostList posts={posts} />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const posts = await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en');
|
||||
|
||||
const categories = [...new Set(posts.map((p) => p.data.category))].sort();
|
||||
|
||||
const count = (cat: string) => posts.filter((p) => p.data.category === cat).length;
|
||||
---
|
||||
|
||||
<BaseLayout title="Categories — Mr Hoelee" description="Browse posts by category.">
|
||||
<h1>Categories</h1>
|
||||
<ul class="post-list">
|
||||
{categories.map((cat) => (
|
||||
<li>
|
||||
<a href={`/categories/${cat}/`}><h2>{cat}</h2></a>
|
||||
<p>{count(cat)} post{count(cat) === 1 ? '' : 's'}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</BaseLayout>
|
||||
+28
-26
@@ -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());
|
||||
---
|
||||
|
||||
<BaseLayout title="hoelee.dev — personal blog">
|
||||
<h1>hoelee.dev</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<BaseLayout title={SITE.title} description={SITE.description}>
|
||||
<section>
|
||||
<h1>Hi, I'm {SITE.author}.</h1>
|
||||
<p>
|
||||
A full-stack developer and DevOps engineer based in Malaysia. I build web
|
||||
apps (PHP/CodeIgniter, Java/Spring, TypeScript/React), self-host a
|
||||
~140-container homelab, and run a small email-hosting business for
|
||||
Malaysian SMEs. I write here about what I learn and ship.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/about/">More about me</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="/posts/">Browse all posts</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="/rss.xml">RSS</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2>Latest posts</h2>
|
||||
{posts.length === 0 ? (
|
||||
<p><em>No posts yet — check back soon.</em></p>
|
||||
) : (
|
||||
<ul class="post-list">
|
||||
{posts.slice(0, 10).map((post) => (
|
||||
<li>
|
||||
<a href={`/posts/${post.slug}/`}>
|
||||
<h2>{post.data.title}</h2>
|
||||
</a>
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</time>
|
||||
<p>{post.data.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<section>
|
||||
<h2>Latest posts</h2>
|
||||
{posts.length === 0 ? (
|
||||
<p><em>No posts yet — check back soon.</em></p>
|
||||
) : (
|
||||
<PostList posts={posts} limit={10} />
|
||||
)}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import AuthorCard from '../../components/AuthorCard.astro';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -12,16 +13,71 @@ export async function getStaticPaths() {
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
|
||||
// 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' });
|
||||
|
||||
// Related posts: 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)
|
||||
.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;
|
||||
return { post: p, score: catMatch + tagOverlap };
|
||||
})
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map((r) => r.post);
|
||||
---
|
||||
|
||||
<BaseLayout title={`${post.data.title} — hoelee.dev`} description={post.data.description} lang={post.data.lang}>
|
||||
<BaseLayout
|
||||
title={`${post.data.title} — Mr Hoelee`}
|
||||
description={post.data.description}
|
||||
lang={post.data.lang}
|
||||
ogImage={post.data.ogImage ?? '/og-default.png'}
|
||||
type="article"
|
||||
article={{
|
||||
publishedTime: post.data.pubDate.toISOString(),
|
||||
modifiedTime: post.data.updatedDate?.toISOString(),
|
||||
tags: post.data.tags,
|
||||
category: post.data.category,
|
||||
}}
|
||||
>
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</time>
|
||||
<span class="tag">{post.data.category}</span>
|
||||
{post.data.tags.map((tag) => <span class="tag">{tag}</span>)}
|
||||
<header class="article-head">
|
||||
<h1>{post.data.title}</h1>
|
||||
<div class="meta">
|
||||
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
|
||||
<span class="sep">·</span>
|
||||
<span>{minutes} min read</span>
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={`/categories/${post.data.category}/`}>{post.data.category}</a>
|
||||
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Content />
|
||||
|
||||
<AuthorCard />
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section class="related">
|
||||
<h2>Related posts</h2>
|
||||
<div class="related-grid">
|
||||
{related.map((p) => (
|
||||
<a class="related-card" href={`/posts/${p.slug}/`}>
|
||||
<h3>{p.data.title}</h3>
|
||||
<time datetime={p.data.pubDate.toISOString()}>{fmt(p.data.pubDate)}</time>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
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());
|
||||
---
|
||||
|
||||
<BaseLayout title="Posts — hoelee.dev">
|
||||
<BaseLayout title="Posts — Mr Hoelee" description="All posts on Mr Hoelee's blog.">
|
||||
<h1>Posts</h1>
|
||||
{posts.length === 0 ? (
|
||||
<p><em>No posts yet.</em></p>
|
||||
) : (
|
||||
<ul class="post-list">
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/posts/${post.slug}/`}>
|
||||
<h2>{post.data.title}</h2>
|
||||
</a>
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</time>
|
||||
<span class="tag">{post.data.category}</span>
|
||||
{post.data.tags.map((tag) => <span class="tag">{tag}</span>)}
|
||||
<p>{post.data.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<PostList posts={posts} />
|
||||
)}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { SITE } from '../config';
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && data.lang === 'en'))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
return rss({
|
||||
title: SITE.name,
|
||||
description: SITE.description,
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.pubDate,
|
||||
link: `/posts/${post.slug}/`,
|
||||
categories: [post.data.category, ...post.data.tags],
|
||||
})),
|
||||
customData: `<language>${SITE.locale}</language>`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/* ============================================================
|
||||
hoelee-blog — global design system
|
||||
Brand colors sourced from www.hoelee.com (Kadence palette).
|
||||
Priority: reading experience → SEO → brand → everything else.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* Brand palette (from hoelee.com Kadence global palette) */
|
||||
--brand: #295cff; /* primary — cobalt/royal blue */
|
||||
--brand-strong: #1c0d5a; /* deep indigo — dark bg anchor, headings */
|
||||
--brand-soft: #0e94ff; /* lighter cyan-blue — hover/links */
|
||||
--ink: #1a1a1a; /* near-black text (never pure #000) */
|
||||
--paper: #ffffff; /* near-white bg (never pure #fff for large areas) */
|
||||
--surface: #f6f8fb; /* cool light gray — cards, code bg */
|
||||
--surface-2: #eef2f7; /* slightly deeper gray — borders on light */
|
||||
--border: #e1ebee; /* cool light border (palette7) */
|
||||
--muted: #57575d; /* secondary text (palette5) */
|
||||
--faint: #636363; /* tertiary text (palette6) */
|
||||
|
||||
/* Semantic (correct/wrong in tutorials) */
|
||||
--good: #13612e; /* green (palette11) */
|
||||
--warn: #f5a524; /* amber (palette15) */
|
||||
--bad: #b82105; /* red (palette13) */
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--fs-base: 17px;
|
||||
--lh-body: 1.7;
|
||||
|
||||
/* Layout */
|
||||
--measure: 46rem; /* 65–75ch measure */
|
||||
--radius: 8px;
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
html[data-theme='dark'] {
|
||||
--brand: #6d8dff;
|
||||
--brand-soft: #60a5fa;
|
||||
--brand-strong: #c9c2ff;
|
||||
--ink: #e5e7eb;
|
||||
--paper: #111827;
|
||||
--surface: #1f2937;
|
||||
--surface-2: #263244;
|
||||
--border: #2a3345;
|
||||
--muted: #9ca3af;
|
||||
--faint: #6b7280;
|
||||
--good: #4ade80;
|
||||
--warn: #fbbf24;
|
||||
--bad: #f87171;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-body);
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.wrap {
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 5rem;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.wrap > * { min-width: 0; }
|
||||
|
||||
/* ---------- Header / nav ---------- */
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: color-mix(in srgb, var(--paper) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav {
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
padding: 0.9rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.4rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.nav .brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
margin-right: auto;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav .brand .dot { color: var(--brand); }
|
||||
.nav a:not(.brand) {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav a:not(.brand):hover { color: var(--brand); }
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.theme-toggle:hover { color: var(--brand); border-color: var(--brand); }
|
||||
|
||||
/* ---------- Typography ---------- */
|
||||
h1, h2, h3, h4 {
|
||||
color: var(--ink);
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 { font-size: 2rem; margin: 0 0 1rem; }
|
||||
h2 { font-size: 1.4rem; margin: 2.25rem 0 0.75rem; }
|
||||
h3 { font-size: 1.15rem; margin: 1.75rem 0 0.5rem; }
|
||||
p { margin: 0 0 1rem; }
|
||||
|
||||
a { color: var(--brand); text-decoration: none; }
|
||||
a:hover { color: var(--brand-soft); }
|
||||
article a { text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
time { color: var(--faint); font-size: 0.88rem; }
|
||||
|
||||
/* ---------- Article ---------- */
|
||||
.article-head {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.article-head .meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.article-head .meta .sep { color: var(--border); }
|
||||
|
||||
/* ---------- Tags & categories ---------- */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
padding: 0.1em 0.6em;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0.35rem 0.35rem 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.tag:hover { color: var(--brand); }
|
||||
|
||||
/* ---------- Code ---------- */
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--surface);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
line-height: 1.55;
|
||||
margin: 1.25rem 0;
|
||||
position: relative;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.code-block { position: relative; }
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.code-block:hover .copy-btn { opacity: 1; }
|
||||
.copy-btn.copied { color: var(--good); border-color: var(--good); }
|
||||
|
||||
/* ---------- Author card ---------- */
|
||||
.author-card {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin: 2.5rem 0;
|
||||
padding: 1.25rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.author-card .avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.author-card .who { font-weight: 600; margin: 0; }
|
||||
.author-card .bio { color: var(--muted); font-size: 0.9rem; margin: 0.25rem 0 0.5rem; }
|
||||
.author-card .links { display: flex; gap: 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* ---------- Related posts ---------- */
|
||||
.related { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
|
||||
.related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.related-card {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.related-card:hover { border-color: var(--brand); }
|
||||
.related-card h3 { margin: 0 0 0.3rem; font-size: 0.95rem; }
|
||||
.related-card time { font-size: 0.78rem; }
|
||||
|
||||
/* ---------- Post list ---------- */
|
||||
.post-list { list-style: none; padding: 0; margin: 0; }
|
||||
.post-list li {
|
||||
padding: 1.25rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.post-list li:last-child { border-bottom: none; }
|
||||
.post-list h2 { margin: 0 0 0.3rem; font-size: 1.25rem; }
|
||||
.post-list .meta { color: var(--faint); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.post-list p { color: var(--muted); margin: 0.4rem 0 0; }
|
||||
|
||||
/* ---------- Callouts (correct / wrong in tutorials) ---------- */
|
||||
.callout {
|
||||
border-left: 3px solid var(--brand);
|
||||
background: var(--surface);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
.callout.good { border-left-color: var(--good); }
|
||||
.callout.warn { border-left-color: var(--warn); }
|
||||
.callout.bad { border-left-color: var(--bad); }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.site-footer {
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--faint);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.site-footer span:last-child {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
.site-footer a { color: var(--muted); text-decoration: none; }
|
||||
.site-footer a:hover { color: var(--brand); }
|
||||
|
||||
/* ---------- Utility ---------- */
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* ---------- Mobile ---------- */
|
||||
@media (max-width: 600px) {
|
||||
.wrap { padding: 1.5rem 1.25rem 4rem; }
|
||||
.nav { padding: 0.8rem 1.25rem; gap: 0.85rem; }
|
||||
h1 { font-size: 1.7rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
.related-grid { grid-template-columns: 1fr; }
|
||||
.author-card { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
Reference in New Issue
Block a user