Skip to content

Commit 771cce4

Browse files
ankur-archclaude
andauthored
feat(blog): add E-E-A-T author profiles with bios and social links (#8037)
* feat(blog): add E-E-A-T author profiles with bios and social links Add author biographies and social profile links (X, LinkedIn, GitHub, Mastodon) to the blog to strengthen E-E-A-T signals. - author-bios.ts: 19 author bios + socials, keyed by slug with name aliases so frontmatter bylines (e.g. "Will Madden", "Søren Bramer Schmidt", "Josh McLeod") resolve to their canonical bio. - AuthorBio.tsx: "About the author(s)" section for posts + a shared AuthorSocialLinks piece rendering FontAwesome brand icon links. - Author page: bio + social icons in the header, plus ProfilePage/Person JSON-LD (sameAs, description, image). Meta description uses the bio. - Post page: "About the author" section, and BlogPosting author schema enriched with url, sameAs, and description. Authors without a bio render gracefully with no profile block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(blog): move author bio to top of post, polish social icons - Surface the "About the author" card near the top of each post (right after the byline, before the body) so readers and LLM/agent crawlers encounter author credentials early; restyle it as a self-contained bordered card. - Render social links with the eclipse Action icon-button treatment used by the footer and share row, so the icons read as a consistent, tightly spaced set instead of drifting apart. - Use fa-square-linkedin (the variant shipped in the FA kit) so the LinkedIn icon renders instead of showing a blank box. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blog): guard AuthorBio against non-array authors value Match the defensive Array.isArray handling used in [slug]/page.tsx so a null or non-array page.data.authors cannot crash post rendering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc010b7 commit 771cce4

4 files changed

Lines changed: 419 additions & 20 deletions

File tree

apps/blog/src/app/(blog)/[slug]/page.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld";
1010
import { BlogShare } from "@/components/BlogShare";
1111
import { BlogCTA } from "@/components/BlogCTA";
1212
import { AuthorAvatarGroup } from "@/components/AuthorAvatarGroup";
13+
import { AuthorBio } from "@/components/AuthorBio";
14+
import { getAuthorBioByName } from "@/lib/author-bios";
15+
import { toAuthorSlug } from "@/lib/authors";
1316
import { SeriesBanner } from "@/components/SeriesBanner";
1417
import { SeriesMarker } from "@/components/SeriesMarker";
1518
import { SeriesNavigation } from "@/components/SeriesNavigation";
@@ -35,6 +38,9 @@ interface PageParams {
3538
interface PersonSchema {
3639
"@type": "Person";
3740
name: string;
41+
url?: string;
42+
description?: string;
43+
sameAs?: string[];
3844
}
3945

4046
interface ImageObjectSchema {
@@ -77,6 +83,18 @@ function toIsoDate(value: unknown): string | undefined {
7783
return date.toISOString();
7884
}
7985

86+
function buildAuthorSchema(name: string): PersonSchema {
87+
const author: PersonSchema = { "@type": "Person", name };
88+
const slug = toAuthorSlug(name);
89+
const bio = getAuthorBioByName(name);
90+
if (slug) author.url = toAbsoluteUrl(withBlogBasePath(`/author/${slug}`));
91+
if (bio) {
92+
author.description = bio.bio;
93+
if (bio.socials.length > 0) author.sameAs = bio.socials.map((s) => s.url);
94+
}
95+
return author;
96+
}
97+
8098
function getBlogPostingJsonLd(page: ReturnType<typeof blog.getPage>): BlogPostingSchema | null {
8199
if (!page) return null;
82100

@@ -127,15 +145,9 @@ function getBlogPostingJsonLd(page: ReturnType<typeof blog.getPage>): BlogPostin
127145
}
128146

129147
if (authorNames.length === 1) {
130-
jsonLd.author = {
131-
"@type": "Person",
132-
name: authorNames[0],
133-
};
148+
jsonLd.author = buildAuthorSchema(authorNames[0]);
134149
} else if (authorNames.length > 1) {
135-
jsonLd.author = authorNames.map((name) => ({
136-
"@type": "Person" as const,
137-
name,
138-
}));
150+
jsonLd.author = authorNames.map(buildAuthorSchema);
139151
}
140152

141153
if (datePublished) {
@@ -225,6 +237,10 @@ export default async function Page(props: { params: Promise<{ slug: string }> })
225237
{seriesContext ? <SeriesMarker series={seriesContext} /> : null}
226238
</header>
227239

240+
{/* About the author(s) — surfaced high in the document for E-E-A-T and
241+
so LLMs/agents encounter author credentials early. */}
242+
<AuthorBio authors={page.data.authors} />
243+
228244
{/* Body */}
229245
<article className="w-full flex flex-col pb-8 mt-12">
230246
<div className="prose min-w-0 [&_figure]:w-full [&_figure]:md:max-w-140 [&_figure]:lg:max-w-200">

apps/blog/src/app/(blog)/author/[slug]/page.tsx

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@ import Link from "next/link";
33
import type { Metadata } from "next";
44

55
import { Avatar } from "@prisma/eclipse";
6+
import { JsonLd } from "@prisma-docs/ui/components/json-ld";
67

78
import { blog } from "@/lib/source";
8-
import {
9-
findAuthorProfile,
10-
getAllAuthorProfiles,
11-
getPostsByAuthorSlug,
12-
} from "@/lib/authors-pages";
13-
import { withBlogBasePath, withBlogBasePathForImageSrc } from "@/lib/url";
9+
import { findAuthorProfile, getAllAuthorProfiles, getPostsByAuthorSlug } from "@/lib/authors-pages";
10+
import { getAuthorBioBySlug } from "@/lib/author-bios";
11+
import { getBaseUrl, withBlogBasePath, withBlogBasePathForImageSrc } from "@/lib/url";
12+
import { AuthorSocialLinks } from "@/components/AuthorBio";
1413
import { BlogGrid, type BlogCardItem } from "@/components/BlogGrid";
1514
import { BLOG_HOME_TITLE } from "@/lib/blog-metadata";
1615

@@ -59,31 +58,61 @@ function buildCardItems(slug: string): BlogCardItem[] {
5958
});
6059
}
6160

61+
function buildAuthorJsonLd(
62+
profile: NonNullable<ReturnType<typeof findAuthorProfile>>,
63+
bio: ReturnType<typeof getAuthorBioBySlug>,
64+
avatarSrc: string | null,
65+
): object {
66+
const profileUrl = new URL(withBlogBasePath(`/author/${profile.slug}`), getBaseUrl()).toString();
67+
68+
const person: Record<string, unknown> = {
69+
"@type": "Person",
70+
"@id": `${profileUrl}#person`,
71+
name: profile.name,
72+
url: profileUrl,
73+
};
74+
if (bio?.bio) person.description = bio.bio;
75+
if (avatarSrc) person.image = new URL(avatarSrc, getBaseUrl()).toString();
76+
const sameAs = bio?.socials.map((s) => s.url) ?? [];
77+
if (sameAs.length > 0) person.sameAs = sameAs;
78+
79+
return {
80+
"@context": "https://schema.org",
81+
"@type": "ProfilePage",
82+
url: profileUrl,
83+
mainEntity: person,
84+
};
85+
}
86+
6287
export default async function AuthorPage(props: { params: Promise<AuthorPageParams> }) {
6388
const { slug } = await props.params;
6489
const profile = findAuthorProfile(slug);
6590
if (!profile) notFound();
6691

6792
const items = buildCardItems(slug);
6893
const avatarSrc = profile.imageSrc ? withBlogBasePathForImageSrc(profile.imageSrc) : null;
94+
const bio = getAuthorBioBySlug(slug);
6995

7096
return (
7197
<main className="flex-1 w-full max-w-249 mx-auto px-4 py-8 z-1">
98+
<JsonLd id="author-structured-data" data={buildAuthorJsonLd(profile, bio, avatarSrc)} />
7299
<Link href="/" className="text-fd-primary hover:underline text-sm">
73100
← Back to Blog
74101
</Link>
75102

76-
<header className="mt-6 mb-10 flex items-center gap-4">
77-
{avatarSrc ? (
78-
<Avatar format="image" src={avatarSrc} alt={profile.name} size="xl" />
79-
) : null}
80-
<div>
103+
<header className="mt-6 mb-10 flex flex-col gap-4 sm:flex-row sm:items-start">
104+
{avatarSrc ? <Avatar format="image" src={avatarSrc} alt={profile.name} size="xl" /> : null}
105+
<div className="min-w-0">
81106
<div className="text-xs uppercase tracking-wide text-foreground-neutral-weak font-semibold mb-2">
82107
Author · {items.length} {items.length === 1 ? "post" : "posts"}
83108
</div>
84109
<h1 className="type-title-3xl md:type-title-4xl text-foreground-neutral break-words hyphens-auto">
85110
{profile.name}
86111
</h1>
112+
{bio ? <p className="mt-3 max-w-2xl text-foreground-neutral-weak">{bio.bio}</p> : null}
113+
{bio ? (
114+
<AuthorSocialLinks socials={bio.socials} name={profile.name} className="mt-3" />
115+
) : null}
87116
</div>
88117
</header>
89118

@@ -105,8 +134,9 @@ export async function generateMetadata({
105134
const profile = findAuthorProfile(slug);
106135
if (!profile) return {};
107136

137+
const bio = getAuthorBioBySlug(slug);
108138
const title = `${profile.name}${BLOG_HOME_TITLE}`;
109-
const description = `Posts by ${profile.name} on the Prisma blog.`;
139+
const description = bio?.bio ?? `Posts by ${profile.name} on the Prisma blog.`;
110140

111141
return {
112142
title,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import Link from "next/link";
2+
import { Action, Avatar } from "@prisma/eclipse";
3+
import { cn } from "@prisma-docs/ui/lib/cn";
4+
5+
import { getAuthorImageSrc, toAuthorSlug } from "@/lib/authors";
6+
import { getAuthorBioByName, type AuthorSocial } from "@/lib/author-bios";
7+
import { withBlogBasePathForImageSrc } from "@/lib/url";
8+
9+
const SOCIAL_META: Record<AuthorSocial["platform"], { icon: string; label: string }> = {
10+
x: { icon: "fa-brands fa-x-twitter", label: "X (Twitter)" },
11+
linkedin: { icon: "fa-brands fa-square-linkedin", label: "LinkedIn" },
12+
github: { icon: "fa-brands fa-github", label: "GitHub" },
13+
mastodon: { icon: "fa-brands fa-mastodon", label: "Mastodon" },
14+
};
15+
16+
/**
17+
* A row of social profile links rendered as FontAwesome brand icons. Used on
18+
* both the author page and the per-post "About the author" section.
19+
*/
20+
export function AuthorSocialLinks({
21+
socials,
22+
name,
23+
className,
24+
}: {
25+
socials: AuthorSocial[];
26+
name: string;
27+
className?: string;
28+
}) {
29+
if (socials.length === 0) return null;
30+
31+
// Matches the icon-button treatment used by the footer and article share row
32+
// (eclipse Action box + gap-2), so social links read as a consistent set.
33+
// The -ml offset cancels the first box's padding to align flush-left.
34+
return (
35+
<div className={cn("-ml-2 flex items-center gap-1", className)}>
36+
{socials.map((social) => {
37+
const meta = SOCIAL_META[social.platform];
38+
return (
39+
<a
40+
key={social.platform}
41+
href={social.url}
42+
target="_blank"
43+
rel="me noopener noreferrer"
44+
aria-label={`${name} on ${meta.label}`}
45+
title={meta.label}
46+
className="text-lg transition-colors hover:[&>div]:bg-background-ppg-strong"
47+
>
48+
<Action color="neutral" size="lg">
49+
<i
50+
className={`${meta.icon} text-current text-foreground-neutral-weak transition-colors`}
51+
aria-hidden="true"
52+
/>
53+
</Action>
54+
</a>
55+
);
56+
})}
57+
</div>
58+
);
59+
}
60+
61+
/**
62+
* E-E-A-T "About the author" card surfaced near the top of a blog post, so
63+
* both readers and LLM/agent crawlers encounter author credentials early.
64+
* Renders one entry per author that has a published bio; authors without a
65+
* bio are silently skipped.
66+
*/
67+
export function AuthorBio({ authors = [] }: { authors?: string[] }) {
68+
const seen = new Set<string>();
69+
const profiles = (Array.isArray(authors) ? authors : [])
70+
.filter((name): name is string => typeof name === "string" && name.trim().length > 0)
71+
.map((name) => name.trim())
72+
.map((name) => ({ name, slug: toAuthorSlug(name), bio: getAuthorBioByName(name) }))
73+
.filter((entry) => entry.slug && entry.bio)
74+
.filter((entry) => {
75+
if (seen.has(entry.slug)) return false;
76+
seen.add(entry.slug);
77+
return true;
78+
});
79+
80+
if (profiles.length === 0) return null;
81+
82+
return (
83+
<section
84+
aria-label={profiles.length === 1 ? "About the author" : "About the authors"}
85+
className="mt-8 rounded-xl border border-stroke-neutral-strong p-5 sm:p-6"
86+
>
87+
<h2 className="text-xs uppercase tracking-wide font-semibold text-foreground-neutral-weak mb-5">
88+
{profiles.length === 1 ? "About the author" : "About the authors"}
89+
</h2>
90+
<div className="flex flex-col gap-6">
91+
{profiles.map(({ name, slug, bio }) => {
92+
const imageSrc = getAuthorImageSrc(name);
93+
return (
94+
<div key={slug} className="flex gap-4">
95+
{imageSrc ? (
96+
<Link href={`/author/${slug}`} className="shrink-0">
97+
<Avatar
98+
format="image"
99+
src={withBlogBasePathForImageSrc(imageSrc)}
100+
alt={name}
101+
size="xl"
102+
/>
103+
</Link>
104+
) : null}
105+
<div className="min-w-0">
106+
<Link
107+
href={`/author/${slug}`}
108+
className="type-title-md text-foreground-neutral hover:text-fd-primary hover:underline"
109+
>
110+
{name}
111+
</Link>
112+
<p className="mt-1 mb-2 text-sm text-foreground-neutral-weak">{bio?.bio}</p>
113+
<AuthorSocialLinks socials={bio?.socials ?? []} name={name} />
114+
</div>
115+
</div>
116+
);
117+
})}
118+
</div>
119+
</section>
120+
);
121+
}

0 commit comments

Comments
 (0)