mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<ResumeSocialMeta | null> {
|
||||
const resume = await dependencies.findResume(input);
|
||||
if (!resume) return null;
|
||||
|
||||
return getResumeSocialMeta(parseStoredResumeData(resume.data), resume.name);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "<p>Builds <strong>systems</strong>\n\nat scale.</p>" }),
|
||||
);
|
||||
|
||||
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: "<p></p>" }));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user