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>/, `${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,
+ };
+};