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)/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)/layout.tsx b/apps/website/app/(home)/layout.tsx index b85e43b70..cdd43b057 100644 --- a/apps/website/app/(home)/layout.tsx +++ b/apps/website/app/(home)/layout.tsx @@ -1,8 +1,8 @@ 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 { STATIC_NEWS_ITEMS } from "~/data/news"; import { PostHogProvider } from "../providers"; import { HomeNavigationMenu } from "./HomeNavigationMenu"; import "~/globals.css"; @@ -39,13 +39,13 @@ 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" }] : []), + ...(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 90752b135..de66e1a54 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -14,11 +14,16 @@ 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 { STATIC_NEWS_ITEMS } from "~/data/news"; +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"; @@ -89,39 +94,10 @@ const RESOURCE_LINKS = [ }, ] as const; -const EVENTS = [ - { - 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", - }, - { - 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", - }, - { - 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", - }, - { - href: "https://luma.com/jijn0d5k", - linkText: "View talk page", - meta: "November 19, 2025 | Zoom", - title: - "Metagov x Future of Science Seminar: Interoperable LLM- and human-centered research with Discourse Graphs", - }, - { - 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; +// 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 = | { @@ -270,7 +246,31 @@ 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)} | By ${blog.author}`, + title: blog.title, + })); + + 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 (
@@ -614,74 +614,42 @@ const Home = async (): Promise => {
-
-
- -
- {EVENTS.map((event) => ( -
-
-

- {event.title} -

-

- {event.meta} -

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

- {blog.date} -

- - {blog.title} - -

- By {blog.author} +

+

+ {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, + }), +); diff --git a/apps/website/app/types/news.ts b/apps/website/app/types/news.ts new file mode 100644 index 000000000..cd15caca5 --- /dev/null +++ b/apps/website/app/types/news.ts @@ -0,0 +1,7 @@ +export type NewsItem = { + date: string; + href: string; + linkText: string; + meta: string; + title: string; +}; diff --git a/apps/website/app/utils/buttondown.ts b/apps/website/app/utils/buttondown.ts new file mode 100644 index 000000000..674f3a232 --- /dev/null +++ b/apps/website/app/utils/buttondown.ts @@ -0,0 +1,93 @@ +import type { NewsItem } from "~/types/news"; +import { formatDisplayDate } from "~/utils/formatDate"; + +const BUTTONDOWN_EMAILS_URL = "https://api.buttondown.com/v1/emails"; + +// Buttondown emails have no "category" field to distinguish newsletters from +// other one-off sends, but the API's subject filter does a substring match — +// so this relies on newsletter subjects consistently containing this text +// (e.g. "DG Newsletter: June 2026"). Override via env if the convention changes, +// or set it to an empty string to disable the filter entirely. +const NEWSLETTER_SUBJECT_FILTER = + process.env.BUTTONDOWN_NEWSLETTER_SUBJECT_FILTER ?? "Newsletter"; + +type ButtondownEmail = { + absolute_url: string | null; + archival_mode: string; + publish_date: string | null; + subject: string; +}; + +type ButtondownEmailsPage = { + next: string | null; + results: ButtondownEmail[]; +}; + +const buildEmailsUrl = (): string => { + 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) { + throw new Error(`Buttondown API request failed: ${response.status}`); + } + + 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..aad6971f2 --- /dev/null +++ b/apps/website/app/utils/formatDate.ts @@ -0,0 +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", + }); 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(); 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",