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 ( +
+
+ + {label} + +
+
+ ); +} + +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 ( +
+
+ + + {label} + + +
+
+ ); +} + +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`; +}