Skip to content

Commit c3e76d9

Browse files
NiallJoeMaherclaude
andcommitted
feat: branded dynamic OG/social cards via @vercel/og
Replace the single Lato/planet OG card with the relaunch design system: five Satori-safe templates (main, post, profile, publication, job) served from one `/og` route, wired into every shareable page from real DB models. - lib/og/{tokens,fonts,templates}.ts(x): design tokens, Google-font loader (edge), and the card components. app/og/route.tsx dispatches on `type` and embeds the white wordmark (public/og/wordmark-white.png) as a cached data URI so Satori never resolves a same-origin <img> mid-render. - lib/og/url.ts: typed builders (one per card) that map our models to the route's params, cap tags, and append a `v=<updatedAt>` cache-buster. - utils/hue.ts: extract the deterministic per-string hue (was inline in the source-profile client) so OG avatars/marks match on-site tints. - Add openGraph.images + twitter (summary_large_image) to home/fallback, about, advertise, discussions, jobs, tag, speakers, volunteer, and the dynamic article/discussion/link/profile/publication/job pages. JSON-LD article image uses the same builder. - Long immutable cache-control on the route; runtime stays edge. Verified: all card types render 1200x630 PNGs and og:image resolves on real pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dd621f2 commit c3e76d9

23 files changed

Lines changed: 986 additions & 265 deletions

File tree

‎app/(app)/[username]/[slug]/page.tsx‎

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
1313
import { serverApi } from "@/server/trpc/caller";
1414
import { JsonLd } from "@/components/JsonLd";
1515
import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data";
16+
import { ogPostImage } from "@/lib/og/url";
17+
18+
// Bare host for a link's "via {source}" chip, e.g. "anthropic.com".
19+
const hostFromUrl = (value?: string | null): string | undefined => {
20+
if (!value) return undefined;
21+
try {
22+
return new URL(value).hostname.replace(/^www\./, "");
23+
} catch {
24+
return undefined;
25+
}
26+
};
1627

1728
type Props = { params: Promise<{ username: string; slug: string }> };
1829

@@ -68,6 +79,7 @@ async function getUserPostUncached(
6879
authorImage: user.image,
6980
authorUsername: user.username,
7081
authorBio: user.bio,
82+
authorJobTitle: user.jobTitle,
7183
})
7284
.from(posts)
7385
.leftJoin(user, eq(posts.authorId, user.id))
@@ -111,6 +123,7 @@ async function getUserPostUncached(
111123
image: postRecord.authorImage,
112124
username: postRecord.authorUsername,
113125
bio: postRecord.authorBio,
126+
jobTitle: postRecord.authorJobTitle,
114127
},
115128
};
116129
}
@@ -145,6 +158,7 @@ async function getUserLinkPostUncached(username: string, postSlug: string) {
145158
authorImage: user.image,
146159
authorUsername: user.username,
147160
authorBio: user.bio,
161+
authorJobTitle: user.jobTitle,
148162
})
149163
.from(posts)
150164
.leftJoin(user, eq(posts.authorId, user.id))
@@ -182,6 +196,7 @@ async function getUserLinkPostUncached(username: string, postSlug: string) {
182196
image: linkPost.authorImage,
183197
username: linkPost.authorUsername,
184198
bio: linkPost.authorBio,
199+
jobTitle: linkPost.authorJobTitle,
185200
},
186201
};
187202
}
@@ -369,6 +384,16 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
369384
}
370385
const tags = userPost.tags.map((tag) => tag.tag.title);
371386
const authorName = userPost.user.name || "Unknown";
387+
const postOgImage = ogPostImage({
388+
kind: "article",
389+
title: userPost.title,
390+
authorName,
391+
authorRole: userPost.user.jobTitle,
392+
authorKey: userPost.user.username ?? authorName,
393+
tags,
394+
readMins: userPost.readTimeMins,
395+
updatedAt: userPost.updatedAt,
396+
});
372397

373398
return {
374399
title: `${userPost.title} | by ${authorName} | Codú`,
@@ -386,18 +411,13 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
386411
url: `/${userPost.user.username ?? username}/${userPost.slug}`,
387412
publishedTime: userPost.published ?? undefined,
388413
modifiedTime: userPost.updatedAt ?? undefined,
389-
images: [
390-
`/og?title=${encodeURIComponent(
391-
userPost.title,
392-
)}&readTime=${userPost.readTimeMins}&author=${encodeURIComponent(
393-
authorName,
394-
)}&date=${userPost.updatedAt}`,
395-
],
414+
images: [postOgImage],
396415
siteName: "Codú",
397416
},
398417
twitter: {
418+
card: "summary_large_image",
399419
description: userPost.excerpt ?? undefined,
400-
images: [`/og?title=${encodeURIComponent(userPost.title)}`],
420+
images: [postOgImage],
401421
},
402422
alternates: {
403423
// Cross-posted content points at the original; native posts
@@ -413,6 +433,15 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
413433
if (userArticle && userArticle.user) {
414434
const tags = userArticle.tags?.map((t) => t.tag.title) || [];
415435
const articleAuthorName = userArticle.user.name || "Unknown";
436+
const articleOgImage = ogPostImage({
437+
kind: "article",
438+
title: userArticle.title,
439+
authorName: articleAuthorName,
440+
authorKey: userArticle.user.username ?? articleAuthorName,
441+
tags,
442+
readMins: userArticle.readTimeMins || 5,
443+
updatedAt: userArticle.updatedAt,
444+
});
416445

417446
return {
418447
title: `${userArticle.title} | by ${articleAuthorName} | Codú`,
@@ -428,18 +457,13 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
428457
url: `/${userArticle.user.username ?? username}/${userArticle.slug}`,
429458
publishedTime: userArticle.published ?? undefined,
430459
modifiedTime: userArticle.updatedAt ?? undefined,
431-
images: [
432-
`/og?title=${encodeURIComponent(
433-
userArticle.title,
434-
)}&readTime=${userArticle.readTimeMins || 5}&author=${encodeURIComponent(
435-
userArticle.user.name || "",
436-
)}&date=${userArticle.updatedAt}`,
437-
],
460+
images: [articleOgImage],
438461
siteName: "Codú",
439462
},
440463
twitter: {
464+
card: "summary_large_image",
441465
description: userArticle.excerpt || "",
442-
images: [`/og?title=${encodeURIComponent(userArticle.title)}`],
466+
images: [articleOgImage],
443467
},
444468
alternates: {
445469
canonical:
@@ -452,6 +476,17 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
452476
const userLinkPost = await getUserLinkPost(username, slug);
453477
if (userLinkPost && userLinkPost.user) {
454478
const linkAuthorName = userLinkPost.user.name || "Unknown";
479+
const linkOgImage = ogPostImage({
480+
kind: "link",
481+
title: userLinkPost.title,
482+
authorName: linkAuthorName,
483+
authorRole: userLinkPost.user.jobTitle,
484+
authorKey: userLinkPost.user.username ?? linkAuthorName,
485+
tags: userLinkPost.tags.map((t) => t.tag.title),
486+
source: hostFromUrl(userLinkPost.externalUrl),
487+
cover: userLinkPost.coverImage,
488+
updatedAt: userLinkPost.updatedAt,
489+
});
455490

456491
return {
457492
title: `${userLinkPost.title} | shared by ${linkAuthorName} | Codú`,
@@ -463,9 +498,14 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
463498
openGraph: {
464499
title: userLinkPost.title,
465500
description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`,
466-
images: userLinkPost.coverImage ? [userLinkPost.coverImage] : undefined,
501+
images: [linkOgImage],
467502
siteName: "Codú",
468503
},
504+
twitter: {
505+
card: "summary_large_image",
506+
description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`,
507+
images: [linkOgImage],
508+
},
469509
// Member-shared links keep Codú as canonical (aggregated links point to source).
470510
alternates: {
471511
canonical: `/${userLinkPost.user.username ?? username}/${userLinkPost.slug}`,

‎app/(app)/[username]/page.tsx‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import Content from "./_usernameClient";
44
import { getServerAuthSession } from "@/server/auth";
55
import { type Metadata } from "next";
66
import { db } from "@/server/db";
7-
import { feed_sources } from "@/server/db/schema";
8-
import { eq, sql } from "drizzle-orm";
7+
import { feed_sources, follow } from "@/server/db/schema";
8+
import { count, eq, sql } from "drizzle-orm";
99
import { JsonLd } from "@/components/JsonLd";
1010
import { getProfilePageSchema } from "@/lib/structured-data";
11+
import { ogProfileImage } from "@/lib/og/url";
1112

1213
type Props = { params: Promise<{ username: string }> };
1314

@@ -18,9 +19,15 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
1819
// Case-insensitive handle resolution (GitHub-style) on lower(username).
1920
const profile = await db.query.user.findFirst({
2021
columns: {
22+
id: true,
2123
bio: true,
2224
name: true,
2325
username: true,
26+
location: true,
27+
jobTitle: true,
28+
topics: true,
29+
createdAt: true,
30+
updatedAt: true,
2431
},
2532
where: (users) => sql`lower(${users.username}) = ${username.toLowerCase()}`,
2633
});
@@ -32,6 +39,23 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
3239
const title = `${name || handle} (@${handle}) | Codú`;
3340
const description = `${name || handle}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`;
3441

42+
const [followerRow] = await db
43+
.select({ value: count() })
44+
.from(follow)
45+
.where(eq(follow.followingId, profile.id));
46+
const joined = `Joined ${new Date(profile.createdAt).toLocaleString("en-US", { month: "short", year: "numeric" })}`;
47+
const ogImage = ogProfileImage({
48+
name: name || handle,
49+
key: handle,
50+
role: profile.jobTitle,
51+
location: profile.location,
52+
bio,
53+
followers: followerRow?.value ?? 0,
54+
joined,
55+
interests: profile.topics,
56+
updatedAt: profile.updatedAt,
57+
});
58+
3559
return {
3660
title,
3761
description,
@@ -44,7 +68,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
4468
type: "profile",
4569
images: [
4670
{
47-
url: "/images/og/home-og.png",
71+
url: ogImage,
4872
width: 1200,
4973
height: 630,
5074
alt: `${name || username}'s profile on Codú`,
@@ -56,7 +80,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
5680
card: "summary_large_image",
5781
title,
5882
description,
59-
images: ["/images/og/home-og.png"],
83+
images: [ogImage],
6084
},
6185
};
6286
}

‎app/(app)/d/[slug]/page.tsx‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { headers } from "next/headers";
33
import { notFound, permanentRedirect } from "next/navigation";
44
import { type Metadata } from "next";
55
import { SITE_ORIGIN } from "@/config/site";
6+
import { ogPostImage } from "@/lib/og/url";
67
import { getServerAuthSession } from "@/server/auth";
78
import { db } from "@/server/db";
89
import { posts, user, post_tags, tag, comments } from "@/server/db/schema";
@@ -171,6 +172,14 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
171172
}
172173

173174
const authorName = post.user.name || "Unknown";
175+
const ogImage = ogPostImage({
176+
kind: "discussion",
177+
title: post.title,
178+
authorName,
179+
authorKey: post.user.username ?? authorName,
180+
tags: post.tags.map((t) => t.tag.title),
181+
updatedAt: post.updatedAt,
182+
});
174183

175184
return {
176185
title: `${post.title} — Discussion | Codú`,
@@ -187,15 +196,12 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
187196
url: canonical,
188197
publishedTime: post.published ?? undefined,
189198
modifiedTime: post.updatedAt ?? undefined,
190-
images: [
191-
`/og?title=${encodeURIComponent(
192-
post.title,
193-
)}&author=${encodeURIComponent(authorName)}&date=${post.updatedAt}`,
194-
],
199+
images: [ogImage],
195200
},
196201
twitter: {
202+
card: "summary_large_image",
197203
description: post.excerpt ?? undefined,
198-
images: [`/og?title=${encodeURIComponent(post.title)}`],
204+
images: [ogImage],
199205
},
200206
};
201207
}

‎app/(app)/discussions/page.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
import Content from "./_client";
22
import { getServerAuthSession } from "@/server/auth";
33
import { serverApi } from "@/server/trpc/caller";
4+
import { ogMainImage } from "@/lib/og/url";
5+
6+
const ogDescription =
7+
"Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.";
8+
const ogImage = ogMainImage("discussions");
49

510
export const metadata = {
611
title: "Discussions — Codú",
7-
description:
8-
"Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.",
12+
description: ogDescription,
913
// Canonical to the bare path so ?sort/?filter param variants don't get indexed.
1014
alternates: { canonical: "/discussions" },
1115
openGraph: {
1216
title: "Discussions — Codú",
13-
description:
14-
"Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.",
17+
description: ogDescription,
1518
type: "website",
1619
siteName: "Codú",
20+
images: [ogImage],
21+
},
22+
twitter: {
23+
card: "summary_large_image",
24+
title: "Discussions — Codú",
25+
description: ogDescription,
26+
images: [ogImage],
1727
},
1828
};
1929

‎app/(app)/jobs/[slug]/page.tsx‎

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,66 @@
1+
import { type Metadata } from "next";
2+
import { and, eq } from "drizzle-orm";
3+
import { db } from "@/server/db";
4+
import { job } from "@/server/db/schema";
5+
import { ogJobImage } from "@/lib/og/url";
16
import JobDetailClient from "./_client";
27

3-
export default async function JobDetailPage({
4-
params,
5-
}: {
6-
params: Promise<{ slug: string }>;
7-
}) {
8-
const { slug } = await params;
8+
type Props = { params: Promise<{ slug: string }> };
9+
10+
const JOB_TYPE_LABEL: Record<string, string> = {
11+
"full-time": "Full-time",
12+
"part-time": "Part-time",
13+
freelancer: "Freelance",
14+
other: "Contract",
15+
};
16+
17+
export async function generateMetadata(props: Props): Promise<Metadata> {
18+
const { slug } = await props.params;
19+
20+
const [listing] = await db
21+
.select()
22+
.from(job)
23+
.where(and(eq(job.slug, slug), eq(job.status, "active")))
24+
.limit(1);
25+
26+
if (!listing) {
27+
return { title: "Job Not Found" };
28+
}
29+
30+
const title = `${listing.jobTitle} at ${listing.companyName} | Codú`;
31+
const description =
32+
`${listing.jobTitle} — ${listing.companyName}. ${listing.jobLocation}.`.trim();
33+
const ogImage = ogJobImage({
34+
company: listing.companyName,
35+
role: listing.jobTitle,
36+
location: listing.jobLocation,
37+
jobType: JOB_TYPE_LABEL[listing.type] ?? "Full-time",
38+
tags: listing.tags,
39+
featured: listing.featured,
40+
updatedAt: listing.updatedAt,
41+
});
42+
43+
return {
44+
title,
45+
description,
46+
alternates: { canonical: `/jobs/${listing.slug}` },
47+
openGraph: {
48+
title,
49+
description,
50+
type: "website",
51+
siteName: "Codú",
52+
images: [ogImage],
53+
},
54+
twitter: {
55+
card: "summary_large_image",
56+
title,
57+
description,
58+
images: [ogImage],
59+
},
60+
};
61+
}
62+
63+
export default async function JobDetailPage(props: Props) {
64+
const { slug } = await props.params;
965
return <JobDetailClient slug={slug} />;
1066
}

0 commit comments

Comments
 (0)