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:
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user