M2: content collections — case studies, blog, RSS, tags, sitemap
Projects + blog as schema-validated content collections; structured case studies (problem/design/outcome), blog with tag pages, reading time, RSS feed (drafts excluded), sitemap, and Shiki dual-theme code highlighting.
This commit is contained in:
@@ -1,11 +1,26 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from "astro/config";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://www.bztmon.com",
|
||||
integrations: [sitemap()],
|
||||
build: {
|
||||
// Keep CSS in external files (no inlined <style> blocks) — friendlier to a strict CSP.
|
||||
inlineStylesheets: "never",
|
||||
},
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
// Dual theme via CSS variables → colours switch with our data-theme, no
|
||||
// hard-coded per-token colours baked to one theme.
|
||||
themes: { light: "github-light", dark: "github-dark" },
|
||||
defaultColor: false,
|
||||
wrap: true,
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
Generated
+199
-2
@@ -8,6 +8,8 @@
|
||||
"name": "bztmon-site",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"astro": "^6.4.7",
|
||||
"tailwindcss": "^4.3.1"
|
||||
@@ -178,6 +180,28 @@
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/rss": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.18.tgz",
|
||||
"integrity": "sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.5.7",
|
||||
"piccolore": "^0.1.3",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/sitemap": {
|
||||
"version": "3.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz",
|
||||
"integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sitemap": "^9.0.0",
|
||||
"stream-replace-string": "^2.0.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/telemetry": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz",
|
||||
@@ -1289,6 +1313,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
|
||||
"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",
|
||||
@@ -2051,12 +2087,20 @@
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2257,6 +2301,24 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz",
|
||||
"integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==",
|
||||
"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",
|
||||
@@ -2977,6 +3039,45 @@
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.1.tgz",
|
||||
"integrity": "sha512-S7c757RD3jvnkiKleILFONy5+kBzc2gLxbDBJmN8DvriXTVqBGOzcMZqUPkX/o259U7SbK9fy6td1EELt2zHSQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^2.2.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^1.0.1",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.4.0",
|
||||
"xml-naming": "^0.1.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",
|
||||
@@ -3370,6 +3471,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz",
|
||||
"integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==",
|
||||
"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",
|
||||
@@ -4759,6 +4872,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"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",
|
||||
@@ -5263,6 +5391,40 @@
|
||||
"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/sitemap/node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sitemap/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/smol-toml": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||
@@ -5294,6 +5456,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-replace-string": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
|
||||
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -5336,6 +5504,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz",
|
||||
"integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz",
|
||||
@@ -5500,7 +5683,6 @@
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
@@ -6207,6 +6389,21 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||
"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",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"astro": "^6.4.7",
|
||||
"tailwindcss": "^4.3.1"
|
||||
|
||||
@@ -5,7 +5,8 @@ import { site } from "../data/site";
|
||||
const links = [
|
||||
{ label: "About", href: "/#about" },
|
||||
{ label: "Skills", href: "/#skills" },
|
||||
{ label: "Projects", href: "/#projects" },
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Contact", href: "/#contact" },
|
||||
];
|
||||
---
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { formatDate } from "../lib/blog";
|
||||
import { readingTime } from "../lib/reading";
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<"blog">[];
|
||||
}
|
||||
const { posts } = Astro.props;
|
||||
---
|
||||
|
||||
<ol class="posts">
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li class="post" data-reveal>
|
||||
<a class="post__link" href={`/blog/${post.id}`}>
|
||||
<div class="post__meta mono">
|
||||
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time>
|
||||
<span class="post__sep">·</span>
|
||||
<span>{readingTime(post.body)}</span>
|
||||
</div>
|
||||
<h3 class="post__title">{post.data.title}</h3>
|
||||
<p class="post__summary">{post.data.summary}</p>
|
||||
<ul class="post__tags">
|
||||
{post.data.tags.map((t) => (
|
||||
<li class="tag">{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.posts {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.post__link {
|
||||
display: block;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease);
|
||||
}
|
||||
.post__link:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--accent-line);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.post__meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.post__sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.post__title {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--step-1);
|
||||
color: var(--text);
|
||||
}
|
||||
.post__summary {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.post__tags {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-3) 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,30 @@
|
||||
---
|
||||
import type { Project } from "../data/projects";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
interface Props {
|
||||
project: Project;
|
||||
entry: CollectionEntry<"projects">;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const href = project.hasCaseStudy ? `/projects/${project.slug}` : undefined;
|
||||
const Tag = href ? "a" : "article";
|
||||
const { entry } = Astro.props;
|
||||
const p = entry.data;
|
||||
const href = `/projects/${entry.id}`;
|
||||
---
|
||||
|
||||
<Tag class="project card" href={href} data-reveal>
|
||||
<a class="project card" href={href} data-reveal>
|
||||
<div class="project__top">
|
||||
<h3 class="project__title">{project.title}</h3>
|
||||
{href && <span class="project__arrow" aria-hidden="true">→</span>}
|
||||
<h3 class="project__title">{p.title}</h3>
|
||||
<span class="project__arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
|
||||
<p class="project__outcome">{project.outcome}</p>
|
||||
<p class="project__summary">{project.summary}</p>
|
||||
<p class="project__outcome">{p.outcome}</p>
|
||||
<p class="project__summary">{p.summary}</p>
|
||||
|
||||
<ul class="project__stack">
|
||||
{project.stack.map((s) => <li class="tag">{s}</li>)}
|
||||
{p.stack.map((s) => <li class="tag">{s}</li>)}
|
||||
</ul>
|
||||
|
||||
<p class="project__meta mono">
|
||||
<span>{project.role}</span><span class="project__sep">·</span><span>{project.period}</span>
|
||||
<span>{p.role}</span><span class="project__sep">·</span><span>{p.period}</span>
|
||||
</p>
|
||||
</Tag>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.project {
|
||||
@@ -81,6 +81,7 @@ const Tag = href ? "a" : "article";
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.project__sep {
|
||||
opacity: 0.6;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineCollection } from "astro:content";
|
||||
import { z } from "astro:schema";
|
||||
import { glob } from "astro/loaders";
|
||||
|
||||
// Case studies. Adding a project = one .md file in src/content/projects/.
|
||||
const projects = defineCollection({
|
||||
loader: glob({ pattern: "**/*.md", base: "./src/content/projects" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
// The one-line "so what" — shown on cards and the case-study header.
|
||||
outcome: z.string(),
|
||||
summary: z.string(),
|
||||
role: z.string(),
|
||||
period: z.string(),
|
||||
stack: z.array(z.string()),
|
||||
featured: z.boolean().default(false),
|
||||
// Lower sorts first on the index + homepage.
|
||||
order: z.number().default(50),
|
||||
// Optional diagram key → rendered by the Diagram component (M3).
|
||||
diagram: z.string().optional(),
|
||||
links: z
|
||||
.array(z.object({ label: z.string(), href: z.string().url() }))
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Blog — write-only, schema-validated, pipeline-publishable.
|
||||
const blog = defineCollection({
|
||||
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
summary: z.string(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { projects, blog };
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Draft: notes on air-gapped registry mirroring"
|
||||
date: 2026-06-17
|
||||
summary: "Work in progress — this draft should never appear in the production build or the RSS feed."
|
||||
tags: ["draft", "registry"]
|
||||
draft: true
|
||||
---
|
||||
|
||||
This post is intentionally marked `draft: true` to verify that drafts are excluded from the
|
||||
production build and the RSS feed. If you can read this on the live site, the draft filter is
|
||||
broken.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: "Init-gating GPU readiness on Kubernetes"
|
||||
date: 2026-06-10
|
||||
summary: "The single highest-leverage reliability fix for edge GPU workloads: never let an inference pod schedule before the GPU is actually ready."
|
||||
tags: ["kubernetes", "gpu", "edge", "reliability"]
|
||||
---
|
||||
|
||||
The most common way a GPU workload fails at the edge isn't the model, the driver, or the
|
||||
network. It's timing. Kubernetes is eager — it will happily schedule your inference pod the
|
||||
moment a node is `Ready`, which is often *before* the NVIDIA device plugin has advertised
|
||||
`nvidia.com/gpu`. The pod starts, can't see a GPU, crash-loops, and now your rollout is
|
||||
poisoned across the fleet.
|
||||
|
||||
The fix is to make readiness explicit. Don't trust node-`Ready`; gate on the GPU.
|
||||
|
||||
## Gate the schedule, not just the start
|
||||
|
||||
A resource request is the first line — a pod that *requests* a GPU won't schedule until the
|
||||
plugin advertises capacity:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 1
|
||||
```
|
||||
|
||||
But on a single-GPU edge node that's recovering from a reboot, you still want a hard check
|
||||
before the workload does anything expensive. An init container that blocks until the device
|
||||
is real keeps the main container honest:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Block until the GPU is visible AND healthy, or fail loudly after a bound.
|
||||
for i in $(seq 1 30); do
|
||||
if nvidia-smi -L | grep -q '^GPU 0'; then
|
||||
echo "GPU ready"; exit 0
|
||||
fi
|
||||
echo "waiting for GPU ($i/30)"; sleep 5
|
||||
done
|
||||
echo "GPU never became ready" >&2
|
||||
exit 1
|
||||
```
|
||||
|
||||
## Why this is the win
|
||||
|
||||
Once readiness is gated, the whole class of "pod started before the GPU" failures disappears
|
||||
— and it disappears *the same way on every node*. That consistency is the real prize at the
|
||||
edge, where no one is standing next to the box to nurse a bad rollout.
|
||||
|
||||
The principle generalises: at the edge, **design the dependency, don't hope for it**. The GPU
|
||||
is just the first dependency worth gating; egress paths and model artifacts are next.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "Shipping this site: GitOps from a homelab to the public internet"
|
||||
date: 2026-06-15
|
||||
summary: "How this portfolio is built and served — Astro to a container image, a self-hosted Gitea registry, ArgoCD, and a Cloudflare Tunnel — with security as acceptance criteria, not polish."
|
||||
tags: ["gitops", "astro", "homelab", "security"]
|
||||
---
|
||||
|
||||
This site is a static Astro build, but how it gets to you is the interesting part. It's
|
||||
served from my homelab Kubernetes cluster over a Cloudflare Tunnel, deployed the same way I'd
|
||||
ship anything else: as an immutable image, pinned by digest, reconciled by GitOps.
|
||||
|
||||
## The pipeline
|
||||
|
||||
1. The site is built and baked into a hardened `nginx-unprivileged` image.
|
||||
2. The image is pushed to a **self-hosted public Gitea registry** — deliberately separate
|
||||
from the private instance that holds my infrastructure code.
|
||||
3. The image digest is pinned in a private `home-ops` repo.
|
||||
4. **ArgoCD** reconciles that repo onto the cluster.
|
||||
5. A **Cloudflare Tunnel** exposes exactly one service — this site — outbound-only.
|
||||
|
||||
No open ports. No server runtime. No registry credential on the cluster, because the public
|
||||
package is anonymous-pull and the image holds nothing secret.
|
||||
|
||||
## Security as acceptance criteria
|
||||
|
||||
The interesting constraint was treating security as a checklist to *pass*, not a vibe:
|
||||
|
||||
```text
|
||||
[x] Static output — no server runtime to attack
|
||||
[x] Strict CSP, no unsafe-inline / unsafe-eval
|
||||
[x] Self-hosted fonts — zero third-party requests
|
||||
[x] No secrets in the client bundle (verified by build-time grep)
|
||||
[x] Outbound-only tunnel, single hostname, no catch-all
|
||||
```
|
||||
|
||||
## Why bother
|
||||
|
||||
Because the site *is* the argument. A platform engineer's portfolio should demonstrate the
|
||||
discipline it's advertising — and "it's a static page" is no excuse to skip the rigour. The
|
||||
deployment story is part of the work.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: "Single-Touch Edge AI Platform"
|
||||
outcome: "Turned a high-level edge-AI design into a single-press deployment running on Kubernetes at the store edge."
|
||||
summary: "Store-edge Kubernetes running GPU-backed AI workloads, deployed from one command, with readiness-gated GPUs so inference never starts before the hardware is ready."
|
||||
role: "Infrastructure / DevOps Engineer · Woolworths"
|
||||
period: "2025 – Present"
|
||||
stack: ["Kubernetes", "Edge", "NVIDIA GPU", "CD pipelines", "Helm", "Python"]
|
||||
featured: true
|
||||
order: 10
|
||||
diagram: "edge-ai"
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Edge AI at retail scale lives or dies on repeatability. A computer-vision workload that
|
||||
runs perfectly in a lab has to come up the same way in a store with no on-site engineer,
|
||||
flaky connectivity, and a GPU that may not be ready the instant Kubernetes wants to schedule
|
||||
against it. The starting point was a high-level design and a pile of manual steps — exactly
|
||||
the gap between "it works" and "it ships."
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No hands at the edge.** Deployment has to be hands-off and idempotent — a single press.
|
||||
- **GPU timing.** Inference pods must never schedule before the GPU device plugin is healthy,
|
||||
or they crash-loop and poison the rollout.
|
||||
- **Heterogeneous stores.** Per-site variables (network, hardware, identity) without forking
|
||||
the platform for every location.
|
||||
|
||||
## Design
|
||||
|
||||
I took the high-level designs and turned them into low-level, problem-solving deployments
|
||||
driven by CD pipelines. The application is packaged as containers and shipped to a
|
||||
store-edge Kubernetes cluster via Helm with end-state manifests. Per-store configuration is
|
||||
injected from a single source of truth, so one pipeline produces a correct deployment for
|
||||
any site.
|
||||
|
||||
The load-bearing piece is **readiness gating**: Bash/Shell probes and Kubernetes watchdogs
|
||||
confirm the GPU device plugin is up *before* inference pods are allowed to run, and pod
|
||||
lifecycle management keeps the workload honest from there.
|
||||
|
||||
## Security & reliability decisions
|
||||
|
||||
- **Init-gated GPU readiness** — the single biggest reliability win; no more pods racing the
|
||||
GPU at boot.
|
||||
- **Single source of truth** for config — drift can't creep in store-to-store.
|
||||
- **Spec-driven, documented-as-code** — the deployment *is* the documentation.
|
||||
|
||||
## Outcome
|
||||
|
||||
A high-level idea becomes a real, repeatable deployment on a single press. New edge sites
|
||||
come up consistently, GPUs come online reliably, and the manual runbook is gone — replaced
|
||||
by a pipeline anyone on the team can trigger.
|
||||
|
||||
## Future improvements
|
||||
|
||||
Push more of the per-store delta into declarative policy, and extend the readiness model to
|
||||
cover the full inference dependency chain (model artifacts, egress, downstream sinks) as a
|
||||
single health gate.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: "Global Infrastructure Modernisation"
|
||||
outcome: "Modernised enterprise infrastructure at scale — ~1,000 VMs, segmented networks, multi-region cloud migration."
|
||||
summary: "Across global IT roles: a ~1,000-VM VMware estate, flat-to-segmented network redesign with SD-WAN and Aruba ClearPass, firewall upgrades, and migration to Azure and Microsoft 365."
|
||||
role: "Infrastructure Engineer · Virtus Health / Linde"
|
||||
period: "2019 – 2025"
|
||||
stack: ["VMware", "Azure", "SD-WAN", "Aruba ClearPass", "FortiGate", "Microsoft 365"]
|
||||
featured: false
|
||||
order: 40
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Enterprise estates accrete. Flat networks, sprawling VM counts, aging firewalls, and
|
||||
on-prem-only services become a security and operations drag. The work: modernise without
|
||||
breaking a global business that runs 24/7.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Keep the lights on** — change a live, multi-region estate without downtime.
|
||||
- **Security and compliance** — segmentation, patching, and auditability throughout.
|
||||
- **Cost-aware** — modernise to cloud where it pays, not for its own sake.
|
||||
|
||||
## Design
|
||||
|
||||
Across global roles I ran and improved a **~1,000-VM VMware estate** and re-segmented **flat
|
||||
sites into isolated VLAN ranges**, layering in **SD-WAN** and **Aruba ClearPass** onboarding
|
||||
for a tiered, authenticated network. **Palo Alto / FortiGate** firewalls were upgraded and
|
||||
redesigned around the new segmentation. Workloads and identity moved to **Azure** (Blob, AVS)
|
||||
and **Microsoft 365** — including an ERP hardware refresh with a new DR solution, and a
|
||||
region-wide PBX-to-VoIP migration.
|
||||
|
||||
## Security & reliability decisions
|
||||
|
||||
- **Flat → segmented** — isolation by design, not by exception.
|
||||
- **Authenticated access** (ClearPass, 802.1x) — the network knows who's on it.
|
||||
- **Patched, current firewalls** — closing the easy doors first.
|
||||
- **DR built in** — recovery designed, not assumed.
|
||||
|
||||
## Outcome
|
||||
|
||||
A more secure, segmented, cloud-leaning estate that's cheaper to run and easier to operate —
|
||||
delivered against live-business constraints across multiple regions.
|
||||
|
||||
## Future improvements
|
||||
|
||||
The throughline from this work to the edge platforms: take the same segmentation and
|
||||
identity rigour and express it as code, so a thousand-VM estate and a single edge node are
|
||||
governed the same way.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "GPU-as-Code on the Edge"
|
||||
outcome: "Brought GPUs online as code — passthrough, readiness-gated, and reproducible across the fleet."
|
||||
summary: "GPU passthrough configured through ESXi via code with end-state manifests and Helm, paired with readiness probes, watchdogs, and DCGM-based health reporting."
|
||||
role: "Infrastructure / DevOps Engineer"
|
||||
period: "2025 – Present"
|
||||
stack: ["GPU passthrough", "ESXi", "DCGM Exporter", "Prometheus", "Bash", "Watchdogs"]
|
||||
featured: false
|
||||
order: 30
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
GPUs are the most failure-prone part of an edge AI stack: passthrough has to be configured
|
||||
on the hypervisor, the device plugin has to be healthy in the cluster, and the workload has
|
||||
to refuse to start until both are true. Doing that by hand, per site, doesn't scale.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **As-code, not click-ops** — GPU passthrough defined in code, not the ESXi UI.
|
||||
- **Fail safe** — a not-ready GPU must block the workload, not crash it.
|
||||
- **Observable** — GPU health has to be visible alongside the rest of the platform.
|
||||
|
||||
## Design
|
||||
|
||||
GPU passthrough is configured through ESXi **via code**, with end-state manifests and Helm
|
||||
charts describing the desired node. In-cluster, **Bash/Shell readiness probes** and
|
||||
Kubernetes **watchdogs** gate inference pods on a healthy GPU device plugin and manage pod
|
||||
lifecycle from there. **DCGM Exporter** feeds GPU and container-workload health into
|
||||
Prometheus and AWX job-level reporting, so a degraded GPU surfaces the same way any other
|
||||
platform signal does.
|
||||
|
||||
## Security & reliability decisions
|
||||
|
||||
- **Readiness gating** — pods wait for the hardware; no boot-time races.
|
||||
- **End-state manifests** — the node's GPU config is declarative and reproducible.
|
||||
- **DCGM telemetry** — GPU failures are detected, not discovered.
|
||||
|
||||
## Outcome
|
||||
|
||||
GPUs come online predictably across the fleet, the dangerous "pod started before the GPU"
|
||||
class of failure is designed out, and GPU health is a first-class metric.
|
||||
|
||||
## Future improvements
|
||||
|
||||
Roll the readiness contract and DCGM thresholds into a single reusable module so any new
|
||||
GPU workload inherits the same guarantees by default.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "IaC Fleet Automation"
|
||||
outcome: "Stood up identical edge sites from code — every store comes up the same way, every time."
|
||||
summary: "Ansible/AWX playbooks wired through a single source-of-truth pipeline: GPU operator, templated networking, image pre-pull and secrets — with air-gapped registry mirroring for disconnected sites."
|
||||
role: "Automation Engineer"
|
||||
period: "2025 – Present"
|
||||
stack: ["Ansible", "AWX", "GitOps", "ACR / NVCR", "Image pre-pull", "Secrets mgmt"]
|
||||
featured: true
|
||||
order: 20
|
||||
diagram: "iac-fleet"
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
A fleet only behaves like a fleet if every node is built the same way. Hand-configuring GPU
|
||||
drivers, CNI, image caches and secrets per site is slow, error-prone, and impossible to
|
||||
audit — and at the edge, half the sites can't reach the internet when you need them to.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Repeatability over cleverness** — the same playbook must produce the same node anywhere.
|
||||
- **Air-gapped reality** — disconnected edge sites still have to build from local images.
|
||||
- **No secrets in code** — credentials delivered at deploy time, never committed.
|
||||
|
||||
## Design
|
||||
|
||||
Ansible playbooks, orchestrated by AWX and wired through a single source-of-truth pipeline,
|
||||
own the whole node build: GPU operator install, templated network attachments, container
|
||||
**image pre-pull**, and secrets injected from a managed store. Company- and site-specific
|
||||
variables are layered on top of a shared base so one playbook set serves the whole fleet.
|
||||
|
||||
For disconnected sites, **air-gapped registry workflows** mirror images across Azure
|
||||
Container Registry and NVCR and pre-stage them locally, so a build never depends on a live
|
||||
internet path at the moment it matters.
|
||||
|
||||
## Security & reliability decisions
|
||||
|
||||
- **Secrets management at deploy time** — nothing sensitive in git.
|
||||
- **Pre-staged, mirrored images** — supply chain stays available and pinned, even offline.
|
||||
- **AWX job-level reporting** — every run is visible and auditable.
|
||||
|
||||
## Outcome
|
||||
|
||||
New edge sites are provisioned from code with consistent results, manual build steps are
|
||||
removed wherever logic allows, and the whole fleet is reproducible — an IaC-first build
|
||||
instead of a runbook.
|
||||
|
||||
## Future improvements
|
||||
|
||||
Tighten the loop from commit to provisioned site, and fold image-mirror freshness into the
|
||||
same pipeline so air-gapped caches are never silently stale.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "Self-Hosted AI & Homelab Platform"
|
||||
outcome: "A production-grade homelab — GitOps from bare metal to local AI, and the platform that serves this very site."
|
||||
summary: "Proxmox with PCIe passthrough under Talos and OpenShift clusters, all driven by ArgoCD GitOps: local LLM inference, split-horizon DNS, 2FA/SSO VPN, full observability and NAS-backed backups."
|
||||
role: "Owner / Operator"
|
||||
period: "Ongoing"
|
||||
stack: ["Talos", "OpenShift", "ArgoCD", "Proxmox", "Local LLM", "Cloudflare Tunnel"]
|
||||
featured: true
|
||||
order: 15
|
||||
diagram: "homelab"
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The best way to stay sharp on platform engineering is to run a real platform — one with the
|
||||
same rigour as production, where the only person on call is you. The goal: a homelab that's a
|
||||
genuine proving ground for Kubernetes, GPUs, AI and security, not a pile of containers.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Run it like production** — GitOps, backups, observability, no snowflake config.
|
||||
- **Secure by default** — nothing exposed that doesn't need to be.
|
||||
- **Reproducible** — rebuild a node from code, not from memory.
|
||||
|
||||
## Design
|
||||
|
||||
Proxmox provides the hypervisor layer with **PCIe passthrough** (GPU and storage) into
|
||||
single-node **Talos** and **OpenShift** clusters. Everything is **ArgoCD GitOps** — the
|
||||
cluster state lives in git and reconciles itself. On top: **local LLM inference** on a
|
||||
Blackwell-class GPU, **split-horizon DNS** via Pi-hole, a VPN with **2FA/SSO**, and a
|
||||
**Prometheus / Grafana** observability stack. ZFS handles storage tiering; restic ships
|
||||
**NAS-backed backups**. Public services reach the internet through a **Cloudflare Tunnel** —
|
||||
which is exactly how this site is served.
|
||||
|
||||
## Security & reliability decisions
|
||||
|
||||
- **GitOps as the source of truth** — drift is reconciled, not chased.
|
||||
- **2FA / SSO and segmented access** — least privilege across the lab.
|
||||
- **Back up state, not just volumes** — restores are drilled, not hoped for.
|
||||
- **Outbound-only public exposure** — a tunnel, not an open port.
|
||||
|
||||
## Outcome
|
||||
|
||||
A homelab that behaves like a platform: rebuildable from code, observable, backed up, and
|
||||
secure enough to host a public site on. It's where new patterns get proven before they go
|
||||
anywhere near real infrastructure — and it's running right now, under this page.
|
||||
|
||||
## Future improvements
|
||||
|
||||
Continue migrating workloads to a unified GitOps story across clusters, and harden the
|
||||
edge-to-cluster path as more public services come online.
|
||||
@@ -1,82 +0,0 @@
|
||||
// Featured project cards for the homepage + the basic /projects index (M1).
|
||||
// In M2 these become full case-study Content Collections at src/content/projects/.
|
||||
// Keep `slug` stable so the M2 migration keeps the same URLs.
|
||||
|
||||
export type Project = {
|
||||
slug: string;
|
||||
title: string;
|
||||
// One-line "so what" outcome — the most important field.
|
||||
outcome: string;
|
||||
summary: string;
|
||||
role: string;
|
||||
period: string;
|
||||
stack: string[];
|
||||
featured: boolean;
|
||||
// Set true in M2 when /projects/[slug] case studies exist.
|
||||
hasCaseStudy?: boolean;
|
||||
};
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
slug: "edge-ai-platform",
|
||||
title: "Single-Touch Edge AI Platform",
|
||||
outcome:
|
||||
"Turned a high-level edge-AI design into a single-press deployment running on Kubernetes at the store edge.",
|
||||
summary:
|
||||
"Store-edge Kubernetes running GPU-backed AI workloads, deployed from one command. High-level designs become real, problem-solving deployments via CD pipelines — with GPU readiness probing and pod-lifecycle watchdogs so inference never starts before the hardware is ready.",
|
||||
role: "Infrastructure / DevOps Engineer · Woolworths",
|
||||
period: "2025 – Present",
|
||||
stack: ["Kubernetes", "Edge", "NVIDIA GPU", "CD pipelines", "Helm", "Python"],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: "self-hosted-ai-homelab",
|
||||
title: "Self-Hosted AI & Homelab Platform",
|
||||
outcome:
|
||||
"A production-grade homelab — GitOps from bare metal to local AI, and the platform that serves this very site.",
|
||||
summary:
|
||||
"Proxmox with PCIe passthrough under single-node Talos and OpenShift clusters, all driven by ArgoCD GitOps. Local hosted AI on a Blackwell GPU, split-horizon DNS (Pi-hole), VPN with 2FA/SSO, Prometheus/Grafana observability, ZFS storage, and NAS-backed restic backups. Optimising, securing and learning in the open.",
|
||||
role: "Owner / Operator",
|
||||
period: "Ongoing",
|
||||
stack: ["Talos", "OpenShift", "ArgoCD", "Proxmox", "Local LLM", "Cloudflare Tunnel"],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: "iac-fleet-automation",
|
||||
title: "IaC Fleet Automation",
|
||||
outcome:
|
||||
"Stood up identical edge sites from code — every store comes up the same way, every time.",
|
||||
summary:
|
||||
"Ansible/AWX playbooks wired through a single source-of-truth pipeline: GPU operator install, templated network attachments, container image pre-pull, and secrets delivered at deploy time. Air-gapped registry workflows mirror images across ACR and NVCR so disconnected edge sites still build.",
|
||||
role: "Automation Engineer",
|
||||
period: "2025 – Present",
|
||||
stack: ["Ansible", "AWX", "GitOps", "ACR / NVCR", "Image pre-pull", "Secrets mgmt"],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: "gpu-as-code",
|
||||
title: "GPU-as-Code on the Edge",
|
||||
outcome:
|
||||
"Brought GPUs online as code — passthrough, readiness-gated, and reproducible across the fleet.",
|
||||
summary:
|
||||
"GPU passthrough configured through ESXi via code with end-state manifests and Helm charts, paired with Bash/Shell readiness probes and Kubernetes watchdogs. DCGM-exporter feeds GPU and container-workload health into Prometheus and AWX job-level reporting.",
|
||||
role: "Infrastructure / DevOps Engineer",
|
||||
period: "2025 – Present",
|
||||
stack: ["GPU passthrough", "ESXi", "DCGM Exporter", "Prometheus", "Bash", "Watchdogs"],
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
slug: "global-infra-modernisation",
|
||||
title: "Global Infrastructure Modernisation",
|
||||
outcome:
|
||||
"Modernised enterprise infrastructure at scale — ~1,000 VMs, segmented networks, multi-region cloud migration.",
|
||||
summary:
|
||||
"Across global IT roles: managed a ~1,000-VM VMware estate, re-segmented flat sites into isolated VLAN ranges with SD-WAN and Aruba ClearPass, upgraded Palo Alto / FortiGate firewalls, and migrated workloads to Azure and Microsoft 365 — including ERP hardware refresh, DR, and PBX-to-VoIP.",
|
||||
role: "Infrastructure Engineer · Virtus Health / Linde",
|
||||
period: "2019 – 2025",
|
||||
stack: ["VMware", "Azure", "SD-WAN", "Aruba ClearPass", "FortiGate", "Microsoft 365"],
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const featuredProjects = projects.filter((p) => p.featured);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
|
||||
// Drafts are excluded from production builds; visible in dev for preview.
|
||||
export async function getPosts(): Promise<CollectionEntry<"blog">[]> {
|
||||
const posts = await getCollection("blog", ({ data }) =>
|
||||
import.meta.env.PROD ? !data.draft : true,
|
||||
);
|
||||
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
}
|
||||
|
||||
export function allTags(posts: CollectionEntry<"blog">[]): string[] {
|
||||
return [...new Set(posts.flatMap((p) => p.data.tags))].sort();
|
||||
}
|
||||
|
||||
export function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString("en-AU", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Reading-time estimate from raw markdown body. ~200 words/min, min 1 min.
|
||||
export function readingTime(body: string | undefined): string {
|
||||
const words = (body ?? "").trim().split(/\s+/).filter(Boolean).length;
|
||||
const mins = Math.max(1, Math.round(words / 200));
|
||||
return `${mins} min read`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { getPosts, formatDate } from "../../lib/blog";
|
||||
import { readingTime } from "../../lib/reading";
|
||||
import { render } from "astro:content";
|
||||
import type { GetStaticPaths } from "astro";
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const posts = await getPosts();
|
||||
return posts.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
---
|
||||
|
||||
<Layout title={entry.data.title} path={`/blog/${entry.id}`} description={entry.data.summary}>
|
||||
<article class="post">
|
||||
<div class="container post__inner">
|
||||
<a class="post__back mono" href="/blog">← All posts</a>
|
||||
|
||||
<header class="post__head" data-reveal>
|
||||
<div class="post__meta mono">
|
||||
<time datetime={entry.data.date.toISOString()}>{formatDate(entry.data.date)}</time>
|
||||
<span class="post__sep">·</span>
|
||||
<span>{readingTime(entry.body)}</span>
|
||||
</div>
|
||||
<h1 class="post__title">{entry.data.title}</h1>
|
||||
<ul class="post__tags">
|
||||
{entry.data.tags.map((t) => (
|
||||
<li><a class="tag" href={`/blog/tags/${t}`}>{t}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<div class="prose post__body" data-reveal>
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.post__inner {
|
||||
max-width: 48rem;
|
||||
padding-block: var(--space-7);
|
||||
}
|
||||
.post__back {
|
||||
display: inline-block;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.post__back:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.post__meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.post__sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.post__title {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step-4);
|
||||
max-width: 26ch;
|
||||
}
|
||||
.post__tags {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-4) 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.post__tags a:hover {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.post__body {
|
||||
margin-top: var(--space-7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import Section from "../../components/Section.astro";
|
||||
import PostList from "../../components/PostList.astro";
|
||||
import { getPosts, allTags } from "../../lib/blog";
|
||||
|
||||
const posts = await getPosts();
|
||||
const tags = allTags(posts);
|
||||
---
|
||||
|
||||
<Layout title="Blog" path="/blog" description="Notes on platform engineering, edge Kubernetes, GPUs and homelab infrastructure.">
|
||||
<Section id="blog" eyebrow="Writing" index="*" title="Notes from the platform.">
|
||||
<p class="lead blog__intro" data-reveal>
|
||||
Lessons from edge Kubernetes, GPUs, and running infrastructure like it matters.
|
||||
</p>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<nav class="blog__tags" aria-label="Filter by tag" data-reveal>
|
||||
<span class="mono blog__tags-label">tags:</span>
|
||||
{tags.map((t) => (
|
||||
<a class="tag blog__tag" href={`/blog/tags/${t}`}>{t}</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<PostList posts={posts} />
|
||||
</Section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.blog__intro {
|
||||
max-width: 50ch;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.blog__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.blog__tags-label {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-faint);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.blog__tag:hover {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import Section from "../../../components/Section.astro";
|
||||
import PostList from "../../../components/PostList.astro";
|
||||
import { getPosts, allTags } from "../../../lib/blog";
|
||||
import type { GetStaticPaths } from "astro";
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const posts = await getPosts();
|
||||
const tags = allTags(posts);
|
||||
return tags.map((tag) => ({
|
||||
params: { tag },
|
||||
props: { tag, posts: posts.filter((p) => p.data.tags.includes(tag)) },
|
||||
}));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { tag, posts } = Astro.props;
|
||||
---
|
||||
|
||||
<Layout title={`#${tag}`} path={`/blog/tags/${tag}`} description={`Posts tagged ${tag}.`}>
|
||||
<Section id="tag" eyebrow="Tag" index="#" title={`Posts tagged “${tag}”`}>
|
||||
<p class="tag-page__back" data-reveal>
|
||||
<a class="btn" href="/blog">← All posts</a>
|
||||
</p>
|
||||
<PostList posts={posts} />
|
||||
</Section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.tag-page__back {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,11 @@ import SkillGroup from "../components/SkillGroup.astro";
|
||||
import ProjectCard from "../components/ProjectCard.astro";
|
||||
import Contact from "../components/Contact.astro";
|
||||
import { skills } from "../data/skills";
|
||||
import { featuredProjects } from "../data/projects";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const featuredProjects = (await getCollection("projects", (p) => p.data.featured)).sort(
|
||||
(a, b) => a.data.order - b.data.order,
|
||||
);
|
||||
---
|
||||
|
||||
<Layout path="/">
|
||||
@@ -25,7 +29,7 @@ import { featuredProjects } from "../data/projects";
|
||||
|
||||
<Section id="projects" eyebrow="Selected work" index="03" title="Platforms built to keep working unattended.">
|
||||
<div class="grid grid--projects">
|
||||
{featuredProjects.map((project) => <ProjectCard project={project} />)}
|
||||
{featuredProjects.map((entry) => <ProjectCard entry={entry} />)}
|
||||
</div>
|
||||
<p class="projects__more" data-reveal>
|
||||
<a class="btn" href="/projects">All projects →</a>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { getCollection, render } from "astro:content";
|
||||
import type { GetStaticPaths } from "astro";
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const projects = await getCollection("projects");
|
||||
return projects.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const p = entry.data;
|
||||
const { Content } = await render(entry);
|
||||
---
|
||||
|
||||
<Layout title={p.title} path={`/projects/${entry.id}`} description={p.summary}>
|
||||
<article class="case">
|
||||
<div class="container case__inner">
|
||||
<a class="case__back mono" href="/projects">← All projects</a>
|
||||
|
||||
<header class="case__head" data-reveal>
|
||||
<p class="eyebrow">Case study</p>
|
||||
<h1 class="case__title">{p.title}</h1>
|
||||
<p class="case__outcome">{p.outcome}</p>
|
||||
|
||||
<p class="case__meta mono">
|
||||
<span>{p.role}</span><span class="case__sep">·</span><span>{p.period}</span>
|
||||
</p>
|
||||
|
||||
<ul class="case__stack">
|
||||
{p.stack.map((s) => <li class="tag">{s}</li>)}
|
||||
</ul>
|
||||
|
||||
{p.links && (
|
||||
<p class="case__links">
|
||||
{p.links.map((l) => (
|
||||
<a class="btn" href={l.href} rel="noopener noreferrer">{l.label} →</a>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="prose case__body" data-reveal>
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.case__inner {
|
||||
max-width: 52rem;
|
||||
padding-block: var(--space-7);
|
||||
}
|
||||
.case__back {
|
||||
display: inline-block;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.case__back:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.case__title {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step-4);
|
||||
}
|
||||
.case__outcome {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--step-1);
|
||||
color: var(--text);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: var(--space-4);
|
||||
max-width: 60ch;
|
||||
}
|
||||
.case__meta {
|
||||
margin-top: var(--space-5);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.case__sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.case__stack {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-4) 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.case__links {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.case__body {
|
||||
margin-top: var(--space-7);
|
||||
}
|
||||
</style>
|
||||
@@ -2,24 +2,28 @@
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import Section from "../../components/Section.astro";
|
||||
import ProjectCard from "../../components/ProjectCard.astro";
|
||||
import { projects } from "../../data/projects";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const projects = (await getCollection("projects")).sort(
|
||||
(a, b) => a.data.order - b.data.order,
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title="Projects" path="/projects" description="Selected platform & infrastructure projects by Jonathon Wright.">
|
||||
<Section id="all-projects" eyebrow="Projects" index="*" title="Everything I've built worth writing about.">
|
||||
<p class="lead projects__intro" data-reveal>
|
||||
Edge Kubernetes, GPU inference, self-hosted AI, and the automation that ties
|
||||
it together. Full case studies are on the way.
|
||||
Edge Kubernetes, GPU inference, self-hosted AI, and the automation that ties it
|
||||
together — each with the problem, the design, and the outcome.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{projects.map((project) => <ProjectCard project={project} />)}
|
||||
{projects.map((entry) => <ProjectCard entry={entry} />)}
|
||||
</div>
|
||||
</Section>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.projects__intro {
|
||||
max-width: 50ch;
|
||||
max-width: 52ch;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.grid {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import type { APIContext } from "astro";
|
||||
import { getPosts } from "../lib/blog";
|
||||
import { site } from "../data/site";
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = await getPosts(); // drafts excluded in prod
|
||||
return rss({
|
||||
title: `${site.name} — Blog`,
|
||||
description:
|
||||
"Notes on platform engineering, edge Kubernetes, GPUs and homelab infrastructure.",
|
||||
site: context.site ?? site.url,
|
||||
items: posts.map((post) => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.date,
|
||||
description: post.data.summary,
|
||||
categories: post.data.tags,
|
||||
link: `/blog/${post.id}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -167,6 +167,89 @@ svg {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* ---- Prose (rendered markdown) ------------------------------------------ */
|
||||
.prose {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--step-0);
|
||||
line-height: 1.75;
|
||||
max-width: 70ch;
|
||||
}
|
||||
.prose > * + * {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.prose h2 {
|
||||
color: var(--text);
|
||||
font-size: var(--step-2);
|
||||
margin-top: var(--space-7);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.prose h2::before {
|
||||
content: "# ";
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
}
|
||||
.prose h3 {
|
||||
color: var(--text);
|
||||
font-size: var(--step-1);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.prose strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
padding-left: 1.3rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.prose li::marker {
|
||||
color: var(--accent);
|
||||
}
|
||||
.prose blockquote {
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: var(--space-4);
|
||||
color: var(--text);
|
||||
font-style: italic;
|
||||
}
|
||||
.prose code:not(pre code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
.prose pre {
|
||||
background: var(--surface) !important;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-4);
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose pre code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Shiki dual-theme: dark is the default (dark-first / JS-off safe);
|
||||
light overrides when the theme is light. We keep our own <pre> background. */
|
||||
.astro-code,
|
||||
.astro-code span {
|
||||
color: var(--shiki-dark);
|
||||
}
|
||||
[data-theme="light"] .astro-code,
|
||||
[data-theme="light"] .astro-code span {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
/* ---- Reveal-on-scroll (progressive enhancement) -------------------------- */
|
||||
/* Default = visible (JS-off safe). The .reveal class is only added by JS when
|
||||
IntersectionObserver is supported AND motion is allowed. */
|
||||
|
||||
Reference in New Issue
Block a user