feat(web): the shared resume page, reflowed on phones

Desktop and tablets get a 64px bar with the owner's name, headline and city,
Copy link and Download PDF, the page on the sunken canvas, and the footer
credit. Phones get the resume as readable text in the template's colour and
body font, in the order the PDF prints it (read from the semantic tree), with
contact details as tap targets and Download and Share pinned.

The owner's rich text is parsed into an allowlist of formatting elements and
plain links, never injected. With downloads off, Download is hidden and
printing shows a note instead of the page. Off, unknown and trashed links all
read "This resume isn't shared right now." with nothing about the owner.
This commit is contained in:
Amruth Pillai
2026-09-29 00:41:54 +02:00
parent a043b28867
commit cc28f78537
8 changed files with 483 additions and 75 deletions
@@ -37,6 +37,9 @@ vi.mock("@tanstack/react-router", () => ({
}),
}));
vi.mock("./pdf-viewer", () => ({ PdfViewer: publicResumeMock.PdfViewer }));
vi.mock("./resume-reflow", () => ({ ResumeReflow: () => <div data-testid="reflow" /> }));
const breakpoint = vi.hoisted(() => ({ value: "desktop" }));
vi.mock("@reactive-resume/ui/hooks/use-breakpoint", () => ({ useBreakpoint: () => breakpoint.value }));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { getBySlug: { queryOptions: () => ({ query: "resume" }) } } },
}));
@@ -49,6 +52,7 @@ const { PublicResumeRoute } = await import("./public-resume");
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
beforeEach(() => {
breakpoint.value = "desktop";
publicResumeMock.flags.disableSignups = false;
publicResumeMock.resume = { data: sampleResumeData, name: "Sample Resume", slug: "sample" };
publicResumeMock.PdfViewer.mockClear();
@@ -71,39 +75,44 @@ const renderPublicResumeRoute = () =>
);
describe("PublicResumeRoute", () => {
it("shows the create-resume link when registration is enabled", () => {
it("links the footer credit home when registration is enabled", () => {
renderPublicResumeRoute();
expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: /Made with Reactive Resume/ })).toHaveAttribute("href", "/");
});
it("hides the create-resume link when registration is disabled", () => {
it("keeps the credit as plain text when registration is disabled", () => {
publicResumeMock.flags.disableSignups = true;
renderPublicResumeRoute();
expect(screen.queryByRole("link", { name: /Build your own resume/ })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Made with Reactive Resume/ })).not.toBeInTheDocument();
expect(screen.getByText(/Made with Reactive Resume/)).toBeInTheDocument();
expect(screen.getByTestId("pdf-viewer")).toBeInTheDocument();
});
it("shows both working download controls by default", () => {
it("leads with the owner's name and downloads from the bar", () => {
renderPublicResumeRoute();
const buttons = screen.getAllByRole("button", { name: "Download PDF" });
expect(buttons).toHaveLength(2);
for (const button of buttons) fireEvent.click(button);
expect(publicResumeMock.onDownloadPDF).toHaveBeenCalledTimes(2);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name);
expect(screen.getByRole("button", { name: /Copy link/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
expect(publicResumeMock.onDownloadPDF).toHaveBeenCalledTimes(1);
});
it("hides both download controls while keeping the public PDF visible", () => {
it("hides Download when downloads are off, keeps the page, and blocks printing with a note", () => {
publicResumeMock.resume = { data: sampleResumeData, name: "Sample", slug: "sample", showDownloadButtons: false };
renderPublicResumeRoute();
expect(screen.queryByRole("button", { name: "Download PDF" })).not.toBeInTheDocument();
expect(screen.getByTestId("pdf-viewer")).toBeVisible();
expect(screen.getByText("Printing is turned off for this resume.")).toHaveClass("print:block");
});
it("shows both controls when the owner enables downloads again", () => {
publicResumeMock.resume = { data: sampleResumeData, name: "Sample", slug: "sample", showDownloadButtons: true };
it("reflows on phones, with Download and Share pinned", () => {
breakpoint.value = "mobile";
renderPublicResumeRoute();
expect(screen.getAllByRole("button", { name: "Download PDF" })).toHaveLength(2);
expect(screen.getByTestId("reflow")).toBeInTheDocument();
expect(screen.queryByTestId("pdf-viewer")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument();
});
it("passes exposed source data directly to the browser viewer and export fallback", () => {
@@ -125,9 +134,7 @@ describe("PublicResumeRoute", () => {
renderPublicResumeRoute();
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
const page = viewerFrame?.parentElement;
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
expect(viewerFrame).not.toHaveClass("min-h-0", "overflow-hidden", "h-svh", "max-h-svh");
});
});
@@ -145,7 +152,7 @@ describe("PublicResumePage at root", () => {
/>
</I18nProvider>,
);
expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/dashboard");
expect(screen.getByRole("link", { name: /Made with Reactive Resume/ })).toHaveAttribute("href", "/dashboard");
expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name);
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, {
@@ -1,16 +1,19 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { useMemo } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { useMemo, useState } from "react";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { orpc } from "@/libs/orpc/client";
import { PdfViewer } from "./pdf-viewer";
import { ResumeReflow } from "./resume-reflow";
const publicResumeRoute = getRouteApi("/$username/$slug");
@@ -30,74 +33,137 @@ type PublicResumePageProps = {
isRoot?: boolean;
};
/**
* The shared resume, for recruiters: no app chrome, the owner's name leads, and Download PDF is the one action. On
* phones it reflows into readable text with Download pinned; with downloads off, Download is gone and printing shows
* a note instead of the page.
*/
export function PublicResumePage({ resume, username, slug, flags, isRoot = false }: PublicResumePageProps) {
const publicResume = useMemo(() => ({ username, slug }), [slug, username]);
const { onDownloadPDF, isExporting } = useResumeExport(resume, {
...(resume ? { publicResumePdf: { publicResume } } : {}),
});
const phone = useBreakpoint() === "mobile";
const [copied, setCopied] = useState(false);
const [, copy] = useCopyToClipboard();
if (!resume) return <LoadingScreen />;
const { basics, picture } = resume.data;
const showDownloadButtons = resume.showDownloadButtons !== false;
const { basics } = resume.data;
const downloads = resume.showDownloadButtons !== false;
const subtitle = [basics.headline, basics.location].filter(Boolean).join(" · ");
const copyLink = async () => {
await copy(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const share = async () => {
if (typeof navigator.share === "function") {
try {
await navigator.share({ title: basics.name || resume.name, url: window.location.href });
} catch {
// Closing the share sheet isn't an error.
}
return;
}
await copy(window.location.href);
toast.add({ description: t`Link copied` });
};
const download = (
<Button
onClick={() => void onDownloadPDF()}
loading={isExporting}
className={phone ? "h-12 flex-1 text-base" : undefined}
>
{!isExporting && <Icon name="download" size={phone ? 20 : 18} />}
<Trans>Download PDF</Trans>
</Button>
);
const credit = (
<footer className="flex justify-center px-4 py-6 text-ink-3 text-sm print:hidden">
{flags.disableSignups ? (
<Trans>Made with Reactive Resume, free and open source</Trans>
) : (
<a href={isRoot ? "/dashboard" : "/"} className="underline-offset-2 hover:text-ink hover:underline">
<Trans>Made with Reactive Resume, free and open source</Trans>
</a>
)}
</footer>
);
return (
<>
<div className="mx-auto flex w-full flex-col items-center gap-6 px-4 py-6 print:m-0 print:block print:max-w-full print:p-0">
<header className="flex w-full max-w-5xl flex-col items-center gap-4 text-center print:hidden">
{picture.url && !picture.hidden && (
<img src={picture.url} alt={basics.name} className="size-20 rounded-full object-cover shadow-sm" />
)}
<div className="space-y-1">
{basics.name && <h1 className="font-semibold text-2xl tracking-tight">{basics.name}</h1>}
{basics.headline && <p className="text-muted-foreground">{basics.headline}</p>}
</div>
{showDownloadButtons && (
<Button onClick={() => void onDownloadPDF()} disabled={isExporting}>
{isExporting ? (
<CircleNotchIcon className="size-4 animate-spin" />
) : (
<DownloadSimpleIcon className="size-4" />
)}
<Trans>Download PDF</Trans>
</Button>
)}
</header>
<div className="flex min-h-svh flex-col bg-sunken print:block print:min-h-0 print:bg-white">
{/* Printing is part of downloading: when the owner turns downloads off, print shows this note. */}
{!downloads && (
<p className="hidden p-8 text-center text-sm print:block">
<Trans>Printing is turned off for this resume.</Trans>
</p>
)}
<main id="main-content" className="w-full max-w-5xl bg-white print:max-w-full">
<PdfViewer data={resume.data} className="block w-full" publicResume={publicResume} />
</main>
{!flags.disableSignups && (
<footer className="flex justify-center print:hidden">
<a
href={isRoot ? "/dashboard" : "/"}
className="flex items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
>
<BrandIcon variant="icon" className="size-5" />
<Trans>Build your own resume</Trans>
</a>
</footer>
<div className={downloads ? "contents" : "contents print:hidden"}>
{phone ? (
<>
<main id="main-content" className="flex-1">
<h1 className="sr-only">{basics.name || resume.name}</h1>
<ResumeReflow data={resume.data} />
{credit}
</main>
<div className="sticky bottom-0 flex gap-2 border-line border-t bg-surface px-4 pt-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] print:hidden">
{downloads && download}
<Button
variant="secondary"
size="icon-lg"
className={downloads ? "size-12" : "h-12 flex-1"}
aria-label={t`Share`}
onClick={() => void share()}
>
<Icon name="ios_share" size={22} />
{!downloads && <Trans>Share</Trans>}
</Button>
</div>
</>
) : (
<>
<header className="sticky top-0 z-10 flex h-16 items-center gap-3 border-line border-b bg-surface px-5 print:hidden">
<div className="grid min-w-0 flex-1">
<h1 className="truncate font-display font-medium text-xl leading-6">{basics.name || resume.name}</h1>
{subtitle && <p className="truncate text-ink-3 text-xs">{subtitle}</p>}
</div>
<Button variant="secondary" aria-live="polite" onClick={() => void copyLink()}>
<Icon name={copied ? "check" : "link"} size={18} />
{copied ? <Trans>Copied</Trans> : <Trans>Copy link</Trans>}
</Button>
{downloads && download}
</header>
<main id="main-content" className="flex flex-1 justify-center px-6 pt-8 pb-5 print:block print:p-0">
<div className="w-full max-w-[860px] bg-white shadow-e2 print:max-w-full print:shadow-none">
<PdfViewer data={resume.data} className="block w-full" publicResume={publicResume} />
</div>
</main>
{credit}
</>
)}
</div>
</div>
);
}
{showDownloadButtons && (
<Button
size="icon-lg"
variant="secondary"
disabled={isExporting}
onClick={() => void onDownloadPDF()}
aria-label={t`Download PDF`}
title={t`Download PDF`}
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-[opacity,scale] duration-150 ease-out-strong hover:opacity-100 print:hidden"
>
{isExporting ? (
<CircleNotchIcon className="size-5 animate-spin" />
) : (
<DownloadSimpleIcon className="size-5" />
)}
</Button>
)}
</>
/** Off, unknown or trashed links read the same, with nothing about the owner. */
export function SharedResumeUnavailable() {
return (
<main id="main-content" className="grid min-h-svh place-items-center bg-sunken px-6 text-center">
<div className="grid max-w-sm gap-2">
<h1 className="font-display font-medium text-[26px] leading-8">
<Trans>This resume isn't shared right now.</Trans>
</h1>
<p className="text-ink-2 text-sm">
<Trans>If someone sent you this link, ask them for a new one.</Trans>
</p>
</div>
</main>
);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { reflowOrder } from "./reflow";
const clone = () => structuredClone(sampleResumeData);
describe("reflowOrder", () => {
it("follows the print order: each page's main column, then its sidebar", () => {
const data = clone();
const order = reflowOrder(data).map((section) => section.sectionId);
const pages = data.metadata.layout.pages;
const printed = pages.flatMap((page) => [...page.main, ...(page.fullWidth ? [] : page.sidebar)]);
// Every reflowed section is printed, in the same relative order.
expect(order.length).toBeGreaterThan(0);
expect(order).toEqual(printed.filter((id) => order.includes(id)));
});
it("leaves out hidden sections and hidden entries", () => {
const data = clone();
data.sections.skills.hidden = true;
const [first, second] = data.sections.projects.items;
if (first) first.hidden = true;
const order = reflowOrder(data);
expect(order.map((section) => section.sectionId)).not.toContain("skills");
const projects = order.find((section) => section.sectionId === "projects")?.itemIds ?? [];
expect(projects).not.toContain(first?.id);
expect(projects).toContain(second?.id);
});
});
@@ -0,0 +1,40 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet/types";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { buildSemanticTree, shouldShowResumeHeader } from "@reactive-resume/pdf/semantic-tree";
/** One section as the phone reflow shows it: its id and the ids of the entries the page prints. */
export type ReflowSection = { sectionId: string; itemIds: string[] };
function collect(node: SemanticNode, into: ReflowSection[]) {
if (node.kind === "section" && node.id) {
const itemIds: string[] = [];
const findItems = (child: SemanticNode) => {
if (child.kind === "item" && child.id) itemIds.push(child.id);
else for (const grandchild of child.children) findItems(grandchild);
};
for (const child of node.children) findItems(child);
into.push({ sectionId: node.id, itemIds });
return;
}
for (const child of node.children) collect(child, into);
}
/**
* The sections and entries in the order the PDF prints them, page by page, with hidden and empty ones left out. The
* semantic tree already applies the template's region order and the shared filtering, so the reflow reads it rather
* than repeating those rules.
*/
export function reflowOrder(data: ResumeData): ReflowSection[] {
const sections: ReflowSection[] = [];
data.metadata.layout.pages.forEach((page, index) => {
const tree = buildSemanticTree({
data,
template: data.metadata.template,
page,
pageNumber: index + 1,
showHeader: shouldShowResumeHeader(data, index),
});
collect(tree, sections);
});
return sections;
}
@@ -0,0 +1,162 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { IconName } from "@reactive-resume/ui/components/icon";
import { useMemo } from "react";
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { reflowOrder } from "./reflow";
import { RichText } from "./rich-text";
type Entry = Record<string, unknown> & { id: string };
// Lists keep their markers and paragraphs their spacing, which the app's reset removes.
const RICH = "grid gap-1 [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5 [&_a]:underline";
// What each kind of entry leads with, what follows it, and the date or place set beside it.
const SHAPES: Record<string, { title: string[]; subtitle: string[]; meta: string[] }> = {
experience: { title: ["position", "company"], subtitle: ["company"], meta: ["period", "location"] },
education: { title: ["school"], subtitle: ["degree", "area", "grade"], meta: ["period", "location"] },
projects: { title: ["name"], subtitle: [], meta: ["period"] },
skills: { title: ["name"], subtitle: ["proficiency"], meta: [] },
languages: { title: ["language"], subtitle: ["fluency"], meta: [] },
interests: { title: ["name"], subtitle: [], meta: [] },
awards: { title: ["title"], subtitle: ["awarder"], meta: ["date"] },
certifications: { title: ["title"], subtitle: ["issuer"], meta: ["date"] },
publications: { title: ["title"], subtitle: ["publisher"], meta: ["date"] },
volunteer: { title: ["organization"], subtitle: [], meta: ["period", "location"] },
references: { title: ["name"], subtitle: ["position"], meta: [] },
profiles: { title: ["network"], subtitle: ["username"], meta: [] },
summary: { title: [], subtitle: [], meta: [] },
"cover-letter": { title: [], subtitle: [], meta: [] },
};
const text = (entry: Entry, field: string) => (typeof entry[field] === "string" ? (entry[field] as string).trim() : "");
function sectionOf(data: ResumeData, sectionId: string): { type: string; items: Entry[] } | null {
if (sectionId === "summary") return { type: "summary", items: [{ id: "summary", content: data.summary.content }] };
if (sectionId in data.sections) {
const section = data.sections[sectionId as keyof ResumeData["sections"]];
return { type: sectionId, items: section.items as unknown as Entry[] };
}
const custom = data.customSections.find((section) => section.id === sectionId);
return custom ? { type: custom.type, items: custom.items as unknown as Entry[] } : null;
}
function EntryView({ type, entry }: { type: string; entry: Entry }) {
const shape = SHAPES[type] ?? SHAPES.projects;
const title = shape?.title.map((field) => text(entry, field)).find(Boolean) ?? "";
const subtitle = [
...(type === "experience" && text(entry, "position") ? ["company"] : []),
...(shape?.subtitle.filter((field) => field !== "company") ?? []),
]
.map((field) => text(entry, field))
.filter((value) => value && value !== title);
const meta = shape?.meta.map((field) => text(entry, field)).filter(Boolean) ?? [];
const website = entry.website as { url?: string; label?: string } | undefined;
const url = typeof entry.url === "string" ? entry.url : website?.url;
const keywords = Array.isArray(entry.keywords) ? (entry.keywords as string[]).filter(Boolean) : [];
const roles = Array.isArray(entry.roles) ? (entry.roles as Entry[]) : [];
const html = text(entry, "content") || text(entry, "description");
return (
<article className="grid gap-0.5">
{title && <h3 className="font-semibold">{title}</h3>}
{(subtitle.length > 0 || meta.length > 0) && (
<p className="text-[#555] text-[14px]">{[...subtitle, ...meta].join(" · ")}</p>
)}
{url && /^https?:/i.test(url) && (
<a className="w-fit text-[14px] underline" href={url} target="_blank" rel="noopener noreferrer nofollow">
{website?.label || url.replace(/^https?:\/\//, "")}
</a>
)}
{keywords.length > 0 && <p className="text-[#555] text-[14px]">{keywords.join(", ")}</p>}
{html && <RichText html={html} className={cn(RICH, "mt-1")} />}
{roles.map((role) => (
<div key={role.id} className="mt-1.5 grid gap-0.5">
<h4 className="font-medium">{text(role, "position")}</h4>
{text(role, "period") && <p className="text-[#555] text-[14px]">{text(role, "period")}</p>}
{text(role, "description") && <RichText html={text(role, "description")} className={RICH} />}
</div>
))}
</article>
);
}
type ContactPill = { icon: IconName; label: string; href: string };
function contactPills(basics: ResumeData["basics"]): ContactPill[] {
const pills: ContactPill[] = [];
if (basics.email) pills.push({ icon: "mail", label: basics.email, href: `mailto:${basics.email}` });
if (basics.phone) pills.push({ icon: "call", label: basics.phone, href: `tel:${basics.phone.replace(/\s+/g, "")}` });
if (basics.website.url)
pills.push({
icon: "language",
label: basics.website.label || basics.website.url.replace(/^https?:\/\//, ""),
href: basics.website.url,
});
for (const field of basics.customFields)
if (field.text && /^(https?:|mailto:|tel:)/i.test(field.link))
pills.push({ icon: "link", label: field.text, href: field.link });
return pills;
}
type ResumeReflowProps = { data: ResumeData };
/**
* Phones: the resume as readable text at 15px, in the order the PDF prints it and in the template's colour and body
* font. Contact details are tap targets. The PDF stays the exact page.
*/
export function ResumeReflow({ data }: ResumeReflowProps) {
const order = useMemo(() => reflowOrder(data), [data]);
const { basics } = data;
const accent = data.metadata.design.colors.primary;
const font = data.metadata.typography.body.fontFamily;
return (
<div
lang={data.metadata.page.locale}
className="grid gap-6 bg-white px-5 py-6 text-[#1a1a1a] text-[15px] leading-[1.5]"
style={{ fontFamily: `"${font}", ui-sans-serif, system-ui, sans-serif` }}
>
<header className="grid gap-2">
<h1 className="font-semibold text-[26px] leading-tight">{basics.name}</h1>
{(basics.headline || basics.location) && (
<p className="text-[#555]">{[basics.headline, basics.location].filter(Boolean).join(" · ")}</p>
)}
<ul className="flex flex-wrap gap-2">
{contactPills(basics).map((pill) => (
<li key={pill.href}>
<a
href={pill.href}
className="flex h-9 items-center gap-1.5 rounded-full border border-[#ddd] px-3 text-[14px]"
{...(pill.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer nofollow" } : {})}
>
<Icon name={pill.icon} size={18} />
<span className="max-w-[16rem] truncate">{pill.label}</span>
</a>
</li>
))}
</ul>
</header>
{order.map(({ sectionId, itemIds }) => {
const section = sectionOf(data, sectionId);
if (!section) return null;
const entries = itemIds.length
? itemIds.map((id) => section.items.find((item) => item.id === id)).filter((item): item is Entry => !!item)
: section.items.filter((item) => !item.hidden);
if (entries.length === 0) return null;
return (
<section key={sectionId} className="grid gap-3">
<h2 className="border-b pb-1 font-semibold text-[17px]" style={{ color: accent, borderColor: accent }}>
{getResumeSectionTitle(data, sectionId)}
</h2>
{entries.map((entry) => (
<EntryView key={entry.id} type={section.type} entry={entry} />
))}
</section>
);
})}
</div>
);
}
@@ -0,0 +1,31 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { RichText } from "./rich-text";
describe("RichText", () => {
it("keeps formatting and plain links", () => {
const { container } = render(
<RichText
html={'<ul><li><p>Led <strong>design</strong> at <a href="https://lumen.health">Lumen</a></p></li></ul>'}
/>,
);
expect(container.querySelector("li strong")?.textContent).toBe("design");
expect(container.querySelector("a")?.getAttribute("href")).toBe("https://lumen.health");
expect(container.querySelector("a")?.getAttribute("rel")).toContain("noopener");
});
it("never runs the owner's markup", () => {
const { container } = render(
<RichText
html={
'<p onclick="alert(1)">Hi<img src=x onerror="alert(1)"><script>alert(1)</script><a href="javascript:alert(1)">x</a></p>'
}
/>,
);
expect(container.querySelector("img, script, [onclick]")).toBeNull();
expect(container.querySelector("a")).toBeNull();
expect(container.textContent).toBe("Hix");
});
});
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { createElement } from "react";
const TAGS: Record<string, string> = {
p: "p",
br: "br",
ul: "ul",
ol: "ol",
li: "li",
strong: "strong",
b: "strong",
em: "em",
i: "em",
u: "u",
s: "s",
strike: "s",
del: "s",
code: "code",
mark: "mark",
blockquote: "blockquote",
h1: "p",
h2: "p",
h3: "p",
h4: "p",
h5: "p",
h6: "p",
};
// Their contents are code or markup, not text anyone wrote for the page.
const DROPPED = new Set(["script", "style", "template", "noscript", "iframe", "object"]);
const SAFE_HREF = /^(https?:|mailto:|tel:)/i;
function toReact(node: Node, key: number): ReactNode {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (DROPPED.has(tag)) return null;
const children = Array.from(element.childNodes).map(toReact);
if (tag === "a") {
const href = element.getAttribute("href")?.trim() ?? "";
if (!SAFE_HREF.test(href)) return createElement("span", { key }, ...children);
return createElement("a", { key, href, target: "_blank", rel: "noopener noreferrer nofollow" }, ...children);
}
const allowed = TAGS[tag];
// Anything else (spans, divs, tables, scripts…) keeps only its text.
if (!allowed) return children.length ? createElement("span", { key }, ...children) : null;
if (allowed === "br") return createElement("br", { key });
if (tag.startsWith("h")) return createElement("p", { key }, createElement("strong", null, ...children));
return createElement(allowed, { key }, ...children);
}
type RichTextProps = { html: string; className?: string };
/**
* A resume's rich text on the public page. The owner's HTML is never injected: it's parsed, and only formatting tags
* and plain links become elements, so whatever else it holds stays inert text.
*/
export function RichText({ html, className }: RichTextProps) {
if (!html.trim()) return null;
const body = new DOMParser().parseFromString(html, "text/html").body;
return <div className={className}>{Array.from(body.childNodes).map(toReact)}</div>;
}
+4
View File
@@ -11,6 +11,10 @@ type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: Re
export const Route = createFileRoute("/$username/$slug")({
ssr: "data-only",
component: lazyRouteComponent(() => import("@/features/resume/public/public-resume"), "PublicResumeRoute"),
notFoundComponent: lazyRouteComponent(
() => import("@/features/resume/public/public-resume"),
"SharedResumeUnavailable",
),
loader: async ({ context, params }) => {
const { username, slug } = params;
const resume = await context.queryClient.ensureQueryData(