From 78d924bbf7ee9719412383ce4f408c8e81b8583e Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:29:56 -0400 Subject: [PATCH 1/9] Merge Events and Updates into a unified News section, add Buttondown newsletters Renames the homepage "Events" section to "News" and merges in blog posts and Buttondown newsletter issues, sorted by date. Newsletters are fetched live from Buttondown's API, filtered to sent + publicly archived emails whose subject contains "Newsletter" (configurable via BUTTONDOWN_NEWSLETTER_SUBJECT_FILTER), and paginate through all results. Requires a BUTTONDOWN_API_KEY env var to activate; falls back to an empty list otherwise so the build isn't blocked on it. Co-Authored-By: Claude Sonnet 5 --- apps/website/.env.example | 4 +- apps/website/app/(home)/layout.tsx | 5 +- apps/website/app/(home)/page.tsx | 106 +++++++++++++-------------- apps/website/app/types/news.ts | 7 ++ apps/website/app/utils/buttondown.ts | 94 ++++++++++++++++++++++++ apps/website/app/utils/formatDate.ts | 6 ++ turbo.json | 2 + 7 files changed, 163 insertions(+), 61 deletions(-) create mode 100644 apps/website/app/types/news.ts create mode 100644 apps/website/app/utils/buttondown.ts create mode 100644 apps/website/app/utils/formatDate.ts diff --git a/apps/website/.env.example b/apps/website/.env.example index a045cab6a..3c8ff6b8f 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -1,3 +1,5 @@ # RESEND_API_KEY= # NEXT_PUBLIC_POSTHOG_KEY=phc_KllQh2hOMmXJ3YwdiLHswb1CfOaEWzoC30mA0u3ZJgp -# NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com \ No newline at end of file +# NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +# BUTTONDOWN_API_KEY= +# BUTTONDOWN_NEWSLETTER_SUBJECT_FILTER=Newsletter \ No newline at end of file diff --git a/apps/website/app/(home)/layout.tsx b/apps/website/app/(home)/layout.tsx index b85e43b70..51558651e 100644 --- a/apps/website/app/(home)/layout.tsx +++ b/apps/website/app/(home)/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import type { ReactElement, ReactNode } from "react"; import { Inter } from "next/font/google"; -import { getAllBlogs } from "~/(home)/blog/readBlogs"; import { Logo } from "~/components/Logo"; import { PostHogProvider } from "../providers"; import { HomeNavigationMenu } from "./HomeNavigationMenu"; @@ -39,13 +38,11 @@ const HomeLayout = async ({ }: { children: ReactNode; }): Promise => { - const hasUpdates = !!(await getAllBlogs()).length; const navigationItems = [ { href: "/#about", label: "About" }, { href: "/#plugins", label: "Plugins" }, { href: "/#resources", label: "Resources" }, - { href: "/#events", label: "Events" }, - ...(hasUpdates ? [{ href: "/#updates", label: "Updates" }] : []), + { href: "/#news", label: "News" }, { href: "/#talks", label: "Talks" }, { href: "/#team", label: "Team" }, { href: "/#supporters", label: "Supporters" }, diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 90752b135..8c38d5f29 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -14,11 +14,14 @@ import { Puzzle, Sparkles, } from "lucide-react"; -import { getLatestBlogs } from "~/(home)/blog/readBlogs"; +import { getAllBlogs } from "~/(home)/blog/readBlogs"; import { Logo } from "~/components/Logo"; import { PlatformBadge } from "~/components/PlatformBadge"; import { TeamPerson } from "~/components/TeamPerson"; import { TEAM_MEMBERS } from "~/data/constants"; +import type { NewsItem } from "~/types/news"; +import { getButtondownNewsletterItems } from "~/utils/buttondown"; +import { formatDisplayDate } from "~/utils/formatDate"; const SLACK_URL = "https://join.slack.com/t/discoursegraphs/shared_invite/zt-37xklatti-cpEjgPQC0YyKYQWPNgAkEg"; @@ -89,26 +92,30 @@ const RESOURCE_LINKS = [ }, ] as const; -const EVENTS = [ +const STATIC_NEWS_ITEMS: NewsItem[] = [ { + date: "2026-06-18", href: "https://discoursegraphs.github.io/panel-qa-site/", linkText: "View panel notes", meta: "June 18, 2026 | Zoom", title: "Frontiers in Research: Open Science Catalyze Panel", }, { + date: "2026-03-27", href: "https://bsky.app/profile/atproto.science/post/3mh6kak5agk2z", linkText: "View event post", meta: "March 27, 2026 | ATScience Conference, Vancouver", title: "Toward Modular Open Science", }, { + date: "2026-03-24", href: "https://www.mcgill.ca/qls/channels/event/qls-seminar-series-matthew-akamatsu-371875", linkText: "View seminar details", meta: "March 24, 2026 | Montreal", title: "Seminar: McGill University Quantitative Life Sciences program", }, { + date: "2025-11-19", href: "https://luma.com/jijn0d5k", linkText: "View talk page", meta: "November 19, 2025 | Zoom", @@ -116,12 +123,16 @@ const EVENTS = [ "Metagov x Future of Science Seminar: Interoperable LLM- and human-centered research with Discourse Graphs", }, { + date: "2025-02-23", href: "https://iosp.io/schedule", linkText: "View full schedule", meta: "February 23-24, 2025 | Denver Museum of Nature and Science", title: "IOSP '25 Winter Workshop: Discourse Graphs", }, -] as const; +]; + +const sortNewsByDateDesc = (left: NewsItem, right: NewsItem): number => + new Date(right.date).getTime() - new Date(left.date).getTime(); type Talk = | { @@ -270,7 +281,24 @@ const ArrowLink = ({ ); const Home = async (): Promise => { - const blogs = await getLatestBlogs(); + const [blogs, newsletterItems] = await Promise.all([ + getAllBlogs(), + getButtondownNewsletterItems(), + ]); + + const blogNewsItems: NewsItem[] = blogs.map((blog) => ({ + date: blog.date, + href: `/blog/${blog.slug}`, + linkText: "View post", + meta: formatDisplayDate(blog.date), + title: blog.title, + })); + + const news = [ + ...STATIC_NEWS_ITEMS, + ...blogNewsItems, + ...newsletterItems, + ].sort(sortNewsByDateDesc); return (
@@ -615,32 +643,37 @@ const Home = async (): Promise => {
-
- +
+
+ + {blogs.length > 0 && ( + See all posts + )} +
- {EVENTS.map((event) => ( + {news.map((item) => (

- {event.title} + {item.title}

- {event.meta} + {item.meta}

- {event.linkText} + {item.linkText}
@@ -649,45 +682,6 @@ const Home = async (): Promise => {
- {blogs.length > 0 && ( -
-
-
- - See all updates -
-
- {blogs.map((blog) => ( - - -

- {blog.date} -

- - {blog.title} - -

- By {blog.author} -

-
-
- ))} -
-
-
- )} -
{ + const params = new URLSearchParams({ status: "sent" }); + + if (NEWSLETTER_SUBJECT_FILTER) { + params.set("subject", NEWSLETTER_SUBJECT_FILTER); + } + + return `${BUTTONDOWN_EMAILS_URL}?${params.toString()}`; +}; + +// archival_mode "enabled" means the email is visible to anyone in the public +// archive (as opposed to subscriber-only, paid-only, or not archived at all). +const isPubliclyArchivedEmail = ( + email: ButtondownEmail, +): email is ButtondownEmail & { absolute_url: string; publish_date: string } => + email.archival_mode === "enabled" && + Boolean(email.publish_date) && + Boolean(email.absolute_url); + +const toNewsItem = ( + email: ButtondownEmail & { absolute_url: string; publish_date: string }, +): NewsItem => ({ + date: email.publish_date, + href: email.absolute_url, + linkText: "View newsletter", + meta: `${formatDisplayDate(email.publish_date)} | Newsletter`, + title: email.subject, +}); + +const fetchAllEmails = async (apiKey: string): Promise => { + const emails: ButtondownEmail[] = []; + let nextUrl: string | null = buildEmailsUrl(); + + while (nextUrl) { + const response = await fetch(nextUrl, { + headers: { Authorization: `Token ${apiKey}` }, + next: { revalidate: 3600 }, + }); + + if (!response.ok) { + console.error(`Buttondown API request failed: ${response.status}`); + break; + } + + const page = (await response.json()) as ButtondownEmailsPage; + + emails.push(...page.results); + nextUrl = page.next; + } + + return emails; +}; + +export const getButtondownNewsletterItems = async (): Promise => { + const apiKey = process.env.BUTTONDOWN_API_KEY; + + if (!apiKey) { + return []; + } + + try { + const emails = await fetchAllEmails(apiKey); + + return emails.filter(isPubliclyArchivedEmail).map(toNewsItem); + } catch (error) { + console.error("Error fetching Buttondown newsletters:", error); + return []; + } +}; diff --git a/apps/website/app/utils/formatDate.ts b/apps/website/app/utils/formatDate.ts new file mode 100644 index 000000000..65242f6de --- /dev/null +++ b/apps/website/app/utils/formatDate.ts @@ -0,0 +1,6 @@ +export const formatDisplayDate = (isoDate: string): string => + new Date(isoDate).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); diff --git a/turbo.json b/turbo.json index b96290138..f6aa74c89 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,8 @@ "globalPassThroughEnv": [ "ANTHROPIC_API_KEY", "BLOB_READ_WRITE_TOKEN", + "BUTTONDOWN_API_KEY", + "BUTTONDOWN_NEWSLETTER_SUBJECT_FILTER", "GEMINI_API_KEY", "GH_CLIENT_SECRET_PROD", "GITHUB_ACTIONS", From 9d873d57658695cf5229a8ef072d6c6245f8b104 Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:28:05 -0400 Subject: [PATCH 2/9] Fix off-by-one-day bug in formatDisplayDate on non-UTC servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new Date() parses date-only ISO strings as UTC midnight, but toLocaleDateString with no timeZone option formats in the host's local zone — shifting the displayed date back a day on any server running behind UTC. Pin formatting to UTC so the displayed date matches the source value regardless of server timezone. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/utils/formatDate.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/website/app/utils/formatDate.ts b/apps/website/app/utils/formatDate.ts index 65242f6de..aad6971f2 100644 --- a/apps/website/app/utils/formatDate.ts +++ b/apps/website/app/utils/formatDate.ts @@ -1,6 +1,10 @@ +// Dates from blog frontmatter and Buttondown are date-only or UTC-midnight +// values; formatting in UTC keeps the displayed day stable regardless of the +// server's local timezone. export const formatDisplayDate = (isoDate: string): string => new Date(isoDate).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", + timeZone: "UTC", }); From 06a35c7eaadf61d7e302bf8649e5be3e373bf403 Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:28:47 -0400 Subject: [PATCH 3/9] Throw instead of silently truncating on a failed Buttondown page fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, a failed pagination request logged an error and broke out of the loop, returning whatever pages had already been fetched as if that were the complete newsletter history — silently showing an incomplete backfill as correct. Throwing lets the existing outer catch discard the partial result and fall back to an empty list, consistent with how a missing API key is already handled. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/utils/buttondown.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/website/app/utils/buttondown.ts b/apps/website/app/utils/buttondown.ts index c20e33899..674f3a232 100644 --- a/apps/website/app/utils/buttondown.ts +++ b/apps/website/app/utils/buttondown.ts @@ -63,8 +63,7 @@ const fetchAllEmails = async (apiKey: string): Promise => { }); if (!response.ok) { - console.error(`Buttondown API request failed: ${response.status}`); - break; + throw new Error(`Buttondown API request failed: ${response.status}`); } const page = (await response.json()) as ButtondownEmailsPage; From 93f43a4329f4d2d319846f705398006db198085c Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:29:37 -0400 Subject: [PATCH 4/9] Cap the homepage News list instead of rendering it unbounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged News section previously rendered every static event, every published blog post, and every matching Buttondown newsletter with no limit — unlike the old Updates section, which capped at 3 recent posts. Left unbounded, this list only grows over time. Cap it to the 10 most recent items; the full blog archive remains reachable via "See all posts". Co-Authored-By: Claude Sonnet 5 --- apps/website/app/(home)/page.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 8c38d5f29..f3429689a 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -134,6 +134,11 @@ const STATIC_NEWS_ITEMS: NewsItem[] = [ const sortNewsByDateDesc = (left: NewsItem, right: NewsItem): number => new Date(right.date).getTime() - new Date(left.date).getTime(); +// Keeps the homepage widget to a "recent news" size instead of growing +// forever as blog posts and newsletters accumulate; the full blog archive +// is still reachable via the "See all posts" link. +const MAX_NEWS_ITEMS = 10; + type Talk = | { embedUrl: string; @@ -294,11 +299,9 @@ const Home = async (): Promise => { title: blog.title, })); - const news = [ - ...STATIC_NEWS_ITEMS, - ...blogNewsItems, - ...newsletterItems, - ].sort(sortNewsByDateDesc); + const news = [...STATIC_NEWS_ITEMS, ...blogNewsItems, ...newsletterItems] + .sort(sortNewsByDateDesc) + .slice(0, MAX_NEWS_ITEMS); return (
From 31e427f643e09afba3204676c167c2a4034ec4d4 Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:30:15 -0400 Subject: [PATCH 5/9] Restore blog post author attribution in the homepage News list The removed Updates section showed "By {author}" for each post; the merged News list dropped that field entirely. Fold it into the meta line, consistent with the "date | label" pattern used by events and newsletters. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/(home)/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index f3429689a..956dbdfb5 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -295,7 +295,7 @@ const Home = async (): Promise => { date: blog.date, href: `/blog/${blog.slug}`, linkText: "View post", - meta: formatDisplayDate(blog.date), + meta: `${formatDisplayDate(blog.date)} | By ${blog.author}`, title: blog.title, })); From c4cc98b63b9707f7dc1a38e89a4f81edbe7ab79b Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:31:53 -0400 Subject: [PATCH 6/9] Share one date-sort comparator instead of duplicating it sortNewsByDateDesc in page.tsx was a byte-for-byte copy of sortBlogsByDate in readBlogs.tsx. Extract sortByDateDesc into a shared generic utility and use it in both places. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/(home)/blog/readBlogs.tsx | 6 ++---- apps/website/app/(home)/page.tsx | 6 ++---- apps/website/app/utils/sortByDate.ts | 4 ++++ 3 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 apps/website/app/utils/sortByDate.ts diff --git a/apps/website/app/(home)/blog/readBlogs.tsx b/apps/website/app/(home)/blog/readBlogs.tsx index 9e471d419..0a72a1414 100644 --- a/apps/website/app/(home)/blog/readBlogs.tsx +++ b/apps/website/app/(home)/blog/readBlogs.tsx @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import matter from "gray-matter"; +import { sortByDateDesc } from "~/utils/sortByDate"; import { BLOG_DIRECTORY } from "./blogDirectory"; import { BlogFrontmatterSchema, type BlogData } from "./blogSchema"; @@ -40,9 +41,6 @@ const processBlogFile = async (filename: string): Promise => { } }; -const sortBlogsByDate = (left: BlogData, right: BlogData): number => - new Date(right.date).getTime() - new Date(left.date).getTime(); - const listBlogFiles = async (): Promise => { const directoryExists = await validateBlogDirectory(); @@ -61,7 +59,7 @@ export const getAllBlogs = async (): Promise => { const blogs = await Promise.all(files.map(processBlogFile)); const validBlogs = blogs.filter((blog): blog is BlogData => blog !== null); - return validBlogs.filter((blog) => blog.published).sort(sortBlogsByDate); + return validBlogs.filter((blog) => blog.published).sort(sortByDateDesc); } catch (error) { console.error("Error reading blog directory:", error); return []; diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 956dbdfb5..06ceaf18f 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -22,6 +22,7 @@ import { TEAM_MEMBERS } from "~/data/constants"; import type { NewsItem } from "~/types/news"; import { getButtondownNewsletterItems } from "~/utils/buttondown"; import { formatDisplayDate } from "~/utils/formatDate"; +import { sortByDateDesc } from "~/utils/sortByDate"; const SLACK_URL = "https://join.slack.com/t/discoursegraphs/shared_invite/zt-37xklatti-cpEjgPQC0YyKYQWPNgAkEg"; @@ -131,9 +132,6 @@ const STATIC_NEWS_ITEMS: NewsItem[] = [ }, ]; -const sortNewsByDateDesc = (left: NewsItem, right: NewsItem): number => - new Date(right.date).getTime() - new Date(left.date).getTime(); - // Keeps the homepage widget to a "recent news" size instead of growing // forever as blog posts and newsletters accumulate; the full blog archive // is still reachable via the "See all posts" link. @@ -300,7 +298,7 @@ const Home = async (): Promise => { })); const news = [...STATIC_NEWS_ITEMS, ...blogNewsItems, ...newsletterItems] - .sort(sortNewsByDateDesc) + .sort(sortByDateDesc) .slice(0, MAX_NEWS_ITEMS); return ( diff --git a/apps/website/app/utils/sortByDate.ts b/apps/website/app/utils/sortByDate.ts new file mode 100644 index 000000000..7fce87f6e --- /dev/null +++ b/apps/website/app/utils/sortByDate.ts @@ -0,0 +1,4 @@ +export const sortByDateDesc = ( + left: T, + right: T, +): number => new Date(right.date).getTime() - new Date(left.date).getTime(); From 659036c09247b985a80a06510c996dcde5b06d20 Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:32:56 -0400 Subject: [PATCH 7/9] Derive static news item meta text from date instead of hand-authoring both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each STATIC_NEWS_ITEMS entry independently hand-wrote both a sortable date and a prose meta string repeating that date, with nothing to keep them in sync. Restructure as event sources with a date + location (and an optional dateLabel override for the one multi-day event), and derive the display string via formatDisplayDate — matching the approach already used for blog posts and newsletters. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/(home)/page.tsx | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 06ceaf18f..9e1b4a91b 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -93,45 +93,65 @@ const RESOURCE_LINKS = [ }, ] as const; -const STATIC_NEWS_ITEMS: NewsItem[] = [ +type StaticEventSource = { + // Overrides the date shown in `meta`, for events spanning multiple days + // (e.g. "February 23-24, 2025") that a single `date` can't represent. + dateLabel?: string; + date: string; + href: string; + linkText: string; + location: string; + title: string; +}; + +const STATIC_EVENT_SOURCES: StaticEventSource[] = [ { date: "2026-06-18", href: "https://discoursegraphs.github.io/panel-qa-site/", linkText: "View panel notes", - meta: "June 18, 2026 | Zoom", + location: "Zoom", title: "Frontiers in Research: Open Science Catalyze Panel", }, { date: "2026-03-27", href: "https://bsky.app/profile/atproto.science/post/3mh6kak5agk2z", linkText: "View event post", - meta: "March 27, 2026 | ATScience Conference, Vancouver", + location: "ATScience Conference, Vancouver", title: "Toward Modular Open Science", }, { date: "2026-03-24", href: "https://www.mcgill.ca/qls/channels/event/qls-seminar-series-matthew-akamatsu-371875", linkText: "View seminar details", - meta: "March 24, 2026 | Montreal", + location: "Montreal", title: "Seminar: McGill University Quantitative Life Sciences program", }, { date: "2025-11-19", href: "https://luma.com/jijn0d5k", linkText: "View talk page", - meta: "November 19, 2025 | Zoom", + location: "Zoom", title: "Metagov x Future of Science Seminar: Interoperable LLM- and human-centered research with Discourse Graphs", }, { date: "2025-02-23", + dateLabel: "February 23-24, 2025", href: "https://iosp.io/schedule", linkText: "View full schedule", - meta: "February 23-24, 2025 | Denver Museum of Nature and Science", + location: "Denver Museum of Nature and Science", title: "IOSP '25 Winter Workshop: Discourse Graphs", }, ]; +const STATIC_NEWS_ITEMS: NewsItem[] = STATIC_EVENT_SOURCES.map((event) => ({ + date: event.date, + href: event.href, + linkText: event.linkText, + meta: `${event.dateLabel ?? formatDisplayDate(event.date)} | ${event.location}`, + title: event.title, +})); + // Keeps the homepage widget to a "recent news" size instead of growing // forever as blog posts and newsletters accumulate; the full blog archive // is still reachable via the "See all posts" link. From 7bd974f2d9f9c461fb67b457ac71bac44a104b7d Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:33:45 -0400 Subject: [PATCH 8/9] Dedupe merged news items by href before rendering Buttondown's page-number pagination isn't guaranteed stable if an email is created mid-fetch, so the same newsletter could theoretically appear twice in newsletterItems. Since href is used as the React key for the news list, dedupe by href before sorting/rendering to avoid duplicate-key warnings and undefined reconciliation behavior. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/(home)/page.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 9e1b4a91b..3ae047bec 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -317,9 +317,18 @@ const Home = async (): Promise => { title: blog.title, })); - const news = [...STATIC_NEWS_ITEMS, ...blogNewsItems, ...newsletterItems] - .sort(sortByDateDesc) - .slice(0, MAX_NEWS_ITEMS); + const allNewsItems = [ + ...STATIC_NEWS_ITEMS, + ...blogNewsItems, + ...newsletterItems, + ]; + // Buttondown's pagination can occasionally return the same email twice + // (e.g. if one is sent mid-fetch); href is used as the React key, so + // dedupe before rendering. + const dedupedNewsItems = Array.from( + new Map(allNewsItems.map((item) => [item.href, item])).values(), + ); + const news = dedupedNewsItems.sort(sortByDateDesc).slice(0, MAX_NEWS_ITEMS); return (
From 03770392fb66e63e5f9ce3439150f00c4db804b0 Mon Sep 17 00:00:00 2001 From: johnny1093 <46250921+jsmorabito@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:36:57 -0400 Subject: [PATCH 9/9] Guard the News nav link and section against an empty list Previously the "Events"/"Updates" nav items and section were only shown when there was content; this PR's merge into "News" dropped that guard entirely, always rendering the nav link and section even if there were nothing to show. Move the static items into a shared app/data/news.ts module so layout.tsx can cheaply check for them without duplicating page.tsx's async blog/newsletter fetches, and guard the section itself with the already-computed `news.length > 0`. Co-Authored-By: Claude Sonnet 5 --- apps/website/app/(home)/layout.tsx | 5 +- apps/website/app/(home)/page.tsx | 136 +++++++++-------------------- apps/website/app/data/news.ts | 63 +++++++++++++ 3 files changed, 107 insertions(+), 97 deletions(-) create mode 100644 apps/website/app/data/news.ts diff --git a/apps/website/app/(home)/layout.tsx b/apps/website/app/(home)/layout.tsx index 51558651e..cdd43b057 100644 --- a/apps/website/app/(home)/layout.tsx +++ b/apps/website/app/(home)/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import type { ReactElement, ReactNode } from "react"; import { Inter } from "next/font/google"; import { Logo } from "~/components/Logo"; +import { STATIC_NEWS_ITEMS } from "~/data/news"; import { PostHogProvider } from "../providers"; import { HomeNavigationMenu } from "./HomeNavigationMenu"; import "~/globals.css"; @@ -42,7 +43,9 @@ const HomeLayout = async ({ { href: "/#about", label: "About" }, { href: "/#plugins", label: "Plugins" }, { href: "/#resources", label: "Resources" }, - { href: "/#news", label: "News" }, + ...(STATIC_NEWS_ITEMS.length > 0 + ? [{ href: "/#news", label: "News" }] + : []), { href: "/#talks", label: "Talks" }, { href: "/#team", label: "Team" }, { href: "/#supporters", label: "Supporters" }, diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 3ae047bec..de66e1a54 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -19,6 +19,7 @@ import { Logo } from "~/components/Logo"; import { PlatformBadge } from "~/components/PlatformBadge"; import { TeamPerson } from "~/components/TeamPerson"; import { TEAM_MEMBERS } from "~/data/constants"; +import { STATIC_NEWS_ITEMS } from "~/data/news"; import type { NewsItem } from "~/types/news"; import { getButtondownNewsletterItems } from "~/utils/buttondown"; import { formatDisplayDate } from "~/utils/formatDate"; @@ -93,65 +94,6 @@ const RESOURCE_LINKS = [ }, ] as const; -type StaticEventSource = { - // Overrides the date shown in `meta`, for events spanning multiple days - // (e.g. "February 23-24, 2025") that a single `date` can't represent. - dateLabel?: string; - date: string; - href: string; - linkText: string; - location: string; - title: string; -}; - -const STATIC_EVENT_SOURCES: StaticEventSource[] = [ - { - date: "2026-06-18", - href: "https://discoursegraphs.github.io/panel-qa-site/", - linkText: "View panel notes", - location: "Zoom", - title: "Frontiers in Research: Open Science Catalyze Panel", - }, - { - date: "2026-03-27", - href: "https://bsky.app/profile/atproto.science/post/3mh6kak5agk2z", - linkText: "View event post", - location: "ATScience Conference, Vancouver", - title: "Toward Modular Open Science", - }, - { - date: "2026-03-24", - href: "https://www.mcgill.ca/qls/channels/event/qls-seminar-series-matthew-akamatsu-371875", - linkText: "View seminar details", - location: "Montreal", - title: "Seminar: McGill University Quantitative Life Sciences program", - }, - { - date: "2025-11-19", - href: "https://luma.com/jijn0d5k", - linkText: "View talk page", - location: "Zoom", - title: - "Metagov x Future of Science Seminar: Interoperable LLM- and human-centered research with Discourse Graphs", - }, - { - date: "2025-02-23", - dateLabel: "February 23-24, 2025", - href: "https://iosp.io/schedule", - linkText: "View full schedule", - location: "Denver Museum of Nature and Science", - title: "IOSP '25 Winter Workshop: Discourse Graphs", - }, -]; - -const STATIC_NEWS_ITEMS: NewsItem[] = STATIC_EVENT_SOURCES.map((event) => ({ - date: event.date, - href: event.href, - linkText: event.linkText, - meta: `${event.dateLabel ?? formatDisplayDate(event.date)} | ${event.location}`, - title: event.title, -})); - // Keeps the homepage widget to a "recent news" size instead of growing // forever as blog posts and newsletters accumulate; the full blog archive // is still reachable via the "See all posts" link. @@ -672,45 +614,47 @@ const Home = async (): Promise => {
-
-
-
- - {blogs.length > 0 && ( - See all posts - )} -
-
- {news.map((item) => ( -
-
-

- {item.title} -

-

- {item.meta} -

-
- 0 && ( +
+
+
+ + {blogs.length > 0 && ( + See all posts + )} +
+
+ {news.map((item) => ( +
- {item.linkText} -
- ))} +
+

+ {item.title} +

+

+ {item.meta} +

+
+ + {item.linkText} +
+ ))} +
-
- + + )}
diff --git a/apps/website/app/data/news.ts b/apps/website/app/data/news.ts new file mode 100644 index 000000000..d61bc62ac --- /dev/null +++ b/apps/website/app/data/news.ts @@ -0,0 +1,63 @@ +import type { NewsItem } from "~/types/news"; +import { formatDisplayDate } from "~/utils/formatDate"; + +type StaticEventSource = { + // Overrides the date shown in `meta`, for events spanning multiple days + // (e.g. "February 23-24, 2025") that a single `date` can't represent. + dateLabel?: string; + date: string; + href: string; + linkText: string; + location: string; + title: string; +}; + +const STATIC_EVENT_SOURCES: StaticEventSource[] = [ + { + date: "2026-06-18", + href: "https://discoursegraphs.github.io/panel-qa-site/", + linkText: "View panel notes", + location: "Zoom", + title: "Frontiers in Research: Open Science Catalyze Panel", + }, + { + date: "2026-03-27", + href: "https://bsky.app/profile/atproto.science/post/3mh6kak5agk2z", + linkText: "View event post", + location: "ATScience Conference, Vancouver", + title: "Toward Modular Open Science", + }, + { + date: "2026-03-24", + href: "https://www.mcgill.ca/qls/channels/event/qls-seminar-series-matthew-akamatsu-371875", + linkText: "View seminar details", + location: "Montreal", + title: "Seminar: McGill University Quantitative Life Sciences program", + }, + { + date: "2025-11-19", + href: "https://luma.com/jijn0d5k", + linkText: "View talk page", + location: "Zoom", + title: + "Metagov x Future of Science Seminar: Interoperable LLM- and human-centered research with Discourse Graphs", + }, + { + date: "2025-02-23", + dateLabel: "February 23-24, 2025", + href: "https://iosp.io/schedule", + linkText: "View full schedule", + location: "Denver Museum of Nature and Science", + title: "IOSP '25 Winter Workshop: Discourse Graphs", + }, +]; + +export const STATIC_NEWS_ITEMS: NewsItem[] = STATIC_EVENT_SOURCES.map( + (event) => ({ + date: event.date, + href: event.href, + linkText: event.linkText, + meta: `${event.dateLabel ?? formatDisplayDate(event.date)} | ${event.location}`, + title: event.title, + }), +);