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
+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;