diff --git a/app/newsroom/page.tsx b/app/newsroom/page.tsx index 3db42d0..49a5c98 100644 --- a/app/newsroom/page.tsx +++ b/app/newsroom/page.tsx @@ -14,6 +14,9 @@ export const metadata = generatePageMetadata({ export default function NewsroomPage() { const posts = getAllPosts() + // getAllPosts() already sorts featured first, so a featured post is posts[0]. + const featuredPost = posts.find((post) => post.featured) ?? null + const recentPosts = featuredPost ? posts.filter((post) => post.slug !== featuredPost.slug) : posts return ( @@ -34,11 +37,39 @@ export default function NewsroomPage() { {posts.length === 0 ? (

No posts yet. Check back soon.

) : ( -
- {posts.map((post, i) => ( - - ))} -
+ <> + {featuredPost && ( +
+
+
+ + Featured + +
+
+ +
+
+ )} + + {recentPosts.length > 0 && ( + <> + {featuredPost && ( +
+
+ + Recent + +
+ )} +
+ {recentPosts.map((post, i) => ( + + ))} +
+ + )} + )} ) diff --git a/components/newsroom/PostCard.tsx b/components/newsroom/PostCard.tsx index e30a655..56b76c2 100644 --- a/components/newsroom/PostCard.tsx +++ b/components/newsroom/PostCard.tsx @@ -8,6 +8,8 @@ const categoryLabels: Record = { mission: 'Mission', industry: 'Industry', partner: 'Partner', + grant: 'Grant', + manifesto: 'Manifesto', } interface PostCardProps { diff --git a/lib/newsroom.ts b/lib/newsroom.ts index 422feb7..58dd6a4 100644 --- a/lib/newsroom.ts +++ b/lib/newsroom.ts @@ -28,13 +28,27 @@ function toPost(slug: string, data: Record): NewsroomPost { excerpt: String(data.excerpt ?? ''), coverImage: data.coverImage ? String(data.coverImage) : undefined, ogImage: data.ogImage ? String(data.ogImage) : undefined, + featured: data.featured === true, } } +/** + * Featured posts sort first, then everything by date descending. Without a + * featured post this is identical to plain date-descending order. + * + * Mirrored by tests/newsroom-featured.test.mjs — keep the two in step. + */ +export function comparePosts(a: NewsroomPost, b: NewsroomPost): number { + const aFeatured = a.featured === true + const bFeatured = b.featured === true + if (aFeatured !== bFeatured) return aFeatured ? -1 : 1 + return new Date(b.date).getTime() - new Date(a.date).getTime() +} + export function getAllPosts(): NewsroomPost[] { return readAllFiles() .map(({ slug, data }) => toPost(slug, data)) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .sort(comparePosts) } export function getPostBySlug(slug: string): { meta: NewsroomPost; content: string } | null { @@ -45,9 +59,10 @@ export function getPostBySlug(slug: string): { meta: NewsroomPost; content: stri return { meta: toPost(slug, data), content } } +/** Prefers a featured post so it holds the home-page slot; otherwise the newest. */ export function getLatestPost(): NewsroomPost | null { const posts = getAllPosts() - return posts[0] ?? null + return posts.find((post) => post.featured === true) ?? posts[0] ?? null } export function formatPostDate(dateStr: string): string { diff --git a/tests/newsroom-featured.test.mjs b/tests/newsroom-featured.test.mjs new file mode 100644 index 0000000..4b07533 --- /dev/null +++ b/tests/newsroom-featured.test.mjs @@ -0,0 +1,138 @@ +// Tests for newsroom featured-post pinning (lib/newsroom.ts). +// +// Node 20 cannot import the TypeScript source directly and the repo takes no +// new dependencies, so the comparator below mirrors comparePosts() in +// lib/newsroom.ts. Keep the two in step — the mirror is annotated there too. +// +// The final test does not mirror anything: it reads the real MDX frontmatter +// off disk and guards the category union that item 6a widened, which is the +// one failure mode a mirror cannot catch (a typo'd category silently falls +// back to 'announcement' in toPost()). +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +/** Mirror of comparePosts() in lib/newsroom.ts */ +function comparePosts(a, b) { + const aFeatured = a.featured === true + const bFeatured = b.featured === true + if (aFeatured !== bFeatured) return aFeatured ? -1 : 1 + return new Date(b.date).getTime() - new Date(a.date).getTime() +} + +/** Mirror of getAllPosts()'s ordering step */ +const sortPosts = (posts) => [...posts].sort(comparePosts) + +/** Mirror of getLatestPost() */ +function pickLatest(posts) { + const sorted = sortPosts(posts) + return sorted.find((p) => p.featured === true) ?? sorted[0] ?? null +} + +const post = (slug, date, featured) => ({ slug, date, ...(featured === undefined ? {} : { featured }) }) + +test('with no featured post, ordering is plain date descending (today\'s behavior)', () => { + const posts = [post('old', '2025-01-01'), post('new', '2026-06-01'), post('mid', '2025-08-01')] + assert.deepEqual( + sortPosts(posts).map((p) => p.slug), + ['new', 'mid', 'old'], + ) +}) + +test('a featured post sorts first even when it is the oldest', () => { + const posts = [ + post('newest', '2026-07-01'), + post('manifesto', '2024-02-01', true), + post('middle', '2026-01-01'), + ] + assert.deepEqual( + sortPosts(posts).map((p) => p.slug), + ['manifesto', 'newest', 'middle'], + ) +}) + +test('multiple featured posts stay date-descending among themselves, above the rest', () => { + const posts = [ + post('plain-new', '2026-07-01'), + post('feat-old', '2025-01-01', true), + post('feat-new', '2026-03-01', true), + post('plain-old', '2024-01-01'), + ] + assert.deepEqual( + sortPosts(posts).map((p) => p.slug), + ['feat-new', 'feat-old', 'plain-new', 'plain-old'], + ) +}) + +test('featured: false is treated the same as an absent flag', () => { + const explicit = sortPosts([post('a', '2026-01-01', false), post('b', '2026-02-01', false)]) + const absent = sortPosts([post('a', '2026-01-01'), post('b', '2026-02-01')]) + assert.deepEqual(explicit.map((p) => p.slug), absent.map((p) => p.slug)) + assert.deepEqual(explicit.map((p) => p.slug), ['b', 'a']) +}) + +test('getLatestPost prefers a featured post over a newer plain one', () => { + const posts = [post('newest', '2026-07-01'), post('pinned', '2024-02-01', true)] + assert.equal(pickLatest(posts).slug, 'pinned') +}) + +test('getLatestPost falls back to the newest when nothing is featured', () => { + const posts = [post('old', '2025-01-01'), post('newest', '2026-07-01')] + assert.equal(pickLatest(posts).slug, 'newest') +}) + +test('getLatestPost returns null for an empty newsroom', () => { + assert.equal(pickLatest([]), null) +}) + +test('removing the featured flag restores the pre-item-6 result exactly', () => { + const withFlag = [post('a', '2026-01-01'), post('b', '2025-01-01', true), post('c', '2026-06-01')] + const withoutFlag = withFlag.map(({ slug, date }) => ({ slug, date })) + assert.equal(pickLatest(withFlag).slug, 'b', 'featured wins while the flag is set') + assert.equal(pickLatest(withoutFlag).slug, 'c', 'newest wins once it is removed') +}) + +// ── Real content, not a mirror ──────────────────────────────────────────────── + +const CATEGORIES = ['announcement', 'mission', 'industry', 'partner', 'grant', 'manifesto'] + +test('every newsroom post declares a category in the union and a valid featured flag', () => { + const dir = path.join(process.cwd(), 'content/newsroom') + if (!fs.existsSync(dir)) return + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.mdx')) + assert.ok(files.length > 0, 'expected at least one newsroom post') + + for (const file of files) { + const raw = fs.readFileSync(path.join(dir, file), 'utf8') + const frontmatter = raw.split('---')[1] ?? '' + + const category = frontmatter.match(/^category:\s*["']?([\w-]+)["']?\s*$/m)?.[1] + assert.ok( + category && CATEGORIES.includes(category), + `${file}: category ${JSON.stringify(category)} is not one of ${CATEGORIES.join(', ')}`, + ) + + const featured = frontmatter.match(/^featured:\s*(\S+)\s*$/m)?.[1] + if (featured !== undefined) { + assert.ok( + featured === 'true' || featured === 'false', + `${file}: featured must be true or false, got ${JSON.stringify(featured)}`, + ) + } + } +}) + +test('at most one post is featured at a time', () => { + const dir = path.join(process.cwd(), 'content/newsroom') + if (!fs.existsSync(dir)) return + const featuredCount = fs + .readdirSync(dir) + .filter((f) => f.endsWith('.mdx')) + .filter((f) => /^featured:\s*true\s*$/m.test(fs.readFileSync(path.join(dir, f), 'utf8'))) + .length + assert.ok( + featuredCount <= 1, + `${featuredCount} posts are featured; the /newsroom FEATURED slot and the home-page slot each show one`, + ) +}) diff --git a/types/index.ts b/types/index.ts index b04957b..93e346e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -176,8 +176,10 @@ export interface NewsroomPost { title: string date: string author: string - category: 'announcement' | 'mission' | 'industry' | 'partner' + category: 'announcement' | 'mission' | 'industry' | 'partner' | 'grant' | 'manifesto' excerpt: string coverImage?: string ogImage?: string + /** Pins the post above the date-sorted list and claims the home-page slot */ + featured?: boolean }