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:
2026-06-17 16:56:46 +10:00
parent 720d579386
commit 22f482d89a
26 changed files with 1139 additions and 105 deletions
+15
View File
@@ -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()],
},
+199 -2
View File
@@ -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",
+2
View File
@@ -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"
+2 -1
View File
@@ -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" },
];
---
+85
View File
@@ -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>
+14 -13
View File
@@ -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;
+39
View File
@@ -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 };
+11
View File
@@ -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.
+40
View File
@@ -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.
+58
View File
@@ -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.
+47
View File
@@ -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.
-82
View File
@@ -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);
+21
View File
@@ -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",
});
}
+6
View File
@@ -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`;
}
+88
View File
@@ -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>
+52
View File
@@ -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>
+33
View File
@@ -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>
+6 -2
View File
@@ -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>
+105
View File
@@ -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>
+9 -5
View File
@@ -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 {
+21
View File
@@ -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}/`,
})),
});
}
+83
View File
@@ -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. */