diff --git a/app/(app)/[username]/[slug]/page.tsx b/app/(app)/[username]/[slug]/page.tsx index f7122466b..b6e6f8e82 100644 --- a/app/(app)/[username]/[slug]/page.tsx +++ b/app/(app)/[username]/[slug]/page.tsx @@ -13,6 +13,17 @@ import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url"; import { serverApi } from "@/server/trpc/caller"; import { JsonLd } from "@/components/JsonLd"; import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data"; +import { ogPostImage } from "@/lib/og/url"; + +// Bare host for a link's "via {source}" chip, e.g. "anthropic.com". +const hostFromUrl = (value?: string | null): string | undefined => { + if (!value) return undefined; + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +}; type Props = { params: Promise<{ username: string; slug: string }> }; @@ -68,6 +79,7 @@ async function getUserPostUncached( authorImage: user.image, authorUsername: user.username, authorBio: user.bio, + authorJobTitle: user.jobTitle, }) .from(posts) .leftJoin(user, eq(posts.authorId, user.id)) @@ -111,6 +123,7 @@ async function getUserPostUncached( image: postRecord.authorImage, username: postRecord.authorUsername, bio: postRecord.authorBio, + jobTitle: postRecord.authorJobTitle, }, }; } @@ -145,6 +158,7 @@ async function getUserLinkPostUncached(username: string, postSlug: string) { authorImage: user.image, authorUsername: user.username, authorBio: user.bio, + authorJobTitle: user.jobTitle, }) .from(posts) .leftJoin(user, eq(posts.authorId, user.id)) @@ -182,6 +196,7 @@ async function getUserLinkPostUncached(username: string, postSlug: string) { image: linkPost.authorImage, username: linkPost.authorUsername, bio: linkPost.authorBio, + jobTitle: linkPost.authorJobTitle, }, }; } @@ -369,6 +384,16 @@ export async function generateMetadata(props: Props): Promise { } const tags = userPost.tags.map((tag) => tag.tag.title); const authorName = userPost.user.name || "Unknown"; + const postOgImage = ogPostImage({ + kind: "article", + title: userPost.title, + authorName, + authorRole: userPost.user.jobTitle, + authorKey: userPost.user.username ?? authorName, + tags, + readMins: userPost.readTimeMins, + updatedAt: userPost.updatedAt, + }); return { title: `${userPost.title} | by ${authorName} | Codú`, @@ -386,18 +411,13 @@ export async function generateMetadata(props: Props): Promise { url: `/${userPost.user.username ?? username}/${userPost.slug}`, publishedTime: userPost.published ?? undefined, modifiedTime: userPost.updatedAt ?? undefined, - images: [ - `/og?title=${encodeURIComponent( - userPost.title, - )}&readTime=${userPost.readTimeMins}&author=${encodeURIComponent( - authorName, - )}&date=${userPost.updatedAt}`, - ], + images: [postOgImage], siteName: "Codú", }, twitter: { + card: "summary_large_image", description: userPost.excerpt ?? undefined, - images: [`/og?title=${encodeURIComponent(userPost.title)}`], + images: [postOgImage], }, alternates: { // Cross-posted content points at the original; native posts @@ -413,6 +433,15 @@ export async function generateMetadata(props: Props): Promise { if (userArticle && userArticle.user) { const tags = userArticle.tags?.map((t) => t.tag.title) || []; const articleAuthorName = userArticle.user.name || "Unknown"; + const articleOgImage = ogPostImage({ + kind: "article", + title: userArticle.title, + authorName: articleAuthorName, + authorKey: userArticle.user.username ?? articleAuthorName, + tags, + readMins: userArticle.readTimeMins || 5, + updatedAt: userArticle.updatedAt, + }); return { title: `${userArticle.title} | by ${articleAuthorName} | Codú`, @@ -428,18 +457,13 @@ export async function generateMetadata(props: Props): Promise { url: `/${userArticle.user.username ?? username}/${userArticle.slug}`, publishedTime: userArticle.published ?? undefined, modifiedTime: userArticle.updatedAt ?? undefined, - images: [ - `/og?title=${encodeURIComponent( - userArticle.title, - )}&readTime=${userArticle.readTimeMins || 5}&author=${encodeURIComponent( - userArticle.user.name || "", - )}&date=${userArticle.updatedAt}`, - ], + images: [articleOgImage], siteName: "Codú", }, twitter: { + card: "summary_large_image", description: userArticle.excerpt || "", - images: [`/og?title=${encodeURIComponent(userArticle.title)}`], + images: [articleOgImage], }, alternates: { canonical: @@ -452,6 +476,17 @@ export async function generateMetadata(props: Props): Promise { const userLinkPost = await getUserLinkPost(username, slug); if (userLinkPost && userLinkPost.user) { const linkAuthorName = userLinkPost.user.name || "Unknown"; + const linkOgImage = ogPostImage({ + kind: "link", + title: userLinkPost.title, + authorName: linkAuthorName, + authorRole: userLinkPost.user.jobTitle, + authorKey: userLinkPost.user.username ?? linkAuthorName, + tags: userLinkPost.tags.map((t) => t.tag.title), + source: hostFromUrl(userLinkPost.externalUrl), + cover: userLinkPost.coverImage, + updatedAt: userLinkPost.updatedAt, + }); return { title: `${userLinkPost.title} | shared by ${linkAuthorName} | Codú`, @@ -463,9 +498,14 @@ export async function generateMetadata(props: Props): Promise { openGraph: { title: userLinkPost.title, description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`, - images: userLinkPost.coverImage ? [userLinkPost.coverImage] : undefined, + images: [linkOgImage], siteName: "Codú", }, + twitter: { + card: "summary_large_image", + description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`, + images: [linkOgImage], + }, // Member-shared links keep Codú as canonical (aggregated links point to source). alternates: { canonical: `/${userLinkPost.user.username ?? username}/${userLinkPost.slug}`, diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx index af17250cd..79bcd9135 100644 --- a/app/(app)/[username]/page.tsx +++ b/app/(app)/[username]/page.tsx @@ -4,10 +4,11 @@ import Content from "./_usernameClient"; import { getServerAuthSession } from "@/server/auth"; import { type Metadata } from "next"; import { db } from "@/server/db"; -import { feed_sources } from "@/server/db/schema"; -import { eq, sql } from "drizzle-orm"; +import { feed_sources, follow } from "@/server/db/schema"; +import { count, eq, sql } from "drizzle-orm"; import { JsonLd } from "@/components/JsonLd"; import { getProfilePageSchema } from "@/lib/structured-data"; +import { ogProfileImage } from "@/lib/og/url"; type Props = { params: Promise<{ username: string }> }; @@ -18,9 +19,15 @@ export async function generateMetadata(props: Props): Promise { // Case-insensitive handle resolution (GitHub-style) on lower(username). const profile = await db.query.user.findFirst({ columns: { + id: true, bio: true, name: true, username: true, + location: true, + jobTitle: true, + topics: true, + createdAt: true, + updatedAt: true, }, where: (users) => sql`lower(${users.username}) = ${username.toLowerCase()}`, }); @@ -32,6 +39,23 @@ export async function generateMetadata(props: Props): Promise { const title = `${name || handle} (@${handle}) | Codú`; const description = `${name || handle}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`; + const [followerRow] = await db + .select({ value: count() }) + .from(follow) + .where(eq(follow.followingId, profile.id)); + const joined = `Joined ${new Date(profile.createdAt).toLocaleString("en-US", { month: "short", year: "numeric" })}`; + const ogImage = ogProfileImage({ + name: name || handle, + key: handle, + role: profile.jobTitle, + location: profile.location, + bio, + followers: followerRow?.value ?? 0, + joined, + interests: profile.topics, + updatedAt: profile.updatedAt, + }); + return { title, description, @@ -44,7 +68,7 @@ export async function generateMetadata(props: Props): Promise { type: "profile", images: [ { - url: "/images/og/home-og.png", + url: ogImage, width: 1200, height: 630, alt: `${name || username}'s profile on Codú`, @@ -56,7 +80,7 @@ export async function generateMetadata(props: Props): Promise { card: "summary_large_image", title, description, - images: ["/images/og/home-og.png"], + images: [ogImage], }, }; } diff --git a/app/(app)/d/[slug]/page.tsx b/app/(app)/d/[slug]/page.tsx index dee4bbf54..a7bddbc3b 100644 --- a/app/(app)/d/[slug]/page.tsx +++ b/app/(app)/d/[slug]/page.tsx @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { notFound, permanentRedirect } from "next/navigation"; import { type Metadata } from "next"; import { SITE_ORIGIN } from "@/config/site"; +import { ogPostImage } from "@/lib/og/url"; import { getServerAuthSession } from "@/server/auth"; import { db } from "@/server/db"; import { posts, user, post_tags, tag, comments } from "@/server/db/schema"; @@ -171,6 +172,14 @@ export async function generateMetadata(props: Props): Promise { } const authorName = post.user.name || "Unknown"; + const ogImage = ogPostImage({ + kind: "discussion", + title: post.title, + authorName, + authorKey: post.user.username ?? authorName, + tags: post.tags.map((t) => t.tag.title), + updatedAt: post.updatedAt, + }); return { title: `${post.title} — Discussion | Codú`, @@ -187,15 +196,12 @@ export async function generateMetadata(props: Props): Promise { url: canonical, publishedTime: post.published ?? undefined, modifiedTime: post.updatedAt ?? undefined, - images: [ - `/og?title=${encodeURIComponent( - post.title, - )}&author=${encodeURIComponent(authorName)}&date=${post.updatedAt}`, - ], + images: [ogImage], }, twitter: { + card: "summary_large_image", description: post.excerpt ?? undefined, - images: [`/og?title=${encodeURIComponent(post.title)}`], + images: [ogImage], }, }; } diff --git a/app/(app)/discussions/page.tsx b/app/(app)/discussions/page.tsx index 517d62318..754caec18 100644 --- a/app/(app)/discussions/page.tsx +++ b/app/(app)/discussions/page.tsx @@ -1,19 +1,29 @@ import Content from "./_client"; import { getServerAuthSession } from "@/server/auth"; import { serverApi } from "@/server/trpc/caller"; +import { ogMainImage } from "@/lib/og/url"; + +const ogDescription = + "Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI."; +const ogImage = ogMainImage("discussions"); export const metadata = { title: "Discussions — Codú", - description: - "Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.", + description: ogDescription, // Canonical to the bare path so ?sort/?filter param variants don't get indexed. alternates: { canonical: "/discussions" }, openGraph: { title: "Discussions — Codú", - description: - "Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.", + description: ogDescription, type: "website", siteName: "Codú", + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title: "Discussions — Codú", + description: ogDescription, + images: [ogImage], }, }; diff --git a/app/(app)/jobs/[slug]/page.tsx b/app/(app)/jobs/[slug]/page.tsx index 52b870b1f..e2199ec51 100644 --- a/app/(app)/jobs/[slug]/page.tsx +++ b/app/(app)/jobs/[slug]/page.tsx @@ -1,10 +1,66 @@ +import { type Metadata } from "next"; +import { and, eq } from "drizzle-orm"; +import { db } from "@/server/db"; +import { job } from "@/server/db/schema"; +import { ogJobImage } from "@/lib/og/url"; import JobDetailClient from "./_client"; -export default async function JobDetailPage({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; +type Props = { params: Promise<{ slug: string }> }; + +const JOB_TYPE_LABEL: Record = { + "full-time": "Full-time", + "part-time": "Part-time", + freelancer: "Freelance", + other: "Contract", +}; + +export async function generateMetadata(props: Props): Promise { + const { slug } = await props.params; + + const [listing] = await db + .select() + .from(job) + .where(and(eq(job.slug, slug), eq(job.status, "active"))) + .limit(1); + + if (!listing) { + return { title: "Job Not Found" }; + } + + const title = `${listing.jobTitle} at ${listing.companyName} | Codú`; + const description = + `${listing.jobTitle} — ${listing.companyName}. ${listing.jobLocation}.`.trim(); + const ogImage = ogJobImage({ + company: listing.companyName, + role: listing.jobTitle, + location: listing.jobLocation, + jobType: JOB_TYPE_LABEL[listing.type] ?? "Full-time", + tags: listing.tags, + featured: listing.featured, + updatedAt: listing.updatedAt, + }); + + return { + title, + description, + alternates: { canonical: `/jobs/${listing.slug}` }, + openGraph: { + title, + description, + type: "website", + siteName: "Codú", + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ogImage], + }, + }; +} + +export default async function JobDetailPage(props: Props) { + const { slug } = await props.params; return ; } diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx index e6dd24212..106c13032 100644 --- a/app/(app)/jobs/page.tsx +++ b/app/(app)/jobs/page.tsx @@ -1,10 +1,25 @@ import type { Metadata } from "next"; import JobsClient from "./_client"; +import { ogMainImage } from "@/lib/og/url"; + +const ogDescription = + "Curated AI developer jobs — roles building with AI, LLMs, and agents. Remote, full-time, freelance, and more."; +const ogImage = ogMainImage("jobs"); export const metadata: Metadata = { title: "AI developer jobs — Codú", - description: - "Curated AI developer jobs — roles building with AI, LLMs, and agents. Remote, full-time, freelance, and more.", + description: ogDescription, + openGraph: { + title: "AI developer jobs — Codú", + description: ogDescription, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title: "AI developer jobs — Codú", + description: ogDescription, + images: [ogImage], + }, }; export default function JobsPage() { diff --git a/app/(app)/s/[sourceSlug]/[slug]/page.tsx b/app/(app)/s/[sourceSlug]/[slug]/page.tsx index ba6c608f8..af99741d7 100644 --- a/app/(app)/s/[sourceSlug]/[slug]/page.tsx +++ b/app/(app)/s/[sourceSlug]/[slug]/page.tsx @@ -8,6 +8,7 @@ import { } from "@/lib/structured-data"; import { getFeedArticle, resolveAggregatedCanonical } from "./_resolvers"; import FeedArticleContent from "./_feedArticleContent"; +import { ogPostImage } from "@/lib/og/url"; type Props = { params: Promise<{ sourceSlug: string; slug: string }> }; @@ -19,17 +20,32 @@ export async function generateMetadata(props: Props): Promise { return { title: "Content Not Found" }; } + const description = + feedArticle.excerpt || `Discussion about ${feedArticle.title}`; + const ogImage = ogPostImage({ + kind: "link", + title: feedArticle.title, + authorName: + feedArticle.sourceAuthor || feedArticle.source.name || sourceSlug, + authorKey: feedArticle.source.slug || sourceSlug, + source: feedArticle.source.name, + cover: feedArticle.ogImageUrl || feedArticle.imageUrl, + updatedAt: feedArticle.updatedAt, + }); + return { title: `${feedArticle.title} | Codú Feed`, - description: feedArticle.excerpt || `Discussion about ${feedArticle.title}`, + description, openGraph: { title: feedArticle.title, - description: - feedArticle.excerpt || `Discussion about ${feedArticle.title}`, - images: - feedArticle.ogImageUrl || feedArticle.imageUrl - ? [feedArticle.ogImageUrl || feedArticle.imageUrl!] - : undefined, + description, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title: feedArticle.title, + description, + images: [ogImage], }, alternates: { // Self-canonical: these are snippet + outbound-link listings (not full-body diff --git a/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx b/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx index 7e79fde76..0f985419a 100644 --- a/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx +++ b/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { api } from "@/server/trpc/react"; import { type RouterOutputs } from "@/server/trpc/shared"; import { UnifiedContentCard } from "@/components/UnifiedContentCard"; +import { hueFromString } from "@/utils/hue"; type SourceProfile = RouterOutputs["publication"]["getBySlug"]; @@ -18,14 +19,6 @@ type Props = { initialProfile: SourceProfile; }; -// Deterministic hue from the slug (sum of char codes mod 360). Math.random is -// unavailable here, and the publication tile colour must be stable per source. -const hueFromSlug = (slug: string): number => { - let sum = 0; - for (let i = 0; i < slug.length; i++) sum += slug.charCodeAt(i); - return sum % 360; -}; - // Two-letter initials for the square logo tile. const initialsFromName = (name: string): string => { const words = name.trim().split(/\s+/).filter(Boolean); @@ -91,7 +84,7 @@ const SourceProfileContent = ({ sourceSlug, initialProfile }: Props) => { ); } - const hue = hueFromSlug(pub.slug ?? sourceSlug); + const hue = hueFromString(pub.slug ?? sourceSlug); const initials = initialsFromName(pub.name); const isFollowing = optimisticFollowing ?? pub.isFollowing; diff --git a/app/(app)/s/[sourceSlug]/page.tsx b/app/(app)/s/[sourceSlug]/page.tsx index 145045453..f2cc058cd 100644 --- a/app/(app)/s/[sourceSlug]/page.tsx +++ b/app/(app)/s/[sourceSlug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { type Metadata } from "next"; import { getSourceProfile } from "./_resolvers"; import SourceProfileContent from "./_sourceProfileClient"; +import { ogPublicationImage } from "@/lib/og/url"; type Props = { params: Promise<{ sourceSlug: string }> }; @@ -15,15 +16,30 @@ export async function generateMetadata(props: Props): Promise { return { title: "Publication Not Found" }; } + const description = + source.tagline || `Articles from ${source.name} on Codú Feed`; + const ogImage = ogPublicationImage({ + name: source.name, + key: source.slug ?? sourceSlug, + tagline: source.tagline, + articleCount: source.articleCount, + followers: source.followerCount, + }); + return { title: `${source.name} | Codú Feed`, - description: source.tagline || `Articles from ${source.name} on Codú Feed`, + description, alternates: { canonical: `/s/${source.slug}` }, openGraph: { title: source.name, - description: - source.tagline || `Articles from ${source.name} on Codú Feed`, - images: source.logoUrl ? [source.logoUrl] : undefined, + description, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title: source.name, + description, + images: [ogImage], }, }; } diff --git a/app/(app)/speakers/page.tsx b/app/(app)/speakers/page.tsx index 9e060433f..336925ce8 100644 --- a/app/(app)/speakers/page.tsx +++ b/app/(app)/speakers/page.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; import { SpeakersClient } from "./_client"; +import { ogMainImage } from "@/lib/og/url"; + +const OG_IMAGE = ogMainImage("home"); const PAGE_URL = "https://www.codu.co/speakers"; const PAGE_TITLE = "Speak at Codú — Pitch a Talk for Our Meetups"; @@ -24,11 +27,13 @@ export const metadata: Metadata = { description: PAGE_DESCRIPTION, url: PAGE_URL, type: "website", + images: [OG_IMAGE], }, twitter: { card: "summary_large_image", title: "Speak at Codú", description: PAGE_DESCRIPTION, + images: [OG_IMAGE], }, }; diff --git a/app/(app)/tag/[slug]/page.tsx b/app/(app)/tag/[slug]/page.tsx index f6f5d631f..129c8f72c 100644 --- a/app/(app)/tag/[slug]/page.tsx +++ b/app/(app)/tag/[slug]/page.tsx @@ -6,6 +6,7 @@ import { db } from "@/server/db"; import { posts, post_tags, tag, user, feed_sources } from "@/server/db/schema"; import { and, desc, eq, lte } from "drizzle-orm"; import { getCamelCaseFromLower } from "@/utils/utils"; +import { ogMainImage } from "@/lib/og/url"; type Props = { params: Promise<{ slug: string }>; @@ -114,6 +115,8 @@ export async function generateMetadata(props: Props): Promise { tagRow.description || `Articles, discussions, and resources tagged #${label} on Codú.`; + const ogImage = ogMainImage("articles"); + return { title, description, @@ -122,6 +125,13 @@ export async function generateMetadata(props: Props): Promise { title, description, siteName: "Codú", + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ogImage], }, }; } diff --git a/app/(app)/volunteer/page.tsx b/app/(app)/volunteer/page.tsx index 27c6723c5..cd934163a 100644 --- a/app/(app)/volunteer/page.tsx +++ b/app/(app)/volunteer/page.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; import { VolunteerClient } from "./_client"; +import { ogMainImage } from "@/lib/og/url"; + +const OG_IMAGE = ogMainImage("home"); const PAGE_URL = "https://www.codu.co/volunteer"; const PAGE_TITLE = "Volunteer with Codú — Help grow our community"; @@ -25,11 +28,13 @@ export const metadata: Metadata = { description: PAGE_DESCRIPTION, url: PAGE_URL, type: "website", + images: [OG_IMAGE], }, twitter: { card: "summary_large_image", title: "Volunteer with Codú", description: PAGE_DESCRIPTION, + images: [OG_IMAGE], }, }; diff --git a/app/(marketing)/about/page.tsx b/app/(marketing)/about/page.tsx index d7c0850d7..b03514ac4 100644 --- a/app/(marketing)/about/page.tsx +++ b/app/(marketing)/about/page.tsx @@ -2,6 +2,9 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Eyebrow, NewsletterCapture } from "@/components/ds"; import { twitterUrl, linkedinUrl } from "@/config/site_settings"; +import { ogMainImage } from "@/lib/og/url"; + +const ogImage = ogMainImage("about"); export const metadata: Metadata = { title: "About Codú — The community for AI builders & indie hackers", @@ -11,7 +14,14 @@ export const metadata: Metadata = { title: "About Codú — The community for AI builders & indie hackers", description: "Learn to build with AI, share what you ship, and grow with people doing the same. Free to join.", - images: "/images/og/home-og.png", + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title: "About Codú — The community for AI builders & indie hackers", + description: + "Learn to build with AI, share what you ship, and grow with people doing the same. Free to join.", + images: [ogImage], }, }; diff --git a/app/(marketing)/advertise/page.tsx b/app/(marketing)/advertise/page.tsx index 75da3a35a..dd857a971 100644 --- a/app/(marketing)/advertise/page.tsx +++ b/app/(marketing)/advertise/page.tsx @@ -2,11 +2,27 @@ import type { Metadata } from "next"; import { Eyebrow } from "@/components/ds"; import { AdvertiseTiers } from "@/components/Advertise/AdvertiseTiers"; import type { SponsorInterest } from "@/schema/sponsor"; +import { ogMainImage } from "@/lib/og/url"; + +const ogTitle = "Advertise with Codú — Reach a global community of AI builders"; +const ogDescription = + "Partner with Codú to reach a global community of AI builders and indie hackers. A few honest placements: a newsletter slot, a featured job, or an ongoing feed partnership."; +const ogImage = ogMainImage("advertise"); export const metadata: Metadata = { - title: "Advertise with Codú — Reach a global community of AI builders", - description: - "Partner with Codú to reach a global community of AI builders and indie hackers. A few honest placements: a newsletter slot, a featured job, or an ongoing feed partnership.", + title: ogTitle, + description: ogDescription, + openGraph: { + title: ogTitle, + description: ogDescription, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title: ogTitle, + description: ogDescription, + images: [ogImage], + }, }; const tiers: { diff --git a/app/layout.tsx b/app/layout.tsx index 522126e69..a5178a9b0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import { headers } from "next/headers"; import { SITE_ORIGIN } from "@/config/site"; +import { ogMainImage } from "@/lib/og/url"; import { getServerAuthSession } from "@/server/auth"; import "@/styles/globals.css"; import Fathom from "@/components/Fathom/Fathom"; @@ -78,7 +79,7 @@ export const metadata = { siteName: "Codú", images: [ { - url: `${SITE_ORIGIN}/images/og/home-og.png`, + url: ogMainImage("home"), width: 1200, height: 630, alt: "Codú — the community for AI builders & indie hackers", @@ -87,6 +88,13 @@ export const metadata = { locale: "en_US", type: "website", }, + twitter: { + card: "summary_large_image", + title: "Codú — The community for AI builders & indie hackers", + description: + "Codú is the community for AI builders and indie hackers. Learn to build with AI, share what you ship, and grow with people doing the same.", + images: [ogMainImage("home")], + }, }; export default async function RootLayout({ diff --git a/app/og/route.tsx b/app/og/route.tsx index 411f4d117..04d7042eb 100644 --- a/app/og/route.tsx +++ b/app/og/route.tsx @@ -1,216 +1,118 @@ +// Single dynamic OG endpoint for @vercel/og (next/og). All shareable pages +// point their openGraph/twitter images here via the builders in lib/og/url.ts. +// +// GET /og?type=main&id=home +// GET /og?type=post&kind=article&title=...&author=...&role=...&hue=200&read=6%20min&tags=RAG,evals&cover=https://... +// GET /og?type=profile&name=...&role=...&hue=200&location=...&bio=...&followers=3200&joined=...&interests=RAG,evals +// GET /og?type=publication&name=...&hue=184&tagline=...&articles=86&followers=12400 +// GET /og?type=job&company=...&logo=A&role=...&location=...&jobType=Full-time&tags=AI-native,LLM&featured=1 import { ImageResponse } from "next/og"; import * as Sentry from "@sentry/nextjs"; -import { Stars, Waves } from "@/components/background/background"; +import { OgImage, type OgParams } from "@/lib/og/templates"; +import { coduFonts } from "@/lib/og/fonts"; export const runtime = "edge"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; -const height = 630; -const width = 1200; +// Edited records pass a `v` param, so cards can cache hard and indefinitely. +const CACHE_CONTROL = "public, immutable, no-transform, max-age=31536000"; -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const origin = `${request.headers.get("x-forwarded-proto") || "http"}://${request.headers.get("host")}`; - - const title = searchParams.get("title"); - const author = searchParams.get("author"); - const readTime = searchParams.get("readTime"); - const date = searchParams.get("date"); - - // Only the title is required — byline/meta lines render when provided, so - // title-only callers (e.g. twitter images) get a valid card, not a 500. - if (!title) { - throw new Error("Missing required parameter: title"); - } +// Load and reuse the font set per worker. +let _fonts: Awaited> | null = null; +const fonts = async () => (_fonts ??= await coduFonts()); - const metaLine = [ - date ? formatDate(date) : null, - readTime ? `${readTime} min read` : null, - ] - .filter(Boolean) - .join(" · "); - - const regularFontData = await fetch( - new URL("@/assets/Lato-Regular.ttf", import.meta.url), - ).then((res) => res.arrayBuffer()); - - const boldFontData = await fetch( - new URL("@/assets/Lato-Bold.ttf", import.meta.url), - ).then((res) => res.arrayBuffer()); - - return new ImageResponse( -
- - -
-
-
-
- planet - {/* Main content */} -
-
- Codu Logo -
-
-
- {title} -
-
-
- {author && ( -
- {author} -
- )} - {metaLine && ( -
- {metaLine} -
- )} -
-
-
-
-
, - { - fonts: [ - { - name: "Lato", - data: regularFontData, - style: "normal", - weight: 400, - }, - { - name: "Lato-Bold", - data: boldFontData, - style: "normal", - weight: 700, - }, - ], - height, - width, - }, - ); - } catch (err) { - Sentry.captureException(err); - return new Response(`Failed to generate the image`, { - status: 500, - }); - } +// Embed the wordmark as a data URI (fetched + cached once per worker) so Satori +// never has to resolve a same-origin mid-render — that fetch is flaky in +// local dev and adds a round-trip in prod. +let _logo: string | null = null; +async function logo(origin: string) { + if (_logo) return _logo; + const res = await fetch(`${origin}/og/wordmark-white.png`); + const bytes = new Uint8Array(await res.arrayBuffer()); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return (_logo = `data:image/png;base64,${btoa(binary)}`); } -function formatDate(dateString: string): string { +const list = (v: string | null) => + v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined; +const num = (v: string | null, d = 0) => (v != null && v !== "" ? Number(v) : d); + +export async function GET(req: Request) { try { - let date: Date; - if (dateString.includes(" ")) { - // Handle the specific format from the URL - const [datePart, timePart] = dateString.split(" "); - const [year, month, day] = datePart.split("-"); - const [time] = timePart.split("."); // Remove milliseconds - const isoString = `${year}-${month}-${day}T${time}Z`; - date = new Date(isoString); + const { searchParams: q, origin } = new URL(req.url); + const wordmark = await logo(origin); + const type = q.get("type") || "main"; + let params: OgParams; + + if (type === "post") { + params = { + type: "post", + kind: (q.get("kind") as "article" | "discussion" | "link") || "article", + title: q.get("title") || "Untitled", + author: { + name: q.get("author") || "Anonymous", + role: q.get("role") || "", + hue: num(q.get("hue"), 184), + }, + tags: list(q.get("tags")), + publication: q.get("pub") + ? { name: q.get("pub")!, hue: num(q.get("pubHue"), 184) } + : undefined, + source: q.get("source") || undefined, + read: q.get("read") || undefined, + cover: q.get("cover") || undefined, + logo: wordmark, + }; + } else if (type === "profile") { + params = { + type: "profile", + name: q.get("name") || "", + role: q.get("role") || "", + hue: num(q.get("hue"), 184), + location: q.get("location") || "", + bio: q.get("bio") || "", + topHelper: q.get("topHelper") === "1", + followers: num(q.get("followers")), + joined: q.get("joined") || "", + interests: list(q.get("interests")), + logo: wordmark, + }; + } else if (type === "publication") { + params = { + type: "publication", + name: q.get("name") || "", + hue: num(q.get("hue"), 184), + tagline: q.get("tagline") || "", + articleCount: num(q.get("articles")), + followers: num(q.get("followers")), + logo: wordmark, + }; + } else if (type === "job") { + params = { + type: "job", + company: q.get("company") || "", + logo: q.get("logo") || (q.get("company") || "?")[0], + role: q.get("role") || "", + location: q.get("location") || "", + jobType: q.get("jobType") || "Full-time", + salary: q.get("salary") || undefined, + tags: list(q.get("tags")), + featured: q.get("featured") === "1", + wordmark, + }; } else { - date = new Date(dateString); + params = { type: "main", id: q.get("id") || "home", logo: wordmark }; } - if (isNaN(date.getTime())) { - throw new Error("Invalid date"); - } - return date.toLocaleString("en-US", { - month: "long", - day: "numeric", - year: "numeric", + return new ImageResponse(OgImage(params), { + ...size, + fonts: await fonts(), + headers: { "cache-control": CACHE_CONTROL }, }); - } catch (error) { - return ""; + } catch (err) { + Sentry.captureException(err); + return new Response("Failed to generate the image", { status: 500 }); } } diff --git a/lib/og/fonts.ts b/lib/og/fonts.ts new file mode 100644 index 000000000..9f36b0314 --- /dev/null +++ b/lib/og/fonts.ts @@ -0,0 +1,48 @@ +// Font loading for the OG cards. Satori needs real binaries (TTF/OTF, not +// woff2). loadGoogleFont fetches a TTF slice from Google on demand, which works +// on the edge runtime with nothing to commit. Weights: Bricolage 800 (display), +// Hanken 400/600 (body), JetBrains Mono 400/600 (labels). + +type FontSpec = { + name: string; + data: ArrayBuffer; + weight: 400 | 600 | 700 | 800; + style: 'normal'; +}; + +// The css2 endpoint serves woff2 to modern UAs; spoofing an old UA makes it +// return a truetype url that Satori can parse. +export async function loadGoogleFont( + family: string, + weight: number, + text?: string, +): Promise { + const params = new URLSearchParams({ family: `${family}:wght@${weight}` }); + if (text) params.set('text', text); + const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; + const css = await fetch(cssUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 5.1)' }, // old UA → ttf + }).then((r) => r.text()); + const url = css.match(/src:\s*url\((.+?)\)\s*format\('(?:truetype|opentype)'\)/)?.[1]; + if (!url) throw new Error(`Could not resolve a TTF for ${family} ${weight}`); + return fetch(url).then((r) => r.arrayBuffer()); +} + +// Load everything Codú OG cards need. Pass it the text you're about to +// render to subset aggressively (smaller payloads); omit for full sets. +export async function coduFonts(text?: string): Promise { + const [bricolage, hanken400, hanken600, mono400, mono600] = await Promise.all([ + loadGoogleFont('Bricolage Grotesque', 800, text), + loadGoogleFont('Hanken Grotesk', 400, text), + loadGoogleFont('Hanken Grotesk', 600, text), + loadGoogleFont('JetBrains Mono', 400, text), + loadGoogleFont('JetBrains Mono', 600, text), + ]); + return [ + { name: 'Bricolage Grotesque', data: bricolage, weight: 800, style: 'normal' }, + { name: 'Hanken Grotesk', data: hanken400, weight: 400, style: 'normal' }, + { name: 'Hanken Grotesk', data: hanken600, weight: 600, style: 'normal' }, + { name: 'JetBrains Mono', data: mono400, weight: 400, style: 'normal' }, + { name: 'JetBrains Mono', data: mono600, weight: 600, style: 'normal' }, + ]; +} diff --git a/lib/og/templates.tsx b/lib/og/templates.tsx new file mode 100644 index 000000000..fd775b934 --- /dev/null +++ b/lib/og/templates.tsx @@ -0,0 +1,342 @@ +// Satori-safe card templates for the OG route. Flexbox only, every container +// has an explicit display/flexDirection, all colours are literal (see +// tokens.ts), no mask-image, and covers are real . OgImage(params) +// dispatches by `type`; each builder returns a 1200×630 element. +import React from 'react'; +import { T, FONT, avatarBg, pubBg, initials, fmtK } from './tokens'; + +const W = 1200; +const H = 630; + +// ---- shared style atoms -------------------------------------------- +const mono = (size: number, color: string = T.muted): React.CSSProperties => ({ + fontFamily: FONT.mono, fontSize: size, color, letterSpacing: '0.02em', +}); +const root = (pad = '72px 76px'): React.CSSProperties => ({ + width: W, height: H, position: 'relative', display: 'flex', flexDirection: 'column', + padding: pad, background: T.canvas, color: T.primary, fontFamily: FONT.sans, overflow: 'hidden', +}); +const spine: React.CSSProperties = { + position: 'absolute', left: 0, top: 0, bottom: 0, width: 6, background: T.accent, +}; +// faint radial glow stands in for the masked dot-grid (Satori-safe) +const glow: React.CSSProperties = { + position: 'absolute', top: -260, right: -200, width: 720, height: 720, borderRadius: 720, + background: 'radial-gradient(circle, rgba(45,212,191,0.10) 0%, rgba(45,212,191,0) 60%)', + display: 'flex', +}; +const topbar: React.CSSProperties = { + position: 'relative', display: 'flex', flexDirection: 'row', + alignItems: 'center', justifyContent: 'space-between', +}; +const url: React.CSSProperties = { ...mono(16, T.faint) }; + +function Wordmark({ src, h = 38 }: { src: string; h?: number }) { + // Explicit width (Satori ignores width:auto); ratio matches wordmark-white.png (1600×519). + const w = (h * 1600) / 519; + return Codú; +} +function Eyebrow({ label }: { label: string }) { + return ( +
+ {"//"} + {label} +
+ ); +} +function Tag({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ==================================================================== +// 1) MAIN-PAGE cards (static surfaces). hed is an array of lines. +// ==================================================================== +export const MAIN: Record = { + home: { eyebrow: 'The community for AI builders', hed: ['Learn to build with AI.', 'Ship what you make.'], sub: 'A knowledge-first community for web devs and indie hackers.' }, + about: { eyebrow: 'About Codú', hed: ['Less theory.', 'More shipping.'], sub: 'Why Codú exists, and who it’s for.' }, + articles: { eyebrow: 'Articles', hed: ['Guides and field notes', 'from people who ship.'], sub: 'Long-form from across the community.' }, + discussions:{ eyebrow: 'Discussions', hed: ['Ask, answer,', 'and figure it out together.'], sub: 'Questions, TILs and working notes from builders.' }, + jobs: { eyebrow: 'Jobs', hed: ['Roles for people', 'building with AI.'], sub: 'Hand-picked teams hiring right now.' }, + advertise: { eyebrow: 'Advertise', hed: ['Reach developers', 'who actually ship.'], sub: 'Sponsor the feed, the newsletter, the jobs board.' }, + weekly: { eyebrow: 'Codú Weekly', hed: ['The best of what', 'builders shipped.'], sub: 'One email a week. No fluff, no filler.' }, +}; + +export function MainCard({ id, logo }: { id: string; logo: string }) { + const c = MAIN[id] || MAIN.home; + return ( +
+
+
+
+ + codu.co +
+
+
+
+ {c.hed.map((line, i) => ( +
{line}
+ ))} +
+
{c.sub}
+
+
+ ); +} + +// ==================================================================== +// 2) POST card — adaptive (Article / Discussion / Link), cover or branded +// ==================================================================== +const KIND: Record = { + article: { label: 'Article', tone: 'neutral' }, + discussion: { label: 'Discussion', tone: 'info' }, + link: { label: 'Link', tone: 'faint' }, +}; + +export type PostParams = { + type: 'post'; + kind: 'article' | 'discussion' | 'link'; + title: string; + author: { name: string; role: string; hue: number }; + tags?: string[]; + publication?: { name: string; hue: number }; + source?: string; + read?: string; + cover?: string; // real image URL → cover layout; omit → branded + logo: string; +}; + +function KindBadge({ kind }: { kind: PostParams['kind'] }) { + const k = KIND[kind] || KIND.article; + const tone = + k.tone === 'info' ? { color: T.info, background: T.infoWash, border: 'none' } + : k.tone === 'neutral' ? { color: T.faint, background: 'transparent', border: `1px solid ${T.hairlineStrong}` } + : { color: T.faint, background: 'transparent', border: `1px solid ${T.hairline}` }; + return ( +
+ {k.label} +
+ ); +} + +function Chips({ p }: { p: PostParams }) { + return ( +
+ + {p.publication && ( +
+
+ {initials(p.publication.name)} +
+ {p.publication.name} +
+ )} + {p.source && ( +
+ via {p.source} + +
+ )} +
+ ); +} + +function Byline({ p }: { p: PostParams }) { + return ( +
+
+
+ {initials(p.author.name)} +
+
+ {p.author.name} + {p.author.role} +
+
+
+ {(p.tags || []).slice(0, 2).map((t) => {t})} + {p.kind === 'article' && p.read && {p.read} read} +
+
+ ); +} + +function Title({ text, size, lines }: { text: string; size: number; lines: number }) { + return ( +
+ {text} +
+ ); +} + +export function PostCard(p: PostParams) { + const hasCover = !!p.cover; + return ( +
+
+
+
+ + +
+ {hasCover ? ( +
+
+ + </div> + <img + src={p.cover} + width={384} + height={384} + alt="" + style={{ width: 384, height: '100%', objectFit: 'cover', borderRadius: 16, border: `1px solid ${T.hairlineStrong}` }} + /> + </div> + ) : ( + <div style={{ position: 'relative', display: 'flex', flex: 1, alignItems: 'center' }}> + <Title text={p.title} size={60} lines={3} /> + </div> + )} + <Byline p={p} /> + </div> + ); +} + +// ==================================================================== +// 3) IDENTITY cards — profile · publication · job +// ==================================================================== +function IdentityShell({ kicker, children, footLeft, logo }: { + kicker: React.ReactNode; children: React.ReactNode; footLeft: React.ReactNode; logo: string; +}) { + return ( + <div style={root()}> + <div style={glow} /> + <div style={spine} /> + <div style={topbar}>{kicker}<span style={url}>codu.co</span></div> + <div style={{ position: 'relative', display: 'flex', flexDirection: 'column', marginTop: 'auto', gap: 30 }}> + {children} + </div> + <div style={{ position: 'relative', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: 26, borderTop: `1px solid ${T.hairline}` }}> + {footLeft} + <Wordmark src={logo} h={30} /> + </div> + </div> + ); +} +function MintBadge({ children }: { children: React.ReactNode }) { + return ( + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 6, fontFamily: FONT.mono, fontSize: 14, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: T.onAccent, background: T.accent, padding: '6px 13px', borderRadius: 999 }}> + {children} + </div> + ); +} +function MetaLine({ children }: { children: React.ReactNode }) { + return <div style={{ display: 'flex', flexDirection: 'row', ...mono(19, T.muted) }}>{children}</div>; +} +const metaB: React.CSSProperties = { color: T.accentSoft, fontWeight: 500 }; + +export type ProfileParams = { + type: 'profile'; name: string; role: string; hue: number; location: string; bio: string; + topHelper?: boolean; followers: number; joined: string; interests?: string[]; logo: string; +}; +export function ProfileCard(u: ProfileParams) { + return ( + <IdentityShell + logo={u.logo} + kicker={<Eyebrow label="PROFILE" />} + footLeft={<MetaLine><span style={metaB}>{fmtK(u.followers)}</span><span> followers · {u.joined}</span></MetaLine>} + > + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 26 }}> + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 100, height: 100, borderRadius: 100, background: avatarBg(u.hue), fontFamily: FONT.display, fontWeight: 700, fontSize: 40, color: T.primary }}> + {initials(u.name)} + </div> + <div style={{ display: 'flex', flexDirection: 'column' }}> + <div style={{ fontFamily: FONT.display, fontWeight: 800, fontSize: 60, letterSpacing: '-0.03em', lineHeight: 1 }}>{u.name}</div> + <div style={{ ...mono(19, T.muted), marginTop: 12 }}>{[u.role, u.location].filter(Boolean).join(' · ')}</div> + </div> + </div> + <div style={{ color: T.muted, fontSize: 25, lineHeight: 1.45, maxWidth: 760, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }}>{u.bio}</div> + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10 }}> + {u.topHelper && <MintBadge>★ Top helper</MintBadge>} + {(u.interests || []).slice(0, 3).map((t) => <Tag key={t}>{t}</Tag>)} + </div> + </IdentityShell> + ); +} + +export type PublicationParams = { + type: 'publication'; name: string; hue: number; tagline: string; articleCount: number; followers: number; logo: string; +}; +export function PublicationCard(p: PublicationParams) { + return ( + <IdentityShell + logo={p.logo} + kicker={<Eyebrow label="PUBLICATION" />} + footLeft={<MetaLine><span style={metaB}>{p.articleCount}</span><span> articles · </span><span style={metaB}>{fmtK(p.followers)}</span><span> followers</span></MetaLine>} + > + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 26 }}> + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 100, height: 100, borderRadius: 12, background: pubBg(p.hue), fontFamily: FONT.display, fontWeight: 800, fontSize: 42, color: '#fff' }}> + {initials(p.name)} + </div> + <div style={{ fontFamily: FONT.display, fontWeight: 800, fontSize: 60, letterSpacing: '-0.03em', lineHeight: 1 }}>{p.name}</div> + </div> + <div style={{ color: T.muted, fontSize: 25, lineHeight: 1.45, maxWidth: 760, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }}>{p.tagline}</div> + </IdentityShell> + ); +} + +export type JobParams = { + type: 'job'; company: string; logo: string; role: string; location: string; + jobType: string; salary?: string; tags?: string[]; featured?: boolean; wordmark: string; +}; +export function JobCard(j: JobParams) { + return ( + <IdentityShell + logo={j.wordmark} + kicker={ + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10 }}> + {j.featured && <MintBadge>Featured</MintBadge>} + <Eyebrow label="JOB" /> + </div> + } + footLeft={<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10 }}>{(j.tags || []).slice(0, 3).map((t) => <Tag key={t}>{t}</Tag>)}</div>} + > + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 18 }}> + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 64, height: 64, borderRadius: 8, background: T.elevated, border: `1px solid ${T.hairline}`, fontFamily: FONT.display, fontWeight: 800, fontSize: 26, color: T.primary }}> + {j.logo} + </div> + <div style={{ display: 'flex', ...mono(22, T.primary) }}>{j.company}</div> + </div> + <div style={{ fontFamily: FONT.display, fontWeight: 800, fontSize: 60, letterSpacing: '-0.03em', lineHeight: 1.02, maxWidth: 900, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }}>{j.role}</div> + <MetaLine> + <span>{j.location} · {j.jobType}{j.salary ? ' · ' : ''}</span> + {j.salary && <span style={metaB}>{j.salary}</span>} + </MetaLine> + </IdentityShell> + ); +} + +// ==================================================================== +// DISPATCH — one entry point the route calls +// ==================================================================== +export type OgParams = + | ({ type: 'main'; id: string; logo: string }) + | PostParams + | ProfileParams + | PublicationParams + | JobParams; + +export function OgImage(params: OgParams): React.ReactElement { + switch (params.type) { + case 'main': return <MainCard id={params.id} logo={params.logo} />; + case 'post': return <PostCard {...params} />; + case 'profile': return <ProfileCard {...params} />; + case 'publication': return <PublicationCard {...params} />; + case 'job': return <JobCard {...params} />; + default: return <MainCard id="home" logo={(params as any).logo} />; + } +} diff --git a/lib/og/tokens.ts b/lib/og/tokens.ts new file mode 100644 index 000000000..9638ec17f --- /dev/null +++ b/lib/og/tokens.ts @@ -0,0 +1,43 @@ +// Design tokens for the OG cards, resolved to literals. Satori (inside +// next/og) can't read CSS custom properties or oklch(), so every colour is a +// plain hex/rgba/hsl string lifted from the design system. + +export const T = { + // canvas ladder + canvas: '#0a0b0e', + surface: '#121419', + elevated: '#181b22', + inset: '#08090c', + // borders + hairline: '#242832', + hairlineStrong: '#2f3440', + // text + primary: '#f4f6f8', + muted: '#9aa3b0', + faint: '#868f9b', + // accent (Mint) + accent: '#2dd4bf', + accentSoft: '#6ee7d6', + onAccent: '#04221d', + // status + info: '#5fa8f5', + infoWash: 'rgba(95,168,245,0.12)', +} as const; + +export const FONT = { + display: 'Bricolage Grotesque', + sans: 'Hanken Grotesk', + mono: 'JetBrains Mono', +} as const; + +// Avatar / publication-mark tints. The app uses oklch(0.5 0.08 H), which +// Satori can't parse, so these hsl values approximate it; the hue itself comes +// from the same hueFromString used on-site (see lib/og/url.ts). +export const avatarBg = (hue: number) => `hsl(${hue}, 22%, 42%)`; +export const pubBg = (hue: number) => `hsl(${hue}, 34%, 46%)`; + +export const initials = (name: string) => + name.split(' ').map((w) => w[0]).slice(0, 2).join(''); + +export const fmtK = (n: number) => + n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : '' + n; diff --git a/lib/og/url.ts b/lib/og/url.ts new file mode 100644 index 000000000..418565fe7 --- /dev/null +++ b/lib/og/url.ts @@ -0,0 +1,137 @@ +// Builders for the dynamic OG route (`app/og/route.tsx`). Each returns a +// relative `/og?...` URL; metadataBase (set in the root layout) resolves it to +// an absolute one. Param names here must match what the route reads. +import { hueFromString } from "@/utils/hue"; + +type Param = string | number | boolean | null | undefined; + +function ogUrl(params: Record<string, Param>): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + search.set(key, String(value)); + } + return `/og?${search.toString()}`; +} + +// Cache-busting stamp so an edited record gets a fresh card (the route sets a +// long immutable cache-control). Seconds keep the URL short. +const version = (date?: string | Date | null): number | undefined => + date ? Math.floor(new Date(date).getTime() / 1000) : undefined; + +export type OgMainId = + | "home" + | "about" + | "advertise" + | "articles" + | "discussions" + | "jobs" + | "weekly"; + +export const ogMainImage = (id: OgMainId): string => ogUrl({ type: "main", id }); + +export type OgPostImageInput = { + kind: "article" | "discussion" | "link"; + title: string; + authorName: string; + authorRole?: string | null; + /** Stable key (username / source slug) the author hue is derived from. */ + authorKey: string; + tags?: string[]; + publicationName?: string | null; + publicationKey?: string | null; + source?: string | null; + readMins?: number | null; + cover?: string | null; + updatedAt?: string | Date | null; +}; + +export const ogPostImage = (post: OgPostImageInput): string => + ogUrl({ + type: "post", + kind: post.kind, + title: post.title, + author: post.authorName, + role: post.authorRole, + hue: hueFromString(post.authorKey), + tags: post.tags?.slice(0, 2).join(","), + pub: post.publicationName, + pubHue: post.publicationKey ? hueFromString(post.publicationKey) : undefined, + source: post.source, + read: post.readMins ? `${post.readMins} min` : undefined, + cover: post.cover, + v: version(post.updatedAt), + }); + +export type OgProfileImageInput = { + name: string; + /** Stable key (username) the avatar hue is derived from. */ + key: string; + role?: string | null; + location?: string | null; + bio?: string | null; + followers?: number; + joined?: string; + interests?: string[]; + topHelper?: boolean; + updatedAt?: string | Date | null; +}; + +export const ogProfileImage = (profile: OgProfileImageInput): string => + ogUrl({ + type: "profile", + name: profile.name, + hue: hueFromString(profile.key), + role: profile.role, + location: profile.location, + bio: profile.bio, + followers: profile.followers, + joined: profile.joined, + interests: profile.interests?.slice(0, 3).join(","), + topHelper: profile.topHelper ? 1 : undefined, + v: version(profile.updatedAt), + }); + +export type OgPublicationImageInput = { + name: string; + /** Stable key (slug) the publication-mark hue is derived from. */ + key: string; + tagline?: string | null; + articleCount?: number; + followers?: number; + updatedAt?: string | Date | null; +}; + +export const ogPublicationImage = (pub: OgPublicationImageInput): string => + ogUrl({ + type: "publication", + name: pub.name, + hue: hueFromString(pub.key), + tagline: pub.tagline, + articles: pub.articleCount, + followers: pub.followers, + v: version(pub.updatedAt), + }); + +export type OgJobImageInput = { + company: string; + role: string; + location: string; + jobType: string; + tags?: string[]; + featured?: boolean; + updatedAt?: string | Date | null; +}; + +export const ogJobImage = (jobListing: OgJobImageInput): string => + ogUrl({ + type: "job", + company: jobListing.company, + logo: jobListing.company.trim()[0]?.toUpperCase(), + role: jobListing.role, + location: jobListing.location, + jobType: jobListing.jobType, + tags: jobListing.tags?.slice(0, 3).join(","), + featured: jobListing.featured ? 1 : undefined, + v: version(jobListing.updatedAt), + }); diff --git a/lib/structured-data/schemas/article.ts b/lib/structured-data/schemas/article.ts index b390d27e9..ef53a6dcd 100644 --- a/lib/structured-data/schemas/article.ts +++ b/lib/structured-data/schemas/article.ts @@ -3,6 +3,7 @@ import { getOrganizationRef } from "./organization"; import { getPersonRef } from "./person"; import { SITE_ORIGIN as BASE_URL } from "@/config/site"; +import { ogPostImage } from "@/lib/og/url"; interface ArticleData { title: string; @@ -37,7 +38,15 @@ export function getArticleSchema( // image URL with article metadata. const ogImageUrl = article.image || - `${BASE_URL}/og?title=${encodeURIComponent(article.title)}&author=${encodeURIComponent(article.author.name || "")}&readTime=${article.readingTime || 5}&date=${article.updatedAt || article.publishedAt || ""}`; + `${BASE_URL}${ogPostImage({ + kind: "article", + title: article.title, + authorName: article.author.name || "Unknown", + authorKey: article.author.username || article.author.name || "Unknown", + tags: article.tags?.map((t) => t.title), + readMins: article.readingTime || 5, + updatedAt: article.updatedAt || article.publishedAt, + })}`; // Determine the canonical URL const mainEntityUrl = diff --git a/public/og/wordmark-white.png b/public/og/wordmark-white.png new file mode 100644 index 000000000..2d2187c20 Binary files /dev/null and b/public/og/wordmark-white.png differ diff --git a/utils/hue.ts b/utils/hue.ts new file mode 100644 index 000000000..e6515fd6b --- /dev/null +++ b/utils/hue.ts @@ -0,0 +1,10 @@ +/** + * Deterministic hue (0–359) from a string. Used for avatar / publication tints + * so a given name or slug always maps to the same colour, on-site and in OG + * images. Math.random would break that stability across renders. + */ +export const hueFromString = (value: string): number => { + let sum = 0; + for (let i = 0; i < value.length; i++) sum += value.charCodeAt(i); + return sum % 360; +};