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 => (
+
+ {items.map((item) => (
+ -
+
+
+
+ {item.title}
+
+
+ {item.label}
+
+
+
+
+
+ ))}
+
+);
+
+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 (
+
+
+
+
+ Back to the team
+
+
+
+
+
+
+
+
+ Author profile
+
+
+ {profile.name}
+
+
+ {profile.summary}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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..b26ab9133
--- /dev/null
+++ b/apps/website/app/data/authorProfiles.test.ts
@@ -0,0 +1,64 @@
+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);
+ }
+ }
+ });
+
+ 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",
+ );
+ });
+
+ 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
new file mode 100644
index 000000000..078038f7f
--- /dev/null
+++ b/apps/website/app/data/authorProfiles.ts
@@ -0,0 +1,132 @@
+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: [
+ {
+ href: "https://biology.washington.edu/people/matthew-akamatsu",
+ label: "University of Washington Biology profile",
+ },
+ ],
+ 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:
+ "Collective grassroots knowledge generation with lab discourse graphs",
+ },
+ {
+ 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",