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, + }; +};