From fdf91f2bf5262c028922d641edd040d70e1e66a3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 22 Jun 2026 12:59:12 +0300 Subject: [PATCH 1/3] feat(storybook): add Open Graph share-preview review & recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Open Graph" Storybook section auditing daily.dev's outbound link previews across every share surface (article, shared post, profile/DevCard, squad, squad invite, source, tag, comment, referral, Plus, homepage) and proposing a unified "Layout A" recommended template. - The current column renders the real images daily.dev serves today; the recommended column renders the Layout A cover for the same real entity so the before/after is apples-to-apples. - Single source of truth in cover.tsx (Layout A atoms + OgCover); RecommendedOg in dailyOgImages.tsx is the adapter every page consumes. - Includes guidelines/research, an X (Twitter) deep dive, a Satori template spec, a benchmark of how the best platforms unfurl, and a link copy/metadata page. Docs/review only — Storybook stories plus one preview.tsx sidebar entry; no app/runtime code paths are touched. Co-Authored-By: Claude Opus 4.8 --- packages/storybook/.storybook/preview.tsx | 1 + .../stories/open-graph/Benchmark.stories.tsx | 526 +++++++++++ .../open-graph/CurrentOpenGraph.stories.tsx | 60 ++ .../open-graph/LinkCopyBehavior.stories.tsx | 314 +++++++ .../OpenGraphComparison.stories.tsx | 82 ++ .../OpenGraphGuidelines.stories.tsx | 314 +++++++ .../stories/open-graph/Overview.stories.tsx | 161 ++++ .../open-graph/RecommendedSpec.stories.tsx | 470 ++++++++++ .../stories/open-graph/XDeepDive.stories.tsx | 376 ++++++++ .../storybook/stories/open-graph/cover.tsx | 454 ++++++++++ .../stories/open-graph/dailyOgImages.tsx | 826 ++++++++++++++++++ .../stories/open-graph/ogStoryLayout.tsx | 322 +++++++ .../stories/open-graph/platformCards.tsx | 614 +++++++++++++ .../storybook/stories/open-graph/useCases.tsx | 591 +++++++++++++ 14 files changed, 5111 insertions(+) create mode 100644 packages/storybook/stories/open-graph/Benchmark.stories.tsx create mode 100644 packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx create mode 100644 packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx create mode 100644 packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx create mode 100644 packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx create mode 100644 packages/storybook/stories/open-graph/Overview.stories.tsx create mode 100644 packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx create mode 100644 packages/storybook/stories/open-graph/XDeepDive.stories.tsx create mode 100644 packages/storybook/stories/open-graph/cover.tsx create mode 100644 packages/storybook/stories/open-graph/dailyOgImages.tsx create mode 100644 packages/storybook/stories/open-graph/ogStoryLayout.tsx create mode 100644 packages/storybook/stories/open-graph/platformCards.tsx create mode 100644 packages/storybook/stories/open-graph/useCases.tsx diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 38d5deaeb84..267f120534f 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -18,6 +18,7 @@ const preview: Preview = { 'Atoms', 'Components', 'Pages', + 'Open Graph', 'Experiments', 'Extension', ], diff --git a/packages/storybook/stories/open-graph/Benchmark.stories.tsx b/packages/storybook/stories/open-graph/Benchmark.stories.tsx new file mode 100644 index 00000000000..6c8945c2802 --- /dev/null +++ b/packages/storybook/stories/open-graph/Benchmark.stories.tsx @@ -0,0 +1,526 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const CardLabel = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +/** How a YouTube link unfurls — the most-shared link type on the web. */ +const YouTubeCard = (): React.ReactElement => ( +
+
+ + + ▶ + + + + 12:04 + +
+
+ +
+
+ How we cut edge cold-starts by 90% with Rust and Wasm +
+
+ The Pragmatic Engineer · 1.2M views +
+
+
+
+); + +/** How a Reddit link post unfurls — the model that sits between GitHub and X. */ +const RedditCard = (): React.ReactElement => ( +
+
+ + + 2.4k + + + ▼ + +
+
+
+ r/programming · Posted by u/idoshamun · 5h +
+
+ How we cut edge cold-starts by 90% with Rust and Wasm +
+ + app.daily.dev + +
+ 💬 340 comments + ↗ Share +
+
+
+
+); + +/** GitHub's auto-generated repo social card. */ +const GitHubRepoCard = (): React.ReactElement => ( +
+
+ + + vercel /{' '} + satori + +
+
+ Enlightened library to convert HTML and CSS to SVG +
+
+ + + TypeScript + + ★ 11.8k + ⑂ 312 +
+
+); + +const Benchmark = (): React.ReactElement => ( + + + The question that matters isn’t how much traffic a platform sends us — + it’s whose links get shared everywhere else: pasted into + WhatsApp, posted on X, dropped in a Slack channel. So we looked at the + platforms whose links travel the most across the web and studied how they + unfurl. The pattern is stark: the most-shared platforms have{' '} + rich, open, contextual previews — and the ones that gate + their metadata get shared less because their links look broken. + + + Whose links get shared the most + + + + + + The three to model + + YouTube, Reddit and GitHub are where the most-shared, best-unfurling links + live. Here’s how each looks and exactly what we should take. + + +
+
+ YouTube — the thumbnail IS the product + +
+ +
+
+
+ GitHub — contextual + live stats + +
+ +
+
+
+ + + + Reddit — between GitHub and X + + You called it: for developers Reddit sits right between GitHub’s + credibility and X’s reach. It’s a social network and a dev + watering hole, and its link cards are a masterclass in context — exactly + the balance daily.dev wants. + +
+
+ How a daily.dev link looks on Reddit + +
+
+ +
+
+ + + + The anti-pattern: don’t be Instagram or TikTok + + + The takeaway for us:{' '} + openness + complete, valid tags is itself a growth lever.{' '} + Every daily.dev link should unfurl richly for every scraper (Facebook, X, + Slack, Discord, and Reddit’s Embedly) — never gated, never half-formed. + + + + + Enhanced principles (from the most-shared platforms) + + + + + Sources + +
+); + +const meta: Meta = { + title: 'Open Graph/6. How the Best Platforms Share', + component: Benchmark, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Benchmarks: Story = { name: 'Platform benchmark' }; diff --git a/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx b/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx new file mode 100644 index 00000000000..44ea412f0b9 --- /dev/null +++ b/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MetaTagsTable, PlatformGrid } from './platformCards'; +import { USE_CASES } from './useCases'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, +} from './ogStoryLayout'; + +const CurrentPreviews = (): React.ReactElement => ( + + + Every place daily.dev content gets shared, rendered the way it actually + unfurls on X, LinkedIn, Facebook, Slack, Discord, WhatsApp and iMessage. + These are the real, live images daily.dev serves today — + pulled straight from the actual URLs (e.g.{' '} + og.daily.dev/api/posts/{id} for posts,{' '} + daily.dev/og-image.png for the homepage), not re-creations. + The tell is obvious: only posts get a real card — profile, source, tag, + squad, invite and Plus all fall back to the same generic image. + + + {USE_CASES.map((uc, i) => ( +
+ + {i + 1}. {uc.name} + + {uc.what} + +
+ +
+ + + +
+ +
+ + {i < USE_CASES.length - 1 && } +
+ ))} +
+); + +const meta: Meta = { + title: 'Open Graph/1. Current Share Previews', + component: CurrentPreviews, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const AllUseCases: Story = { name: 'All use cases' }; diff --git a/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx b/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx new file mode 100644 index 00000000000..888b882d1c7 --- /dev/null +++ b/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx @@ -0,0 +1,314 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, + TwoCol, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +/** Anatomy of a link preview, labeling the parts that are NOT the image. */ +const PreviewAnatomy = (): React.ReactElement => ( +
+
+
+
+ + d. + + + app.daily.dev · favicon + domain + +
+
+ og:title — the headline +
+
+ og:description — the supporting line shown on FB, Slack, Discord, + WhatsApp. +
+
+
+); + +const SHARE_TEXT_CODE = `// Today — shared/src/lib/share.ts +getTwitterShareLink(link, text) // → "{text} via @dailydotdev" +// "text" is usually just the post title, so a tweet reads: +// "How we cut edge cold-starts… via @dailydotdev app.daily.dev/posts/…" + +// Recommended — context-aware, pre-filled share copy (still user-editable) +share.article = \`\${title}\\n\\nvia @dailydotdev\`; +share.invite = \`I use daily.dev to keep up with dev news — join me 👇\`; +share.profile = \`My developer profile on @dailydotdev 👇\`; +share.squad = \`We're sharing the best \${topic} content in this Squad 👇\`; +// Keep "via @dailydotdev" for attribution; add 1 relevant emoji max; no hashtag spam.`; + +const LinkCopyBehavior = (): React.ReactElement => ( + + + The image gets the attention, but half of a great link preview is + everything around it: the title wording, the description, the little + favicon and brand avatar, the URL that shows, and the message we pre-fill + when someone taps “share”. This page covers all of it — current state vs. + what we should do — for the parts that live outside the Open + Graph image. + + + Anatomy — the non-image parts +
+ +
+ + On messengers and Slack/Discord/Facebook the text{' '} + does most of the work — the image may be small or absent. The favicon + and domain are the trust signal. The pre-filled share message is what + actually frames the link for the recipient. + +
+
+ + + + Title & description copy + + + + + Favicon, site name & brand avatar + + Slack, Discord, iMessage and Reddit show a small favicon next to the + domain; X shows the @dailydotdev profile avatar. These are + tiny but they’re the brand’s handshake. + + + + + + The pre-filled share message + + When a user shares from inside daily.dev, we pre-fill the text. Today it’s + basically the title plus “via @dailydotdev”. Tailoring it per context (and + keeping it editable) makes the share feel personal and lifts CTR. + + {SHARE_TEXT_CODE} + + } + right={ + + } + /> + + + + URLs & link behavior + + + + + + Priorities (beyond the image) + +
+); + +const meta: Meta = { + title: 'Open Graph/7. Link Copy, Metadata & Behavior', + component: LinkCopyBehavior, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const CopyAndBehavior: Story = { name: 'Copy, metadata & behavior' }; diff --git a/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx b/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx new file mode 100644 index 00000000000..74210b94538 --- /dev/null +++ b/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + LinkedInCard, + MetaTagsTable, + WhatsAppCard, + XCard, +} from './platformCards'; +import type { OgData } from './platformCards'; +import { USE_CASES } from './useCases'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, + TwoCol, +} from './ogStoryLayout'; + +const Stack = ({ data }: { data: OgData }): React.ReactElement => ( +
+ + +
+ +
+ +
+); + +const Comparison = (): React.ReactElement => ( + + + A side-by-side of what we ship today (the{' '} + real, live images, pulled from the actual URLs) against a + single, unified template system that adapts per share type. Each surface + keeps the same visual language so a daily.dev link is instantly + recognizable, while the headline, attribution and context change to fit + the object being shared. Red column = today; green column = proposed. + + + {USE_CASES.map((uc, i) => ( +
+ + {i + 1}. {uc.name} + + {uc.what} + +
+ } + right={} + /> +
+ + } + right={} + /> + + {i < USE_CASES.length - 1 && } +
+ ))} +
+); + +const meta: Meta = { + title: 'Open Graph/2. Current vs Recommended', + component: Comparison, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const SideBySide: Story = { name: 'Side by side' }; diff --git a/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx b/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx new file mode 100644 index 00000000000..367bcbb2615 --- /dev/null +++ b/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx @@ -0,0 +1,314 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from './ogStoryLayout'; + +const Guidelines = (): React.ReactElement => ( + + + A condensed field guide to how link previews actually work across + platforms in 2026, the technical details that matter, and the concrete + changes we should make to daily.dev’s sharing. Pair this with the “Current + vs Recommended” page for the per-surface mock-ups. + + + The one image spec that covers everything + + Ship a single 1200 × 630 px (1.91:1) image. It satisfies + Facebook, LinkedIn, X large cards, Slack, Discord, WhatsApp and iMessage + without cropping the important bits. Keep the file under{' '} + ~500 KB (hard ceiling ~1 MB or some crawlers skip it), + prefer PNG for text-heavy cards / JPEG for photographic ones, and keep all + critical content inside a centered ~80% safe area since + some surfaces center-crop toward square. + + + + + + Practical takeaway: write for the strictest platform.{' '} + Keep og:title ≤ ~60 chars and og:description ≤ ~110 chars so nothing + important is clipped on LinkedIn/X while still reading well on + Slack/Facebook where more is shown. + + + + + Tags we should always emit + ', + 'Lets Reddit (Embedly), Discord & others build richer cards', + '✗ Likely missing — add for the Reddit/dev audience', + ], + ]} + /> + + {` + + + + + + + + + + + + + +`} + + + + Design principles for the generated image + + + + + Lessons from the most-shared platforms + + We studied the platforms whose links travel the most across the web (see + the Benchmark page). The winners — YouTube, Reddit, GitHub — all share + rich, open, contextual cards; the gated ones (Instagram, TikTok) get + shared less because their links look broken. What that means for us: + + + + + + Caching & invalidation + + Crawlers cache aggressively: Facebook and LinkedIn hold previews for{' '} + up to ~7 days, and they fetch your tags exactly once per + URL. Two consequences: + + + + + + Recommended rollout order + + + + + Sources + + +); + +const meta: Meta = { + title: 'Open Graph/3. Guidelines & Research', + component: Guidelines, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Research: Story = { name: 'Guidelines & research' }; diff --git a/packages/storybook/stories/open-graph/Overview.stories.tsx b/packages/storybook/stories/open-graph/Overview.stories.tsx new file mode 100644 index 00000000000..1f529cb12b4 --- /dev/null +++ b/packages/storybook/stories/open-graph/Overview.stories.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { RecommendedOg } from './dailyOgImages'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const TOC: Array<{ n: string; name: string; what: string }> = [ + { + n: '1', + name: 'Current Share Previews', + what: 'Every share type as it unfurls today on 7 platforms, with a meta-tag inspector.', + }, + { + n: '2', + name: 'Current vs Recommended', + what: 'Side-by-side mock-ups and the fix for each surface.', + }, + { + n: '3', + name: 'Guidelines & Research', + what: 'Platform spec cheat-sheet, tags to emit, caching, priorities.', + }, + { + n: '4', + name: 'X (Twitter) Deep Dive', + what: 'How X actually renders links and how to win the surface where the image is everything.', + }, + { + n: '5', + name: 'Recommended Template Spec', + what: 'The real @vercel/og template, tokens, text rules and rollout.', + }, + { + n: '6', + name: 'How the Best Platforms Share', + what: 'What GitHub, X, Reddit & co. do — and which platforms drive the most shares.', + }, + { + n: '7', + name: 'Link Copy, Metadata & Behavior', + what: 'Everything outside the image: titles, descriptions, favicon, URLs, share text.', + }, +]; + +const Overview = (): React.ReactElement => ( + + + When a developer shares a daily.dev link — an article, their profile, a + squad, an invite — that preview is often the first time someone sees us. + It’s free, high-intent distribution, and right now we’re leaving most of + it on the table: titles get truncated by a “| daily.dev” suffix, several + share types fall back to one generic image, and nothing feels distinctly + ours. This initiative fixes the whole surface — the image, the copy, and + the link behavior — across every place we’re shared. + + +
+ +
+ + Why this matters now + + + + + What’s in this section +
+ {TOC.map((t) => ( +
+ + {t.n} + +
+
+ {t.name} +
+
+ {t.what} +
+
+
+ ))} +
+ + Open the numbered pages under “Open Graph” in the sidebar in order. Start + with Current vs Recommended (2) if you just want to see the before/after. + +
+); + +const meta: Meta = { + title: 'Open Graph/0. Overview', + component: Overview, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Start: Story = { name: 'Start here' }; diff --git a/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx b/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx new file mode 100644 index 00000000000..d64444e3e69 --- /dev/null +++ b/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx @@ -0,0 +1,470 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { RecommendedOg } from './dailyOgImages'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const Callout = ({ + n, + top, + left, +}: { + n: number; + top: string; + left: string; +}): React.ReactElement => ( + + {n} + +); + +const Anatomy = (): React.ReactElement => ( +
+
+ +
+ + + + + +
+); + +const TEMPLATE_CODE = `// share-card.tsx — Satori (@vercel/og) template. Flexbox + inline styles only. +// One component, driven by \`kind\`. No grid, no external CSS. + +type Kind = + | 'article' | 'shared' | 'profile' | 'squad' + | 'invite' | 'tag' | 'comment' | 'source' | 'generic'; + +interface ShareCardProps { + kind: Kind; + title: string; // already truncated to <= 3 lines server-side + subtitle?: string; // 0–2 lines + identity?: { name: string; handle?: string; avatar?: string }; // top-left + upvotes?: string; // article / shared / comment -> engagement bar + comments?: string; + meta?: string; // other kinds -> glass meta pill ("4,210 members") + cover?: string; // absolute https URL; drives backdrop + art thumbnail +} + +export function ShareCard(p: ShareCardProps) { + const engage = ['article', 'shared', 'comment'].includes(p.kind); + return ( +
+ {/* Ambient backdrop — a pre-blurred cover (Satori can't blur at runtime), + else a brand-gradient glow. A dark gradient on top keeps text legible. */} + {p.cover + ? + :
} +
+ + {/* Top bar: identity (left) + the real daily.dev logo (right) */} +
+ + +
+ + {/* Left content: headline + subtitle on top, the glass bar pinned to the bottom */} +
+
+
{p.title}
+ {p.subtitle &&
{p.subtitle}
} +
+ {/* Glass bar — pre-composited translucent fill (no runtime backdrop-blur in Satori) */} +
+ {engage + ? <> + : {p.meta}} +
+
+ + {/* Art bottom-right: cover thumbnail, avatar, or brand tile (radius 54) */} +
+ +
+
+ ); +} + +// Satori clamps text with the webkit box trio: +const clamp = (lines: number) => + ({ display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' });`; + +const ROUTE_CODE = `// /api/share route — edge runtime. Generates 1200×630 once, then CDN-cached. +import { ImageResponse } from '@vercel/og'; // or 'next/og' + +export const runtime = 'edge'; + +const FONT = (w: string) => + fetch(\`https://og.daily.dev/fonts/Inter-\${w}.ttf\`).then((r) => r.arrayBuffer()); + +export async function GET(req: Request) { + const u = new URL(req.url); + const kind = (u.searchParams.get('kind') ?? 'generic') as Kind; + const props = await resolveProps(kind, u.searchParams); // fetch post/squad/user, truncate title + + const [regular, bold, extrabold] = await Promise.all([FONT('Regular'), FONT('Bold'), FONT('ExtraBold')]); + + return new ImageResponse(, { + width: 1200, + height: 630, + fonts: [ + { name: 'Inter', data: regular, weight: 400, style: 'normal' }, + { name: 'Inter', data: bold, weight: 700, style: 'normal' }, + { name: 'Inter', data: extrabold, weight: 800, style: 'normal' }, + ], + headers: { + // version the URL with ?v={contentHash}; then this immutable cache is safe. + 'cache-control': 'public, immutable, no-transform, max-age=31536000', + }, + }); +}`; + +const META_CODE = `// Webapp side — point og:image at the versioned generator and keep tags lean. +const v = post.contentHash; // bust X/LinkedIn/FB caches on edit or redesign +const img = \`https://og.daily.dev/api/share?kind=article&id=\${post.id}&v=\${v}\`; + +const seo: NextSeoProps = { + title: post.title, // NO "| daily.dev" suffix + description: clamp(post.summary, 110), + openGraph: { + type: 'article', + title: post.title, + description: clamp(post.summary, 110), + siteName: 'daily.dev', + images: [{ url: img, width: 1200, height: 630, alt: post.title }], + }, + twitter: { cardType: 'summary_large_image', site: '@dailydotdev' }, + additionalMetaTags: [ + { name: 'twitter:image:alt', content: post.title }, + ...(post.author?.twitter ? [{ name: 'twitter:creator', content: post.author.twitter }] : []), + { name: 'twitter:label1', content: 'Author' }, { name: 'twitter:data1', content: post.source.name }, + { name: 'twitter:label2', content: 'Reading time' }, { name: 'twitter:data2', content: \`\${post.readTime} min\` }, + ], +};`; + +const RecommendedSpec = (): React.ReactElement => ( + + + A single, contextual template that renders every share type — driven by a{' '} + kind parameter — plus the text/naming rules and the actual{' '} + @vercel/og (Satori) implementation it should ship as on{' '} + og.daily.dev. The goal: a daily.dev link is recognizable at a + glance on any platform, and reads perfectly even on X where the image is + the entire message. + + + Anatomy of the card +
+ +
    +
  1. + Identity — source / author / sharer top-left, where + the context lives (a small avatar + name). +
  2. +
  3. + daily.dev logo — top-right, the real wordmark; + present but secondary, never competes with the message. +
  4. +
  5. + Headline — the hero. ≤ 3 lines, the only thing X + shows; sits over an ambient backdrop derived from the cover. +
  6. +
  7. + Engagement bar — avocado upvote + comment in a + glass/blur pill; the ownable daily.dev signal. +
  8. +
  9. + Cover art — rounded thumbnail bottom-right (becomes + an avatar/tile for profiles, squads, tags…). +
  10. +
+
+ + + + Design tokens + + + + + The text system — titles, descriptions, names + + The copy matters as much as the picture. Three global rules, then a + pattern per surface. + + + + + + + 1 · The Satori template + + One component, switched on kind. Satori supports flexbox, + inline styles, gradients, borders and the webkit line-clamp trio — no CSS + grid, no stylesheets. Fonts must be supplied explicitly (TTF/OTF/WOFF; not + WOFF2). + + {TEMPLATE_CODE} + + 2 · The edge route + + Generate once at the edge, then serve immutable from the CDN. The{' '} + ?v= content hash is what lets us safely use a one-year + immutable cache while still busting stale previews on edit. + + {ROUTE_CODE} + + 3 · Meta tags on the webapp + + Point og:image at the versioned generator and keep the tags + lean. Note: no title suffix, explicit width/height, alt text, and the + Slack-only label/data pairs. + + {META_CODE} + + + + Rollout against the existing code + + + + + Sources + +
+); + +const meta: Meta = { + title: 'Open Graph/5. Recommended Template Spec', + component: RecommendedSpec, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Spec: Story = { name: 'Template spec & code' }; diff --git a/packages/storybook/stories/open-graph/XDeepDive.stories.tsx b/packages/storybook/stories/open-graph/XDeepDive.stories.tsx new file mode 100644 index 00000000000..0b883bbdf2c --- /dev/null +++ b/packages/storybook/stories/open-graph/XDeepDive.stories.tsx @@ -0,0 +1,376 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactNode } from 'react'; +import { XCard } from './platformCards'; +import type { OgData } from './platformCards'; +import { RecommendedOg } from './dailyOgImages'; +import { USE_CASES } from './useCases'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, + TwoCol, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const article = USE_CASES.find((u) => u.id === 'article'); + +const labelStyle: React.CSSProperties = { + fontFamily: SANS, + fontSize: 12, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: 0.6, + color: '#71717a', + marginBottom: 8, +}; + +/** Realistic X timeline chrome wrapped around a link card. */ +const Tweet = ({ + data, + tweetText, +}: { + data: OgData; + tweetText: string; +}): React.ReactElement => ( +
+
+ +
+
+ + Ido Shamun + + + @idoshamun · 2h + +
+
+ {tweetText} +
+ +
+ 💬 24 + 🔁 108 + ♥ 642 + 📊 23K +
+
+
+
+); + +/** Overlay the 80% safe area + rounded-corner danger zones on an image. */ +const SafeAreaFrame = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+
{children}
+ {/* 80% safe area */} +
+ + 80% safe area + + {/* corner danger markers */} + {['0 0 auto auto', '0 auto auto 0', 'auto 0 0 auto', 'auto auto 0 0'].map( + (inset) => ( + + ), + )} +
+); + +const XDeepDive = (): React.ReactElement => ( + + + X is the highest-leverage surface for developer sharing — and the most + unforgiving. On a modern summary_large_image card, X shows{' '} + only the image and a small domain pill: no title, no + description, no body text outside the picture. Whatever you want a reader + to know has to live inside the 1200×630 frame. This page covers exactly + how X behaves and how our generated image should be built for it. + + + Why X is where the image has to do all the work + + + + + The same link, in an X timeline + + Today the card leads with the publisher’s cover and a small daily.dev mark + — the headline is barely legible and the brand is easy to miss. The + recommended card bakes the headline, author and brand into the image, so + it reads at a glance even at timeline scale. + + {article && ( +
+ + } + right={ + + } + /> +
+ )} + + + + Design for the X thumbnail, not the full-size image + + Keep everything that matters inside the centered{' '} + 80% safe area, and out of the four corners (rounded + + domain pill). Headline ≥ 48px at 1200px width, bold, with a hard contrast + floor so it survives next to vibrant posts in the feed. + +
+ + + +
+ +
+
+ + + + X-specific tag & image spec + + + + + Large vs summary card + + We should always request summary_large_image. The small{' '} + summary card (square thumbnail + text) is the weak fallback — + shown here only so the difference is obvious. With the small card the + image shrinks to a tile and the text X shows is our og:title/description, + so those still need to read well. + + {article && ( +
+
+
summary_large_image (use this)
+ +
+
+
summary (avoid)
+ +
+
+ )} + + + + X recommendations + + + + + Sources + +
+); + +const meta: Meta = { + title: 'Open Graph/4. X (Twitter) Deep Dive', + component: XDeepDive, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Optimizing: Story = { name: 'Optimizing for X' }; diff --git a/packages/storybook/stories/open-graph/cover.tsx b/packages/storybook/stories/open-graph/cover.tsx new file mode 100644 index 00000000000..6377a4aaac1 --- /dev/null +++ b/packages/storybook/stories/open-graph/cover.tsx @@ -0,0 +1,454 @@ +import React from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import LogoText from '@dailydotdev/shared/src/svg/LogoText'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote'; +import { DiscussIcon } from '@dailydotdev/shared/src/components/icons/Discuss'; + +/** + * The locked "Layout A — clean" design system, self-contained. Provides the + * shared atoms (logo, glass engagement/stat bars, title/subtitle, art tiles) + * and the generalized `OgCover` used by every recommended share-card mock-up: + * identity top-left, daily.dev logo top-right, title + meta on the left, and + * cover art bottom-right. `RecommendedOg` in `dailyOgImages.tsx` is the adapter + * every story page consumes. + */ + +export const SANS = + '-apple-system, "Helvetica Neue", Helvetica, Inter, Arial, "Segoe UI", system-ui, sans-serif'; + +export const INK = '#0B0E13'; +export const CABBAGE = '#CE3DF3'; +const ONION = '#8A63F4'; +export const TEXT = '#FFFFFF'; +export const TERTIARY = '#A6AEBF'; +export const SECONDARY = '#D7DCE6'; +export const GRAD = `linear-gradient(135deg, ${CABBAGE}, ${ONION})`; +export const white = { + ['--theme-text-primary' as string]: '#FFFFFF', +} as CSSProperties; + +const COVER = + 'https://media.daily.dev/image/upload/s--P4t4XyoV--/f_auto/v1722860399/public/Placeholder%2001'; + +export interface CoverData { + title: string; + source: string; + sourceColor: string; + readTime: string; + upvotes: string; + comments: string; + cover: string; +} +export const XD: CoverData = { + title: 'How we cut edge cold-starts by 90% with Rust and Wasm', + source: 'The Pragmatic Engineer', + sourceColor: '#FF8E3B', + readTime: '6m read', + upvotes: '312', + comments: '448', + cover: COVER, +}; + +export const clamp = (lines: number): CSSProperties => ({ + display: '-webkit-box', + WebkitLineClamp: lines, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}); + +export const Root = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +export const Ambient = ({ src }: { src: string }): React.ReactElement => ( + <> + +
+ +); + +export const Logo = (): React.ReactElement => ( + + + + + + + + +); + +const Stat = ({ + Cmp, + count, +}: { + Cmp: typeof UpvoteIcon; + count: string; +}): React.ReactElement => ( + + + + + + {count} + + +); + +// The glass/blur bar chrome — shared by the engagement bar and meta pills. +export const GlassBar = ({ + children, + style, +}: { + children: ReactNode; + style?: CSSProperties; +}): React.ReactElement => ( +
+ {children} +
+); + +export const Actions = ({ d }: { d: CoverData }): React.ReactElement => ( + + + + + + + +); + +export const MetaPill = ({ text }: { text: string }): React.ReactElement => ( + + + {text} + + +); + +export interface StatItem { + label: string; + Cmp: typeof UpvoteIcon; + value: string; + color?: string; +} + +// Glass bar with several icon+number stats (profile reputation/streak/posts, +// squad members/posts/upvotes) — same chrome as the engagement bar. +export const StatBar = ({ + stats, +}: { + stats: StatItem[]; +}): React.ReactElement => ( + + + {stats.map((s, i) => ( + + {i > 0 && ( + + )} + + + + + + {s.value} + + + + ))} + + +); + +// White primary button (daily.dev primary on a dark surface). +export const PrimaryButton = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( + + {children} + +); + +// Headline/title for the generalized cover (any text, sized per use case). +export const Title = ({ + children, + size = 5.2, + lines = 3, +}: { + children: ReactNode; + size?: number; + lines?: number; +}): React.ReactElement => ( + + {children} + +); + +export const Subtitle = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( + + {children} + +); + +// Brand backdrop for covers with no cover image (profile/squad/tag/etc.). +const BrandBackdrop = (): React.ReactElement => ( + <> +
+
+ +); + +export interface OgCoverProps { + // When true, fills its positioned parent (e.g. a platform preview frame). + // Otherwise it is a self-sizing 1200×630 box. + fill?: boolean; + backdrop?: string; + eyebrow?: ReactNode; + title?: ReactNode; + subtitle?: ReactNode; + meta?: ReactNode; + art?: ReactNode; + logo?: ReactNode; + // Where the left content column starts (raise it for content-dense covers). + contentTop?: string; +} + +/** + * The generalized Layout A — same clean structure for every share type: + * identity top-left, logo top-right, title + meta on the left, art bottom-right. + */ +export const OgCover = ({ + fill = false, + backdrop, + eyebrow, + title, + subtitle, + meta, + art, + logo, + contentTop = '15.5cqw', +}: OgCoverProps): React.ReactElement => { + const body = ( + <> + {backdrop ? : } + {art && ( +
+ {art} +
+ )} +
+ {eyebrow} + {logo ?? } +
+
+
+ {title} + {subtitle} +
+ {meta &&
{meta}
} +
+ + ); + if (fill) { + return ( +
+ {body} +
+ ); + } + return {body}; +}; diff --git a/packages/storybook/stories/open-graph/dailyOgImages.tsx b/packages/storybook/stories/open-graph/dailyOgImages.tsx new file mode 100644 index 00000000000..f7d8bf78870 --- /dev/null +++ b/packages/storybook/stories/open-graph/dailyOgImages.tsx @@ -0,0 +1,826 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote'; +import { UserIcon } from '@dailydotdev/shared/src/components/icons/User'; +import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad'; +import { DocsIcon } from '@dailydotdev/shared/src/components/icons/Docs'; +import { DevPlusIcon } from '@dailydotdev/shared/src/components/icons/DevPlus'; +import { + OgCover, + Ambient, + Logo, + Title, + Subtitle, + MetaPill, + Actions, + StatBar, + PrimaryButton, + GlassBar, + GRAD, + CABBAGE, + INK, + SANS, + TEXT, + SECONDARY, + TERTIARY, + clamp, + white, + XD, +} from './cover'; +import type { StatItem } from './cover'; + +// =========================================================================== +// RECOMMENDED — one unified, contextual system +// =========================================================================== + +export type RecommendedKind = + | 'article' + | 'shared' + | 'profile' + | 'squad' + | 'invite' + | 'tag' + | 'comment' + | 'plus' + | 'generic'; + +interface RecommendedProps { + kind?: RecommendedKind; + title?: string; + subtitle?: string; + cover?: string; + name?: string; + handle?: string; + avatarSrc?: string; + meta?: string; + sharer?: string; + upvotes?: string; + comments?: string; + // profile stats + reputation?: string; + streak?: string; + posts?: string; + reads?: string; + tags?: string[]; + sources?: string[]; // favorite source logo URLs ("reads the most") + // squad stats + members?: string; + // invite / referral / plus + cta?: string; // white primary button label + count?: string; // prominent count (number shows in cabbage) + countWord?: string; // e.g. "developers" + mascot?: string; // Charm illustration URL for the art slot + square?: boolean; // render the square (1:1) summary-card variant +} + +// Square (1:1) variant — responsive fallback for summary cards. The headline +// is dropped (unreadable at thumbnail size, and it's already in the link); +// keep only what reads: the cover art, the daily.dev logo, and the source. +const SquareCover = ({ + cover, + source, +}: { + cover?: string; + source?: string; +}): React.ReactElement => ( +
+ {cover ? ( + + ) : ( +
+ )} + {/* top bar: source (left) + logo (right) */} +
+ {source ? ( + + + + {source} + + + ) : ( + + )} + +
+ {/* centered cover art — the hero of the square */} + {cover && ( +
+ +
+ )} +
+); + +// ---- Layout A building blocks for the recommended template ---------------- +const RArt = ({ + src, + circle = false, +}: { + src: string; + circle?: boolean; +}): React.ReactElement => ( + +); +const RTile = ({ + children, + circle = false, +}: { + children: React.ReactNode; + circle?: boolean; +}): React.ReactElement => ( +
+ {children} +
+); +const RMark = (): React.ReactElement => ( + + + +); +const REyebrow = ({ + text, + sub, + src, + dot = false, +}: { + text: string; + sub?: string; + src?: string; + dot?: boolean; +}): React.ReactElement => ( +
+ {src && ( + + )} + {!src && dot && ( + + )} + + {text} + + {sub && ( + + · {sub} + + )} +
+); + +// Charm mascot in the art slot — bigger, anchored bottom-right and dropped a +// bit lower so it fills the corner instead of floating small. (The art slot is +// position: absolute, so this positions against it.) +const RMascot = ({ src }: { src: string }): React.ReactElement => ( + +); + +// Art fallback chain shared by invite/generic: Charm mascot → circular cover +// → the brand tile when neither is available. +const mascotOrCoverArt = (mascot?: string, cover?: string): React.ReactNode => { + if (mascot) { + return ; + } + if (cover) { + return ; + } + return ( + + + + ); +}; + +// Prominent count line — the number pops in cabbage, with a leading icon. +const ProminentCount = ({ + count, + word, +}: { + count: string; + word?: string; +}): React.ReactElement => ( + + + + + + {count} + {word ? ` ${word}` : ''} + + +); + +// Community proof — a stack of real member avatars + the count, the way the +// squad/source pages surface the community. Used for source + tag. Faces are +// real daily.dev developer avatars. +const COMMUNITY_FACES = [ + 'https://media.daily.dev/image/upload/s--FcI3RdS1--/f_auto/v1745335145/avatars/avatar_R9RafYjp15h3mJ9XdIkhy', + 'https://media.daily.dev/image/upload/s--AVEMGQgE--/f_auto/v1744349812/avatars/avatar_RVvUzGofSIHGyTxdjqDN1', + 'https://media.daily.dev/image/upload/s--CwdXky60--/f_auto/v1733031652/avatars/avatar_rmFJzNXUNPh163VaQkmF0', +]; +const Community = ({ count }: { count: string }): React.ReactElement => ( + + + + {COMMUNITY_FACES.map((src, i) => ( + + ))} + + + {count} + + + +); + +// ---- Developer profile — mirrors the real DevCard ------------------------ +// Rounded avatar with a clean 1px hairline border (subtle, low-opacity white — +// a soft edge rather than a heavy band). +const TiltAvatar = ({ + label, + src, +}: { + label?: string; + src?: string; +}): React.ReactElement => { + const base: CSSProperties = { + width: '100%', + height: '100%', + borderRadius: '8cqw', + border: '1px solid rgba(255,255,255,0.2)', + boxSizing: 'border-box', + boxShadow: '0 4cqw 11cqw rgba(0,0,0,0.5)', + }; + return src ? ( + + ) : ( +
+ + {label} + +
+ ); +}; + +// The DevCard stats bar: 3 sections, each icon + number + label. +const ProfileStatSection = ({ + value, + label, +}: { + value: string; + label: string; +}): React.ReactElement => ( + + + {value} + + + {label} + + +); +const ProfileStats = ({ + reputation, + streak, + reads, +}: { + reputation?: string; + streak?: string; + reads?: string; +}): React.ReactElement => { + const divider = ( + + ); + return ( +
+ {reputation && ( + + )} + {reputation && streak && divider} + {streak && } + {streak && reads && divider} + {reads && } +
+ ); +}; + +const TagChips = ({ tags }: { tags: string[] }): React.ReactElement => ( + + {tags.map((t) => ( + + #{t} + + ))} + +); + +// Favorite sources the developer reads most — a "Reads" label + source logos +// (rounded squares on white), like the DevCard footer. +const SourceLogos = ({ logos }: { logos: string[] }): React.ReactElement => ( + + + Reads from + + + {logos.map((l) => ( + + + + ))} + + +); + +/** + * The recommended template = the locked "Layout A — clean" cover, one system + * for every share type. Article/shared/comment keep the real engagement bar + * (avocado upvote + comment); other types show stats, a meta pill, or a CTA. + */ +export const RecommendedOg = ({ + kind = 'article', + title = 'How to build a dynamic Open Graph image pipeline at the edge', + subtitle, + cover, + name, + handle, + avatarSrc, + meta, + sharer, + upvotes = '312', + comments = '448', + reputation, + streak, + posts, + reads, + tags, + sources, + members, + cta, + count, + countWord, + mascot, + square = false, +}: RecommendedProps): React.ReactElement => { + if (square) { + return ; + } + const engagement = ; + const pill = meta ? : undefined; + const sub = subtitle ? {subtitle} : undefined; + const ctaNode = cta ? {cta} : undefined; + + const squadStats: StatItem[] = []; + if (members) { + squadStats.push({ label: 'members', Cmp: SquadIcon, value: members }); + } + if (posts) { + squadStats.push({ label: 'posts', Cmp: DocsIcon, value: posts }); + } + if (upvotes) { + squadStats.push({ label: 'upvotes', Cmp: UpvoteIcon, value: upvotes }); + } + + switch (kind) { + case 'shared': + return ( + + } + title={{title}} + subtitle={name ? {name} : undefined} + meta={engagement} + art={cover ? : undefined} + /> + ); + case 'comment': + // No art tile — the quote gets the full width. + return ( + + } + title={{title}} + subtitle={meta ? {meta} : undefined} + meta={engagement} + /> + ); + case 'profile': + // One ordered column with equal gaps: name/role → stats → tags → sources. + return ( + } + title={ + + + + {title} + + {subtitle && {subtitle}} + + {(reputation || streak || reads) && ( + + + + )} + {sources?.length ? : null} + {tags?.length ? : null} + + } + art={} + /> + ); + case 'squad': + return ( + } + title={{title}} + subtitle={sub} + meta={squadStats.length ? : pill} + art={ + cover ? ( + + ) : ( + + + {(title ?? 'S')[0]} + + + ) + } + /> + ); + case 'tag': + return ( + } + title={{title}} + subtitle={sub} + meta={ctaNode ?? (meta ? : undefined)} + art={ + mascot ? ( + + ) : ( + + + # + + + ) + } + /> + ); + case 'invite': + return ( + + } + title={{title}} + subtitle={ + count ? : sub + } + meta={ctaNode ?? pill} + art={mascotOrCoverArt(mascot, cover)} + /> + ); + case 'plus': + return ( + + + + + + daily.dev Plus + +
+ } + title={{title}} + subtitle={sub} + meta={ctaNode ?? (meta ? : undefined)} + art={ + mascot ? ( + + ) : ( + + + + + + ) + } + /> + ); + case 'generic': + return ( + } + title={{title}} + subtitle={sub} + meta={ctaNode ?? (meta ? : undefined)} + art={mascotOrCoverArt(mascot, cover)} + /> + ); + default: + return ( + } + title={{title}} + meta={engagement} + art={cover ? : undefined} + /> + ); + } +}; diff --git a/packages/storybook/stories/open-graph/ogStoryLayout.tsx b/packages/storybook/stories/open-graph/ogStoryLayout.tsx new file mode 100644 index 00000000000..71405b036eb --- /dev/null +++ b/packages/storybook/stories/open-graph/ogStoryLayout.tsx @@ -0,0 +1,322 @@ +import React from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +/** + * Presentational scaffolding shared by the Open Graph review stories. Uses the + * daily.dev theme CSS variables so it follows Storybook's light/dark toggle, + * but keeps everything provider-free so the stories render standalone. + */ + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +export const Page = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +export const PageHeader = ({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children?: ReactNode; +}): React.ReactElement => ( +
+
+ {eyebrow} +
+

+ {title} +

+ {children && ( +
+ {children} +
+ )} +
+); + +export const Heading = ({ + children, + badge, +}: { + children: ReactNode; + badge?: string; +}): React.ReactElement => ( +
+

+ {children} +

+ {badge && ( + + {badge} + + )} +
+); + +export const Muted = ({ + children, + style, +}: { + children: ReactNode; + style?: CSSProperties; +}): React.ReactElement => ( +

+ {children} +

+); + +export const Bullets = ({ + items, + tone = 'neutral', + title, +}: { + items: string[]; + tone?: 'neutral' | 'bad' | 'good'; + title?: string; +}): React.ReactElement => { + const color = { bad: '#d4342c', good: '#1f9d55', neutral: 'inherit' }[tone]; + const mark = { bad: '✗', good: '✓', neutral: '•' }[tone]; + return ( +
+ {title && ( +
+ {title} +
+ )} +
    + {items.map((item) => ( +
  • + + {mark} + + {item} +
  • + ))} +
+
+ ); +}; + +export const Divider = (): React.ReactElement => ( +
+); + +export const TwoCol = ({ + left, + right, + leftLabel, + rightLabel, +}: { + left: ReactNode; + right: ReactNode; + leftLabel: string; + rightLabel: string; +}): React.ReactElement => ( +
+ {[ + { label: leftLabel, node: left, tone: '#d4342c' }, + { label: rightLabel, node: right, tone: '#1f9d55' }, + ].map((col) => ( +
+
+ {col.label} +
+ {col.node} +
+ ))} +
+); + +const specCell: CSSProperties = { + padding: '8px 12px', + fontSize: 13, + textAlign: 'left', + borderBottom: '1px solid var(--theme-divider-tertiary)', + verticalAlign: 'top', +}; +const specHead: CSSProperties = { + ...specCell, + fontWeight: 700, + color: 'var(--theme-text-primary)', + borderBottom: '2px solid var(--theme-divider-secondary)', +}; + +export const SpecTable = ({ + columns, + rows, +}: { + columns: string[]; + rows: string[][]; +}): React.ReactElement => ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((r) => ( + + {r.map((c, ci) => ( + + ))} + + ))} + +
+ {c} +
+ {c} +
+
+); + +export const CodeBlock = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+    {children}
+  
+); diff --git a/packages/storybook/stories/open-graph/platformCards.tsx b/packages/storybook/stories/open-graph/platformCards.tsx new file mode 100644 index 00000000000..52d30578c56 --- /dev/null +++ b/packages/storybook/stories/open-graph/platformCards.tsx @@ -0,0 +1,614 @@ +import React from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +/** + * Faithful mock-ups of how a shared link unfurls on each major platform. + * These intentionally use raw inline styles (not the daily.dev design tokens) + * because the goal is to reproduce the *third-party* chrome of X, LinkedIn, + * Facebook, Slack, Discord and the messengers as closely as possible so the + * team can review our Open Graph output in a realistic context. + */ + +export type CardType = 'summary' | 'summary_large_image'; + +export interface OgData { + /** Host shown in the preview, e.g. "app.daily.dev". */ + domain: string; + /** og:url path, shown in the meta table only. */ + path?: string; + /** og:title — the headline platforms display. */ + title: string; + /** og:description. */ + description: string; + /** og:image URL. Used when imageNode is not provided. */ + image?: string; + /** Render a faithful mock of a dynamically generated image instead of . */ + imageNode?: ReactNode; + /** Square-ratio variant of the generated image, for summary cards. */ + squareNode?: ReactNode; + /** twitter:card. */ + cardType: CardType; + /** og:site_name. */ + siteName?: string; + /** og:image:alt / twitter:image:alt. */ + imageAlt?: string; + /** twitter:site handle, e.g. "@dailydotdev". */ + twitterSite?: string; +} + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const clamp = (lines: number): CSSProperties => ({ + display: '-webkit-box', + WebkitLineClamp: lines, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}); + +const Favicon = ({ size = 16 }: { size?: number }): React.ReactElement => ( + + d. + +); + +const ImageFrame = ({ + data, + radius = 0, + ratio = 1200 / 630, +}: { + data: OgData; + radius?: number; + ratio?: number; +}): React.ReactElement => ( +
+ {data.imageNode ? ( +
{data.imageNode}
+ ) : ( + {data.imageAlt + )} +
+); + +const SquareThumb = ({ + data, + size, +}: { + data: OgData; + size: number; +}): React.ReactElement => ( +
+ {data.squareNode ? ( + // A dedicated square-ratio design (logo + cover art + source, no title). +
{data.squareNode}
+ ) : ( + // Fallback: keep the 1.91:1 design intact and letterbox it into the + // square so the whole card still reads — rather than squishing it. +
+ {data.imageNode ? ( +
{data.imageNode}
+ ) : ( + {data.imageAlt + )} +
+ )} +
+); + +// --------------------------------------------------------------------------- +// X / Twitter +// --------------------------------------------------------------------------- +export const XCard = ({ data }: { data: OgData }): React.ReactElement => { + if (data.cardType === 'summary') { + return ( +
+ +
+
{data.domain}
+
+ {data.title} +
+
+ {data.description} +
+
+
+ ); + } + + return ( +
+ + + {data.domain} + +
+ ); +}; + +// --------------------------------------------------------------------------- +// LinkedIn +// --------------------------------------------------------------------------- +export const LinkedInCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.title} +
+
+ {data.domain} +
+
+
+); + +// --------------------------------------------------------------------------- +// Facebook +// --------------------------------------------------------------------------- +export const FacebookCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.domain} +
+
+ {data.title} +
+
+ {data.description} +
+
+
+); + +// --------------------------------------------------------------------------- +// Slack +// --------------------------------------------------------------------------- +export const SlackCard = ({ data }: { data: OgData }): React.ReactElement => ( +
+
+ + + {data.siteName || 'daily.dev'} + +
+
+ {data.title} +
+
+ {data.description} +
+
+ +
+
+); + +// --------------------------------------------------------------------------- +// Discord +// --------------------------------------------------------------------------- +export const DiscordCard = ({ data }: { data: OgData }): React.ReactElement => ( +
+
+ {data.siteName || 'daily.dev'} +
+
+ {data.title} +
+
+ {data.description} +
+
+ +
+
+); + +// --------------------------------------------------------------------------- +// WhatsApp (received bubble) +// --------------------------------------------------------------------------- +export const WhatsAppCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.title} +
+
+ {data.description} +
+
+ {data.domain} +
+
+
+); + +// --------------------------------------------------------------------------- +// iMessage (received bubble) +// --------------------------------------------------------------------------- +export const IMessageCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.title} +
+
+ {data.domain} +
+
+
+); + +// --------------------------------------------------------------------------- +// Platform grid — renders one preview per platform for a given OgData. +// --------------------------------------------------------------------------- +const PLATFORMS: Array<{ label: string; Card: typeof XCard }> = [ + { label: 'X / Twitter', Card: XCard }, + { label: 'LinkedIn', Card: LinkedInCard }, + { label: 'Facebook', Card: FacebookCard }, + { label: 'Slack', Card: SlackCard }, + { label: 'Discord', Card: DiscordCard }, + { label: 'WhatsApp', Card: WhatsAppCard }, + { label: 'iMessage', Card: IMessageCard }, +]; + +const platformLabelStyle: CSSProperties = { + fontFamily: SANS, + fontSize: 12, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: 0.6, + color: '#71717a', + marginBottom: 8, +}; + +export const PlatformGrid = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ {PLATFORMS.map(({ label, Card }) => ( +
+
{label}
+ +
+ ))} +
+); + +// --------------------------------------------------------------------------- +// Meta-tag inspector table with per-platform length warnings. +// --------------------------------------------------------------------------- +const LIMITS = { title: 60, description: 110 }; + +const Row = ({ + tag, + value, + limit, +}: { + tag: string; + value?: string; + limit?: number; +}): React.ReactElement => { + const len = value?.length ?? 0; + const over = limit ? len > limit : false; + return ( + + + {tag} + + + {value || } + + + {limit ? `${len} / ${limit}` : len || ''} + + + ); +}; + +export const MetaTagsTable = ({ + data, +}: { + data: OgData; +}): React.ReactElement => { + const url = `https://${data.domain}${data.path || ''}`; + return ( + + + + + + + + + + + +
+ ); +}; diff --git a/packages/storybook/stories/open-graph/useCases.tsx b/packages/storybook/stories/open-graph/useCases.tsx new file mode 100644 index 00000000000..ee53ae7914e --- /dev/null +++ b/packages/storybook/stories/open-graph/useCases.tsx @@ -0,0 +1,591 @@ +import React from 'react'; +import { + cloudinaryCharmEmptySquads, + cloudinaryCharmReadLater, + cloudinaryCharmNotEnoughTags, +} from '@dailydotdev/shared/src/lib/image'; +import type { OgData } from './platformCards'; +import { RecommendedOg } from './dailyOgImages'; + +export const DEFAULT_OG_IMAGE = + 'https://media.daily.dev/image/upload/s--VAY5ToZt--/f_auto/v1724209435/public/daily.dev%20-%20open%20graph'; + +// The CURRENT column uses the REAL images daily.dev serves today (raw, not +// re-created). Reality check (verified live 2026-06-17): only POSTS get a +// generated card; profile, source, tag, squad, invite, Plus and the app +// default all fall back to the SAME generic image. The marketing homepage +// serves its own og-image.png. +const LANDING_OG = 'https://daily.dev/og-image.png'; + +// A real, live daily.dev post (so the current post card is the genuine asset). +const POST_OG = 'https://og.daily.dev/api/posts/qojM1enSN'; +// The shared variant adds the sharer (real endpoint, ?userid=). +const SHARED_OG = `${POST_OG}?userid=u_123`; +const POST_TITLE = 'How to use traces to avoid breaking changes'; +const POST_SOURCE = 'Community Picks'; +const POST_COVER = + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/2a3c2ca8481678b5c9178cd656700187'; +// The post's real source logo (Community Picks) and the real commenter avatar. +const POST_SOURCE_LOGO = + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1655817725/logos/community'; +const COMMENT_AVATAR = + 'https://media.daily.dev/image/upload/s--IbwvoTYq--/f_auto/v1772778404/avatars/avatar_giQfoBCN9hYxcvRVo3s95'; + +// Profile share = the real DevCard "wide" image (ProfileLayout sets og:image to +// /devcards/v2/{userId}.png?type=wide — a generated DevCard, NOT the generic). +const PROFILE_OG = + 'https://api.daily.dev/devcards/v2/28849d86070e4c099c877ab6837c61f0.png?type=wide'; +// Real assets for the recommended profile: the user's avatar, their rank-based +// DevCard cover (the bg changes with reputation rank), and real source logos. +const PROFILE_AVATAR = + 'https://media.daily.dev/image/upload/s---xy_OAwk--/f_auto,q_auto/v1703781380/avatars/avatar_28849d86070e4c099c877ab6837c61f0'; +const PROFILE_RANK_COVER = + 'https://media.daily.dev/image/upload/s--xDkKz00z--/f_auto/v1707920136/covers/cover_28849d86070e4c099c877ab6837c61f0'; +// The user's real "reads the most" sources, straight from their DevCard. +const PROFILE_SOURCES = [ + 'https://media.daily.dev/image/upload/s--fk_6ycEi--/f_auto,q_auto/v1780996001/logos/collections', + 'https://media.daily.dev/image/upload/s--stRJbTCn--/f_auto/v1752953684/squads/ad08ba59-6646-487f-a8bd-2147d9e572a6', + 'https://media.daily.dev/image/upload/s--V91DY4ls--/f_auto,q_auto/v1772617267/logos/agents_digest', + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/hn', +]; +// Real WebDev squad share image (its own image is what production serves). +const WEBDEV_OG = + 'https://media.daily.dev/image/upload/s--3B1fh4kU--/f_auto,q_auto/v1/squads/94fc7a56-e6d2-403f-acd6-b988b426574f'; +// Real freeCodeCamp source logo. +const FREECODECAMP_LOGO = + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1628412854/logos/freecodecamp'; + +export interface UseCase { + id: string; + /** Human label for the share type. */ + name: string; + /** What object is being shared and from where. */ + what: string; + /** ReferralCampaignKey / route that produces it. */ + source: string; + /** Problems with the current treatment. */ + issues: string[]; + /** What we should change. */ + recommendations: string[]; + current: OgData; + recommended: OgData; +} + +export const USE_CASES: UseCase[] = [ + { + id: 'article', + name: 'Article / Post', + what: 'A developer shares an aggregated article from the feed or post page.', + source: '/posts/[slug] · ReferralCampaignKey.SharePost', + issues: [ + 'og:title always gets a " | daily.dev" suffix appended, eating ~12 chars of the headline and pushing it over the 60-char sweet spot.', + 'og:description falls back to a truncated, often HTML-stripped excerpt with no consistent value framing.', + 'The generated image leads with the publisher’s cover; daily.dev branding is small and easy to miss.', + 'No og:image:alt / twitter:image:alt is set.', + ], + recommendations: [ + 'Drop the title suffix for shareable content — site_name already conveys the brand. Reserve the suffix for navigational pages.', + 'Lead the image with the headline + author + reading time over a consistent branded frame; treat the cover as a supporting thumbnail.', + 'Always emit og:image:alt mirroring the post title.', + 'Add twitter:label/data (Author, Reading time) for richer Slack/X-adjacent unfurls.', + ], + current: { + domain: 'app.daily.dev', + path: '/posts/how-to-use-traces-to-avoid-breaking-changes', + title: 'How to use traces to avoid breaking changes | daily.dev', + description: + 'How distributed traces help you catch breaking changes before they ship…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: POST_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/posts/how-to-use-traces-to-avoid-breaking-changes', + title: 'How to use traces to avoid breaking changes', + description: + 'How distributed traces help you catch breaking changes before they ship — a practical walkthrough.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'How to use traces to avoid breaking changes', + imageNode: ( + + ), + squareNode: ( + + ), + }, + }, + { + id: 'shared-post', + name: 'Shared post (with attribution)', + what: 'A user shares a post via their personal share link, attributing the share to them.', + source: + '/posts/[id]/share?userid=… · og.daily.dev/api/posts/{id}?userid={uid}', + issues: [ + 'Attribution ("X shared") is small and inconsistent vs. the standard post image.', + 'Same title-suffix and description issues as a normal post.', + 'The sharer’s avatar/identity is not reliably surfaced, weakening the social proof that drives the click.', + ], + recommendations: [ + 'Make the sharer a first-class element: avatar + "shared by {name}" pill at the top of the image.', + 'Keep the rest of the article template identical so the share feels like a native daily.dev object.', + ], + current: { + domain: 'app.daily.dev', + path: '/posts/.../share?userid=u_123', + title: 'How to use traces to avoid breaking changes | daily.dev', + description: + 'How distributed traces help you catch breaking changes before they ship…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: SHARED_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/posts/.../share?userid=u_123', + title: 'How to use traces to avoid breaking changes', + description: + 'Ido shared this on daily.dev — how traces catch breaking changes before they ship.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido shared: How to use traces to avoid breaking changes', + imageNode: ( + + ), + }, + }, + { + id: 'profile', + name: 'Developer profile', + what: 'Sharing a user profile / DevCard.', + source: + '/[username] · DevCard v2 wide image · ReferralCampaignKey.ShareProfile', + issues: [ + 'The profile shares the DevCard image (/devcards/v2/{id}.png?type=wide) — a different visual language from post shares, and not 1.91:1, so it gets letterboxed/cropped in unfurls.', + 'Bio (og:description) is often empty; falls back to the generic site description.', + 'Title carries the " | daily.dev" suffix.', + ], + recommendations: [ + 'Use the same template family as posts, in "developer" mode: avatar, name, @handle, headline stat (streak, reputation).', + 'Default description to a generated one-liner ("{name} reads about React, Rust & AI on daily.dev") when bio is empty.', + ], + current: { + domain: 'app.daily.dev', + path: '/idoshamun', + title: 'Ido Shamun (@idoshamun) | daily.dev', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: PROFILE_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/idoshamun', + title: 'Ido Shamun (@idoshamun)', + description: + 'Ido reads about AI, LLMs & web dev on daily.dev — 20.5k posts read, 1,087-day longest streak.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido Shamun on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'squad', + name: 'Squad', + what: 'Sharing a Squad’s public page.', + source: + '/squads/[handle] · getSquadOpenGraph() · ReferralCampaignKey.ShareSource', + issues: [ + 'Uses the squad’s own uploaded banner, whose dimensions/quality vary wildly and may not be 1.91:1.', + 'Falls back to the generic brand image when no banner is set — zero context.', + 'No member count / activity signal in the preview to drive joins.', + ], + recommendations: [ + 'Render a generated, on-brand squad card: squad name + avatar + member count + "active today" signal, with the banner as a backdrop.', + 'Never fall back to the bare generic image — always generate a contextual squad card.', + ], + current: { + domain: 'app.daily.dev', + path: '/squads/webdev', + title: 'WebDev | daily.dev', + description: + 'The official daily.dev web development community. Led by thought leaders and webdev experts. Join the Squad!', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: WEBDEV_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/squads/webdev', + title: 'WebDev — a Squad on daily.dev', + description: + 'The official daily.dev web development community, led by thought leaders and webdev experts.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'WebDev squad on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'squad-invite', + name: 'Squad invite link', + what: 'A member invites someone to a private/public Squad.', + source: '/squads/[handle]/[token]', + issues: [ + 'Title is good ("{inviter} invited you to {squad}") but the image is just the squad banner — the invite framing lives only in text the platform may truncate.', + 'No inviter identity in the image; the personal, social hook is lost.', + ], + recommendations: [ + 'Bake the invite framing into the image: inviter avatar + "invited you to join {squad}" + member count + CTA chip.', + 'Keep the strong title but ensure the image stands on its own when the title is clipped.', + ], + current: { + domain: 'app.daily.dev', + path: '/squads/webdev/abc123token', + title: 'Ido invited you to WebDev', + description: + 'The official daily.dev web development community. Led by thought leaders and webdev experts. Join the Squad!', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: WEBDEV_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/squads/webdev/abc123token', + title: 'Ido invited you to join WebDev on daily.dev', + description: + 'Join the official daily.dev web development community. It’s free.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido invited you to join WebDev', + imageNode: ( + + ), + }, + }, + { + id: 'source', + name: 'Source / Publication', + what: 'Sharing a publication/source page.', + source: '/sources/[source]', + issues: [ + 'Uses the bare generic brand image (defaultOpenGraph) — no indication of which source it is.', + 'Title + suffix only; description is the source description if present.', + ], + recommendations: [ + 'Generate a source card with the source logo, name and "Followed by N developers on daily.dev".', + ], + current: { + domain: 'app.daily.dev', + path: '/sources/freecodecamp', + title: 'freeCodeCamp | daily.dev', + description: 'The latest from freeCodeCamp on daily.dev.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/sources/freecodecamp', + title: 'freeCodeCamp on daily.dev', + description: + 'Followed by developers across daily.dev. Get every new post in your feed.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'freeCodeCamp on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'tag', + name: 'Tag / Topic feed', + what: 'Sharing a tag feed (e.g. /tags/react).', + source: 'ReferralCampaignKey.ShareTag', + issues: ['Generic brand image; the topic name appears only in text.'], + recommendations: [ + 'Generate a topic card: "#react" + a one-line topic description + a few sample source logos.', + ], + current: { + domain: 'app.daily.dev', + path: '/tags/react', + title: 'react | daily.dev', + description: + 'Explore the React JavaScript library for building user interfaces.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/tags/react', + title: '#react on daily.dev', + description: + 'Explore the React JavaScript library for building user interfaces.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'The #react topic on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'comment', + name: 'Comment / Discussion', + what: 'Sharing a specific comment on a post.', + source: 'ReferralCampaignKey.ShareComment', + issues: [ + 'Reuses the post image; the comment that the user actually wanted to highlight is invisible in the preview.', + ], + recommendations: [ + 'Generate a discussion card: comment excerpt as the hero, commenter avatar/name, post title as context.', + ], + current: { + domain: 'app.daily.dev', + path: '/posts/qojM1enSN#c_456', + title: 'How to use traces to avoid breaking changes | daily.dev', + description: + 'Reuses the post image — the highlighted comment is invisible in the preview.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: POST_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/posts/qojM1enSN#c_456', + title: '“Great article! Thanks for sharing 🙌” — sirajju on daily.dev', + description: + 'sirajju commented on “How to use traces to avoid breaking changes”. Join the discussion.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Comment by sirajju on a daily.dev discussion', + imageNode: ( + + ), + }, + }, + { + id: 'invite', + name: 'Invite friends / Referral', + what: 'Personal referral link (the generic /?cid root link from Invite friends).', + source: '/?cid=… · ReferralCampaignKey.Generic', + issues: [ + 'The root referral link (app.daily.dev/?cid=…&userid=…) falls back to the generic brand image — no referrer, no incentive, no personalization.', + 'Highest-intent growth surface, weakest preview. (A separate og.daily.dev/api/refs card exists for /join links, but it’s off the unified template and currently returns empty for many users.)', + ], + recommendations: [ + 'Generate a referral card with the referrer’s avatar + "{name} invited you to daily.dev" + a clear CTA, on the unified template.', + ], + current: { + domain: 'app.daily.dev', + path: '/?cid=generic&userid=u_123', + title: 'daily.dev | Where developers grow together', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/?cid=generic&userid=u_123', + title: 'Ido invited you to daily.dev', + description: + 'The professional network where developers grow together. Free forever.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido invited you to daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'plus', + name: 'Plus subscription', + what: 'Sharing / gifting daily.dev Plus.', + source: '/plus', + issues: [ + 'Generic brand image; nothing communicates the Plus value or the gift.', + ], + recommendations: [ + 'Generate a Plus card with the Plus mark, headline benefit and gift framing when applicable.', + ], + current: { + domain: 'app.daily.dev', + path: '/plus', + title: 'Unlock Premium Developer Features with Plus | daily.dev', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/plus', + title: 'daily.dev Plus — your feed, supercharged', + description: + 'AI summaries, advanced filters, and an ad-free feed. Try Plus free for 14 days.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'daily.dev Plus', + imageNode: ( + + ), + }, + }, + { + id: 'home', + name: 'Homepage / default', + what: 'The root URL and any page without a specific override.', + source: 'next-seo.ts defaultOpenGraph', + issues: [ + 'Single static image; fine as a true fallback but currently does double duty for many specific share types it should not.', + ], + recommendations: [ + 'Keep as the genuine last-resort fallback only. Every specific surface above should override it.', + ], + current: { + domain: 'app.daily.dev', + path: '/', + title: 'daily.dev | Where developers grow together', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news. Get the best content from the top tech publications on any topic you want.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/', + title: 'daily.dev | Where developers grow together', + description: + 'The professional network for developers. One feed for the best engineering content, every day.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'daily.dev — where developers grow together', + image: LANDING_OG, + }, + }, +]; From 8a5fe142526b7318b50a72ecaf1e8f29aced3226 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 26 Jun 2026 15:44:36 +0200 Subject: [PATCH 2/3] feat(webapp): contextual OG share-card image generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render share previews (post, comment, source, squad, profile, tag, invite, Plus) as real webapp pages that daily-api screenshots — the same pipeline as the DevCard v2 image — instead of the Satori og.daily.dev service. Rendering in a real browser means line-clamp, backdrop-blur, the real fonts and the design-system tokens all work natively. - components/image-generator/ShareCard: the "Layout A" card system (identity eyebrow, headline, engagement/stat/community/CTA meta, art). - pages/image-generator/share/[type]/[id]: one dynamic page with a per-type loader; queries only public fields so the logged-out scraper can render them. - next-seo: getShareImageUrl() helper; repoint og:image for posts (incl. ?userid sharer), sources and tags at /api/og//.png. Requires the daily-api /og screenshot route (separate PR). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/image-generator/ShareCard.tsx | 719 ++++++++++++++++++ packages/webapp/next-seo.ts | 28 + .../image-generator/share/[type]/[id].tsx | 376 +++++++++ .../pages/posts/[id]/analytics/index.tsx | 6 +- packages/webapp/pages/posts/[id]/index.tsx | 3 +- .../webapp/pages/posts/[id]/share/index.tsx | 5 +- packages/webapp/pages/sources/[source].tsx | 9 +- packages/webapp/pages/tags/[tag].tsx | 11 +- 8 files changed, 1152 insertions(+), 5 deletions(-) create mode 100644 packages/webapp/components/image-generator/ShareCard.tsx create mode 100644 packages/webapp/pages/image-generator/share/[type]/[id].tsx diff --git a/packages/webapp/components/image-generator/ShareCard.tsx b/packages/webapp/components/image-generator/ShareCard.tsx new file mode 100644 index 00000000000..00e66755622 --- /dev/null +++ b/packages/webapp/components/image-generator/ShareCard.tsx @@ -0,0 +1,719 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import LogoText from '@dailydotdev/shared/src/svg/LogoText'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote'; +import { DiscussIcon } from '@dailydotdev/shared/src/components/icons/Discuss'; +import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad'; +import { DocsIcon } from '@dailydotdev/shared/src/components/icons/Docs'; +import { UserIcon } from '@dailydotdev/shared/src/components/icons/User'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + cloudinaryCharmEmptySquads, + cloudinaryCharmNotEnoughTags, +} from '@dailydotdev/shared/src/lib/image'; + +// Compact count: 1234 -> "1.2K", 1200000 -> "1.2M". +const fmt = (n?: number): string => { + if (!n) { + return '0'; + } + if (n < 1000) { + return `${n}`; + } + if (n < 1_000_000) { + return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '')}K`; + } + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; +}; + +/** + * "Layout A" share-card design system, rendered as real DOM (screenshotted at + * 1200×630 by the daily-api `/og` route via the scraper). Because this renders + * in a real browser — not Satori — `line-clamp`, `backdrop-blur`, the real + * fonts and the daily.dev design tokens all work natively; no truncation or + * font-metric workarounds needed. + * + * The page wraps the chosen card in `#screenshot_wrapper` sized to exactly + * 1200×630; each card fills that frame. + */ + +export interface Identity { + name: string; + image?: string; + meta?: string; // trailing context, e.g. "6m read" + label?: string; // verb after the name, e.g. "invited you" / "shared" + // Show an avatar slot: an image, or an initial fallback when `fallback` is + // set. Label-only eyebrows ("Squad", "daily.dev", "Topic") set neither, so + // no avatar/logo renders before the text. + fallback?: boolean; +} + +const Wordmark = (): ReactElement => ( + + + + +); + +const Backdrop = ({ cover }: { cover?: string }): ReactElement => ( +
+ {cover ? ( + <> + +
+ + ) : ( +
+ )} +
+); + +const IdentityAvatar = ({ + identity, + shape, +}: { + identity: Identity; + shape: string; +}): ReactElement | null => { + if (identity.image) { + return ( + + ); + } + // Person with no avatar: the initial on the brand gradient. Label-only + // eyebrows (Squad / daily.dev / Topic) opt out entirely. + if (identity.fallback) { + return ( + + {identity.name?.charAt(0)?.toUpperCase() || ''} + + ); + } + return null; +}; + +const IdentityRow = ({ identity }: { identity: Identity }): ReactElement => { + // Compact eyebrow matching the storybook REyebrow (~44px avatar, 28px text). + const avatarShape = + 'h-11 w-11 shrink-0 rounded-full border border-[rgba(255,255,255,0.2)]'; + return ( +
+ + + {identity.name} + {!!identity.label && {identity.label}} + {!!identity.meta && · {identity.meta}} + +
+ ); +}; + +const Title = ({ + children, + className = 'text-[60px]', + lines = 'line-clamp-3', +}: { + children: ReactNode; + className?: string; + lines?: string; +}): ReactElement => ( + + {children} + +); + +const Subtitle = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const GlassBar = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +const EngagementBar = ({ + upvotes, + comments, +}: { + upvotes: number; + comments: number; +}): ReactElement => ( + + + + {upvotes} + + + + + {comments} + + +); + +const MetaPill = ({ text }: { text: string }): ReactElement => ( + + {text} + +); + +interface Stat { + id: string; + icon: ReactNode; + value: string; +} + +// Glass bar of icon+number stats (squad members / posts / upvotes). +const StatBar = ({ stats }: { stats: Stat[] }): ReactElement => ( + + + {stats.map((s, i) => ( + + {i > 0 && ( + + )} + + {s.icon} + {s.value} + + + ))} + + +); + +// Community proof — a pile of member avatars + the count (squads / sources). +const CommunityBar = ({ + faces, + count, +}: { + faces: string[]; + count: string; +}): ReactElement => ( + + + + {faces.map((src, i) => ( + 0 ? '-ml-3' : '' + }`} + /> + ))} + + + {count} + + + +); + +// Prominent member count for invite cards: a user icon + the number in the +// brand cabbage colour + a word ("developers"), e.g. "26,606 developers". +const ProminentCount = ({ + count, + word, +}: { + count: string; + word?: string; +}): ReactElement => ( + + + + + {count} + + {word ? ` ${word}` : ''} + + +); + +const PrimaryButton = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const Tile = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +const ImageArt = ({ + src, + circle, +}: { + src: string; + circle?: boolean; +}): ReactElement => ( + +); + +// Charm mascot (the dog) anchored into the bottom-right corner — overflows the +// art slot a touch; the frame's overflow-hidden clips it at the canvas edge. +const Mascot = ({ src }: { src: string }): ReactElement => ( + +); + +interface OgFrameProps { + backdrop?: string; + identity: Identity; + children: ReactNode; // content column (title + subtitle, or richer) + meta?: ReactNode; // bottom-left slot + art?: ReactNode; // bottom-right slot +} + +const OgFrame = ({ + backdrop, + identity, + children, + meta, + art, +}: OgFrameProps): ReactElement => ( +
+ + + {/* top bar */} +
+ + +
+ + {/* content column, vertically centred between top bar and meta/bottom */} +
+ {children} +
+ + {/* meta slot */} + {!!meta &&
{meta}
} + + {/* art slot */} + {!!art && ( +
+ {art} +
+ )} +
+); + +// ---- per-type cards ------------------------------------------------------- + +export interface PostCardData { + title?: string; + summary?: string; + image?: string; + numUpvotes?: number; + numComments?: number; + readTime?: number; + source?: { name?: string; image?: string; type?: string }; + author?: { name?: string; username?: string; image?: string }; + sharer?: { name?: string; image?: string }; +} + +export const PostShareCard = ({ + data, +}: { + data: PostCardData; +}): ReactElement => { + const isUserSource = data.source?.type === 'user'; + const identity: Identity = data.sharer + ? { + name: data.sharer.name, + image: data.sharer.image, + fallback: true, + label: 'shared this', + } + : { + name: isUserSource ? data.author?.name : data.source?.name, + image: isUserSource ? data.author?.image : data.source?.image, + fallback: true, + meta: data.readTime ? `${data.readTime}m read` : undefined, + }; + // On a shared card the source name is the subtitle (like the storybook mock); + // on a regular article it's the post summary. + const subtitle = data.sharer ? data.source?.name : data.summary; + return ( + : undefined} + meta={ + + } + > + {data.title} + {!!subtitle && {subtitle}} + + ); +}; + +export interface CommentCardData { + content: string; + numUpvotes?: number; + replies?: number; // replies to THIS comment (children), not the post total + author: { name: string; image?: string }; + postTitle?: string; +} + +export const CommentShareCard = ({ + data, +}: { + data: CommentCardData; +}): ReactElement => ( + + } + > + {`“${data.content}”`} + {!!data.postTitle && {`on “${data.postTitle}”`}} + +); + +export interface SourceCardData { + name: string; + image?: string; + description?: string; + followers?: number; + faces?: string[]; // member/follower avatars for the community pile +} + +export const SourceShareCard = ({ + data, +}: { + data: SourceCardData; +}): ReactElement => { + // Community proof (the storybook Community pattern): member faces + count. + const count = data.followers + ? `Followed by ${fmt(data.followers)} developers` + : undefined; + let meta: ReactNode; + if (data.faces?.length) { + meta = ; + } else if (count) { + meta = ; + } + return ( + : undefined} + meta={meta} + > + + {data.name} + + {!!data.description && {data.description}} + + ); +}; + +export interface SquadCardData { + name: string; + image?: string; + description?: string; + members?: number; + posts?: number; + upvotes?: number; + sharer?: { name?: string; image?: string }; // optional referrer (?userid) +} + +export const SquadShareCard = ({ + data, +}: { + data: SquadCardData; +}): ReactElement => { + // Matches the storybook squad card: a Members / Posts / Upvotes stat bar. + const stats: Stat[] = []; + if (data.members) { + stats.push({ + id: 'members', + icon: , + value: fmt(data.members), + }); + } + if (data.posts) { + stats.push({ + id: 'posts', + icon: , + value: fmt(data.posts), + }); + } + if (data.upvotes) { + stats.push({ + id: 'upvotes', + icon: , + value: fmt(data.upvotes), + }); + } + const art = data.image ? ( + + ) : ( + + + {data.name?.[0]} + + + ); + + // Shared via a referral (?userid) → the invite treatment (storybook): + // "{inviter} invited you", "Join {squad}", a prominent member count, and a + // "Tap to join" CTA. Otherwise the plain squad card with its stat bar. + if (data.sharer) { + return ( + Tap to join} + > + + {`Join ${data.name}`} + + {!!data.members && ( + + )} + + ); + } + + return ( + 0 ? : undefined} + > + + {data.name} + + {!!data.description && {data.description}} + + ); +}; + +// ---- profile card (distinct from the DevCard) ----------------------------- +export interface ProfileCardData { + name: string; + handle?: string; + image?: string; + bio?: string; + reputation?: number; + streak?: number; + reads?: number; + tags?: string[]; + sources?: string[]; // most-read source logos +} + +const ProfileStats = ({ + stats, +}: { + stats: Array<{ value: string; label: string }>; +}): ReactElement => ( + + + {stats.map((s, i) => ( + + {i > 0 && ( + + )} + + + {s.value} + + + {s.label} + + + + ))} + + +); + +const SourceLogos = ({ logos }: { logos: string[] }): ReactElement => ( + + + Reads from + + + {logos.map((src) => ( + + + + ))} + + +); + +const ProfileTagChips = ({ tags }: { tags: string[] }): ReactElement => ( + + {tags.map((t) => ( + + #{t} + + ))} + +); + +export const ProfileShareCard = ({ + data, +}: { + data: ProfileCardData; +}): ReactElement => { + const stats: Array<{ value: string; label: string }> = []; + if (data.reputation) { + stats.push({ value: fmt(data.reputation), label: 'Reputation' }); + } + if (data.streak) { + stats.push({ value: fmt(data.streak), label: 'Longest streak' }); + } + if (data.reads) { + stats.push({ value: fmt(data.reads), label: 'Posts read' }); + } + return ( + : undefined} + > +
+ + {data.name} + + {!!data.bio && {data.bio}} + {stats.length > 0 && ( +
+ +
+ )} + {!!data.sources?.length && ( +
+ +
+ )} + {!!data.tags?.length && ( +
+ +
+ )} +
+
+ ); +}; + +export const TagShareCard = ({ tag }: { tag: string }): ReactElement => ( + } + meta={{`Explore #${tag}`}} + > + {`#${tag}`} + + Follow this topic to fill your feed with the best posts about it. + + +); + +export const InviteShareCard = ({ + name, + image, +}: { + name: string; + image?: string; +}): ReactElement => ( + } + meta={Tap to join} + > + Join me on daily.dev + The one feed developers actually read + +); + +export const PlusShareCard = (): ReactElement => ( + + + + + + + } + meta={Upgrade to Plus} + > + Supercharge your daily.dev + + Plus unlocks advanced AI, custom feeds, and a clutter-free, ad-free + experience. + + +); diff --git a/packages/webapp/next-seo.ts b/packages/webapp/next-seo.ts index a298db89991..c6cca142f4f 100644 --- a/packages/webapp/next-seo.ts +++ b/packages/webapp/next-seo.ts @@ -40,6 +40,34 @@ export const defaultSeo: Partial = { ], }; +// Contextual share images rendered by the webapp and screenshotted by +// daily-api at /og//.png (mirrors the devcard v2 image pipeline). +export type ShareImageType = + | 'posts' + | 'comments' + | 'sources' + | 'squads' + | 'tags' + | 'invite' + | 'plus'; + +export const getShareImageUrl = ( + type: ShareImageType, + id: string, + params?: Record, +): string => { + const url = new URL( + `/og/${type}/${encodeURIComponent(id)}.png`, + process.env.NEXT_PUBLIC_API_URL, + ); + Object.entries(params ?? {}).forEach(([key, value]) => { + if (value) { + url.searchParams.set(key, value); + } + }); + return url.toString(); +}; + export const getSquadOpenGraph = ({ squad, }: { diff --git a/packages/webapp/pages/image-generator/share/[type]/[id].tsx b/packages/webapp/pages/image-generator/share/[type]/[id].tsx new file mode 100644 index 00000000000..5437562fd6d --- /dev/null +++ b/packages/webapp/pages/image-generator/share/[type]/[id].tsx @@ -0,0 +1,376 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import { useRouter } from 'next/router'; +import { useQuery } from '@tanstack/react-query'; +import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { + CommentShareCard, + InviteShareCard, + PlusShareCard, + PostShareCard, + ProfileShareCard, + SourceShareCard, + SquadShareCard, + TagShareCard, +} from '../../../../components/image-generator/ShareCard'; + +// These pages render anonymously and are screenshotted, so they must query +// ONLY public fields. The app's data hooks (usePostById / SQUAD_QUERY / …) +// pull user-scoped fields (bookmark/user state, membership, content +// preferences) which require auth — an anonymous request to them returns +// "Access denied". So we use minimal, public-only queries (mirroring the old +// og.daily.dev Satori service) and fire them immediately — no boot/auth needed. + +const OG_POST_QUERY = ` + query Post($id: ID!) { + post(id: $id) { + title + summary + image + readTime + numUpvotes + numComments + source { name image handle type } + author { name username image } + sharedPost { title summary image } + } + } +`; + +const OG_COMMENT_QUERY = ` + query Comment($id: ID!) { + comment(id: $id) { + content + numUpvotes + author { name image } + post { title } + children { edges { node { id } } } + } + } +`; + +const OG_SOURCE_QUERY = ` + query Source($id: ID!) { + source(id: $id) { + id + name + image + description + membersCount + } + } +`; + +const OG_SQUAD_QUERY = ` + query Source($id: ID!) { + source(id: $id) { + id + name + image + description + membersCount + flags { totalPosts totalUpvotes } + } + } +`; + +// Minimal public user query — the exact shape the old referral og route used +// anonymously (avoids the auth-gated fields in the full devcard query). +const OG_USER_QUERY = ` + query User($id: ID!) { + user(id: $id) { + name + image + } + } +`; + +// Decorative community face pile. Per the designer, these are a fixed pool of +// AI-generated avatars (not real followers — there's no query for a source's +// follower avatars). We deterministically pick 3 from the pool seeded by the +// entity id, so a given source always shows the same 3 (stable across the +// preview and the cached screenshot) while different sources rotate. +// TODO: replace with the final 20 AI-generated avatars hosted on media.daily.dev. +const COMMUNITY_FACES = [ + 'https://media.daily.dev/image/upload/s--FcI3RdS1--/f_auto/v1745335145/avatars/avatar_R9RafYjp15h3mJ9XdIkhy', + 'https://media.daily.dev/image/upload/s--AVEMGQgE--/f_auto/v1744349812/avatars/avatar_RVvUzGofSIHGyTxdjqDN1', + 'https://media.daily.dev/image/upload/s--CwdXky60--/f_auto/v1733031652/avatars/avatar_rmFJzNXUNPh163VaQkmF0', +]; + +const pickFaces = (seed: string, count = 3): string[] => { + if (COMMUNITY_FACES.length === 0) { + return []; + } + // Bounded string hash (kept under a prime to avoid float overflow without + // bitwise ops) → a stable per-seed offset into the pool. + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) % 2147483647; + } + const start = hash % COMMUNITY_FACES.length; + return Array.from( + { length: Math.min(count, COMMUNITY_FACES.length) }, + (_, i) => COMMUNITY_FACES[(start + i) % COMMUNITY_FACES.length], + ); +}; + +interface OgUser { + name?: string; + image?: string; +} + +interface OgPost { + title?: string; + summary?: string; + image?: string; + readTime?: number; + numUpvotes?: number; + numComments?: number; + source?: { name?: string; image?: string; handle?: string; type?: string }; + author?: { name?: string; username?: string; image?: string }; + sharedPost?: { title?: string; summary?: string; image?: string }; +} + +interface OgComment { + content: string; + numUpvotes?: number; + author?: { name?: string; image?: string }; + post?: { title?: string }; + children?: { edges?: Array<{ node?: { id?: string } }> }; +} + +interface OgSource { + id?: string; + name: string; + image?: string; + description?: string; + membersCount?: number; + flags?: { totalPosts?: number; totalUpvotes?: number }; +} + +const PostLoader = ({ id }: { id: string }): ReactElement | null => { + const { query } = useRouter(); + const userId = query?.userid as string; + const { data } = useQuery({ + queryKey: ['og-post', id], + queryFn: () => gqlClient.request<{ post: OgPost }>(OG_POST_QUERY, { id }), + }); + const { data: sharerData } = useQuery({ + queryKey: ['og-user', userId], + queryFn: () => + gqlClient.request<{ user: OgUser }>(OG_USER_QUERY, { id: userId }), + enabled: !!userId, + }); + const post = data?.post; + if (!post) { + return null; + } + // Skip the generic placeholder cover, falling back to a shared post's image. + const cover = + post.image && !post.image.includes('public/Placeholder') + ? post.image + : post.sharedPost?.image; + const sharer = sharerData?.user + ? { name: sharerData.user.name, image: sharerData.user.image } + : undefined; + return ( + + ); +}; + +const CommentLoader = ({ id }: { id: string }): ReactElement | null => { + const { data } = useQuery({ + queryKey: ['og-comment', id], + queryFn: () => + gqlClient.request<{ comment: OgComment }>(OG_COMMENT_QUERY, { id }), + }); + const comment = data?.comment; + if (!comment) { + return null; + } + return ( + + ); +}; + +const SourceLoader = ({ id }: { id: string }): ReactElement | null => { + const { data } = useQuery({ + queryKey: ['og-source', id], + queryFn: () => + gqlClient.request<{ source: OgSource }>(OG_SOURCE_QUERY, { id }), + }); + const source = data?.source; + if (!source) { + return null; + } + return ( + + ); +}; + +const SquadLoader = ({ id }: { id: string }): ReactElement | null => { + const { query } = useRouter(); + const userId = query?.userid as string; + const { data } = useQuery({ + queryKey: ['og-squad', id], + queryFn: () => + gqlClient.request<{ source: OgSource }>(OG_SQUAD_QUERY, { id }), + }); + const { data: sharerData } = useQuery({ + queryKey: ['og-user', userId], + queryFn: () => + gqlClient.request<{ user: OgUser }>(OG_USER_QUERY, { id: userId }), + enabled: !!userId, + }); + const squad = data?.source; + if (!squad) { + return null; + } + return ( + + ); +}; + +const InviteLoader = ({ id }: { id: string }): ReactElement | null => { + const { data } = useQuery({ + queryKey: ['og-user', id], + queryFn: () => gqlClient.request<{ user: OgUser }>(OG_USER_QUERY, { id }), + }); + const user = data?.user; + if (!user) { + return null; + } + return ; +}; + +// Distinct profile card (Layout A) — reuses the public DevCard query for data +// (reputation, streak, posts read, most-read tags & sources) but renders the +// share-card layout, not the DevCard. Both can be tested side by side. +const ProfileLoader = ({ id }: { id: string }): ReactElement | null => { + const { devcard } = useDevCard(id); + const user = devcard?.user; + if (!user) { + return null; + } + return ( + source.image) + .filter((image): image is string => !!image) + .slice(0, 4), + }} + /> + ); +}; + +interface ShareImagePageProps { + type: string; + id: string; +} + +const ShareImagePage = ({ type, id }: ShareImagePageProps): ReactElement => { + const card = ((): ReactElement | null => { + switch (type) { + case 'posts': + return ; + case 'comments': + return ; + case 'sources': + return ; + case 'squads': + return ; + case 'profile': + return ; + case 'tags': + return ; + case 'invite': + return ; + case 'plus': + return ; + default: + return null; + } + })(); + + return ( +
+ {card} +
+ ); +}; + +export function getStaticPaths(): GetStaticPathsResult { + return { paths: [], fallback: 'blocking' }; +} + +export function getStaticProps({ + params, +}: GetStaticPropsContext): GetStaticPropsResult { + const type = params?.type as string; + const id = params?.id as string; + if (!type || !id) { + return { notFound: true, revalidate: false }; + } + return { props: { type, id }, revalidate: 60 }; +} + +export default ShareImagePage; diff --git a/packages/webapp/pages/posts/[id]/analytics/index.tsx b/packages/webapp/pages/posts/[id]/analytics/index.tsx index ce5650abcc3..4a3245f1f4a 100644 --- a/packages/webapp/pages/posts/[id]/analytics/index.tsx +++ b/packages/webapp/pages/posts/[id]/analytics/index.tsx @@ -92,6 +92,7 @@ import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/type import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; import { useCampaignEstimation } from '@dailydotdev/shared/src/features/boost/useCampaignEstimation'; import { useCampaigns } from '@dailydotdev/shared/src/features/boost/useCampaigns'; +import { getShareImageUrl } from '../../../../next-seo'; import { getSeoDescription } from '../../../../components/PostSEOSchema'; import type { Props } from '../index'; import { seoTitle } from '../index'; @@ -139,7 +140,10 @@ export const getServerSideProps: GetServerSideProps< ...pageSeoTitles.openGraph, images: [ { - url: `https://og.daily.dev/api/posts/${post?.id}`, + url: getShareImageUrl('posts', post?.id ?? ''), + width: 1200, + height: 630, + alt: post?.title || 'Post cover image', }, ], article: { diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index c326b8b2e2f..8cdf371d9b9 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -51,6 +51,7 @@ import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditio import { isPostRedesignEligible } from '@dailydotdev/shared/src/hooks/post/usePostRedesign'; import { featurePostRedesign } from '@dailydotdev/shared/src/lib/featureManagement'; import { PostFocusCard } from '@dailydotdev/shared/src/components/post/focus/PostFocusCard'; +import { getShareImageUrl } from '../../../next-seo'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -362,7 +363,7 @@ export async function getStaticProps({ ...pageSeoTitles.openGraph, images: [ { - url: `https://og.daily.dev/api/posts/${post?.id}`, + url: getShareImageUrl('posts', post?.id ?? ''), width: 1200, height: 630, alt: post?.title || 'Post cover image', diff --git a/packages/webapp/pages/posts/[id]/share/index.tsx b/packages/webapp/pages/posts/[id]/share/index.tsx index dd9d73c2897..41945ae89b9 100644 --- a/packages/webapp/pages/posts/[id]/share/index.tsx +++ b/packages/webapp/pages/posts/[id]/share/index.tsx @@ -14,6 +14,7 @@ import { useUserShortByIdQuery } from '@dailydotdev/shared/src/hooks/user/useUse import { USER_SHORT_BY_ID } from '@dailydotdev/shared/src/graphql/users'; import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; import { StaleTime } from '@dailydotdev/shared/src/lib/query'; +import { getShareImageUrl } from '../../../../next-seo'; import { getPageSeoTitles } from '../../../../components/layouts/utils'; import { getSeoDescription } from '../../../../components/PostSEOSchema'; import type { Props } from '../index'; @@ -87,7 +88,9 @@ export const getServerSideProps: GetServerSideProps< ...pageSeoTitles.openGraph, images: [ { - url: `https://og.daily.dev/api/posts/${post?.id}?userid=${shareUser.id}`, + url: getShareImageUrl('posts', post?.id ?? '', { + userid: shareUser.id, + }), width: 1200, height: 630, alt: post?.title || 'Post cover image', diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index 21a41177528..66deea0f943 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -69,7 +69,7 @@ import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { ArchiveScopeType } from '@dailydotdev/shared/src/graphql/archive'; import Custom404 from '../404'; -import { defaultOpenGraph, defaultSeo } from '../../next-seo'; +import { defaultOpenGraph, defaultSeo, getShareImageUrl } from '../../next-seo'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; import { getLayout } from '../../components/layouts/FeedLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; @@ -464,6 +464,13 @@ export async function getStaticProps({ openGraph: { ...defaultOpenGraph, ...seoTitles.openGraph, + images: [ + { + url: getShareImageUrl('sources', source.id), + width: 1200, + height: 630, + }, + ], }, description: source?.description || defaultSeo.description, }; diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx index 3ce293f80f0..df40ddd6c39 100644 --- a/packages/webapp/pages/tags/[tag].tsx +++ b/packages/webapp/pages/tags/[tag].tsx @@ -24,7 +24,7 @@ import { getPageSeoTitles } from '../../components/layouts/utils'; import { getLayout } from '../../components/layouts/FeedLayout'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; import type { DynamicSeoProps } from '../../components/common'; -import { defaultOpenGraph, defaultSeo } from '../../next-seo'; +import { defaultOpenGraph, defaultSeo, getShareImageUrl } from '../../next-seo'; import { getAppOrigin } from '../../lib/seo'; const appOrigin = getAppOrigin(); @@ -144,6 +144,7 @@ interface TagPageParams extends ParsedUrlQuery { const getSeoData = ( title: string, description = `Find all the recent posts, videos, updates and discussions about ${title}`, + tagSlug = title, ): NextSeoProps => { const seoTitles = getPageSeoTitles(`${title} posts`); @@ -153,6 +154,13 @@ const getSeoData = ( openGraph: { ...defaultOpenGraph, ...seoTitles.openGraph, + images: [ + { + url: getShareImageUrl('tags', tagSlug), + width: 1200, + height: 630, + }, + ], }, description, }; @@ -227,6 +235,7 @@ export async function getStaticProps({ const seo = getSeoData( initialData.flags?.title || tag, initialData.flags?.description, + tag, ); return { From 70f898d002b654fc5905591094d5963feb9c7173 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 26 Jun 2026 21:15:23 +0200 Subject: [PATCH 3/3] fix(webapp): strict-typecheck share cards; revert og:image on strict-dirty pages - coalesce possibly-undefined names/ids to satisfy tsconfig.strict (ShareCard identity, sources getShareImageUrl) - revert the og:image repoint on posts/[id]/analytics and posts/[id]/share: those files carry pre-existing strict-typing debt, so touching them tripped typecheck:strict:changed. That wiring moves to a follow-up; the new render pages + post/source/tag repoints remain. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/webapp/components/image-generator/ShareCard.tsx | 4 ++-- packages/webapp/pages/posts/[id]/analytics/index.tsx | 6 +----- packages/webapp/pages/posts/[id]/share/index.tsx | 5 +---- packages/webapp/pages/sources/[source].tsx | 2 +- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/webapp/components/image-generator/ShareCard.tsx b/packages/webapp/components/image-generator/ShareCard.tsx index 00e66755622..0f9173fa5e7 100644 --- a/packages/webapp/components/image-generator/ShareCard.tsx +++ b/packages/webapp/components/image-generator/ShareCard.tsx @@ -361,13 +361,13 @@ export const PostShareCard = ({ const isUserSource = data.source?.type === 'user'; const identity: Identity = data.sharer ? { - name: data.sharer.name, + name: data.sharer.name ?? '', image: data.sharer.image, fallback: true, label: 'shared this', } : { - name: isUserSource ? data.author?.name : data.source?.name, + name: (isUserSource ? data.author?.name : data.source?.name) ?? '', image: isUserSource ? data.author?.image : data.source?.image, fallback: true, meta: data.readTime ? `${data.readTime}m read` : undefined, diff --git a/packages/webapp/pages/posts/[id]/analytics/index.tsx b/packages/webapp/pages/posts/[id]/analytics/index.tsx index 4a3245f1f4a..ce5650abcc3 100644 --- a/packages/webapp/pages/posts/[id]/analytics/index.tsx +++ b/packages/webapp/pages/posts/[id]/analytics/index.tsx @@ -92,7 +92,6 @@ import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/type import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; import { useCampaignEstimation } from '@dailydotdev/shared/src/features/boost/useCampaignEstimation'; import { useCampaigns } from '@dailydotdev/shared/src/features/boost/useCampaigns'; -import { getShareImageUrl } from '../../../../next-seo'; import { getSeoDescription } from '../../../../components/PostSEOSchema'; import type { Props } from '../index'; import { seoTitle } from '../index'; @@ -140,10 +139,7 @@ export const getServerSideProps: GetServerSideProps< ...pageSeoTitles.openGraph, images: [ { - url: getShareImageUrl('posts', post?.id ?? ''), - width: 1200, - height: 630, - alt: post?.title || 'Post cover image', + url: `https://og.daily.dev/api/posts/${post?.id}`, }, ], article: { diff --git a/packages/webapp/pages/posts/[id]/share/index.tsx b/packages/webapp/pages/posts/[id]/share/index.tsx index 41945ae89b9..dd9d73c2897 100644 --- a/packages/webapp/pages/posts/[id]/share/index.tsx +++ b/packages/webapp/pages/posts/[id]/share/index.tsx @@ -14,7 +14,6 @@ import { useUserShortByIdQuery } from '@dailydotdev/shared/src/hooks/user/useUse import { USER_SHORT_BY_ID } from '@dailydotdev/shared/src/graphql/users'; import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; import { StaleTime } from '@dailydotdev/shared/src/lib/query'; -import { getShareImageUrl } from '../../../../next-seo'; import { getPageSeoTitles } from '../../../../components/layouts/utils'; import { getSeoDescription } from '../../../../components/PostSEOSchema'; import type { Props } from '../index'; @@ -88,9 +87,7 @@ export const getServerSideProps: GetServerSideProps< ...pageSeoTitles.openGraph, images: [ { - url: getShareImageUrl('posts', post?.id ?? '', { - userid: shareUser.id, - }), + url: `https://og.daily.dev/api/posts/${post?.id}?userid=${shareUser.id}`, width: 1200, height: 630, alt: post?.title || 'Post cover image', diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index 36c846f0613..36917bfea32 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -465,7 +465,7 @@ export async function getStaticProps({ ...seoTitles.openGraph, images: [ { - url: getShareImageUrl('sources', source.id), + url: getShareImageUrl('sources', source.id ?? ''), width: 1200, height: 630, },