feat: design-system upgrade + brand to 'Mr Hoelee'
Deploy / build (push) Successful in 16s

- 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:
hoelee committed 2026-09-06 06:21:28 +08:00
1 parent 43b6f65c1c
commit a0b238bc98
25 files changed
+1306 -120

No files matched your search

+23 -11
View File
@@ -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>
&middot;
<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>
+22
View File
@@ -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>
+22
View File
@@ -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
View File
@@ -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>
+63 -7
View File
@@ -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>
+4 -17
View File
@@ -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>
+22
View File
@@ -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>`,
});
}