diff --git a/apps/web/src/features/resume/public/public-resume.test.tsx b/apps/web/src/features/resume/public/public-resume.test.tsx
index b6c48e53b..e984710ad 100644
--- a/apps/web/src/features/resume/public/public-resume.test.tsx
+++ b/apps/web/src/features/resume/public/public-resume.test.tsx
@@ -37,6 +37,9 @@ vi.mock("@tanstack/react-router", () => ({
}),
}));
vi.mock("./pdf-viewer", () => ({ PdfViewer: publicResumeMock.PdfViewer }));
+vi.mock("./resume-reflow", () => ({ ResumeReflow: () =>
-
- {picture.url && !picture.hidden && (
-
- )}
-
- {basics.name &&
{basics.name}
}
- {basics.headline &&
{basics.headline}
}
-
- {showDownloadButtons && (
-
- )}
-
+
+ {/* Printing is part of downloading: when the owner turns downloads off, print shows this note. */}
+ {!downloads && (
+
+ Printing is turned off for this resume.
+
+ )}
-
-
-
-
- {!flags.disableSignups && (
-
+
+ {phone ? (
+ <>
+
+ {basics.name || resume.name}
+
+ {credit}
+
+
+ {downloads && download}
+
+
+ >
+ ) : (
+ <>
+
+
+
{basics.name || resume.name}
+ {subtitle &&
{subtitle}
}
+
+
+ {downloads && download}
+
+
+
+
+ {credit}
+ >
)}
+
+ );
+}
- {showDownloadButtons && (
-
- )}
- >
+/** Off, unknown or trashed links read the same, with nothing about the owner. */
+export function SharedResumeUnavailable() {
+ return (
+
+
+
+ This resume isn't shared right now.
+
+
+ If someone sent you this link, ask them for a new one.
+
+
+
);
}
diff --git a/apps/web/src/features/resume/public/reflow.test.ts b/apps/web/src/features/resume/public/reflow.test.ts
new file mode 100644
index 000000000..1c6fe34df
--- /dev/null
+++ b/apps/web/src/features/resume/public/reflow.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/features/resume/public/reflow.ts b/apps/web/src/features/resume/public/reflow.ts
new file mode 100644
index 000000000..847ac6854
--- /dev/null
+++ b/apps/web/src/features/resume/public/reflow.ts
@@ -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;
+}
diff --git a/apps/web/src/features/resume/public/resume-reflow.tsx b/apps/web/src/features/resume/public/resume-reflow.tsx
new file mode 100644
index 000000000..38af62d16
--- /dev/null
+++ b/apps/web/src/features/resume/public/resume-reflow.tsx
@@ -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
& { 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 = {
+ 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 (
+
+ {title && {title}
}
+ {(subtitle.length > 0 || meta.length > 0) && (
+ {[...subtitle, ...meta].join(" · ")}
+ )}
+ {url && /^https?:/i.test(url) && (
+
+ {website?.label || url.replace(/^https?:\/\//, "")}
+
+ )}
+ {keywords.length > 0 && {keywords.join(", ")}
}
+ {html && }
+ {roles.map((role) => (
+
+
{text(role, "position")}
+ {text(role, "period") &&
{text(role, "period")}
}
+ {text(role, "description") &&
}
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ {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 (
+
+
+ {getResumeSectionTitle(data, sectionId)}
+
+ {entries.map((entry) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/features/resume/public/rich-text.test.tsx b/apps/web/src/features/resume/public/rich-text.test.tsx
new file mode 100644
index 000000000..46ba87607
--- /dev/null
+++ b/apps/web/src/features/resume/public/rich-text.test.tsx
@@ -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(
+ Led design at Lumen
'}
+ />,
+ );
+ 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(
+ Hi
x'
+ }
+ />,
+ );
+ expect(container.querySelector("img, script, [onclick]")).toBeNull();
+ expect(container.querySelector("a")).toBeNull();
+ expect(container.textContent).toBe("Hix");
+ });
+});
diff --git a/apps/web/src/features/resume/public/rich-text.tsx b/apps/web/src/features/resume/public/rich-text.tsx
new file mode 100644
index 000000000..1dcc557a5
--- /dev/null
+++ b/apps/web/src/features/resume/public/rich-text.tsx
@@ -0,0 +1,67 @@
+import type { ReactNode } from "react";
+import { createElement } from "react";
+
+const TAGS: Record = {
+ 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 {Array.from(body.childNodes).map(toReact)}
;
+}
diff --git a/apps/web/src/routes/$username/$slug.tsx b/apps/web/src/routes/$username/$slug.tsx
index 7005f17b3..c6af9ab68 100644
--- a/apps/web/src/routes/$username/$slug.tsx
+++ b/apps/web/src/routes/$username/$slug.tsx
@@ -11,6 +11,10 @@ type LoaderData = Omit & { 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(