From 9d0dc367068acb6cb90a53b049534ca68f3d250f Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 17 Aug 2026 22:19:52 +0200 Subject: [PATCH] feat(seo): render social card metadata for public resumes Public resume pages only produced their OpenGraph and Twitter tags client side, so a shared link had no card at all. The server now injects them into the shell and swaps in the resume's own title and description. The lookup is scoped to public, password-free resumes and deliberately avoids resumeService.getBySlug: that counts a view and would expose a protected resume's summary to an unauthenticated crawler. User-authored values are escaped before they reach the HTML, and any lookup failure falls back to the plain shell. getResumeSocialMeta is shared with the client route head so the two cannot drift. --- apps/server/src/static/web.test.ts | 68 +++++++++++++++++++ apps/server/src/static/web.ts | 65 +++++++++++++++++- apps/web/src/routes/$username/$slug.tsx | 20 +++--- packages/api/package.json | 1 + .../api/src/features/resume/social-meta.ts | 46 +++++++++++++ packages/resume/package.json | 1 + packages/resume/src/social-meta.test.ts | 48 +++++++++++++ packages/resume/src/social-meta.ts | 40 +++++++++++ 8 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 packages/api/src/features/resume/social-meta.ts create mode 100644 packages/resume/src/social-meta.test.ts create mode 100644 packages/resume/src/social-meta.ts diff --git a/apps/server/src/static/web.test.ts b/apps/server/src/static/web.test.ts index 1a8d14fe5..de16b0006 100644 --- a/apps/server/src/static/web.test.ts +++ b/apps/server/src/static/web.test.ts @@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ env: { APP_URL: "https://rxresu.me" }, serveStatic: vi.fn((_options?: unknown) => vi.fn()), + getPublicResumeSocialMeta: vi.fn(), +})); + +vi.mock("@reactive-resume/api/features/resume/social-meta", () => ({ + getPublicResumeSocialMeta: mocks.getPublicResumeSocialMeta, })); vi.mock("node:fs", () => ({ @@ -41,6 +46,7 @@ describe("web app fallback classification", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(fs.readFile).mockResolvedValue("app"); + mocks.getPublicResumeSocialMeta.mockResolvedValue(null); }); it("serves the shell for the root app route without noindex", async () => { @@ -83,6 +89,68 @@ describe("web app fallback classification", () => { expect(await dashboardResponse.text()).not.toContain('rel="canonical"'); }); + describe("public resume social cards", () => { + const shell = `Reactive Resume — A free and open-source resume builder`; + + it("injects resume-specific social metadata and replaces the shell title", async () => { + vi.mocked(fs.readFile).mockResolvedValue(shell); + mocks.getPublicResumeSocialMeta.mockResolvedValue({ + name: "Jane Doe", + title: "Jane Doe — Staff Engineer", + description: "Builds resilient distributed systems.", + template: "azurill", + }); + + const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text(); + + expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" }); + expect(html).toContain("Jane Doe - Reactive Resume"); + expect(html).toContain(''); + expect(html).not.toContain("Marketing copy."); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it("escapes user-authored values so resume content cannot break out of the attribute", async () => { + vi.mocked(fs.readFile).mockResolvedValue(shell); + mocks.getPublicResumeSocialMeta.mockResolvedValue({ + name: 'Jane" onload="alert(1)', + title: "", + description: 'Ends with " and & ampersand', + template: "azurill", + }); + + const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text(); + + expect(html).not.toContain(""); + expect(html).not.toContain('onload="alert(1)'); + expect(html).toContain(''); + expect(html).toContain('content="Ends with " and & ampersand"'); + }); + + it("serves the plain shell when the resume is not publicly shareable", async () => { + vi.mocked(fs.readFile).mockResolvedValue(shell); + + const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text(); + + expect(html).toBe(shell); + }); + + it("serves the plain shell when the lookup fails", async () => { + vi.mocked(fs.readFile).mockResolvedValue(shell); + mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable")); + + const response = await handleWebApp(new Request("https://example.com/jane/resume")); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe(shell); + }); + }); + it("caches versioned homepage media immutably", async () => { const headers = new Headers(); diff --git a/apps/server/src/static/web.ts b/apps/server/src/static/web.ts index df2cb4fed..b6f32a69b 100644 --- a/apps/server/src/static/web.ts +++ b/apps/server/src/static/web.ts @@ -160,6 +160,51 @@ function createRootSeoMarkup(canonicalUrl: string) { `; } +// Resume names, headlines, and summaries are user-authored, so they must never reach the served +// HTML unescaped. +const escapeAttribute = (value: string) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +async function createPublicResumeSeoMarkup(pathname: string, origin: string) { + const [username, slug] = getPathSegments(pathname); + if (!username || !slug) return null; + + // A card render must never take down the page: any lookup failure falls back to the plain shell. + const meta = await import("@reactive-resume/api/features/resume/social-meta") + .then((module) => module.getPublicResumeSocialMeta({ username, slug })) + .catch(() => null); + if (!meta) return null; + + const canonicalUrl = `${origin}/${username}/${slug}`; + const imageUrl = `${origin}/templates/jpg/${meta.template}.jpg`; + const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`); + const title = escapeAttribute(meta.title); + const description = escapeAttribute(meta.description); + + return { + pageTitle, + description, + markup: ` + + + + + + + + + + + + `, + }; +} + export const serveWebDistStatic = serveStatic({ root: staticRoot, onFound: (_path, context) => { @@ -208,7 +253,23 @@ export async function handleWebApp(request: Request) { const html = await fs.readFile(indexHtmlPath, "utf-8"); const canonicalUrl = new URL("/", env.APP_URL).toString(); - const responseHtml = pathname === "/" ? html.replace("", `${createRootSeoMarkup(canonicalUrl)}`) : html; - return new Response(responseHtml, { headers }); + if (pathname === "/") { + return new Response(html.replace("", `${createRootSeoMarkup(canonicalUrl)}`), { headers }); + } + + if (isPublicResumePath(pathname)) { + const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin); + if (resumeSeo) { + // The shell's generic title/description are replaced so shares and previews show the resume, + // not the marketing copy baked into index.html. + const withTitle = html + .replace(/[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}`) + .replace(/]*>/, ``); + + return new Response(withTitle.replace("", `${resumeSeo.markup}`), { headers }); + } + } + + return new Response(html, { headers }); } diff --git a/apps/web/src/routes/$username/$slug.tsx b/apps/web/src/routes/$username/$slug.tsx index ee7bc8288..a4b677364 100644 --- a/apps/web/src/routes/$username/$slug.tsx +++ b/apps/web/src/routes/$username/$slug.tsx @@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { RouterOutput } from "@/libs/orpc/client"; import { ORPCError } from "@orpc/client"; import { createFileRoute, lazyRouteComponent, notFound, redirect } from "@tanstack/react-router"; +import { getResumeSocialMeta } from "@reactive-resume/resume/social-meta"; import { orpc } from "@/libs/orpc/client"; import { createNoindexFollowMeta, createResumeSocialMeta, getCanonicalRootUrl } from "@/libs/seo"; @@ -25,23 +26,22 @@ export const Route = createFileRoute("/$username/$slug")({ return { meta: [{ title: `${name} - Reactive Resume` }, createNoindexFollowMeta()] }; } - const { basics, summary, metadata } = resume.data; - const socialTitle = basics.headline ? `${name} — ${basics.headline}` : name; - const summaryText = summary.content - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); - const description = summaryText || basics.headline || name; + const social = getResumeSocialMeta(resume.data, resume.name || "Resume"); const base = getCanonicalRootUrl(typeof window === "undefined" ? undefined : window.location.origin); const canonicalUrl = `${base}${params.username}/${params.slug}`; - const imageUrl = `${base}templates/jpg/${metadata.template}.jpg`; + const imageUrl = `${base}templates/jpg/${social.template}.jpg`; return { meta: [ - { title: `${name} - Reactive Resume` }, + { title: `${social.name} - Reactive Resume` }, createNoindexFollowMeta(), - ...createResumeSocialMeta({ canonicalUrl, title: socialTitle, description, imageUrl }), + ...createResumeSocialMeta({ + canonicalUrl, + title: social.title, + description: social.description, + imageUrl, + }), ], links: [{ rel: "canonical", href: canonicalUrl }], }; diff --git a/packages/api/package.json b/packages/api/package.json index 3c5f5fcb2..bd886608a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,6 +8,7 @@ "./features/flags": "./src/features/flags/index.ts", "./features/resume/export": "./src/features/resume/export.ts", "./features/resume/public-pdf": "./src/features/resume/public-pdf.ts", + "./features/resume/social-meta": "./src/features/resume/social-meta.ts", "./features/storage": "./src/features/storage/index.ts", "./routers": "./src/routers/index.ts" }, diff --git a/packages/api/src/features/resume/social-meta.ts b/packages/api/src/features/resume/social-meta.ts new file mode 100644 index 000000000..00de6be3f --- /dev/null +++ b/packages/api/src/features/resume/social-meta.ts @@ -0,0 +1,46 @@ +import type { ResumeSocialMeta } from "@reactive-resume/resume/social-meta"; +import { getResumeSocialMeta } from "@reactive-resume/resume/social-meta"; +import { parseStoredResumeData } from "./resume-data-validation"; + +export type PublicResumeSocialMetaInput = { username: string; slug: string }; + +export type PublicResumeSocialMetaDependencies = { + findResume(input: PublicResumeSocialMetaInput): Promise<{ name: string; data: unknown } | null>; +}; + +// Only public, password-free resumes are matched. Password-protected resumes must not leak their +// summary to an unauthenticated crawler, and this read deliberately skips the view counting and +// access gating in resumeService.getBySlug — a card render is not a visit. +const findResume = async ({ username, slug }: PublicResumeSocialMetaInput) => { + const [{ db }, schema, { and, eq, isNull }] = await Promise.all([ + import("@reactive-resume/db/client"), + import("@reactive-resume/db/schema"), + import("drizzle-orm"), + ]); + const [resume] = await db + .select({ name: schema.resume.name, data: schema.resume.data }) + .from(schema.resume) + .innerJoin(schema.user, eq(schema.resume.userId, schema.user.id)) + .where( + and( + eq(schema.resume.slug, slug), + eq(schema.user.username, username), + eq(schema.resume.isPublic, true), + isNull(schema.resume.password), + ), + ); + + return resume ?? null; +}; + +const defaultDependencies: PublicResumeSocialMetaDependencies = { findResume }; + +export async function getPublicResumeSocialMeta( + input: PublicResumeSocialMetaInput, + dependencies: PublicResumeSocialMetaDependencies = defaultDependencies, +): Promise { + const resume = await dependencies.findResume(input); + if (!resume) return null; + + return getResumeSocialMeta(parseStoredResumeData(resume.data), resume.name); +} diff --git a/packages/resume/package.json b/packages/resume/package.json index bacc12222..234095ad6 100644 --- a/packages/resume/package.json +++ b/packages/resume/package.json @@ -9,6 +9,7 @@ "./icons": "./src/icons.ts", "./markdown": "./src/markdown.ts", "./patch": "./src/patch.ts", + "./social-meta": "./src/social-meta.ts", "./stylesheet": "./src/stylesheet/index.ts", "./stylesheet/registry": "./src/stylesheet/registry/index.ts", "./stylesheet/types": "./src/stylesheet/semantic-types.ts" diff --git a/packages/resume/src/social-meta.test.ts b/packages/resume/src/social-meta.test.ts new file mode 100644 index 000000000..448fa2c28 --- /dev/null +++ b/packages/resume/src/social-meta.test.ts @@ -0,0 +1,48 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { getResumeSocialMeta } from "./social-meta"; + +const buildData = (overrides: { name?: string; headline?: string; summary?: string; template?: string }): ResumeData => + ({ + basics: { name: overrides.name ?? "", headline: overrides.headline ?? "" }, + summary: { content: overrides.summary ?? "" }, + metadata: { template: overrides.template ?? "azurill" }, + }) as ResumeData; + +describe("getResumeSocialMeta", () => { + it("combines the name and headline into the social title", () => { + const meta = getResumeSocialMeta(buildData({ name: "Jane Doe", headline: "Staff Engineer" })); + + expect(meta.title).toBe("Jane Doe — Staff Engineer"); + expect(meta.name).toBe("Jane Doe"); + }); + + it("falls back to the resume name when the basics name is blank", () => { + const meta = getResumeSocialMeta(buildData({}), "Untitled Resume"); + + expect(meta.name).toBe("Untitled Resume"); + expect(meta.title).toBe("Untitled Resume"); + expect(meta.description).toBe("Untitled Resume"); + }); + + it("strips markup and collapses whitespace out of the summary", () => { + const meta = getResumeSocialMeta( + buildData({ name: "Jane", summary: "

Builds systems\n\nat scale.

" }), + ); + + expect(meta.description).toBe("Builds systems at scale."); + }); + + it("prefers the headline when the summary is empty markup", () => { + const meta = getResumeSocialMeta(buildData({ name: "Jane", headline: "Staff Engineer", summary: "

" })); + + expect(meta.description).toBe("Staff Engineer"); + }); + + it("truncates a long summary on a word boundary", () => { + const meta = getResumeSocialMeta(buildData({ name: "Jane", summary: "word ".repeat(80) })); + + expect(meta.description.length).toBeLessThanOrEqual(161); + expect(meta.description.endsWith("word…")).toBe(true); + }); +}); diff --git a/packages/resume/src/social-meta.ts b/packages/resume/src/social-meta.ts new file mode 100644 index 000000000..dd2c6811e --- /dev/null +++ b/packages/resume/src/social-meta.ts @@ -0,0 +1,40 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; + +export type ResumeSocialMeta = { + name: string; + title: string; + description: string; + template: string; +}; + +// Social cards truncate around this length; trimming here keeps the ellipsis on a word boundary +// instead of letting the crawler cut mid-word. +const DESCRIPTION_LIMIT = 160; + +const truncate = (text: string) => { + if (text.length <= DESCRIPTION_LIMIT) return text; + + const clipped = text.slice(0, DESCRIPTION_LIMIT); + const lastSpace = clipped.lastIndexOf(" "); + + return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`; +}; + +/** + * Derives the title/description/template used for OpenGraph and Twitter cards on a public resume. + * Shared by the client route head and the server-side HTML injection so the two never drift. + */ +export const getResumeSocialMeta = (data: ResumeData, fallbackName = "Resume"): ResumeSocialMeta => { + const name = data.basics.name || fallbackName; + const summary = data.summary.content + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + + return { + name, + title: data.basics.headline ? `${name} — ${data.basics.headline}` : name, + description: truncate(summary || data.basics.headline || name), + template: data.metadata.template, + }; +};