Add blog series support and first post
CI / ci (push) Successful in 26s
CI / ci (pull_request) Successful in 29s
Docker / docker (pull_request) Successful in 17s

- Add optional series field to blog collection schema (name, part)
- Update blog route to collect and sort series posts, boost related
  scoring when series matches, and render a series section with styles
  and responsive tweaks
- Add "Coffee & Code #1" blog post markdown for the new series
This commit is contained in:
2026-03-07 13:38:46 +01:00
parent 814f292427
commit f963fcb6bb
3 changed files with 307 additions and 4 deletions
+198 -4
View File
@@ -11,13 +11,38 @@ export async function getStaticPaths() {
params: { slug: post.slug },
props: {
post,
seriesPosts: post.data.series
? posts
.filter(
(candidate) =>
candidate.data.series?.name ===
post.data.series?.name,
)
.sort((a, b) => {
const partDifference =
(a.data.series?.part ?? Number.MAX_SAFE_INTEGER) -
(b.data.series?.part ?? Number.MAX_SAFE_INTEGER);
if (partDifference !== 0) return partDifference;
return (
a.data.pubDate.valueOf() -
b.data.pubDate.valueOf()
);
})
: [],
relatedPosts: posts
.filter((candidate) => candidate.slug !== post.slug)
.map((candidate) => ({
post: candidate,
score: candidate.data.tags.filter((tag) =>
post.data.tags.includes(tag),
).length,
score:
(candidate.data.series?.name &&
candidate.data.series.name === post.data.series?.name
? 10
: 0) +
candidate.data.tags.filter((tag) =>
post.data.tags.includes(tag),
).length,
}))
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
@@ -33,8 +58,13 @@ export async function getStaticPaths() {
}));
}
const { post, relatedPosts = [] } = Astro.props as {
const {
post,
seriesPosts = [],
relatedPosts = [],
} = Astro.props as {
post: BlogPost;
seriesPosts: BlogPost[];
relatedPosts: BlogPost[];
};
@@ -54,6 +84,13 @@ function formatDate(date: Date) {
<main class="main">
<div class="container">
<header class="header fade-in">
{
post.data.series && (
<p class="series-label">
{post.data.series.name} #{post.data.series.part}
</p>
)
}
<h1 class="title">{post.data.title}</h1>
<p class="date">{formatDate(post.data.pubDate)}</p>
{
@@ -75,6 +112,60 @@ function formatDate(date: Date) {
<Content />
</article>
{
post.data.series && (
<section
class="series fade-in"
aria-labelledby="series-posts"
>
<div class="series-head">
<h2 id="series-posts">{post.data.series.name}</h2>
<a href="/blog" class="series-link">
Browse the blog
</a>
</div>
<p class="series-note">
This post is part of an ongoing series about
building warm, intentional places on the web.
</p>
<ol class="series-list">
{seriesPosts.map((seriesPost) => (
<li
class:list={[
"series-item",
{
current:
seriesPost.slug === post.slug,
},
]}
>
{seriesPost.slug === post.slug ? (
<span class="series-current">
#{seriesPost.data.series?.part}{" "}
{seriesPost.data.title}
</span>
) : (
<a
href={`/blog/${seriesPost.slug}`}
class="series-entry"
>
<span class="series-part">
#{seriesPost.data.series?.part}
</span>
<span>{seriesPost.data.title}</span>
</a>
)}
</li>
))}
</ol>
<p class="series-footnote">
Future Coffee & Code entries will show up here as
they are written.
</p>
</section>
)
}
{
relatedPosts.length > 0 && (
<section
@@ -164,6 +255,14 @@ function formatDate(date: Date) {
margin-bottom: var(--space-lg);
}
.series-label {
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.78rem;
margin-bottom: var(--space-xs);
}
.divider {
color: var(--color-surface);
text-align: center;
@@ -299,6 +398,96 @@ function formatDate(date: Date) {
margin-top: var(--space-xs);
}
.series {
margin-top: var(--space-xl);
padding: var(--space-lg);
border-radius: 8px;
border: 1px solid
color-mix(in srgb, var(--color-accent) 28%, transparent);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--color-accent) 9%, transparent),
transparent 70%
),
color-mix(in srgb, var(--color-bg-light) 84%, transparent);
}
.series-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.series-head h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
}
.series-link {
color: var(--color-blue);
font-size: 0.85rem;
white-space: nowrap;
}
.series-link:hover {
color: var(--color-accent-bright);
}
.series-note,
.series-footnote {
color: var(--color-text-dim);
line-height: 1.7;
}
.series-note {
margin-bottom: var(--space-md);
}
.series-footnote {
margin-top: var(--space-md);
font-size: 0.9rem;
}
.series-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-left: 1.25rem;
}
.series-item {
color: var(--color-text-dim);
}
.series-entry,
.series-current {
display: inline-flex;
flex-wrap: wrap;
gap: var(--space-xs);
line-height: 1.6;
}
.series-entry {
color: var(--color-blue);
}
.series-entry:hover {
color: var(--color-accent-bright);
}
.series-current {
color: var(--color-text);
}
.series-part {
color: var(--color-accent);
}
.related {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
@@ -431,6 +620,11 @@ function formatDate(date: Date) {
flex-direction: column;
align-items: flex-start;
}
.series-head {
flex-direction: column;
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {