From ffc0b85eee3937a79a63e53e763d9865420afb2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:41:35 +0000 Subject: [PATCH] feat: social cards that show the resume they link to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every published resume shared the same generic Tiny CV card, so a link to someone's CV previewed as marketing art rather than their resume. Render a per-resume card instead, using the browser renderer that already backs PDF export: an internal 1200x630 surface lays out the real sheet on the app's paper background, and the card route screenshots it at 2x. Because the card is the same ResumeDesktopSheet the published page and the PDF use, a card can never drift from the resume it links to. The page is scaled to 1.2 and anchored to the top, so the card shows the head of the resume — name, headline, contact, summary, and the first role — at a readable size instead of a shrunken full page. The card URL carries a version derived from the resume's publish time, so a republished resume gets a fresh card while unchanged ones stay cached at the edge. Anything that stops us rendering the resume itself — no browser, no store, rate limited, unknown slug — falls back to the generic card rather than breaking the preview. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M7fKRZGJ2XH6E4AXktKRzN --- README.md | 4 +- docs/production-launch-checklist.md | 4 +- src/app/[slug]/social-card/route.ts | 76 +++++++++++++++++++ .../_components/public-resume-renderer.tsx | 14 +++- src/app/_lib/api-rate-limit.ts | 2 + src/app/_lib/resume-social-card.test.ts | 57 ++++++++++++++ src/app/_lib/resume-social-card.ts | 62 +++++++++++++++ src/app/globals.css | 25 ++++++ src/app/internal/social-card/[slug]/page.tsx | 63 +++++++++++++++ 9 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/app/[slug]/social-card/route.ts create mode 100644 src/app/_lib/resume-social-card.test.ts create mode 100644 src/app/_lib/resume-social-card.ts create mode 100644 src/app/internal/social-card/[slug]/page.tsx diff --git a/README.md b/README.md index 9a9a870..bacb3f7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This repo is intended to be publishable as an open-core app. | --- | --- | --- | --- | | File-backed local mode | Editor, preview, templates, local publishing flows | Nothing beyond Node and pnpm | Fast local development and simple self-hosting | | Database-backed mode | Durable workspaces, accounts, account-owned resumes, full developer API | Postgres | Serious self-hosting and production deployments | -| Browser-backed jobs | Chromium-measured publish fit and async PDF jobs | `TINYCV_BROWSER_WS_ENDPOINT` or `TINYCV_CHROME_EXECUTABLE_PATH` | Production PDF/export parity | +| Browser-backed jobs | Chromium-measured publish fit, async PDF jobs, and per-resume social cards | `TINYCV_BROWSER_WS_ENDPOINT` or `TINYCV_CHROME_EXECUTABLE_PATH` | Production PDF/export parity | | Billing and machine payments | Stripe checkout, premium entitlements, x402/MPP paid endpoints | Extra provider credentials | Optional commercial add-ons | ## Quick Start @@ -192,7 +192,7 @@ High-level deployment requirements: - Postgres for durable production storage - explicit secrets for editor, platform, auth, and worker flows -- Chromium access for browser-measured publish fit and PDF jobs +- Chromium access for browser-measured publish fit, PDF jobs, and social cards - a scheduled job or worker invocation for `/api/v1/jobs/process` ## Verification diff --git a/docs/production-launch-checklist.md b/docs/production-launch-checklist.md index 8d07b9d..f63a9d1 100644 --- a/docs/production-launch-checklist.md +++ b/docs/production-launch-checklist.md @@ -7,7 +7,7 @@ This guide is the public production checklist for self-hosting Tiny CV with dura Tiny CV works best when you treat the deployment as two layers: - core app: Next.js server that handles the editor, API, auth, and public resumes -- background/browser layer: a worker or scheduled caller plus Chromium access for publish-fit measurement and PDF jobs +- background/browser layer: a worker or scheduled caller plus Chromium access for publish-fit measurement, PDF jobs, and social cards You can run both on one machine for small deployments or split them across services later. @@ -64,7 +64,7 @@ pnpm check:prod ### 3. Browser-Backed Jobs -API publish-fit measurement and async PDF jobs need Chromium access. +API publish-fit measurement, async PDF jobs, and the per-resume social cards on published pages need Chromium access. Without it, published pages fall back to the generic Tiny CV card. Set one of: diff --git a/src/app/[slug]/social-card/route.ts b/src/app/[slug]/social-card/route.ts new file mode 100644 index 0000000..b61fa8d --- /dev/null +++ b/src/app/[slug]/social-card/route.ts @@ -0,0 +1,76 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { + ApiRateLimitError, + ApiRateLimitUnavailableError, + assertApiRateLimit, +} from "@/app/_lib/api-rate-limit"; +import { BrowserRendererUnavailableError } from "@/app/_lib/browser-renderer"; +import { + getPublishedResumeBySlug, + HostedResumeStoreUnavailableError, +} from "@/app/_lib/hosted-resume-store"; +import { + buildResumeSocialCardRenderUrl, + renderResumeSocialCard, + SOCIAL_CARD_CONTENT_TYPE, +} from "@/app/_lib/resume-social-card"; +import { TINYCV_SOCIAL_CARD_PATH } from "@/app/_lib/site-metadata"; + +export const maxDuration = 60; + +const CARD_CACHE_CONTROL = "public, max-age=3600, s-maxage=604800, stale-while-revalidate=604800"; +const FALLBACK_CACHE_CONTROL = "public, max-age=60, s-maxage=300"; + +export async function GET( + request: NextRequest, + context: RouteContext<"/[slug]/social-card">, +) { + const { slug } = await context.params; + + try { + await assertApiRateLimit({ action: "social:card", request }); + + const resume = await getPublishedResumeBySlug(slug); + + if (!resume) { + return buildFallbackCardResponse(request); + } + + const card = await renderResumeSocialCard( + buildResumeSocialCardRenderUrl(request.nextUrl.origin, resume.slug), + ); + + return new Response(card as BodyInit, { + headers: { + "Cache-Control": CARD_CACHE_CONTROL, + "Content-Type": SOCIAL_CARD_CONTENT_TYPE, + }, + }); + } catch (error) { + if (isRecoverableCardError(error)) { + return buildFallbackCardResponse(request); + } + + throw error; + } +} + +/** + * A link preview should never look broken, so anything that stops us rendering + * the resume itself falls back to the generic Tiny CV card. + */ +function buildFallbackCardResponse(request: NextRequest) { + return NextResponse.redirect(new URL(TINYCV_SOCIAL_CARD_PATH, request.nextUrl.origin), { + headers: { "Cache-Control": FALLBACK_CACHE_CONTROL }, + status: 307, + }); +} + +function isRecoverableCardError(error: unknown) { + return ( + error instanceof ApiRateLimitError || + error instanceof ApiRateLimitUnavailableError || + error instanceof BrowserRendererUnavailableError || + error instanceof HostedResumeStoreUnavailableError + ); +} diff --git a/src/app/_components/public-resume-renderer.tsx b/src/app/_components/public-resume-renderer.tsx index 61d3921..c11295d 100644 --- a/src/app/_components/public-resume-renderer.tsx +++ b/src/app/_components/public-resume-renderer.tsx @@ -6,7 +6,11 @@ import { getPageMetrics } from "@/app/_lib/cv-fit"; import { parseCvMarkdown } from "@/app/_lib/cv-markdown"; import { canRemoveBrandingForResume } from "@/app/_lib/entitlements"; import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types"; -import { getTinyCvSocialCardImage } from "@/app/_lib/site-metadata"; +import { + buildResumeSocialCardPath, + SOCIAL_CARD_CONTENT_TYPE, + SOCIAL_CARD_IMAGE_SIZE, +} from "@/app/_lib/resume-social-card"; export async function PublicResumeRenderer({ autoPrint = false, @@ -60,7 +64,13 @@ export async function buildPublicResumeMetadata( : `${document.name}'s resume, published with Tiny CV.`; const title = `${document.name} | Resume`; const canonicalPath = options?.canonicalPath ?? `/${resume.slug}`; - const socialCardImage = getTinyCvSocialCardImage(); + const socialCardImage = { + alt: `The top of ${document.name}'s resume`, + height: SOCIAL_CARD_IMAGE_SIZE.height, + type: SOCIAL_CARD_CONTENT_TYPE, + url: buildResumeSocialCardPath(resume), + width: SOCIAL_CARD_IMAGE_SIZE.width, + } as const; return { alternates: { diff --git a/src/app/_lib/api-rate-limit.ts b/src/app/_lib/api-rate-limit.ts index 4b50992..f6e96d8 100644 --- a/src/app/_lib/api-rate-limit.ts +++ b/src/app/_lib/api-rate-limit.ts @@ -15,6 +15,7 @@ export type ApiRateLimitAction = | "api:resume_publish" | "api:resume_update" | "api:validate" + | "social:card" | "workspace:attach" | "workspace:bootstrap" | "workspace:create" @@ -44,6 +45,7 @@ const DEFAULT_POLICIES: Record = { "api:resume_publish": { max: 30, windowSeconds: 60 }, "api:resume_update": { max: 120, windowSeconds: 60 }, "api:validate": { max: 120, windowSeconds: 60 }, + "social:card": { max: 120, windowSeconds: 60 }, "workspace:attach": { max: 30, windowSeconds: 60 }, "workspace:bootstrap": { max: 20, windowSeconds: 60 * 60 }, "workspace:create": { max: 30, windowSeconds: 60 }, diff --git a/src/app/_lib/resume-social-card.test.ts b/src/app/_lib/resume-social-card.test.ts new file mode 100644 index 0000000..4c9dc0e --- /dev/null +++ b/src/app/_lib/resume-social-card.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types"; +import { + buildResumeSocialCardPath, + buildResumeSocialCardRenderUrl, +} from "@/app/_lib/resume-social-card"; + +function buildResume(overrides: Partial = {}): HostedResumePublicRecord { + return { + createdAt: "2026-05-01T00:00:00.000Z", + fitScale: 1, + id: "resume_123", + isPublished: true, + markdown: "# Steady Blue Heron", + publishedAt: "2026-06-01T00:00:00.000Z", + slug: "SteadyBlueHeron", + templateKey: "engineer", + title: "Steady Blue Heron", + updatedAt: "2026-06-02T00:00:00.000Z", + ...overrides, + }; +} + +describe("resume-social-card", () => { + it("versions the card path by publish time so edits are not served from cache", () => { + const publishedAt = buildResumeSocialCardPath(buildResume()); + const republished = buildResumeSocialCardPath( + buildResume({ publishedAt: "2026-06-03T00:00:00.000Z" }), + ); + + expect(publishedAt).toBe( + `/SteadyBlueHeron/social-card?v=${Date.parse("2026-06-01T00:00:00.000Z").toString(36)}`, + ); + expect(republished).not.toBe(publishedAt); + }); + + it("falls back to the update time when a resume has no publish time", () => { + expect(buildResumeSocialCardPath(buildResume({ publishedAt: null }))).toBe( + `/SteadyBlueHeron/social-card?v=${Date.parse("2026-06-02T00:00:00.000Z").toString(36)}`, + ); + }); + + it("escapes slugs in the card path", () => { + expect(buildResumeSocialCardPath(buildResume({ slug: "steady blue/heron" }))).toContain( + "/steady%20blue%2Fheron/social-card", + ); + }); + + it("builds render URLs against the requesting origin", () => { + expect(buildResumeSocialCardRenderUrl("https://tiny.cv", "SteadyBlueHeron")).toBe( + "https://tiny.cv/internal/social-card/SteadyBlueHeron", + ); + expect(buildResumeSocialCardRenderUrl("http://localhost:3000/", "SteadyBlueHeron")).toBe( + "http://localhost:3000/internal/social-card/SteadyBlueHeron", + ); + }); +}); diff --git a/src/app/_lib/resume-social-card.ts b/src/app/_lib/resume-social-card.ts new file mode 100644 index 0000000..ea96f9d --- /dev/null +++ b/src/app/_lib/resume-social-card.ts @@ -0,0 +1,62 @@ +import "server-only"; + +import { getBrowserTimeoutMs, withBrowserPage } from "@/app/_lib/browser-renderer"; +import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types"; + +export const SOCIAL_CARD_CONTENT_TYPE = "image/png"; + +/** Open Graph's 1.91:1 landscape card, rendered at 2x for high density displays. */ +export const SOCIAL_CARD_LAYOUT = { + deviceScaleFactor: 2, + height: 630, + width: 1200, +} as const; + +export const SOCIAL_CARD_IMAGE_SIZE = { + height: SOCIAL_CARD_LAYOUT.height * SOCIAL_CARD_LAYOUT.deviceScaleFactor, + width: SOCIAL_CARD_LAYOUT.width * SOCIAL_CARD_LAYOUT.deviceScaleFactor, +} as const; + +/** + * How far the 8.5in page is scaled up on the card. It leaves a ~110px gutter on + * each side, and the page runs off the bottom edge so the card shows the top of + * the resume at a readable size rather than a shrunken whole page. + */ +export const SOCIAL_CARD_PAGE_SCALE = 1.2; + +export function buildResumeSocialCardPath(resume: HostedResumePublicRecord) { + return `/${encodeURIComponent(resume.slug)}/social-card?v=${buildSocialCardVersion(resume)}`; +} + +export function buildResumeSocialCardRenderUrl(origin: string, slug: string) { + return new URL( + `/internal/social-card/${encodeURIComponent(slug)}`, + origin.replace(/\/+$/, ""), + ).toString(); +} + +export async function renderResumeSocialCard(renderUrl: string) { + return await withBrowserPage(async (page) => { + await page.setViewport({ + deviceScaleFactor: SOCIAL_CARD_LAYOUT.deviceScaleFactor, + height: SOCIAL_CARD_LAYOUT.height, + width: SOCIAL_CARD_LAYOUT.width, + }); + await page.goto(renderUrl, { + timeout: getBrowserTimeoutMs(), + waitUntil: "networkidle0", + }); + await page.evaluate(() => window.document.fonts.ready.then(() => undefined)); + + return await page.screenshot({ + captureBeyondViewport: false, + type: "png", + }); + }); +} + +function buildSocialCardVersion(resume: HostedResumePublicRecord) { + const timestamp = Date.parse(resume.publishedAt ?? resume.updatedAt); + + return Number.isFinite(timestamp) ? timestamp.toString(36) : "1"; +} diff --git a/src/app/globals.css b/src/app/globals.css index fa0198f..facf663 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -150,6 +150,31 @@ textarea { } } +/* + * Screenshot surface for a resume's social card. The sheet is anchored to the + * top and runs off the bottom edge, so the card shows the head of the resume at + * a readable size instead of a shrunken full page. + */ +.social-card-stage { + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 50% -24%, rgba(15, 118, 110, 0.09), transparent 62%), + linear-gradient(180deg, #fbf8f2 0%, #f1ece3 100%); +} + +.social-card-paper { + position: absolute; + left: 50%; + top: 44px; + width: calc(816px * var(--cv-stage-scale)); + transform: translateX(-50%); +} + +.social-card-paper .cv-sheet { + border-radius: 0.55rem 0.55rem 0 0; +} + .cv-document { height: 100%; position: relative; diff --git a/src/app/internal/social-card/[slug]/page.tsx b/src/app/internal/social-card/[slug]/page.tsx new file mode 100644 index 0000000..083a4c6 --- /dev/null +++ b/src/app/internal/social-card/[slug]/page.tsx @@ -0,0 +1,63 @@ +import type { CSSProperties } from "react"; +import { notFound } from "next/navigation"; +import { ResumeDesktopSheet } from "@/app/_components/resume-live-document"; +import { parseCvMarkdown } from "@/app/_lib/cv-markdown"; +import { + getPublishedResumeBySlug, + HostedResumeStoreUnavailableError, +} from "@/app/_lib/hosted-resume-store"; +import { SOCIAL_CARD_LAYOUT, SOCIAL_CARD_PAGE_SCALE } from "@/app/_lib/resume-social-card"; +import { TINYCV_NOINDEX_METADATA } from "@/app/_lib/site-metadata"; + +export const dynamic = "force-dynamic"; +export const metadata = TINYCV_NOINDEX_METADATA; + +/** + * The surface the social card screenshot is taken from. It renders the same + * sheet the published page and the PDF use, so a card can never drift from the + * resume it links to. + */ +export default async function ResumeSocialCardPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + let resume = null; + + try { + resume = await getPublishedResumeBySlug(slug); + } catch (error) { + if (error instanceof HostedResumeStoreUnavailableError) { + notFound(); + } + + throw error; + } + + if (!resume) { + notFound(); + } + + const document = parseCvMarkdown(resume.markdown); + + return ( +
+
+ +
+ + {/* The screenshot must hold the card and nothing else, including the dev overlay. */} + +
+ ); +}