- 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:
1 parent
43b6f65c1c
commit
a0b238bc98
25 files changed
+1306
-120
No files matched your search
+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>`,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user