From 2b765cefd3c23442f134a5b08c02f5bc18e05e96 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 2 Sep 2026 14:46:54 -0600 Subject: [PATCH 1/5] ENG-2223 Add linked author profiles --- .../app/(home)/authors/[slug]/page.tsx | 180 ++++++++++++++++++ apps/website/app/(home)/blog/[slug]/page.tsx | 17 +- apps/website/app/(home)/blog/page.tsx | 7 +- apps/website/app/(home)/page.tsx | 34 ++-- apps/website/app/components/AuthorLink.tsx | 44 +++++ apps/website/app/components/TeamPerson.tsx | 26 ++- apps/website/app/data/authorProfiles.test.ts | 43 +++++ apps/website/app/data/authorProfiles.ts | 126 ++++++++++++ apps/website/app/data/constants.ts | 4 +- 9 files changed, 460 insertions(+), 21 deletions(-) create mode 100644 apps/website/app/(home)/authors/[slug]/page.tsx create mode 100644 apps/website/app/components/AuthorLink.tsx create mode 100644 apps/website/app/data/authorProfiles.test.ts create mode 100644 apps/website/app/data/authorProfiles.ts diff --git a/apps/website/app/(home)/authors/[slug]/page.tsx b/apps/website/app/(home)/authors/[slug]/page.tsx new file mode 100644 index 000000000..d2259d870 --- /dev/null +++ b/apps/website/app/(home)/authors/[slug]/page.tsx @@ -0,0 +1,180 @@ +import type { Metadata } from "next"; +import type { ReactElement } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ExternalLink } from "lucide-react"; +import { + AUTHOR_PROFILES, + getAuthorProfileBySlug, + type AuthorWork, +} from "~/data/authorProfiles"; + +type AuthorPageProps = { + params: Promise<{ + slug: string; + }>; +}; + +const WorkList = ({ items }: { items: AuthorWork[] }): ReactElement => ( + +); + +export const generateStaticParams = (): Array<{ slug: string }> => + AUTHOR_PROFILES.map(({ slug }) => ({ slug })); + +export const generateMetadata = async ({ + params, +}: AuthorPageProps): Promise => { + const { slug } = await params; + const profile = getAuthorProfileBySlug(slug); + + if (!profile) { + return { title: "Author" }; + } + + return { + title: `${profile.name} | Discourse Graphs`, + description: profile.summary, + alternates: { + canonical: `/authors/${profile.slug}`, + }, + openGraph: { + title: `${profile.name} | Discourse Graphs`, + description: profile.summary, + type: "profile", + images: [{ url: profile.image, alt: profile.name }], + url: `/authors/${profile.slug}`, + }, + }; +}; + +const AuthorPage = async ({ + params, +}: AuthorPageProps): Promise => { + const { slug } = await params; + const profile = getAuthorProfileBySlug(slug); + + if (!profile) { + notFound(); + } + + return ( +
+
+ +
+
+ ); +}; + +export default AuthorPage; diff --git a/apps/website/app/(home)/blog/[slug]/page.tsx b/apps/website/app/(home)/blog/[slug]/page.tsx index cf35225fd..333cf47ab 100644 --- a/apps/website/app/(home)/blog/[slug]/page.tsx +++ b/apps/website/app/(home)/blog/[slug]/page.tsx @@ -7,6 +7,8 @@ import { createBreadcrumbStructuredData, createStructuredDataDocument, } from "~/utils/structuredData"; +import { AuthorLink } from "~/components/AuthorLink"; +import { getAuthorProfileByName } from "~/data/authorProfiles"; import type { BlogData } from "../blogSchema"; import { getAllBlogs, getBlogBySlug } from "../readBlogs"; import { getBlogPostPath, getCanonicalMetadata, getCanonicalUrl } from "~/seo"; @@ -47,12 +49,18 @@ const buildBlogPostMetadata = ({ metadata: pageMetadata, }); const canonicalPath = getBlogPostPath(blog.slug); + const authorProfile = getAuthorProfileByName(blog.author); return { ...pageMetadata, title: blog.title, description, - authors: [{ name: blog.author }], + authors: [ + { + name: blog.author, + url: authorProfile ? `/authors/${authorProfile.slug}` : undefined, + }, + ], keywords: blog.tags.length ? blog.tags : pageMetadata.keywords, alternates: { ...pageMetadata.alternates, @@ -119,7 +127,12 @@ const BlogPost = async ({ params }: Params): Promise => { )}

- By {blog.author} | {blog.date} + By{" "} + {" "} + | {blog.date}

{blog.tags.length > 0 && (
    diff --git a/apps/website/app/(home)/blog/page.tsx b/apps/website/app/(home)/blog/page.tsx index 2fbba3407..89f096452 100644 --- a/apps/website/app/(home)/blog/page.tsx +++ b/apps/website/app/(home)/blog/page.tsx @@ -6,6 +6,7 @@ import { createBreadcrumbStructuredData, createStructuredDataDocument, } from "~/utils/structuredData"; +import { AuthorLink } from "~/components/AuthorLink"; import { getAllBlogs } from "./readBlogs"; import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; @@ -67,7 +68,11 @@ const BlogIndex = async (): Promise => {

- by {blog.author} + by{" "} +
)) diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 5e68e6d81..694680697 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -16,6 +16,7 @@ import { Sparkles, } from "lucide-react"; import { getLatestBlogs } from "~/(home)/blog/readBlogs"; +import { AuthorLink, LinkedAuthorText } from "~/components/AuthorLink"; import { Logo } from "~/components/Logo"; import { PlatformBadge } from "~/components/PlatformBadge"; import { TeamPerson } from "~/components/TeamPerson"; @@ -622,25 +623,32 @@ const Home = async (): Promise => {
{RESOURCE_LINKS.map((resource) => ( -

- {resource.meta} +

-

- {resource.title} +

+ + {resource.title} +

-

+ Open resource

- + + ))}
@@ -710,7 +718,11 @@ const Home = async (): Promise => { {blog.title}

- By {blog.author} + By{" "} +

@@ -757,7 +769,7 @@ const Home = async (): Promise => { {talk.title}

- {talk.speakers} +

diff --git a/apps/website/app/components/AuthorLink.tsx b/apps/website/app/components/AuthorLink.tsx new file mode 100644 index 000000000..f0a8705d7 --- /dev/null +++ b/apps/website/app/components/AuthorLink.tsx @@ -0,0 +1,44 @@ +import type { ReactElement, ReactNode } from "react"; +import Link from "next/link"; +import { getAuthorProfileByName } from "~/data/authorProfiles"; + +const AUTHOR_NAME_PATTERN = /(Matthew Akamatsu|Matt Akamatsu|Joel Chan)/gu; + +type AuthorLinkProps = { + authorName: string; + className?: string; +}; + +export const AuthorLink = ({ + authorName, + className, +}: AuthorLinkProps): ReactElement => { + const profile = getAuthorProfileByName(authorName); + + if (!profile) { + return {authorName}; + } + + return ( + + {authorName} + + ); +}; + +export const LinkedAuthorText = ({ text }: { text: string }): ReactNode[] => + text.split(AUTHOR_NAME_PATTERN).map((segment, index) => { + const profile = getAuthorProfileByName(segment); + + if (!profile) { + return segment; + } + + return ( + + ); + }); diff --git a/apps/website/app/components/TeamPerson.tsx b/apps/website/app/components/TeamPerson.tsx index 39a4d82f5..913497426 100644 --- a/apps/website/app/components/TeamPerson.tsx +++ b/apps/website/app/components/TeamPerson.tsx @@ -1,19 +1,24 @@ -"use client"; +import type { ReactElement } from "react"; import Image from "next/image"; import Link from "next/link"; -export interface TeamMember { +export type TeamMember = { name: string; title: string; image: string; + profileHref?: string; links?: { twitter?: string; github?: string; website?: string; }; -} +}; -export function TeamPerson({ member }: { member: TeamMember }) { +export const TeamPerson = ({ + member, +}: { + member: TeamMember; +}): ReactElement => { return (
@@ -26,7 +31,16 @@ export function TeamPerson({ member }: { member: TeamMember }) {

- {member.name} + {member.profileHref ? ( + + {member.name} + + ) : ( + member.name + )}

{member.title}

@@ -73,4 +87,4 @@ export function TeamPerson({ member }: { member: TeamMember }) { )}
); -} +}; diff --git a/apps/website/app/data/authorProfiles.test.ts b/apps/website/app/data/authorProfiles.test.ts new file mode 100644 index 000000000..9f14982ec --- /dev/null +++ b/apps/website/app/data/authorProfiles.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + AUTHOR_PROFILES, + getAuthorProfileByName, + getAuthorProfileBySlug, +} from "./authorProfiles"; + +describe("author profiles", () => { + it("publishes only the requested Matt and Joel profiles", () => { + expect(AUTHOR_PROFILES.map(({ slug }) => slug).sort()).toEqual([ + "joel-chan", + "matthew-akamatsu", + ]); + }); + + it("resolves the author names used by existing website content", () => { + expect(getAuthorProfileByName("Matt Akamatsu")?.slug).toBe( + "matthew-akamatsu", + ); + expect(getAuthorProfileByName("Matthew Akamatsu")?.slug).toBe( + "matthew-akamatsu", + ); + expect(getAuthorProfileByName("Joel Chan")?.slug).toBe("joel-chan"); + expect(getAuthorProfileByName("Devs")).toBeUndefined(); + }); + + it("provides complete linked profile sections", () => { + for (const profile of AUTHOR_PROFILES) { + expect(getAuthorProfileBySlug(profile.slug)).toBe(profile); + expect(profile.affiliations.length).toBeGreaterThan(0); + expect(profile.publications.length).toBeGreaterThan(0); + expect(profile.talks.length).toBeGreaterThan(0); + + for (const link of [ + ...profile.publications, + ...profile.talks, + ...profile.externalProfiles, + ]) { + expect(link.href).toMatch(/^https?:\/\//u); + } + } + }); +}); diff --git a/apps/website/app/data/authorProfiles.ts b/apps/website/app/data/authorProfiles.ts new file mode 100644 index 000000000..65b55ae01 --- /dev/null +++ b/apps/website/app/data/authorProfiles.ts @@ -0,0 +1,126 @@ +export type AuthorProfileLink = { + href: string; + label: string; +}; + +export type AuthorWork = AuthorProfileLink & { + title: string; +}; + +export type AuthorProfile = { + affiliations: Array<{ + href: string; + name: string; + role: string; + }>; + aliases: string[]; + externalProfiles: AuthorProfileLink[]; + image: string; + name: string; + publications: AuthorWork[]; + slug: string; + summary: string; + talks: AuthorWork[]; +}; + +export const AUTHOR_PROFILES: AuthorProfile[] = [ + { + affiliations: [ + { + href: "/#team", + name: "Discourse Graphs", + role: "Research", + }, + ], + aliases: ["Matt Akamatsu"], + externalProfiles: [], + image: "/team/matt.png", + name: "Matthew Akamatsu", + publications: [ + { + href: "https://research.protocol.ai/blog/2023/discourse-graphs-and-the-future-of-science/", + label: "Protocol Labs Research", + title: "Discourse Graphs and the Future of Science", + }, + ], + slug: "matthew-akamatsu", + summary: "Researcher on the Discourse Graphs team.", + talks: [ + { + href: "https://atmosphereconf-vods.wisp.place/videos/keynote-towards-modular-open-science", + label: "Atmosphere Conference", + title: "Keynote: Towards Modular Open Science", + }, + { + href: "https://www.youtube-nocookie.com/embed/JOn_dJ-g3vY", + label: "HCIL Brown Bag Speaker Series", + title: "HCIL Brown Bag Speaker Series: Matt Akamatsu", + }, + { + href: "https://www.youtube-nocookie.com/embed/Fm-lzNhVMKs", + label: "Topos Institute", + title: "Discourse Graphs: A New Model for Scientific Communication", + }, + { + href: "https://www.youtube-nocookie.com/embed/2xGQepp-f-8", + label: "DeSci Denver 2024", + title: "Open Sourcing Scientific Research with Lab Discourse Graphs", + }, + ], + }, + { + affiliations: [ + { + href: "/#team", + name: "Discourse Graphs", + role: "Research", + }, + ], + aliases: [], + externalProfiles: [ + { + href: "http://joelchan.me/", + label: "Personal website", + }, + ], + image: "/team/joel.png", + name: "Joel Chan", + publications: [ + { + href: "https://commonplace.knowledgefutures.org/pub/m76tk163/release/1", + label: "Commonplace", + title: "Sustainable authorship models for scholarly communication", + }, + ], + slug: "joel-chan", + summary: "Researcher on the Discourse Graphs team.", + talks: [ + { + href: "https://www.youtube-nocookie.com/embed/53kLyq7PceQ", + label: "Protocol Labs Research Seminar", + title: "Accelerating Scientific Discovery with Discourse Graphs", + }, + ], + }, +]; + +const AUTHOR_PROFILES_BY_SLUG = new Map( + AUTHOR_PROFILES.map((profile) => [profile.slug, profile]), +); + +const AUTHOR_PROFILES_BY_NAME = new Map( + AUTHOR_PROFILES.flatMap((profile) => + [profile.name, ...profile.aliases].map( + (name) => [name.toLowerCase(), profile] as const, + ), + ), +); + +export const getAuthorProfileBySlug = ( + slug: string, +): AuthorProfile | undefined => AUTHOR_PROFILES_BY_SLUG.get(slug); + +export const getAuthorProfileByName = ( + name: string, +): AuthorProfile | undefined => + AUTHOR_PROFILES_BY_NAME.get(name.trim().toLowerCase()); diff --git a/apps/website/app/data/constants.ts b/apps/website/app/data/constants.ts index 50ac0f39c..3550988f4 100644 --- a/apps/website/app/data/constants.ts +++ b/apps/website/app/data/constants.ts @@ -1,4 +1,4 @@ -import { TeamMember } from "~/components/TeamPerson"; +import type { TeamMember } from "~/components/TeamPerson"; export const DESCRIPTION = "Discourse Graphs are a tool and ecosystem for collaborative knowledge synthesis, enabling researchers to map ideas and arguments in a modular, composable graph format."; @@ -10,11 +10,13 @@ export const TEAM_MEMBERS: TeamMember[] = [ name: "Joel Chan", title: "Research", image: "/team/joel.png", + profileHref: "/authors/joel-chan", }, { name: "Matthew Akamatsu", title: "Research", image: "/team/matt.png", + profileHref: "/authors/matthew-akamatsu", }, { name: "Michael Gartner", From 750663282bfbce1173ba5f9ddad07d1b9c614844 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 2 Sep 2026 14:52:00 -0600 Subject: [PATCH 2/5] ENG-2223 Correct Topos talk title --- apps/website/app/data/authorProfiles.test.ts | 12 ++++++++++++ apps/website/app/data/authorProfiles.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/website/app/data/authorProfiles.test.ts b/apps/website/app/data/authorProfiles.test.ts index 9f14982ec..744723c8c 100644 --- a/apps/website/app/data/authorProfiles.test.ts +++ b/apps/website/app/data/authorProfiles.test.ts @@ -40,4 +40,16 @@ describe("author profiles", () => { } } }); + + it("uses the verified Topos Institute recording title", () => { + const mattProfile = getAuthorProfileBySlug("matthew-akamatsu"); + const toposTalk = mattProfile?.talks.find( + ({ href }) => + href === "https://www.youtube-nocookie.com/embed/Fm-lzNhVMKs", + ); + + expect(toposTalk?.title).toBe( + "Collective grassroots knowledge generation with lab discourse graphs", + ); + }); }); diff --git a/apps/website/app/data/authorProfiles.ts b/apps/website/app/data/authorProfiles.ts index 65b55ae01..3ff60c6d4 100644 --- a/apps/website/app/data/authorProfiles.ts +++ b/apps/website/app/data/authorProfiles.ts @@ -59,7 +59,8 @@ export const AUTHOR_PROFILES: AuthorProfile[] = [ { href: "https://www.youtube-nocookie.com/embed/Fm-lzNhVMKs", label: "Topos Institute", - title: "Discourse Graphs: A New Model for Scientific Communication", + title: + "Collective grassroots knowledge generation with lab discourse graphs", }, { href: "https://www.youtube-nocookie.com/embed/2xGQepp-f-8", From 517491b32a8b34417a5696bfebeb2a27c6658eca Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 2 Sep 2026 15:25:16 -0600 Subject: [PATCH 3/5] ENG-2223 Add verified Matt profile --- apps/website/app/data/authorProfiles.test.ts | 9 +++++++++ apps/website/app/data/authorProfiles.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/website/app/data/authorProfiles.test.ts b/apps/website/app/data/authorProfiles.test.ts index 744723c8c..b26ab9133 100644 --- a/apps/website/app/data/authorProfiles.test.ts +++ b/apps/website/app/data/authorProfiles.test.ts @@ -52,4 +52,13 @@ describe("author profiles", () => { "Collective grassroots knowledge generation with lab discourse graphs", ); }); + + it("links Matt's University of Washington profile", () => { + const mattProfile = getAuthorProfileBySlug("matthew-akamatsu"); + + expect(mattProfile?.externalProfiles).toContainEqual({ + href: "https://biology.washington.edu/people/matthew-akamatsu", + label: "University of Washington Biology profile", + }); + }); }); diff --git a/apps/website/app/data/authorProfiles.ts b/apps/website/app/data/authorProfiles.ts index 3ff60c6d4..078038f7f 100644 --- a/apps/website/app/data/authorProfiles.ts +++ b/apps/website/app/data/authorProfiles.ts @@ -33,7 +33,12 @@ export const AUTHOR_PROFILES: AuthorProfile[] = [ }, ], aliases: ["Matt Akamatsu"], - externalProfiles: [], + externalProfiles: [ + { + href: "https://biology.washington.edu/people/matthew-akamatsu", + label: "University of Washington Biology profile", + }, + ], image: "/team/matt.png", name: "Matthew Akamatsu", publications: [ From 2081e57ebcfdcd1327b037ebb25a72e9cd6fff55 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 2 Sep 2026 15:39:55 -0600 Subject: [PATCH 4/5] ENG-2223 Stabilize React type resolution --- package.json | 2 ++ pnpm-lock.yaml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/package.json b/package.json index eb019f0d4..0c86a54c6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "prepare": "husky" }, "devDependencies": { + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", "husky": "^9.1.7", "lint-staged": "^16.3.2", "prettier": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8393d9435..ac899dd77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,12 @@ importers: specifier: 'catalog:' version: 1.372.10 devDependencies: + '@types/react': + specifier: 19.1.12 + version: 19.1.12 + '@types/react-dom': + specifier: 19.1.9 + version: 19.1.9(@types/react@19.1.12) husky: specifier: ^9.1.7 version: 9.1.7 From 94a018f8917f9762f36153a3ef7d3beaa01b1906 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 2 Sep 2026 15:44:01 -0600 Subject: [PATCH 5/5] Revert "ENG-2223 Stabilize React type resolution" This reverts commit 67148d27a38ecbc2291d42b2eb7a901c14870dfa. --- package.json | 2 -- pnpm-lock.yaml | 6 ------ 2 files changed, 8 deletions(-) diff --git a/package.json b/package.json index 0c86a54c6..eb019f0d4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,6 @@ "prepare": "husky" }, "devDependencies": { - "@types/react": "19.1.12", - "@types/react-dom": "19.1.9", "husky": "^9.1.7", "lint-staged": "^16.3.2", "prettier": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac899dd77..8393d9435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,12 +97,6 @@ importers: specifier: 'catalog:' version: 1.372.10 devDependencies: - '@types/react': - specifier: 19.1.12 - version: 19.1.12 - '@types/react-dom': - specifier: 19.1.9 - version: 19.1.9(@types/react@19.1.12) husky: specifier: ^9.1.7 version: 9.1.7