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
+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}/`,
})),
});
}