Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
951767e
chore(website): redesign blog post page
NicholasKissel Jun 6, 2026
0c89a85
chore(website): redesign changelog post page to match blog
NicholasKissel Jun 6, 2026
3f05e78
chore(website): center article + TOC layout on blog and changelog
NicholasKissel Jun 6, 2026
0950925
chore(website): shrink table of contents text on blog and changelog
NicholasKissel Jun 6, 2026
3c210a8
chore(website): remove table of contents from blog and changelog posts
NicholasKissel Jun 6, 2026
48217fc
chore(website): merge changelog calendar into blog index, drop toggle
NicholasKissel Jun 6, 2026
e93fb9e
chore(website): drop redundant changelog category eyebrow on changelo…
NicholasKissel Jun 6, 2026
34b0f9e
chore(website): make blog index a single-column list of horizontal cards
NicholasKissel Jun 6, 2026
42f125d
chore(website): use large stacked blog cards in single-column list
NicholasKissel Jun 6, 2026
bf497e3
chore(website): show date as subtitle and drop author block on change…
NicholasKissel Jun 6, 2026
4493e25
chore(website): align blog post heading weights with site typography
NicholasKissel Jun 6, 2026
4e4aaa6
chore(website): hide author and category tag on blog index cards
NicholasKissel Jun 6, 2026
61d310d
chore(website): stick changelog sidebar and add footer spacing on blo…
NicholasKissel Jun 6, 2026
203476e
chore(website): lower changelog sticky offset and add bottom fade
NicholasKissel Jun 6, 2026
784092d
chore(website): use date subtitle and drop category/author on blog posts
NicholasKissel Jun 6, 2026
3cd8e8c
chore(website): remove standalone changelog index, redirect /changelo…
NicholasKissel Jun 6, 2026
7bd4e1e
chore(website): extract shared BlogArticle component to dedupe post p…
NicholasKissel Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export default defineConfig({
'/solutions/per-tenant-db': '/',
'/solutions/user-session-store': '/',
'/solutions/workflows': '/',
// Changelog list view merged into the blog index
'/changelog': '/blog',
},
prefetch: {
prefetchAll: true,
Expand Down
250 changes: 250 additions & 0 deletions website/src/components/BlogArticle.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
---
import { getCollection, render } from 'astro:content';
import { getPostImage } from '@/lib/postImage';
import { ArticleSocials } from '@/components/ArticleSocials';
import { Prose } from '@/components/Prose';
import { formatTimestamp } from '@/lib/formatDate';
import { CATEGORIES } from '@/lib/article';
import { Icon, faArrowLeft, faArrowRight } from '@rivet-gg/icons';
import * as mdxComponents from '@/components/mdx';

interface Props {
// biome-ignore lint/suspicious/noExplicitAny: content collection entry
entry: any;
image?: { src: string; width: number; height: number } | null;
section: 'blog' | 'changelog';
}

const { entry, image, section } = Astro.props;
const { Content } = await render(entry);

const { title, description } = entry.data as unknown as { title: string; description: string };

// "Read next" pulls from the same section the reader is currently in.
const readNextBase = section === 'changelog' ? '/changelog/' : '/blog/';
const allPosts = await getCollection('posts');
const otherArticles = allPosts
.filter(
(p) =>
p.id !== entry.id &&
!p.data.unpublished &&
(section === 'changelog' ? p.data.category === 'changelog' : p.data.category !== 'changelog'),
)
.sort((a, b) => b.data.published.getTime() - a.data.published.getTime())
.slice(0, 3)
.map((post) => {
const data = post.data as unknown as { title: string };
return {
slug: post.id.replace(/\/page$/, ''),
title: data.title,
category: { ...CATEGORIES[post.data.category], id: post.data.category },
published: post.data.published,
image: getPostImage(post),
};
});
---

<div class="blog-article relative w-full" style="--header-height: 5rem;">
<div class="mx-auto w-full max-w-[62rem] px-6 pb-24 pt-32 md:pt-40">
<article class="mx-auto w-full max-w-[44rem]">
<!-- Back link -->
<a
href="/blog/"
class="group flex items-center gap-2 text-sm text-zinc-500 transition-colors hover:text-white"
>
<Icon icon={faArrowLeft} className="h-3 w-auto transition-transform group-hover:-translate-x-0.5" />
Blog
</a>

<!-- Header -->
<header class="mb-12 mt-8">
<time datetime={entry.data.published.toISOString()} class="text-sm font-medium text-zinc-500">
{formatTimestamp(entry.data.published)}
</time>
<h1 class="mt-2 text-4xl font-normal leading-[1.1] tracking-tight text-white [text-wrap:balance] md:text-[3.25rem]">
{title}
</h1>
{description && (
<p class="mt-5 text-lg font-light leading-7 text-zinc-400 [text-wrap:pretty] md:text-xl">
{description}
</p>
)}
</header>

{image && (
<img
src={image.src}
alt={title}
width={image.width}
height={image.height}
class="mb-12 aspect-[2/1] w-full rounded-2xl border border-white/10 object-cover"
loading="eager"
decoding="async"
/>
)}

<Prose as="div" className="blog-prose w-full max-w-none">
<Content components={mdxComponents} />
</Prose>

<div class="mt-16 border-t border-white/10 pt-8">
<ArticleSocials title={title} client:load />
</div>
</article>

<!-- Read next -->
{otherArticles.length > 0 && (
<div class="mx-auto mt-24 w-full max-w-[62rem] border-t border-white/10 pt-10">
<h2 class="text-sm font-medium uppercase tracking-[0.12em] text-zinc-500">Read next</h2>
<div class="mt-6 grid grid-cols-1 gap-6 md:grid-cols-3">
{otherArticles.map((article) => (
<a href={`${readNextBase}${article.slug}/`} class="group flex flex-col">
{article.image && (
<img
src={article.image.src}
alt={article.title}
width={600}
height={300}
class="aspect-[2/1] w-full rounded-xl border border-white/10 object-cover transition-colors group-hover:border-white/25"
loading="lazy"
decoding="async"
/>
)}
<div class="mt-3 flex items-center gap-x-3 text-xs">
<span class="text-accent font-semibold uppercase tracking-[0.1em]">{article.category.name}</span>
<time datetime={article.published.toISOString()} class="text-zinc-500">
{formatTimestamp(article.published)}
</time>
</div>
<h3 class="mt-2 text-base font-normal leading-snug text-white transition-colors group-hover:text-accent">
{article.title}
</h3>
<span class="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 transition-colors group-hover:text-white">
Read article
<Icon icon={faArrowRight} className="h-2.5 w-auto transition-transform group-hover:translate-x-0.5" />
</span>
</a>
))}
</div>
</div>
)}
</div>
</div>

<style is:global>
/* Editorial typography scoped to blog + changelog articles only, so docs Prose stays untouched. */
.blog-article .blog-prose {
font-size: 1.0625rem;
line-height: 1.8;
color: hsl(var(--muted-foreground));
}

.blog-article .blog-prose p,
.blog-article .blog-prose ul,
.blog-article .blog-prose ol {
margin-top: 1.4em;
margin-bottom: 1.4em;
}

.blog-article .blog-prose > :first-child {
margin-top: 0;
}

.blog-article .blog-prose h2 {
font-size: 1.75rem;
line-height: 1.2;
letter-spacing: -0.02em;
margin-top: 2.75rem;
margin-bottom: 1rem;
color: #fff;
}

.blog-article .blog-prose h3 {
font-size: 1.3rem;
line-height: 1.3;
letter-spacing: -0.015em;
margin-top: 2.25rem;
margin-bottom: 0.75rem;
color: #fff;
}

.blog-article .blog-prose h4 {
font-size: 1.075rem;
margin-top: 2rem;
margin-bottom: 0.5rem;
color: #fff;
}

.blog-article .blog-prose a {
color: #ff6a33;
text-decoration: underline;
text-decoration-color: rgb(255 69 0 / 0.35);
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
font-weight: 500;
transition: text-decoration-color 0.15s ease, color 0.15s ease;
}

.blog-article .blog-prose a:hover {
color: #ff8a5c;
text-decoration-color: rgb(255 69 0 / 0.9);
}

.blog-article .blog-prose strong {
color: #fff;
font-weight: 600;
}

.blog-article .blog-prose :not(pre) > code {
background: rgb(255 255 255 / 0.06);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
padding: 0.1em 0.4em;
font-size: 0.875em;
color: #e6e6e6;
}

.blog-article .blog-prose pre {
border-radius: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.1);
background: rgb(255 255 255 / 0.025);
padding: 1.25rem 1.4rem;
font-size: 0.9rem;
line-height: 1.7;
margin-top: 1.75rem;
margin-bottom: 1.75rem;
}

.blog-article .blog-prose blockquote {
border-left: 2px solid #ff4500;
padding-left: 1.25rem;
font-style: normal;
color: hsl(var(--muted-foreground));
}

.blog-article .blog-prose img,
.blog-article .blog-prose video {
border-radius: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.1);
margin-top: 2rem;
margin-bottom: 2rem;
}

.blog-article .blog-prose hr {
border-color: rgb(255 255 255 / 0.1);
margin-top: 2.75rem;
margin-bottom: 2.75rem;
}

.blog-article .blog-prose li {
margin-top: 0.4em;
margin-bottom: 0.4em;
}

.blog-article .blog-prose h2 a,
.blog-article .blog-prose h3 a,
.blog-article .blog-prose h4 a {
color: inherit;
text-decoration: none;
}
</style>
Loading
Loading