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..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",