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.
This commit is contained in:
Amruth Pillai
2026-08-17 22:19:52 +02:00
parent d0fa9ae8da
commit 9d0dc36706
8 changed files with 277 additions and 12 deletions
+68
View File
@@ -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("<html>app</html>");
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 = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
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("<title>Jane Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
expect(html).not.toContain("Marketing copy.");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
expect(html).toContain('<meta property="og:type" content="profile">');
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
});
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: "<script>alert(1)</script>",
description: 'Ends with " and & ampersand',
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).not.toContain('onload="alert(1)');
expect(html).toContain('<meta property="og:title" content="&lt;script&gt;alert(1)&lt;/script&gt;">');
expect(html).toContain('content="Ends with &quot; and &amp; 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();
+63 -2
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
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: `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
`,
};
}
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("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`) : html;
return new Response(responseHtml, { headers });
if (pathname === "/") {
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { 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>/, `<title>${resumeSeo.pageTitle}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
}
}
return new Response(html, { headers });
}