Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion site/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +16,7 @@ export function generateMetadata(): Metadata {
export default function HomePage() {
const repo = loadRepo();
const days = groupByDay(loadStories());
const releasesByDay = groupReleasesByDay(loadReleases());

return (
<main className="min-h-screen bg-background">
Expand All @@ -22,7 +25,7 @@ export default function HomePage() {
feedSubtitle={publicationSubtitle(repo)}
/>
<SectionNav />
<HomepageFeed days={days} />
<HomepageFeed days={days} releasesByDay={releasesByDay} />
</main>
);
}
146 changes: 146 additions & 0 deletions site/src/app/releases/[tag]/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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),
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: '#0d0d0c',
color: '#f0ede8',
fontFamily: 'serif',
alignItems: 'center',
justifyContent: 'center',
fontSize: 48,
}}
>
Not found
</div>
),
size,
);
}

const dateStr = new Date(release.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});

return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#0d0d0c',
color: '#f0ede8',
fontFamily: 'serif',
padding: '80px 100px',
justifyContent: 'space-between',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 18,
letterSpacing: '0.2em',
color: '#8a8780',
textTransform: 'uppercase',
fontFamily: 'monospace',
}}
>
<div style={{ display: 'flex' }}>Gitpulse</div>
<div style={{ display: 'flex', gap: 24, color: '#b8860b' }}>
<span>{release.isPrerelease ? 'PRE-RELEASE' : 'RELEASE EDITION'}</span>
<span style={{ color: '#8a8780' }}>·</span>
<span style={{ color: '#8a8780' }}>{release.tag}</span>
</div>
</div>

<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
<div
style={{
fontSize: 56,
lineHeight: 1.15,
fontStyle: 'italic',
letterSpacing: '-0.005em',
display: 'flex',
}}
>
“{clamp(release.quip || release.name || release.tag, 140)}”
</div>
<div
style={{
width: 96,
height: 4,
backgroundColor: '#b8860b',
marginTop: 8,
}}
/>
</div>

<div
style={{
fontSize: 18,
letterSpacing: '0.18em',
color: '#8a8780',
textTransform: 'uppercase',
fontFamily: 'monospace',
display: 'flex',
gap: 24,
}}
>
<span>{release.prCount} PRs</span>
<span>·</span>
<span>{release.contributorCount} contributors</span>
<span>·</span>
<span>{dateStr}</span>
</div>
</div>
),
size,
);
}

function clamp(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
106 changes: 106 additions & 0 deletions site/src/app/releases/[tag]/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// /releases/<tag>/<slug>/ — 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<RouteParams>;
}

// 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<Metadata> {
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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Reject alternate slugs that decode to the same release, so /releases/
// <tag>/<wrong-slug>/ 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 (
<main className="min-h-screen bg-background">
<JsonLd data={jsonLd} />
<ReleaseEditionHero release={release} />
{release.prCount > 0 && <ReleaseEditionStatBar release={release} />}
<div className="max-w-[1080px] mx-auto px-6 pb-20">
<ReleaseEditionTopStories stories={release.topStories} />
<ReleaseEditionChangelog stories={changelogStories} />
</div>
</main>
);
}

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;
}
111 changes: 111 additions & 0 deletions site/src/app/releases/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-background">
<div className="max-w-2xl mx-auto px-6 py-20 text-center">
<div className="font-feed-mono text-[0.6875rem] uppercase tracking-[0.2em] text-feed-gold mb-4">
Release Editions
</div>
<h1 className="font-feed-display text-3xl text-foreground mb-4">
No editions yet
</h1>
<p className="font-feed-body text-foreground-secondary">
Special editions appear here when you publish a release on GitHub.
</p>
</div>
</main>
);
}

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 (
<main className="min-h-screen bg-background">
<JsonLd data={listJsonLd} />
<div className="max-w-[1200px] mx-auto px-6 pt-10 pb-6 text-center">
<p className="font-feed-mono text-[13px] tracking-wide text-muted mb-3">
Release notes &amp; changelog
</p>
<h1 className="font-feed-display text-4xl md:text-5xl text-foreground mb-2">
{publicationName(repo)} Releases
</h1>
</div>
<div className="max-w-3xl mx-auto px-6">
<EditionsDivider />
{hero && <ReleasesListHero release={hero} />}
{standard.map((release) => (
<ReleasesListStandardCard key={release.tag} release={release} />
))}
{compact.length > 0 && (
<>
<EarlierDivider />
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
{compact.map((release) => (
<ReleasesListCompactRow key={release.tag} release={release} />
))}
</div>
</>
)}
</div>
</main>
);
}

function EditionsDivider() {
return (
<div className="flex items-center gap-4 py-6">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" />
<span className="font-feed-mono text-[0.5625rem] font-semibold uppercase tracking-[0.25em] text-feed-gold flex items-center gap-2">
<span className="w-1 h-1 bg-feed-gold rotate-45 opacity-50" />
The Editions
<span className="w-1 h-1 bg-feed-gold rotate-45 opacity-50" />
</span>
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" />
</div>
);
}

function EarlierDivider() {
return (
<div className="flex items-center gap-4 py-6">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" />
<span className="font-feed-display text-lg text-foreground whitespace-nowrap">
Earlier Editions
</span>
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" />
</div>
);
}
Loading
Loading