diff --git a/site/src/app/page.tsx b/site/src/app/page.tsx
index dfb1b8e..aa0399f 100644
--- a/site/src/app/page.tsx
+++ b/site/src/app/page.tsx
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import { groupByDay } from '@/lib/stories';
import { loadStories } from '@/lib/stories-loader';
+import { groupReleasesByDay } from '@/lib/releases';
+import { loadReleases } from '@/lib/releases-loader';
import { loadRepo, publicationName, publicationSubtitle } from '@/lib/repo';
import { buildHomeMetadata } from '@/lib/seo';
import { FeedHeader } from '@/components/FeedHeader';
@@ -14,6 +16,7 @@ export function generateMetadata(): Metadata {
export default function HomePage() {
const repo = loadRepo();
const days = groupByDay(loadStories());
+ const releasesByDay = groupReleasesByDay(loadReleases());
return (
@@ -22,7 +25,7 @@ export default function HomePage() {
feedSubtitle={publicationSubtitle(repo)}
/>
-
+
);
}
diff --git a/site/src/app/releases/[tag]/[slug]/opengraph-image.tsx b/site/src/app/releases/[tag]/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..36f0c81
--- /dev/null
+++ b/site/src/app/releases/[tag]/[slug]/opengraph-image.tsx
@@ -0,0 +1,146 @@
+import { ImageResponse } from 'next/og';
+import { loadReleases, loadRelease } from '@/lib/releases-loader';
+import { releaseSlug } from '@/lib/urls';
+
+export const dynamic = 'force-static';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+export const alt = 'gitpulse release edition';
+
+// Sentinel matching the page's stub when no releases exist yet.
+const EMPTY_STUB_TAG = '__no_releases_yet__';
+
+// generateStaticParams returns raw segment values — Next.js encodes them
+// when emitting the static filesystem path and decodes them back into the
+// `params` object. Pre-encoding here would double-encode tags that contain
+// special characters (e.g. 'release/v1.0.0').
+export function generateStaticParams() {
+ const releases = loadReleases();
+ if (releases.length === 0) {
+ return [{ tag: EMPTY_STUB_TAG, slug: 'placeholder' }];
+ }
+ return releases.map((r) => ({
+ tag: r.tag,
+ slug: releaseSlug(r),
+ }));
+}
+
+export default async function OG({
+ params,
+}: {
+ params: Promise<{ tag: string; slug: string }>;
+}) {
+ const { tag } = await params;
+ const release = tag === EMPTY_STUB_TAG ? null : loadRelease(tag);
+ if (!release) {
+ return new ImageResponse(
+ (
+
+ Not found
+
+ ),
+ size,
+ );
+ }
+
+ const dateStr = new Date(release.publishedAt).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+
+ return new ImageResponse(
+ (
+
+
+
Gitpulse
+
+ {release.isPrerelease ? 'PRE-RELEASE' : 'RELEASE EDITION'}
+ ·
+ {release.tag}
+
+
+
+
+
+ “{clamp(release.quip || release.name || release.tag, 140)}”
+
+
+
+
+
+ {release.prCount} PRs
+ ·
+ {release.contributorCount} contributors
+ ·
+ {dateStr}
+
+
+ ),
+ size,
+ );
+}
+
+function clamp(s: string, n: number): string {
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
+}
diff --git a/site/src/app/releases/[tag]/[slug]/page.tsx b/site/src/app/releases/[tag]/[slug]/page.tsx
new file mode 100644
index 0000000..f991299
--- /dev/null
+++ b/site/src/app/releases/[tag]/[slug]/page.tsx
@@ -0,0 +1,106 @@
+// /releases/// — release detail. Lifted shape from gitsky's
+// apps/web/app/[owner]/[repo]/releases/[tag]/page.tsx. Single-repo,
+// no DB — reads from public/data/{releases,stories}.
+
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { loadReleases, loadRelease } from '@/lib/releases-loader';
+import { loadStories } from '@/lib/stories-loader';
+import type { Story } from '@/lib/stories';
+import type { Release } from '@/lib/releases';
+import { loadRepo } from '@/lib/repo';
+import {
+ buildReleaseMetadata,
+ canonicalUrl,
+} from '@/lib/seo';
+import { JsonLd, buildReleaseJsonLd } from '@/lib/json-ld';
+import { releasePath, releaseSlug, releaseOgImagePath } from '@/lib/urls';
+import { ReleaseEditionHero } from '@/components/ReleaseEditionHero';
+import { ReleaseEditionStatBar } from '@/components/ReleaseEditionStatBar';
+import { ReleaseEditionTopStories } from '@/components/ReleaseEditionTopStories';
+import { ReleaseEditionChangelog } from '@/components/ReleaseEditionChangelog';
+
+interface RouteParams {
+ tag: string;
+ slug: string;
+}
+
+interface PageProps {
+ params: Promise;
+}
+
+// Sentinel route emitted only when there are no releases yet. Next 16's
+// `output: 'export'` errors out on an empty generateStaticParams[] — this
+// stub generates one URL that 404s, keeping the route file valid.
+const EMPTY_STUB_TAG = '__no_releases_yet__';
+
+// generateStaticParams returns raw segment values — Next.js encodes them
+// when emitting the static filesystem path and decodes them back into the
+// `params` object. Pre-encoding here would double-encode tags that contain
+// special characters (e.g. 'release/v1.0.0').
+export function generateStaticParams(): RouteParams[] {
+ const releases = loadReleases();
+ if (releases.length === 0) {
+ return [{ tag: EMPTY_STUB_TAG, slug: 'placeholder' }];
+ }
+ return releases.map((r) => ({
+ tag: r.tag,
+ slug: releaseSlug(r),
+ }));
+}
+
+export async function generateMetadata({
+ params,
+}: PageProps): Promise {
+ const { tag, slug } = await params;
+ if (tag === EMPTY_STUB_TAG) return {};
+ const release = loadRelease(tag);
+ if (!release) return {};
+ if (slug !== releaseSlug(release)) return {};
+ return buildReleaseMetadata(release);
+}
+
+export default async function ReleaseDetailPage({ params }: PageProps) {
+ const { tag, slug } = await params;
+ if (tag === EMPTY_STUB_TAG) notFound();
+ const release = loadRelease(tag);
+ if (!release) notFound();
+ // Reject alternate slugs that decode to the same release, so /releases/
+ // // 404s instead of serving identical content under two
+ // canonical URLs.
+ if (slug !== releaseSlug(release)) notFound();
+
+ const allStories = loadStories();
+ const changelogStories = resolveChangelog(release, allStories);
+ const repo = loadRepo();
+
+ const url = canonicalUrl(releasePath(release));
+ const jsonLd = buildReleaseJsonLd({
+ release,
+ canonicalUrl: url,
+ imageUrl: canonicalUrl(releaseOgImagePath(release)),
+ repo,
+ });
+
+ return (
+
+
+
+ {release.prCount > 0 && }
+
+
+
+
+
+ );
+}
+
+function resolveChangelog(release: Release, allStories: Story[]): Story[] {
+ const lookup = new Map(allStories.map((s) => [s.id, s]));
+ const out: Story[] = [];
+ for (const id of release.changelogStoryIds) {
+ const s = lookup.get(id);
+ if (s) out.push(s);
+ }
+ return out;
+}
diff --git a/site/src/app/releases/page.tsx b/site/src/app/releases/page.tsx
new file mode 100644
index 0000000..0e06a79
--- /dev/null
+++ b/site/src/app/releases/page.tsx
@@ -0,0 +1,111 @@
+// /releases/ — list page. Lifted from gitsky's
+// apps/web/app/[owner]/[repo]/releases/page.tsx. Single-repo: no
+// owner/repo params, no DB — reads from public/data/releases.
+
+import type { Metadata } from 'next';
+import { loadReleases } from '@/lib/releases-loader';
+import { loadRepo, publicationName } from '@/lib/repo';
+import {
+ buildReleasesListMetadata,
+ canonicalUrl,
+} from '@/lib/seo';
+import { JsonLd, buildReleasesListJsonLd } from '@/lib/json-ld';
+import { releasePath, releasesIndexPath } from '@/lib/urls';
+import { ReleasesListHero } from '@/components/ReleasesListHero';
+import { ReleasesListStandardCard } from '@/components/ReleasesListStandardCard';
+import { ReleasesListCompactRow } from '@/components/ReleasesListCompactRow';
+
+export function generateMetadata(): Metadata {
+ return buildReleasesListMetadata();
+}
+
+export default function ReleasesIndexPage() {
+ const releases = loadReleases();
+ const repo = loadRepo();
+
+ if (releases.length === 0) {
+ return (
+
+
+
+ Release Editions
+
+
+ No editions yet
+
+
+ Special editions appear here when you publish a release on GitHub.
+
+
+
+ );
+ }
+
+ const [hero, ...rest] = releases;
+ const standard = rest.slice(0, 3);
+ const compact = rest.slice(3);
+
+ const listJsonLd = buildReleasesListJsonLd({
+ releases,
+ canonicalUrl: canonicalUrl(releasesIndexPath()),
+ repo,
+ releaseUrl: (r) => canonicalUrl(releasePath(r)),
+ });
+
+ return (
+
+
+
+
+ Release notes & changelog
+
+
+ {publicationName(repo)} Releases
+
+
+
+
+ {hero &&
}
+ {standard.map((release) => (
+
+ ))}
+ {compact.length > 0 && (
+ <>
+
+
+ {compact.map((release) => (
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function EditionsDivider() {
+ return (
+
+
+
+
+ The Editions
+
+
+
+
+ );
+}
+
+function EarlierDivider() {
+ return (
+
+
+
+ Earlier Editions
+
+
+
+ );
+}
diff --git a/site/src/app/sitemap.ts b/site/src/app/sitemap.ts
index baf9fa7..18a11ee 100644
--- a/site/src/app/sitemap.ts
+++ b/site/src/app/sitemap.ts
@@ -1,13 +1,22 @@
import type { MetadataRoute } from 'next';
import { canonicalUrl } from '@/lib/seo';
import { loadStories } from '@/lib/stories-loader';
-import { storyPath } from '@/lib/urls';
+import { loadReleases } from '@/lib/releases-loader';
+import { storyPath, releasePath, releasesIndexPath } from '@/lib/urls';
export const dynamic = 'force-static';
export default function sitemap(): MetadataRoute.Sitemap {
const stories = loadStories();
- const newest = stories[0]?.committedAt;
+ const releases = loadReleases();
+ const newestStory = stories[0]?.committedAt;
+ const newestRelease = releases[0]?.publishedAt;
+ const newest =
+ newestStory && newestRelease
+ ? newestStory.localeCompare(newestRelease) > 0
+ ? newestStory
+ : newestRelease
+ : (newestStory ?? newestRelease);
const entries: MetadataRoute.Sitemap = [
{
@@ -27,5 +36,22 @@ export default function sitemap(): MetadataRoute.Sitemap {
});
}
+ // The /releases/ index page exists regardless of whether releases have
+ // been published yet — keep it in the sitemap so crawlers can discover it.
+ entries.push({
+ url: canonicalUrl(releasesIndexPath()),
+ ...(newestRelease ? { lastModified: new Date(newestRelease) } : {}),
+ changeFrequency: 'weekly',
+ priority: 0.9,
+ });
+ for (const release of releases) {
+ entries.push({
+ url: canonicalUrl(releasePath(release)),
+ lastModified: new Date(release.publishedAt),
+ changeFrequency: 'monthly',
+ priority: 0.85,
+ });
+ }
+
return entries;
}
diff --git a/site/src/components/HomepageFeed.tsx b/site/src/components/HomepageFeed.tsx
index 193e58f..63db6f1 100644
--- a/site/src/components/HomepageFeed.tsx
+++ b/site/src/components/HomepageFeed.tsx
@@ -5,27 +5,54 @@
*/
import { type StoryDay } from '@/lib/stories';
+import type { ReleasesByDay } from '@/lib/releases';
import { PRFeedItem } from '@/components/PRFeedItem';
import { FixesBrief } from '@/components/FixesBrief';
import { HousekeepingDrawer } from '@/components/HousekeepingDrawer';
+import { SpecialEditionCard } from '@/components/SpecialEditionCard';
interface HomepageFeedProps {
days: StoryDay[];
+ releasesByDay?: ReleasesByDay;
}
-export function HomepageFeed({ days }: HomepageFeedProps) {
- if (days.length === 0) return ;
+export function HomepageFeed({ days, releasesByDay = {} }: HomepageFeedProps) {
+ const hasContent = days.length > 0 || Object.keys(releasesByDay).length > 0;
+ if (!hasContent) return ;
+
+ // Merge: every date that has either stories or releases.
+ const allDates = new Set([
+ ...days.map((d) => d.date),
+ ...Object.keys(releasesByDay),
+ ]);
+ const sortedDates = Array.from(allDates).sort((a, b) => b.localeCompare(a));
+ const dayMap = new Map(days.map((d) => [d.date, d]));
return (
- {days.map((day) => (
-
-
-
-
-
-
- ))}
+ {sortedDates.map((date) => {
+ const day = dayMap.get(date);
+ const dateReleases = releasesByDay[date] ?? [];
+ const dateLabel =
+ day?.dateLabel ??
+ new Intl.DateTimeFormat('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(new Date(date + 'T12:00:00Z'));
+ return (
+
+
+ {dateReleases.map((release) => (
+
+ ))}
+ {day && }
+ {day && }
+ {day && }
+
+ );
+ })}
);
}
diff --git a/site/src/components/ReleaseEditionChangelog.tsx b/site/src/components/ReleaseEditionChangelog.tsx
new file mode 100644
index 0000000..d06bc6b
--- /dev/null
+++ b/site/src/components/ReleaseEditionChangelog.tsx
@@ -0,0 +1,120 @@
+// Lifted from gitsky's apps/web/app/o/[slug]/releases/[owner]/[repo]/[tag]/
+// components/ReleaseEditionChangelog.tsx. Adaptations:
+// - 'use client' dropped (we navigate via )
+// - openPanel(...) replaced with
+// - takes resolved Story[] from server instead of denormalized ReleaseTopStory[]
+// (changelog IDs are stored bare in the release file; the page loader
+// resolves them against the on-disk story set)
+
+import Link from 'next/link';
+import { primaryCategory, type Story } from '@/lib/stories';
+import { storyPath } from '@/lib/urls';
+
+const CATEGORY_CONFIG: Record<
+ string,
+ { label: string; icon: string; color: string }
+> = {
+ feature: { label: 'Features', icon: '✦', color: 'text-feed-teal bg-feed-teal/10' },
+ bugfix: { label: 'Bug Fixes', icon: '●', color: 'text-negative bg-negative/10' },
+ refactor: { label: 'Improvements', icon: '◆', color: 'text-[#58a6ff] bg-[#58a6ff]/10' },
+ performance: { label: 'Performance', icon: '▲', color: 'text-[#58a6ff] bg-[#58a6ff]/10' },
+ docs: { label: 'Documentation', icon: '◇', color: 'text-muted bg-muted/10' },
+ test: { label: 'Testing', icon: '◈', color: 'text-muted bg-muted/10' },
+ other: { label: 'Other', icon: '○', color: 'text-muted bg-muted/10' },
+};
+
+export function ReleaseEditionChangelog({ stories }: { stories: Story[] }) {
+ if (stories.length === 0) return null;
+
+ const groups = groupByCategory(stories);
+ const sortedKeys = Object.keys(groups).sort(
+ (a, b) => groups[b]!.length - groups[a]!.length,
+ );
+
+ return (
+
+
+ {sortedKeys.map((key) => (
+
+ ))}
+
+ );
+}
+
+function SectionDivider({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+function ChangelogGroup({
+ categoryKey,
+ items,
+}: {
+ categoryKey: string;
+ items: Story[];
+}) {
+ const config = CATEGORY_CONFIG[categoryKey] ?? CATEGORY_CONFIG.other!;
+
+ return (
+
+
+
+ {config.icon}
+
+
+ {config.label}
+
+
+ {items.length}
+
+
+ {items.map((story) => (
+
+ ))}
+
+ );
+}
+
+function ChangelogItem({ story }: { story: Story }) {
+ const ref = story.kind === 'pr' ? `#${story.prNumber}` : story.sha.slice(0, 7);
+ return (
+
+
+
+ {story.headline}
+
+
+ {ref}
+
+
+
+ @{story.author}
+
+
+ );
+}
+
+function groupByCategory(stories: Story[]): Record {
+ const groups: Record = {};
+ for (const story of stories) {
+ const category = primaryCategory(story)?.key ?? 'other';
+ (groups[category] ||= []).push(story);
+ }
+ return groups;
+}
diff --git a/site/src/components/ReleaseEditionHero.tsx b/site/src/components/ReleaseEditionHero.tsx
new file mode 100644
index 0000000..782d7e6
--- /dev/null
+++ b/site/src/components/ReleaseEditionHero.tsx
@@ -0,0 +1,83 @@
+// Lifted from gitsky's apps/web/app/[owner]/[repo]/releases/[tag]/components/
+// ReleaseEditionHero.tsx. Drops imageUrl block (deferred for v1) and the
+// owner/repo context for the "All Releases" link.
+
+import Link from 'next/link';
+import { formatReleaseDate, type Release } from '@/lib/releases';
+import { releasesIndexPath } from '@/lib/urls';
+
+export function ReleaseEditionHero({ release }: { release: Release }) {
+ const dateStr = formatReleaseDate(release.publishedAt);
+
+ return (
+
+
+
+
+
+
+ {release.releaseStory && }
+
+ ← All Releases
+
+
+
+ );
+}
+
+function HeroLabel({ isPrerelease }: { isPrerelease: boolean }) {
+ return (
+
+
+ {isPrerelease ? 'Pre-release Edition' : 'Release Edition'}
+
+
+ );
+}
+
+function VersionBadge({ tag }: { tag: string }) {
+ return (
+
+ {tag}
+
+ );
+}
+
+function Quip({ text }: { text: string }) {
+ if (!text) return null;
+ return (
+
+ “{text}”
+
+ );
+}
+
+function ReleaseName({ name, date }: { name: string | null; date: string }) {
+ if (!name) {
+ return (
+
+ {date}
+
+ );
+ }
+ return (
+
+ {name} · {date}
+
+ );
+}
+
+function ReleaseStory({ text }: { text: string }) {
+ return (
+
+ {text.split('\n').map((para, i) => (
+
0 ? 'mt-4' : ''}>
+ {para}
+
+ ))}
+
+ );
+}
diff --git a/site/src/components/ReleaseEditionStatBar.tsx b/site/src/components/ReleaseEditionStatBar.tsx
new file mode 100644
index 0000000..4c9cdad
--- /dev/null
+++ b/site/src/components/ReleaseEditionStatBar.tsx
@@ -0,0 +1,45 @@
+// Lifted from gitsky's apps/web/app/o/[slug]/releases/[owner]/[repo]/[tag]/
+// components/ReleaseEditionStatBar.tsx. Drops the "Highlights" and
+// "Coverage" stats (gitpulse doesn't track those). 4 cells instead of 6.
+
+import type { Release } from '@/lib/releases';
+import { formatLines } from '@/lib/releases';
+
+export function ReleaseEditionStatBar({ release }: { release: Release }) {
+ const stats = [
+ { value: String(release.prCount), label: 'PRs Merged' },
+ { value: String(release.contributorCount), label: 'Contributors' },
+ {
+ value: `+${formatLines(release.totalAdditions)}`,
+ label: 'Additions',
+ className: 'text-positive',
+ },
+ {
+ value: `-${formatLines(release.totalDeletions)}`,
+ label: 'Deletions',
+ className: 'text-negative',
+ },
+ ];
+
+ return (
+
+
+ {stats.map((stat, i) => (
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+ {i < stats.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/site/src/components/ReleaseEditionTopStories.tsx b/site/src/components/ReleaseEditionTopStories.tsx
new file mode 100644
index 0000000..d4f7bee
--- /dev/null
+++ b/site/src/components/ReleaseEditionTopStories.tsx
@@ -0,0 +1,188 @@
+// Lifted from gitsky's apps/web/app/o/[slug]/releases/[owner]/[repo]/[tag]/
+// components/ReleaseEditionTopStories.tsx. Adaptations:
+// - 'use client' dropped (we navigate, not open a panel)
+// - openPanel(...) replaced with
+// - story.prGithubId → story.storyId
+// - story.categories[0]?.key → story.primaryCategoryKey
+// - highlightKeys block dropped (gitpulse doesn't track them)
+
+import Link from 'next/link';
+import type { ReleaseTopStory } from '@/lib/releases';
+import { slugify } from '@/lib/utils/slugify';
+
+function storyHref(story: ReleaseTopStory): string {
+ const slug = slugify(story.headline);
+ return slug
+ ? `/stories/${story.storyId}/${slug}/`
+ : `/stories/${story.storyId}/`;
+}
+
+export function ReleaseEditionTopStories({
+ stories,
+}: {
+ stories: ReleaseTopStory[];
+}) {
+ if (stories.length === 0) return null;
+
+ const [lead, ...rest] = stories;
+ const secondary = rest.slice(0, 2);
+ const compact = rest.slice(2);
+
+ return (
+
+
+ {lead && }
+ {secondary.length > 0 && }
+ {compact.length > 0 && (
+
+ )}
+
+ );
+}
+
+function SectionDivider({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+function LeadStory({ story, rank }: { story: ReleaseTopStory; rank: number }) {
+ return (
+
+
+ {rank}
+
+
+
+
+ {story.headline}
+
+
+
+ {story.standfirst && (
+
+ {story.standfirst}
+
+ )}
+
+ );
+}
+
+function SecondaryStories({ stories }: { stories: ReleaseTopStory[] }) {
+ return (
+
+ {stories.map((story, i) => (
+
+
+ {i + 2}
+
+
+
+
+ {story.headline}
+
+
+
+ {story.standfirst && (
+
+ {story.standfirst}
+
+ )}
+
+ ))}
+
+ );
+}
+
+function CompactStories({
+ stories,
+ startRank,
+}: {
+ stories: ReleaseTopStory[];
+ startRank: number;
+}) {
+ return (
+
+ {stories.map((story, i) => (
+ -
+
+ {startRank + i}
+
+
+
+ {story.headline}
+
+ {story.standfirst && (
+
+ {story.standfirst}
+
+ )}
+
+
+
+ ))}
+
+ );
+}
+
+function StoryMeta({ story }: { story: ReleaseTopStory }) {
+ return (
+
+ #{story.prNumber}
+ ·
+ by @{story.authorLogin}
+ ·
+
+ +{story.additions} / -{story.deletions}
+
+
+ );
+}
+
+function CategoryLabel({
+ category,
+ small,
+}: {
+ category: string;
+ small?: boolean;
+}) {
+ const colorMap: Record = {
+ feature: 'text-feed-teal',
+ bugfix: 'text-negative',
+ refactor: 'text-[#a78bfa]',
+ performance: 'text-[#58a6ff]',
+ security: 'text-[#fbbf24]',
+ docs: 'text-muted',
+ test: 'text-muted',
+ };
+ const color = colorMap[category] ?? 'text-muted';
+ const size = small ? 'text-[0.5rem]' : 'text-[0.55rem]';
+
+ return (
+
+ {category}
+
+ );
+}
diff --git a/site/src/components/ReleasesListCompactRow.tsx b/site/src/components/ReleasesListCompactRow.tsx
new file mode 100644
index 0000000..cd450e5
--- /dev/null
+++ b/site/src/components/ReleasesListCompactRow.tsx
@@ -0,0 +1,86 @@
+// Lifted from gitsky's ReleasesListCompactRow.tsx. Single-repo: drops
+// the repoName chip on the top metadata row. Keeps the semver-type
+// detection and color tokens verbatim.
+
+import Link from 'next/link';
+import { formatLines, type Release } from '@/lib/releases';
+import { releasePath } from '@/lib/urls';
+
+export function ReleasesListCompactRow({ release }: { release: Release }) {
+ const url = releasePath(release);
+ const dateStr = new Date(release.publishedAt).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ });
+ const semverType = getSemverType(release.tag);
+ const linesLabel = formatLines(release.totalAdditions + release.totalDeletions);
+
+ return (
+
+
+
+
+ {release.tag}
+
+
+ {semverType}
+
+ {release.isPrerelease && (
+
+ Pre
+
+ )}
+
+ {dateStr}
+
+
+
+ “{release.quip}”
+
+
+
+
+ {release.prCount} PRs
+
+ +{linesLabel} lines
+
+
+ Read →
+
+
+
+
+ );
+}
+
+function getVersionColors(type: string) {
+ const m: Record = {
+ major: 'text-feed-gold border-feed-gold/40',
+ minor: 'text-feed-teal border-feed-teal/25',
+ patch: 'text-muted border-border-light',
+ };
+ return m[type] ?? m.patch!;
+}
+
+function getBadgeColors(type: string) {
+ const m: Record = {
+ major: 'bg-feed-gold/[0.12] text-feed-gold',
+ minor: 'bg-feed-teal/10 text-feed-teal',
+ patch: 'bg-muted/[0.12] text-muted',
+ };
+ return m[type] ?? m.patch!;
+}
+
+function getSemverType(tag: string): string {
+ const cleaned = tag.replace(/^v/, '');
+ const parts = cleaned.split('.');
+ if (parts.length < 3) return 'minor';
+ const [, minor, patch] = parts;
+ if (patch !== '0') return 'patch';
+ if (minor !== '0') return 'minor';
+ return 'major';
+}
diff --git a/site/src/components/ReleasesListHero.tsx b/site/src/components/ReleasesListHero.tsx
new file mode 100644
index 0000000..9c1381c
--- /dev/null
+++ b/site/src/components/ReleasesListHero.tsx
@@ -0,0 +1,5 @@
+// Hero card on the /releases/ list. For gitpulse (single-repo) this is
+// visually identical to SpecialEditionCard — re-export it under the
+// gitsky name so the route file matches the gitsky pattern.
+
+export { SpecialEditionCard as ReleasesListHero } from './SpecialEditionCard';
diff --git a/site/src/components/ReleasesListStandardCard.tsx b/site/src/components/ReleasesListStandardCard.tsx
new file mode 100644
index 0000000..e01f160
--- /dev/null
+++ b/site/src/components/ReleasesListStandardCard.tsx
@@ -0,0 +1,67 @@
+// Lifted from gitsky's apps/web/app/[owner]/[repo]/releases/components/
+// ReleasesListStandardCard.tsx. Single-repo adaptation: drops `owner`,
+// `repoName` chips. Adds a Pre-release badge.
+
+import Link from 'next/link';
+import { formatLines, formatReleaseDate, type Release } from '@/lib/releases';
+import { releasePath } from '@/lib/urls';
+
+export function ReleasesListStandardCard({ release }: { release: Release }) {
+ const url = releasePath(release);
+ const dateStr = formatReleaseDate(release.publishedAt);
+ const topStory = release.topStories[0];
+ const linesLabel = formatLines(release.totalAdditions + release.totalDeletions);
+
+ return (
+
+
+
+
+
+ Release Edition
+
+ ·
+
+ {release.tag}
+
+ {release.isPrerelease && (
+
+ Pre-release
+
+ )}
+ ·
+ {dateStr}
+
+ {release.quip && (
+
+ “{release.quip}”
+
+ )}
+ {release.name && (
+
+ Release — {release.name}
+
+ )}
+ {topStory && (
+
+ Top story: {topStory.headline}…
+
+ )}
+
+
+
+ {release.prCount} PRs
+
+
+ {release.contributorCount} contributors
+
+ +{linesLabel} lines
+
+
+ Read Edition →
+
+
+
+
+ );
+}
diff --git a/site/src/components/SpecialEditionCard.tsx b/site/src/components/SpecialEditionCard.tsx
new file mode 100644
index 0000000..e116035
--- /dev/null
+++ b/site/src/components/SpecialEditionCard.tsx
@@ -0,0 +1,185 @@
+// Editorial "Special Edition" card for releases. Renders inline at the top
+// of its publication day in the homepage feed. Adapted from gitsky's
+// SpecialEditionCard — drops imageUrl, highlightCount, slug/owner-context
+// (gitpulse is single-repo), reduces the stats strip from 4 → 3 cells.
+
+import Link from 'next/link';
+import { formatLines, type Release, type ReleaseTopStory } from '@/lib/releases';
+import { releasePath } from '@/lib/urls';
+
+export function SpecialEditionCard({ release }: { release: Release }) {
+ const url = releasePath(release);
+ const topStories = release.topStories.slice(0, 3);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {topStories.length > 0 && }
+
+
+
+
+ );
+}
+
+function CornerOrnaments() {
+ const positions = [
+ 'top-[10px] left-[10px]',
+ 'top-[10px] right-[10px]',
+ 'bottom-[10px] left-[10px]',
+ 'bottom-[10px] right-[10px]',
+ ];
+ return (
+ <>
+ {positions.map((pos) => (
+
+ ))}
+ >
+ );
+}
+
+function TopRow({ tag, isPrerelease }: { tag: string; isPrerelease: boolean }) {
+ return (
+
+
+
+ Release Edition
+
+
+
+ {isPrerelease && (
+
+ Pre-release
+
+ )}
+
+ {tag}
+
+
+
+ );
+}
+
+function Quip({ text }: { text: string }) {
+ if (!text) return null;
+ return (
+
+ “{text}”
+
+ );
+}
+
+function ReleaseName({ name }: { name: string | null }) {
+ if (!name) return null;
+ return (
+
+ Release — {name}
+
+ );
+}
+
+function StatsStrip({ release }: { release: Release }) {
+ const linesLabel = formatLines(release.totalAdditions + release.totalDeletions);
+ const stats = [
+ { value: String(release.prCount), label: 'PRs Merged' },
+ { value: String(release.contributorCount), label: 'Contributors' },
+ { value: `+${linesLabel}`, label: 'Lines' },
+ ];
+
+ return (
+
+ {stats.map((stat, i) => (
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+ {i < stats.length - 1 && (
+
+ )}
+
+ ))}
+
+ );
+}
+
+function TopStoriesList({ stories }: { stories: ReleaseTopStory[] }) {
+ return (
+
+
+ Top Stories
+
+
+ {stories.map((story, i) => (
+ -
+
+ {i + 1}
+
+
+
+ {story.headline}
+
+ {story.standfirst && (
+
+ {story.standfirst}
+
+ )}
+
+
+
+ ))}
+
+
+ );
+}
+
+function CategoryBadge({ category }: { category: string }) {
+ if (category === 'feature') {
+ return (
+
+ Feature
+
+ );
+ }
+ if (category === 'bugfix') {
+ return (
+
+ Fix
+
+ );
+ }
+ return null;
+}
+
+function CtaRow({ url, prCount }: { url: string; prCount: number }) {
+ return (
+
+
+ Read the Full Edition
+ →
+
+
+ {prCount} {prCount === 1 ? 'story' : 'stories'} inside
+
+
+ );
+}
diff --git a/site/src/lib/json-ld.tsx b/site/src/lib/json-ld.tsx
index b88f83a..1826bd5 100644
--- a/site/src/lib/json-ld.tsx
+++ b/site/src/lib/json-ld.tsx
@@ -5,6 +5,7 @@
import { getBaseUrl } from './seo';
import type { Story } from './stories';
import { primaryCategory } from './stories';
+import type { Release } from './releases';
import type { RepoInfo } from './repo';
import { publicationName, publicationSubtitle } from './repo';
@@ -69,3 +70,63 @@ export function buildStoryJsonLd(opts: {
wordCount,
};
}
+
+// NewsArticle schema for a release. Lifted shape from gitsky/lib/json-ld.tsx.
+export function buildReleaseJsonLd(opts: {
+ release: Release;
+ canonicalUrl: string;
+ imageUrl?: string;
+ repo: RepoInfo;
+}) {
+ const { release, canonicalUrl, imageUrl, repo } = opts;
+ const baseUrl = getBaseUrl();
+ const headline = release.name
+ ? `${release.name} (${release.tag})`
+ : `${publicationName(repo)} ${release.tag}`;
+ const wordCount = release.releaseStory.split(/\s+/).filter(Boolean).length;
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'NewsArticle',
+ headline,
+ description: release.quip || '',
+ ...(imageUrl ? { image: imageUrl } : {}),
+ datePublished: release.publishedAt,
+ dateModified: release.publishedAt,
+ author: {
+ '@type': 'Person',
+ name: release.authorLogin,
+ ...(release.authorUrl ? { url: release.authorUrl } : {}),
+ },
+ publisher: { ...PUBLISHER_BASE, url: baseUrl || '/' },
+ mainEntityOfPage: canonicalUrl,
+ articleSection: 'Release',
+ wordCount,
+ };
+}
+
+// CollectionPage + ItemList for /releases/.
+export function buildReleasesListJsonLd(opts: {
+ releases: Release[];
+ canonicalUrl: string;
+ repo: RepoInfo;
+ releaseUrl: (release: Release) => string;
+}) {
+ const { releases, canonicalUrl, repo, releaseUrl } = opts;
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+ name: `Releases — ${publicationName(repo)}`,
+ url: canonicalUrl,
+ mainEntity: {
+ '@type': 'ItemList',
+ numberOfItems: releases.length,
+ itemListElement: releases.map((r, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: r.name ?? r.tag,
+ url: releaseUrl(r),
+ })),
+ },
+ };
+}
diff --git a/site/src/lib/releases-loader.ts b/site/src/lib/releases-loader.ts
new file mode 100644
index 0000000..7440b4a
--- /dev/null
+++ b/site/src/lib/releases-loader.ts
@@ -0,0 +1,40 @@
+// Server-only release loader. Mirror of stories-loader.ts.
+
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import type { Release } from './releases';
+
+const RELEASES_DIR = join(process.cwd(), 'public', 'data', 'releases');
+
+export function loadReleases(): Release[] {
+ let files: string[] = [];
+ try {
+ files = readdirSync(RELEASES_DIR).filter(
+ (f) => f.endsWith('.json') && f !== 'manifest.json',
+ );
+ } catch {
+ return [];
+ }
+ // Skip per-file parse errors so one corrupt file doesn't take down the
+ // whole listing. The analyzer validates on write, so we only lose entries
+ // if disk corrupts something between write and read.
+ const out: Release[] = [];
+ for (const f of files) {
+ try {
+ const release = JSON.parse(
+ readFileSync(join(RELEASES_DIR, f), 'utf8'),
+ ) as Release;
+ out.push(release);
+ } catch (err) {
+ console.warn(
+ `[releases-loader] skipping ${f}: ${err instanceof Error ? err.message : err}`,
+ );
+ }
+ }
+ return out.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
+}
+
+export function loadRelease(tag: string): Release | null {
+ const releases = loadReleases();
+ return releases.find((r) => r.tag === tag) ?? null;
+}
diff --git a/site/src/lib/releases.test.ts b/site/src/lib/releases.test.ts
new file mode 100644
index 0000000..e3f701a
--- /dev/null
+++ b/site/src/lib/releases.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect } from 'vitest';
+import {
+ formatLines,
+ formatReleaseDate,
+ groupReleasesByDay,
+ type Release,
+} from './releases';
+
+function makeRelease(overrides: Partial = {}): Release {
+ return {
+ schemaVersion: 1,
+ tag: 'v1.0.0',
+ name: null,
+ publishedAt: '2026-05-02T12:00:00Z',
+ authorLogin: 'octocat',
+ isPrerelease: false,
+ releaseUrl: '',
+ previousTag: null,
+ quip: '',
+ releaseStory: '',
+ prCount: 0,
+ contributorCount: 0,
+ totalAdditions: 0,
+ totalDeletions: 0,
+ topStories: [],
+ changelogStoryIds: [],
+ inputsHash: 'h',
+ ...overrides,
+ };
+}
+
+describe('formatLines', () => {
+ it('returns the raw number under 1000', () => {
+ expect(formatLines(50)).toBe('50');
+ expect(formatLines(999)).toBe('999');
+ });
+
+ it('formats thousands with one decimal', () => {
+ expect(formatLines(1500)).toBe('1.5k');
+ expect(formatLines(12345)).toBe('12.3k');
+ });
+
+ it('returns 0 for zero', () => {
+ expect(formatLines(0)).toBe('0');
+ });
+});
+
+describe('formatReleaseDate', () => {
+ it('formats an ISO date as "Month Day, Year"', () => {
+ expect(formatReleaseDate('2026-05-02T12:00:00Z')).toBe('May 2, 2026');
+ });
+
+ it('uses UTC at the day boundary (no host-tz date shift)', () => {
+ // Without timeZone: 'UTC', this would render as May 1 in any
+ // host TZ west of GMT — pinning the formatter to UTC keeps the
+ // displayed date consistent with publishedAt.
+ expect(formatReleaseDate('2026-05-02T00:00:00Z')).toBe('May 2, 2026');
+ expect(formatReleaseDate('2026-05-02T23:59:59Z')).toBe('May 2, 2026');
+ });
+});
+
+describe('groupReleasesByDay', () => {
+ it('groups releases by their publishedAt date', () => {
+ const a = makeRelease({ tag: 'v1.0.0', publishedAt: '2026-05-02T01:00:00Z' });
+ const b = makeRelease({ tag: 'v1.1.0', publishedAt: '2026-05-02T20:00:00Z' });
+ const c = makeRelease({ tag: 'v0.9.0', publishedAt: '2026-04-30T12:00:00Z' });
+ const grouped = groupReleasesByDay([a, b, c]);
+ expect(Object.keys(grouped).sort()).toEqual(['2026-04-30', '2026-05-02']);
+ expect(grouped['2026-05-02']!.map((r) => r.tag)).toEqual(['v1.0.0', 'v1.1.0']);
+ expect(grouped['2026-04-30']!.map((r) => r.tag)).toEqual(['v0.9.0']);
+ });
+
+ it('returns an empty object for an empty input', () => {
+ expect(groupReleasesByDay([])).toEqual({});
+ });
+});
diff --git a/site/src/lib/releases.ts b/site/src/lib/releases.ts
new file mode 100644
index 0000000..fb85cce
--- /dev/null
+++ b/site/src/lib/releases.ts
@@ -0,0 +1,79 @@
+// Pure types + helpers for releases. Mirrors lib/stories.ts. Safe to
+// import from client components.
+
+export interface ReleaseTopStory {
+ storyId: string;
+ prNumber: number;
+ headline: string;
+ standfirst: string;
+ authorLogin: string;
+ primaryCategoryKey: string;
+ additions: number;
+ deletions: number;
+}
+
+export interface Release {
+ schemaVersion: number;
+ tag: string;
+ name: string | null;
+ publishedAt: string;
+ authorLogin: string;
+ authorUrl?: string;
+ isPrerelease: boolean;
+ releaseUrl: string;
+ previousTag: string | null;
+ quip: string;
+ releaseStory: string;
+ prCount: number;
+ contributorCount: number;
+ totalAdditions: number;
+ totalDeletions: number;
+ topStories: ReleaseTopStory[];
+ changelogStoryIds: string[];
+ inputsHash: string;
+}
+
+export interface ReleaseManifestEntry {
+ tag: string;
+ slug: string;
+ publishedAt: string;
+ isPrerelease: boolean;
+}
+
+export interface ReleaseManifest {
+ schemaVersion: number;
+ generatedAt: string;
+ entries: ReleaseManifestEntry[];
+}
+
+// Pin to UTC so the formatted date matches the ISO publishedAt regardless
+// of the host timezone — without this, dates near midnight UTC can shift
+// by a day on the build machine.
+const RELEASES_DATE_FMT = new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ timeZone: 'UTC',
+});
+
+export function formatReleaseDate(iso: string): string {
+ return RELEASES_DATE_FMT.format(new Date(iso));
+}
+
+export function formatLines(total: number): string {
+ if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
+ return String(total);
+}
+
+export interface ReleasesByDay {
+ [dateISO: string]: Release[];
+}
+
+export function groupReleasesByDay(releases: Release[]): ReleasesByDay {
+ const out: ReleasesByDay = {};
+ for (const r of releases) {
+ const date = r.publishedAt.slice(0, 10);
+ (out[date] ||= []).push(r);
+ }
+ return out;
+}
diff --git a/site/src/lib/seo.ts b/site/src/lib/seo.ts
index caf8221..7bb6bca 100644
--- a/site/src/lib/seo.ts
+++ b/site/src/lib/seo.ts
@@ -14,7 +14,14 @@ import {
type Story,
primaryCategory,
} from './stories';
-import { storyPath, storyOgImagePath } from './urls';
+import type { Release } from './releases';
+import {
+ storyPath,
+ storyOgImagePath,
+ releasePath,
+ releaseOgImagePath,
+ releasesIndexPath,
+} from './urls';
const SITE_NAME = 'Gitpulse';
@@ -136,3 +143,61 @@ export function buildStoryMetadata(story: Story): Metadata {
export function storyPrimaryCategory(story: Story): string | null {
return primaryCategory(story)?.key ?? null;
}
+
+// ── Releases ─────────────────────────────────────────────
+
+export function buildReleaseMetadata(
+ release: Release,
+ repo: RepoInfo = loadRepo(),
+): Metadata {
+ const titleBase = release.name
+ ? `${release.name} (${release.tag})`
+ : release.tag;
+ const title = `${titleBase} — ${publicationName(repo)} · ${SITE_NAME}`;
+ const description = truncateDescription(
+ release.quip || `Release ${release.tag} of ${publicationName(repo)}.`,
+ );
+ const url = canonicalUrl(releasePath(release));
+ const ogImage = canonicalUrl(releaseOgImagePath(release));
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ type: 'article',
+ url,
+ siteName: SITE_NAME,
+ images: [{ url: ogImage, width: 1200, height: 630, alt: titleBase }],
+ publishedTime: release.publishedAt,
+ authors: [release.authorLogin],
+ },
+ twitter: { card: 'summary_large_image', title, description },
+ alternates: { canonical: url },
+ };
+}
+
+export function buildReleasesListMetadata(
+ repo: RepoInfo = loadRepo(),
+): Metadata {
+ const title = `Releases — ${publicationName(repo)} · ${SITE_NAME}`;
+ const description = truncateDescription(
+ `Special editions for every release of ${publicationName(repo)}.`,
+ );
+ const url = canonicalUrl(releasesIndexPath());
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ type: 'website',
+ url,
+ siteName: SITE_NAME,
+ },
+ twitter: { card: 'summary_large_image', title, description },
+ alternates: { canonical: url },
+ };
+}
diff --git a/site/src/lib/urls.test.ts b/site/src/lib/urls.test.ts
index 508a375..e3566fe 100644
--- a/site/src/lib/urls.test.ts
+++ b/site/src/lib/urls.test.ts
@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest';
-import { storySlug, storyPath, storyOgImagePath } from './urls';
+import {
+ storySlug,
+ storyPath,
+ storyOgImagePath,
+ releasePath,
+ releaseOgImagePath,
+ releaseSlug,
+ releasesIndexPath,
+} from './urls';
import type { Story } from './stories';
+import type { Release } from './releases';
function makeStory(overrides: Partial = {}): Story {
return {
@@ -60,3 +69,76 @@ describe('storyOgImagePath', () => {
);
});
});
+
+function makeRelease(overrides: Partial = {}): Release {
+ return {
+ schemaVersion: 1,
+ tag: 'v1.0.0',
+ name: null,
+ publishedAt: '2026-05-02T12:00:00Z',
+ authorLogin: 'octocat',
+ isPrerelease: false,
+ releaseUrl: 'https://github.com/x/y/releases/tag/v1.0.0',
+ previousTag: null,
+ quip: '',
+ releaseStory: '',
+ prCount: 0,
+ contributorCount: 0,
+ totalAdditions: 0,
+ totalDeletions: 0,
+ topStories: [],
+ changelogStoryIds: [],
+ inputsHash: 'h',
+ ...overrides,
+ };
+}
+
+describe('releasesIndexPath', () => {
+ it('returns the releases index path', () => {
+ expect(releasesIndexPath()).toBe('/releases/');
+ });
+});
+
+describe('releaseSlug', () => {
+ it('uses the release name when present', () => {
+ const release = makeRelease({ name: 'First Stable Release' });
+ expect(releaseSlug(release)).toBe('first-stable-release');
+ });
+
+ it('falls back to the tag when name is null', () => {
+ const release = makeRelease({ name: null, tag: 'v1.0.0' });
+ expect(releaseSlug(release)).toBe('v1-0-0');
+ });
+
+ it('falls back to the tag when name is an empty string', () => {
+ // Regression: `??` would have let '' through, yielding an empty slug.
+ const release = makeRelease({ name: '', tag: 'v1.0.0' });
+ expect(releaseSlug(release)).toBe('v1-0-0');
+ });
+});
+
+describe('releasePath', () => {
+ it('builds /releases/// when name is set', () => {
+ const release = makeRelease({ tag: 'v1.0.0', name: 'First' });
+ expect(releasePath(release)).toBe('/releases/v1.0.0/first/');
+ });
+
+ it('builds /releases/// when name is null', () => {
+ const release = makeRelease({ tag: 'v1.0.0', name: null });
+ expect(releasePath(release)).toBe('/releases/v1.0.0/v1-0-0/');
+ });
+
+ it('encodes tags containing slashes', () => {
+ const release = makeRelease({ tag: 'release/v1.0.0', name: 'First' });
+ expect(releasePath(release)).toBe('/releases/release%2Fv1.0.0/first/');
+ });
+});
+
+describe('releaseOgImagePath', () => {
+ it('appends opengraph-image.png at the slug path', () => {
+ const release = makeRelease({ tag: 'v1.0.0', name: 'First' });
+ expect(releaseOgImagePath(release)).toBe(
+ '/releases/v1.0.0/first/opengraph-image.png',
+ );
+ });
+});
diff --git a/site/src/lib/urls.ts b/site/src/lib/urls.ts
index ca2e83e..297a29f 100644
--- a/site/src/lib/urls.ts
+++ b/site/src/lib/urls.ts
@@ -1,5 +1,6 @@
import { slugify } from './utils/slugify';
import type { Story } from './stories';
+import type { Release } from './releases';
/**
* Centralized URL builders for gitpulse routes.
@@ -25,3 +26,30 @@ export function storyOgImagePath(story: Story): string {
? `/stories/${story.id}/${slug}/opengraph-image.png`
: `/stories/${story.id}/opengraph-image.png`;
}
+
+// ── Releases ─────────────────────────────────────────────
+
+export function releasesIndexPath(): string {
+ return '/releases/';
+}
+
+export function releaseSlug(release: Release): string {
+ // `||` (not `??`) so empty-string names fall back to the tag.
+ return slugify(release.name || release.tag);
+}
+
+export function releasePath(release: Release): string {
+ const slug = releaseSlug(release);
+ const tagSegment = encodeURIComponent(release.tag);
+ return slug
+ ? `/releases/${tagSegment}/${slug}/`
+ : `/releases/${tagSegment}/`;
+}
+
+export function releaseOgImagePath(release: Release): string {
+ const slug = releaseSlug(release);
+ const tagSegment = encodeURIComponent(release.tag);
+ return slug
+ ? `/releases/${tagSegment}/${slug}/opengraph-image.png`
+ : `/releases/${tagSegment}/opengraph-image.png`;
+}