diff --git a/CLAUDE.md b/CLAUDE.md
index 1b699bd..34b78cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -408,6 +408,7 @@ Do not suppress warnings. Do not add `// eslint-disable` unless the finding is a
| 10 | Rate limiting on `/api/generate` | **Ready** — blocked on #1, #3 |
| 11 | Stable job identity (UUID) — fix silent data loss on delete/move for duplicate company+role | **Ready** — blocked on #5 |
| 16 | "Evaluate Job URL" flow — fetch → Playwright fallback, streaming status, AI fit scoring, user-confirm before write to pending | **Ready** — blocked on #5, #10 |
+| 17 | Styled PDF resume export (Playwright + HTML/CSS template) — `stylePdfEnabled` config flag, `resume-template.ts`, `resume-pdf.ts`, `/api/export/resume-pdf` route | **Done** |
**Starting a slice:** Load only the files listed in the Token Routing table for this task type. Write the Z test. Run it. Confirm it fails. Then proceed.
diff --git a/app/api/__tests__/resume-pdf-route.test.ts b/app/api/__tests__/resume-pdf-route.test.ts
new file mode 100644
index 0000000..4c18ec3
--- /dev/null
+++ b/app/api/__tests__/resume-pdf-route.test.ts
@@ -0,0 +1,86 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { POST } from "@/app/api/export/resume-pdf/route";
+import { readConfig } from "@/lib/config-repository";
+import { githubRead } from "@/lib/github";
+import { renderResumePdf } from "@/lib/resume-pdf";
+import type { AppConfig } from "@/lib/config";
+
+vi.mock("@/lib/config-repository", () => ({
+ readConfig: vi.fn(),
+}));
+
+vi.mock("@/lib/github", () => ({
+ githubRead: vi.fn(),
+}));
+
+vi.mock("@/lib/resume-pdf", () => ({
+ renderResumePdf: vi.fn(),
+}));
+
+function request(body: unknown = {}) {
+ return new NextRequest("http://localhost/api/export/resume-pdf", {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function configWith(stylePdfEnabled: boolean): AppConfig {
+ return { export: { stylePdfEnabled } };
+}
+
+beforeEach(() => {
+ vi.mocked(githubRead).mockResolvedValue(JSON.stringify({ name: "Jordan Rivera" }));
+ vi.mocked(renderResumePdf).mockReset();
+});
+
+describe("POST /api/export/resume-pdf", () => {
+ it("returns 400 when styled PDF export is disabled in config", async () => {
+ vi.mocked(readConfig).mockResolvedValue(configWith(false));
+
+ const response = await POST(request());
+
+ expect(response.status).toBe(400);
+ expect(renderResumePdf).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 when export config is entirely absent (defaults to disabled)", async () => {
+ vi.mocked(readConfig).mockResolvedValue({});
+
+ const response = await POST(request());
+
+ expect(response.status).toBe(400);
+ });
+
+ it("returns 503 when config allows it but the renderer returns null (Playwright unavailable)", async () => {
+ vi.mocked(readConfig).mockResolvedValue(configWith(true));
+ vi.mocked(renderResumePdf).mockResolvedValue(null);
+
+ const response = await POST(request());
+
+ expect(response.status).toBe(503);
+ });
+
+ it("returns a PDF with the correct headers on success", async () => {
+ vi.mocked(readConfig).mockResolvedValue(configWith(true));
+ vi.mocked(renderResumePdf).mockResolvedValue(Buffer.from("pdf-bytes"));
+
+ const response = await POST(request({ company: "Acme Corp", role: "Design Engineer" }));
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toBe("application/pdf");
+ expect(response.headers.get("Content-Disposition")).toContain("jordan-rivera-acme-corp-design-engineer.pdf");
+ });
+
+ it("returns 500 with an error message when profile.json fails to parse", async () => {
+ vi.mocked(readConfig).mockResolvedValue(configWith(true));
+ vi.mocked(githubRead).mockResolvedValue("not json");
+
+ const response = await POST(request());
+
+ expect(response.status).toBe(500);
+ const body = await response.json();
+ expect(body.error).toBeTruthy();
+ });
+});
diff --git a/app/api/export/resume-pdf/route.ts b/app/api/export/resume-pdf/route.ts
new file mode 100644
index 0000000..71839e4
--- /dev/null
+++ b/app/api/export/resume-pdf/route.ts
@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from "next/server";
+import { githubRead } from "@/lib/github";
+import { readConfig } from "@/lib/config-repository";
+import { resolveExportStyle } from "@/lib/config";
+import { buildResumeHtml } from "@/lib/resume-template";
+import { renderResumePdf } from "@/lib/resume-pdf";
+import type { ProfileData } from "@/lib/docx-resume";
+import { resumeFilenameSlug } from "@/lib/resume-format";
+
+export const runtime = "nodejs";
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json() as {
+ company?: string;
+ role?: string;
+ tailoredBullets?: Record;
+ tailoredProfileBullets?: string[];
+ };
+
+ const [config, profileRaw] = await Promise.all([
+ readConfig(),
+ githubRead("data/profile.json"),
+ ]);
+
+ const style = resolveExportStyle(config.export);
+ if (!style.stylePdfEnabled) {
+ return NextResponse.json(
+ { error: "Styled PDF export is disabled. Enable it under Settings > Export." },
+ { status: 400 }
+ );
+ }
+
+ const profile = JSON.parse(profileRaw) as ProfileData;
+ const candidate = config.candidate ?? {};
+
+ const html = buildResumeHtml(profile, candidate, {
+ tailoredBullets: body.tailoredBullets,
+ company: body.company,
+ tailoredProfileBullets: body.tailoredProfileBullets,
+ });
+
+ const pdfBuffer = await renderResumePdf({ html });
+ if (!pdfBuffer) {
+ return NextResponse.json(
+ { error: "Styled PDF export requires Playwright, which is not available in this deployment." },
+ { status: 503 }
+ );
+ }
+
+ const company = body.company ?? "company";
+ const role = body.role ?? "role";
+ const name = profile.name ?? candidate.name;
+ const filename = `${resumeFilenameSlug(name, company, role)}.pdf`;
+
+ return new NextResponse(pdfBuffer as unknown as BodyInit, {
+ headers: {
+ "Content-Type": "application/pdf",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ },
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/app/api/export/resume/route.ts b/app/api/export/resume/route.ts
index 8b2a49f..1c21432 100644
--- a/app/api/export/resume/route.ts
+++ b/app/api/export/resume/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { githubRead } from "@/lib/github";
import { readConfig } from "@/lib/config-repository";
import { generateResumeDOCX, type ProfileData } from "@/lib/docx-resume";
+import { resumeFilenameSlug } from "@/lib/resume-format";
export async function POST(req: NextRequest) {
try {
@@ -25,8 +26,8 @@ export async function POST(req: NextRequest) {
const company = body.company ?? "company";
const role = body.role ?? "role";
- const slug = `${company}-${role}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
- const filename = `resume-${slug}.docx`;
+ const name = profile.name ?? candidate.name;
+ const filename = `${resumeFilenameSlug(name, company, role)}.docx`;
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
diff --git a/app/api/tailor-resume/route.ts b/app/api/tailor-resume/route.ts
index c99759d..c7b8ab6 100644
--- a/app/api/tailor-resume/route.ts
+++ b/app/api/tailor-resume/route.ts
@@ -32,15 +32,19 @@ export async function POST(req: NextRequest) {
const systemPrompt = buildSystemPrompt(profile);
const experienceList = (profile.experience ?? [])
- .map((e) => `- ${e.role} at ${e.company}`)
- .join("\n");
+ .map((e, idx) => {
+ const budget = idx < 2 ? 4 : 2;
+ const pool = e.bullets.map((b, i) => ` ${i + 1}. ${b}`).join("\n");
+ return `- ${e.role} at ${e.company} (select ${budget} bullets)\n Available pool:\n${pool}`;
+ })
+ .join("\n\n");
const userPrompt = `Tailor this candidate's resume for the following role. Return only valid JSON — no prose, no markdown fences.
ROLE: ${role} at ${company}
${jdText ? `\nJOB DESCRIPTION:\n${jdText.slice(0, 6000)}` : ""}
-CANDIDATE'S CURRENT EXPERIENCE POSITIONS (use exact company and role names):
+CANDIDATE'S EXPERIENCE — bullet pools per role (use exact company and role names):
${experienceList}
Return a JSON object with this exact shape:
@@ -55,7 +59,7 @@ Return a JSON object with this exact shape:
Rules:
- title: mirror the target role's language, max 3 segments
- profileBullets: reorder or lightly reword the existing 4 profile bullets to front-load what this JD emphasizes most — do not invent new ones
-- experience: reorder and lightly reword bullets to match JD keywords — do not fabricate experience; include all positions from the candidate's history
+- experience: for each role, choose the BEST bullets from its available pool and lightly reword them to match JD keywords — do not fabricate bullets not in the pool; include all positions; the two most recent roles get exactly 4 bullets, all older roles get exactly 2 bullets
- Apply all writing style rules from the system prompt
- Return only the JSON object, nothing else`;
diff --git a/app/settings/export/page.tsx b/app/settings/export/page.tsx
index d1aef8d..df3887c 100644
--- a/app/settings/export/page.tsx
+++ b/app/settings/export/page.tsx
@@ -176,6 +176,26 @@ export default function ExportSettings() {
+
+
Styled PDF resume (beta)
+
+ updateExport("stylePdfEnabled", e.target.checked)}
+ />
+
+ Enable styled PDF export
+
+
+
+ Renders a design-forward PDF resume (Inter, custom colors) alongside the DOCX export. Requires a
+ self-hosted deployment with ENABLE_PLAYWRIGHT_FALLBACK=true —
+ it will not work on Vercel or other serverless hosts.
+
+
+
Save export settings
diff --git a/app/settings/profile-ai/page.tsx b/app/settings/profile-ai/page.tsx
index ee21660..8bdfc20 100644
--- a/app/settings/profile-ai/page.tsx
+++ b/app/settings/profile-ai/page.tsx
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
-import type { Profile, ExperienceEntry, EducationEntry } from "@/lib/profile";
+import type { Profile, ExperienceEntry, EducationEntry, StrengthGroup } from "@/lib/profile";
+import { linesToBullets } from "@/lib/resume-format";
const INPUT = "w-full border border-border rounded-lg px-3 py-2 text-sm bg-card dark:text-white focus:outline-none focus:ring-2 focus:ring-primary";
const LABEL = "block text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-1";
@@ -17,7 +18,17 @@ export default function ProfileAiSettings() {
useEffect(() => {
fetch("/api/profile")
.then((r) => r.json())
- .then((data) => { setProfile(data); setLoading(false); })
+ .then((data: Profile) => {
+ let migrated = data;
+ if (!migrated.strengthGroups?.length && migrated.strengths?.length) {
+ migrated = { ...migrated, strengthGroups: [{ label: "Strengths", items: migrated.strengths }] };
+ }
+ if (!migrated.summaryBullets?.length && migrated.summary) {
+ migrated = { ...migrated, summaryBullets: linesToBullets(migrated.summary) };
+ }
+ setProfile(migrated);
+ setLoading(false);
+ })
.catch(() => { toast.error("Failed to load profile"); setLoading(false); });
}, []);
@@ -25,6 +36,20 @@ export default function ProfileAiSettings() {
setProfile((p) => ({ ...p, ...patch }));
}
+ function updateStrengthGroup(idx: number, patch: Partial) {
+ const next = [...(profile.strengthGroups ?? [])];
+ next[idx] = { ...next[idx], ...patch };
+ update({ strengthGroups: next });
+ }
+
+ function addStrengthGroup() {
+ update({ strengthGroups: [...(profile.strengthGroups ?? []), { label: "", items: [] }] });
+ }
+
+ function removeStrengthGroup(idx: number) {
+ update({ strengthGroups: (profile.strengthGroups ?? []).filter((_, i) => i !== idx) });
+ }
+
function updateExp(idx: number, patch: Partial) {
const next = [...(profile.experience ?? [])];
next[idx] = { ...next[idx], ...patch };
@@ -128,8 +153,14 @@ export default function ProfileAiSettings() {
update({ title: e.target.value })} />
- Professional summary
-
Portfolio URL
@@ -140,14 +171,37 @@ export default function ProfileAiSettings() {
update({ portfolio_password: e.target.value })} />
-
-
Core strengths (one per line)
-
update({ strengths: e.target.value.split("\n").map((s) => s.trim()).filter(Boolean) })}
- />
+
+
+ Core strengths
+ + Add skill group
+
+
+ Optionally split strengths into labeled groups (e.g. UX & Design, Tools, Technical). One group is fine too.
+
+ {(profile.strengthGroups ?? []).map((group, gi) => (
+
+
+ updateStrengthGroup(gi, { label: e.target.value })}
+ />
+ removeStrengthGroup(gi)} className={`${BTN_SM} shrink-0 hover:text-destructive`}>×
+
+
updateStrengthGroup(gi, { items: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
+ />
+
+ ))}
+ {(profile.strengthGroups ?? []).length === 0 && (
+
No skill groups yet.
+ )}
@@ -207,15 +261,15 @@ export default function ProfileAiSettings() {
Institution
- updateEdu(i, { institution: e.target.value })} />
+ updateEdu(i, { institution: e.target.value })} />
Degree
- updateEdu(i, { degree: e.target.value })} />
+ updateEdu(i, { degree: e.target.value })} />
Graduated
- updateEdu(i, { graduated: e.target.value })} />
+ updateEdu(i, { graduated: e.target.value })} />
diff --git a/assets/fonts/inter/Inter-Regular.woff2 b/assets/fonts/inter/Inter-Regular.woff2
new file mode 100644
index 0000000..f15b025
Binary files /dev/null and b/assets/fonts/inter/Inter-Regular.woff2 differ
diff --git a/assets/fonts/inter/Inter-SemiBold.woff2 b/assets/fonts/inter/Inter-SemiBold.woff2
new file mode 100644
index 0000000..d189794
Binary files /dev/null and b/assets/fonts/inter/Inter-SemiBold.woff2 differ
diff --git a/components/AIGenerationCard.tsx b/components/AIGenerationCard.tsx
index e5dc793..16daa8e 100644
--- a/components/AIGenerationCard.tsx
+++ b/components/AIGenerationCard.tsx
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { GenerationType } from "@/lib/prompts";
import type { TailoredResume } from "@/app/api/tailor-resume/route";
+import { resumeFilenameSlug } from "@/lib/resume-format";
async function triggerDownload(url: string, body: Record
, filename: string) {
const res = await fetch(url, {
@@ -77,6 +78,8 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
const [exporting, setExporting] = useState(null);
const [tailoring, setTailoring] = useState(false);
const [tailored, setTailored] = useState(null);
+ const [stylePdfEnabled, setStylePdfEnabled] = useState(false);
+ const [resumeName, setResumeName] = useState(undefined);
const [originalProfile, setOriginalProfile] = useState<{
title?: string;
profileBullets?: string[];
@@ -88,12 +91,23 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
useEffect(() => {
fetch("/api/profile", { cache: "no-store" })
.then((r) => r.json())
- .then((d: Record & { title?: string; summary?: string; experience?: TailoredResume["experience"] }) => {
+ .then((d: Record & { name?: string; title?: string; summary?: string; summaryBullets?: string[]; experience?: TailoredResume["experience"] }) => {
setOriginalProfile({
title: d.title as string | undefined,
- profileBullets: typeof d.summary === "string" ? [d.summary] : undefined,
+ profileBullets: d.summaryBullets?.length ? d.summaryBullets : (typeof d.summary === "string" && d.summary ? [d.summary] : undefined),
experience: d.experience,
});
+ if (d.name) setResumeName(d.name);
+ })
+ .catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ fetch("/api/config", { cache: "no-store" })
+ .then((r) => r.json())
+ .then((d: { export?: { stylePdfEnabled?: boolean }; candidate?: { name?: string } }) => {
+ setStylePdfEnabled(!!d.export?.stylePdfEnabled);
+ setResumeName((prev) => prev ?? d.candidate?.name);
})
.catch(() => {});
}, []);
@@ -184,6 +198,7 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
}
const slug = `${company}-${role}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
+ const resumeSlug = resumeFilenameSlug(resumeName, company, role);
async function exportCoverLetterDocx() {
setExporting("cover-letter-docx");
@@ -200,7 +215,7 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
async function exportResumeDocx() {
setExporting("resume-docx");
try {
- await triggerDownload("/api/export/resume", { company, role }, `resume-${slug}.docx`);
+ await triggerDownload("/api/export/resume", { company, role }, `${resumeSlug}.docx`);
toast.success("Resume downloaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
@@ -209,6 +224,18 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
}
}
+ async function exportResumePdf() {
+ setExporting("resume-pdf");
+ try {
+ await triggerDownload("/api/export/resume-pdf", { company, role }, `${resumeSlug}.pdf`);
+ toast.success("Resume PDF downloaded");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Export failed");
+ } finally {
+ setExporting(null);
+ }
+ }
+
async function tailorResume() {
setTailoring(true);
setTailored(null);
@@ -240,7 +267,7 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
await triggerDownload(
"/api/export/resume",
{ company, role, tailoredBullets, tailoredProfileBullets: tailored.profileBullets },
- `resume-${slug}.docx`
+ `${resumeSlug}.docx`
);
toast.success("Tailored resume downloaded");
} catch (err) {
@@ -250,6 +277,27 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
}
}
+ async function exportTailoredResumePdf() {
+ if (!tailored) return;
+ setExporting("tailored-resume-pdf");
+ try {
+ const tailoredBullets: Record = {};
+ for (const exp of tailored.experience) {
+ tailoredBullets[`${exp.company}::${exp.role}`] = exp.bullets;
+ }
+ await triggerDownload(
+ "/api/export/resume-pdf",
+ { company, role, tailoredBullets, tailoredProfileBullets: tailored.profileBullets },
+ `${resumeSlug}.pdf`
+ );
+ toast.success("Tailored resume PDF downloaded");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Export failed");
+ } finally {
+ setExporting(null);
+ }
+ }
+
const hasOutput = output.length > 0;
if (!hasProfile) return null;
@@ -301,8 +349,9 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
{/* Job URL note */}
{url && (
-
- JD: {url}
+
+ JD:
+ {url}
)}
@@ -375,16 +424,30 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
{type === "tailor-resume" ? (
tailored && (
-
- Download tailored resume .docx
-
+ <>
+
+ Download tailored resume .docx
+
+ {stylePdfEnabled && (
+
+ Download tailored resume .pdf
+
+ )}
+ >
)
) : (
<>
@@ -423,6 +486,18 @@ export default function AIGenerationCard({ company, role, url, hasProfile }: Pro
>
Resume .docx
+ {stylePdfEnabled && (
+
+ Resume .pdf
+
+ )}
>
)}
diff --git a/data/config.sample.json b/data/config.sample.json
index d52536c..112d704 100644
--- a/data/config.sample.json
+++ b/data/config.sample.json
@@ -73,6 +73,7 @@
"marginTopDxa": 864,
"marginBottomDxa": 864,
"marginLeftDxa": 1080,
- "marginRightDxa": 1080
+ "marginRightDxa": 1080,
+ "stylePdfEnabled": false
}
}
diff --git a/lib/__tests__/config.test.ts b/lib/__tests__/config.test.ts
new file mode 100644
index 0000000..f176558
--- /dev/null
+++ b/lib/__tests__/config.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { resolveExportStyle } from "@/lib/config";
+
+describe("resolveExportStyle", () => {
+ it("returns stylePdfEnabled false when no style is given", () => {
+ // Arrange
+ // (no override)
+
+ // Act
+ const resolved = resolveExportStyle();
+
+ // Assert
+ expect(resolved.stylePdfEnabled).toBe(false);
+ });
+
+ it("returns stylePdfEnabled true when the override sets it true", () => {
+ // Arrange
+ const override = { stylePdfEnabled: true };
+
+ // Act
+ const resolved = resolveExportStyle(override);
+
+ // Assert
+ expect(resolved.stylePdfEnabled).toBe(true);
+ });
+
+ it("treats an explicit false override the same as an absent field", () => {
+ // Arrange
+ const withExplicitFalse = resolveExportStyle({ stylePdfEnabled: false });
+ const withAbsentField = resolveExportStyle({});
+
+ // Act / Assert
+ expect(withExplicitFalse.stylePdfEnabled).toBe(withAbsentField.stylePdfEnabled);
+ });
+});
diff --git a/lib/__tests__/env.test.ts b/lib/__tests__/env.test.ts
new file mode 100644
index 0000000..56242cf
--- /dev/null
+++ b/lib/__tests__/env.test.ts
@@ -0,0 +1,32 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { isPlaywrightFallbackEnabled } from "@/lib/env";
+
+describe("isPlaywrightFallbackEnabled", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("returns false when the env var is unset", () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", undefined);
+
+ // Act / Assert
+ expect(isPlaywrightFallbackEnabled()).toBe(false);
+ });
+
+ it("returns true when the env var is exactly 'true'", () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "true");
+
+ // Act / Assert
+ expect(isPlaywrightFallbackEnabled()).toBe(true);
+ });
+
+ it("returns false for any other value, not just 'false'", () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "1");
+
+ // Act / Assert
+ expect(isPlaywrightFallbackEnabled()).toBe(false);
+ });
+});
diff --git a/lib/__tests__/resume-format.test.ts b/lib/__tests__/resume-format.test.ts
new file mode 100644
index 0000000..bbd37ac
--- /dev/null
+++ b/lib/__tests__/resume-format.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect } from "vitest";
+import { formatDateRange, companySlug, linesToBullets } from "@/lib/resume-format";
+
+describe("companySlug", () => {
+ it("returns an empty string for empty input", () => {
+ // Arrange
+ const company = "";
+
+ // Act
+ const slug = companySlug(company);
+
+ // Assert
+ expect(slug).toBe("");
+ });
+
+ it("lowercases a single-word company name", () => {
+ // Arrange
+ const company = "Anthropic";
+
+ // Act
+ const slug = companySlug(company);
+
+ // Assert
+ expect(slug).toBe("anthropic");
+ });
+
+ it("hyphenates a multi-word company name with punctuation", () => {
+ // Arrange
+ const company = "J.P. Morgan & Co.";
+
+ // Act
+ const slug = companySlug(company);
+
+ // Assert
+ expect(slug).toBe("j-p-morgan-co");
+ });
+
+ it("never leaves a leading or trailing hyphen", () => {
+ // Arrange
+ const company = " -Rocket Companies- ";
+
+ // Act
+ const slug = companySlug(company);
+
+ // Assert
+ expect(slug.startsWith("-")).toBe(false);
+ expect(slug.endsWith("-")).toBe(false);
+ });
+});
+
+describe("formatDateRange", () => {
+ it("formats a single-month range as one month spelled out on both ends", () => {
+ // Arrange
+ const start = "2024-01";
+ const end = "2024-01";
+
+ // Act
+ const range = formatDateRange(start, end);
+
+ // Assert
+ expect(range).toBe("January 2024 – January 2024");
+ });
+
+ it("formats a start and end month across a full range", () => {
+ // Arrange
+ const start = "2017-05";
+ const end = "2022-11";
+
+ // Act
+ const range = formatDateRange(start, end);
+
+ // Assert
+ expect(range).toBe("May 2017 – November 2022");
+ });
+
+ it("renders 'Present' when end is null", () => {
+ // Arrange
+ const start = "2024-10";
+ const end = null;
+
+ // Act
+ const range = formatDateRange(start, end);
+
+ // Assert
+ expect(range).toBe("October 2024 – Present");
+ });
+
+ it("formats December correctly at the year boundary", () => {
+ // Arrange
+ const start = "2011-12";
+ const end = "2012-01";
+
+ // Act
+ const range = formatDateRange(start, end);
+
+ // Assert
+ expect(range).toBe("December 2011 – January 2012");
+ });
+});
+
+describe("linesToBullets", () => {
+ it("returns an empty array for an empty string", () => {
+ // Arrange
+ const text = "";
+
+ // Act
+ const bullets = linesToBullets(text);
+
+ // Assert
+ expect(bullets).toEqual([]);
+ });
+
+ it("returns a single bullet for one plain line", () => {
+ // Arrange
+ const text = "20+ years leading enterprise UX";
+
+ // Act
+ const bullets = linesToBullets(text);
+
+ // Assert
+ expect(bullets).toEqual(["20+ years leading enterprise UX"]);
+ });
+
+ it("strips a leading bullet marker and surrounding whitespace from each line", () => {
+ // Arrange
+ const text = "• 20+ years leading enterprise UX\n• Proven track record building teams";
+
+ // Act
+ const bullets = linesToBullets(text);
+
+ // Assert
+ expect(bullets).toEqual(["20+ years leading enterprise UX", "Proven track record building teams"]);
+ });
+
+ it("drops blank lines between bullets", () => {
+ // Arrange
+ const text = "• First point\n\n• Second point\n \n• Third point";
+
+ // Act
+ const bullets = linesToBullets(text);
+
+ // Assert
+ expect(bullets).toEqual(["First point", "Second point", "Third point"]);
+ });
+
+ it("strips hyphen and asterisk bullet markers as well as the dot leader character", () => {
+ // Arrange
+ const text = "- Dash bullet\n* Asterisk bullet\n· Dot bullet";
+
+ // Act
+ const bullets = linesToBullets(text);
+
+ // Assert
+ expect(bullets).toEqual(["Dash bullet", "Asterisk bullet", "Dot bullet"]);
+ });
+
+ it("does not strip a hyphen that is part of the sentence rather than a leading marker", () => {
+ // Arrange
+ const text = "Twenty-five years of cross-functional leadership";
+
+ // Act
+ const bullets = linesToBullets(text);
+
+ // Assert
+ expect(bullets).toEqual(["Twenty-five years of cross-functional leadership"]);
+ });
+});
diff --git a/lib/__tests__/resume-pdf.test.ts b/lib/__tests__/resume-pdf.test.ts
new file mode 100644
index 0000000..db514da
--- /dev/null
+++ b/lib/__tests__/resume-pdf.test.ts
@@ -0,0 +1,101 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderResumePdf } from "@/lib/resume-pdf";
+
+describe("renderResumePdf", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("returns null without invoking the Playwright loader when the fallback flag is off", async () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "false");
+ const loadPlaywrightImpl = vi.fn();
+
+ // Act
+ const result = await renderResumePdf({ html: "", loadPlaywrightImpl });
+
+ // Assert
+ expect(result).toBeNull();
+ expect(loadPlaywrightImpl).not.toHaveBeenCalled();
+ });
+
+ it("returns null when the flag is on but Playwright cannot be loaded", async () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "true");
+ const loadPlaywrightImpl = vi.fn().mockResolvedValue(null);
+
+ // Act
+ const result = await renderResumePdf({ html: "", loadPlaywrightImpl });
+
+ // Assert
+ expect(result).toBeNull();
+ });
+
+ it("closes a fresh browser instance on each call, without leaking across calls", async () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "true");
+ const pdfBuffer = Buffer.from("pdf-bytes");
+ const makeBrowser = () => {
+ const close = vi.fn();
+ const page = { setContent: vi.fn(), pdf: vi.fn().mockResolvedValue(pdfBuffer) };
+ return { newPage: vi.fn().mockResolvedValue(page), close };
+ };
+ const browserOne = makeBrowser();
+ const browserTwo = makeBrowser();
+ const loadPlaywrightImpl = vi.fn().mockResolvedValue({
+ chromium: { launch: vi.fn().mockResolvedValueOnce(browserOne).mockResolvedValueOnce(browserTwo) },
+ });
+
+ // Act
+ await renderResumePdf({ html: "", loadPlaywrightImpl });
+ await renderResumePdf({ html: "", loadPlaywrightImpl });
+
+ // Assert
+ expect(browserOne.close).toHaveBeenCalledTimes(1);
+ expect(browserTwo.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("still calls page.pdf() for an empty HTML string, deferring input validation to the caller", async () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "true");
+ const pdf = vi.fn().mockResolvedValue(Buffer.from("pdf-bytes"));
+ const page = { setContent: vi.fn(), pdf };
+ const browser = { newPage: vi.fn().mockResolvedValue(page), close: vi.fn() };
+ const loadPlaywrightImpl = vi.fn().mockResolvedValue({ chromium: { launch: vi.fn().mockResolvedValue(browser) } });
+
+ // Act
+ await renderResumePdf({ html: "", loadPlaywrightImpl });
+
+ // Assert
+ expect(pdf).toHaveBeenCalled();
+ });
+
+ it("propagates a page.pdf() rejection rather than swallowing it", async () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "true");
+ const page = { setContent: vi.fn(), pdf: vi.fn().mockRejectedValue(new Error("render crash")) };
+ const browser = { newPage: vi.fn().mockResolvedValue(page), close: vi.fn() };
+ const loadPlaywrightImpl = vi.fn().mockResolvedValue({ chromium: { launch: vi.fn().mockResolvedValue(browser) } });
+
+ // Act / Assert
+ await expect(renderResumePdf({ html: "", loadPlaywrightImpl })).rejects.toThrow("render crash");
+ expect(browser.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns the PDF buffer from page.pdf() on the happy path", async () => {
+ // Arrange
+ vi.stubEnv("ENABLE_PLAYWRIGHT_FALLBACK", "true");
+ const pdfBuffer = Buffer.from("pdf-bytes");
+ const page = { setContent: vi.fn(), pdf: vi.fn().mockResolvedValue(pdfBuffer) };
+ const browser = { newPage: vi.fn().mockResolvedValue(page), close: vi.fn() };
+ const loadPlaywrightImpl = vi.fn().mockResolvedValue({ chromium: { launch: vi.fn().mockResolvedValue(browser) } });
+
+ // Act
+ const result = await renderResumePdf({ html: "", loadPlaywrightImpl });
+
+ // Assert
+ expect(result).toBe(pdfBuffer);
+ expect(page.setContent).toHaveBeenCalledWith("", expect.objectContaining({ waitUntil: "networkidle" }));
+ expect(browser.close).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/__tests__/resume-template.test.ts b/lib/__tests__/resume-template.test.ts
new file mode 100644
index 0000000..6d9b0c0
--- /dev/null
+++ b/lib/__tests__/resume-template.test.ts
@@ -0,0 +1,323 @@
+import { describe, it, expect } from "vitest";
+import { buildResumeHtml } from "@/lib/resume-template";
+import type { ProfileData } from "@/lib/docx-resume";
+import type { CandidateConfig } from "@/lib/config";
+
+const candidate: CandidateConfig = {
+ name: "Jordan Rivera",
+ email: "jordan@example.com",
+ phone: "555.123.4567",
+};
+
+describe("buildResumeHtml", () => {
+ it("renders just the name and an empty experience section is omitted for a minimal profile", () => {
+ // Arrange
+ const profile: ProfileData = {};
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("Jordan Rivera ");
+ expect(html).not.toContain("Experience ");
+ expect(html).not.toContain("Skills ");
+ expect(html).not.toContain("Awards ");
+ expect(html).not.toContain("Education ");
+ });
+
+ it("renders one experience role as a title, employer, dates, and bullet list", () => {
+ // Arrange
+ const profile: ProfileData = {
+ experience: [
+ {
+ company: "Anthropic",
+ role: "Staff Designer",
+ start: "2023-01",
+ end: null,
+ bullets: ["Shipped the thing."],
+ },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("Experience ");
+ expect(html).toContain("Staff Designer ");
+ expect(html).toContain("Anthropic");
+ expect(html).toContain("January 2023 – Present");
+ expect(html).toContain("Shipped the thing. ");
+ });
+
+ it("renders multiple experience roles in the given order", () => {
+ // Arrange
+ const profile: ProfileData = {
+ experience: [
+ { company: "First Co", role: "Role One", start: "2022-01", end: "2023-01", bullets: [] },
+ { company: "Second Co", role: "Role Two", start: "2020-01", end: "2022-01", bullets: [] },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ const firstIndex = html.indexOf("Role One");
+ const secondIndex = html.indexOf("Role Two");
+ expect(firstIndex).toBeGreaterThan(-1);
+ expect(secondIndex).toBeGreaterThan(firstIndex);
+ });
+
+ it("escapes HTML-significant characters in bullet text", () => {
+ // Arrange
+ const profile: ProfileData = {
+ experience: [
+ {
+ company: "AT&T",
+ role: "Engineer",
+ start: "2020-01",
+ end: "2021-01",
+ bullets: ["Improved flow for R&D team"],
+ },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("AT&T");
+ expect(html).toContain("Improved <checkout> flow for R&D team");
+ expect(html).not.toContain("");
+ });
+
+ it("wraps each experience role's title and employer row in a break-inside: avoid block", () => {
+ // Arrange
+ const profile: ProfileData = {
+ experience: [
+ { company: "Anthropic", role: "Staff Designer", start: "2023-01", end: null, bullets: [] },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toMatch(/break-inside:\s*avoid/);
+ expect(html).toMatch(/\s*
Staff Designer<\/h3>/);
+ });
+
+ it("keeps the bullet list outside the break-inside: avoid wrapper so a long role can split across pages", () => {
+ // Arrange
+ const profile: ProfileData = {
+ experience: [
+ { company: "Anthropic", role: "Staff Designer", start: "2023-01", end: null, bullets: ["Did a thing."] },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+ const headOpen = html.indexOf('');
+ const listOpen = html.indexOf("
");
+
+ // Assert
+ expect(headOpen).toBeGreaterThan(-1);
+ expect(listOpen).toBeGreaterThan(headOpen);
+ const headSection = html.slice(headOpen, listOpen);
+ expect(headSection).not.toContain("");
+ });
+
+ it("omits education, awards, and skills sections when those fields are absent", () => {
+ // Arrange
+ const profile: ProfileData = { experience: [] };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).not.toContain("Education ");
+ expect(html).not.toContain("Awards ");
+ expect(html).not.toContain("Skills ");
+ });
+
+ it("renders a full profile end to end with sections in Profile, Skills, Experience, Awards, Education order", () => {
+ // Arrange
+ const profile: ProfileData = {
+ summary: "Fifteen years of design leadership.",
+ strengths: ["Design Systems", "DesignOps"],
+ experience: [
+ { company: "Anthropic", role: "Staff Designer", start: "2023-01", end: null, bullets: ["Did a thing."] },
+ ],
+ awards: ["Best in Show — Design Awards, 2020"],
+ education: [
+ { institution: "State University", degree: "B.A. Design", graduated: "2010-05", honors: "Cum Laude" },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ const order = ["Profile ", "Skills ", "Experience ", "Awards ", "Education "];
+ const indices = order.map((marker) => html.indexOf(marker));
+ expect(indices.every((i) => i > -1)).toBe(true);
+ expect(indices).toEqual([...indices].sort((a, b) => a - b));
+ expect(html).toContain("State University");
+ expect(html).toContain("Best in Show");
+ });
+
+ it("appends a company-specific portfolio link when both portfolio_url and company are given", () => {
+ // Arrange
+ const profile: ProfileData = { portfolio_url: "https://example.com/p/" };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate, { company: "Acme Corp" });
+
+ // Assert
+ expect(html).toContain("Portfolio: https://example.com/p/acme-corp");
+ });
+
+ it("renders profile.summaryBullets as a bullet list instead of a paragraph", () => {
+ // Arrange
+ const profile: ProfileData = {
+ summary: "Should not appear as a paragraph.",
+ summaryBullets: ["20+ years leading enterprise UX", "Proven track record building teams"],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain(" 20+ years leading enterprise UX ");
+ expect(html).toContain("Proven track record building teams ");
+ expect(html).not.toContain("Should not appear as a paragraph.");
+ expect(html).not.toContain('');
+ });
+
+ it("prefers tailoredProfileBullets over summaryBullets when both are given", () => {
+ // Arrange
+ const profile: ProfileData = {
+ summaryBullets: ["Untailored bullet."],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate, { tailoredProfileBullets: ["Tailored bullet."] });
+
+ // Assert
+ expect(html).toContain("
Tailored bullet. ");
+ expect(html).not.toContain("Untailored bullet.");
+ });
+
+ it("renders tailoredProfileBullets as a list instead of the summary paragraph when provided", () => {
+ // Arrange
+ const profile: ProfileData = { summary: "Should not appear." };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate, { tailoredProfileBullets: ["Tailored bullet one."] });
+
+ // Assert
+ expect(html).toContain("Tailored bullet one. ");
+ expect(html).not.toContain("Should not appear.");
+ });
+
+ it("omits the Skills section when strengthGroups is an empty array and strengths is absent", () => {
+ // Arrange
+ const profile: ProfileData = { strengthGroups: [] };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).not.toContain("Skills ");
+ });
+
+ it("renders a single strength group as a labeled skills line", () => {
+ // Arrange
+ const profile: ProfileData = {
+ strengthGroups: [{ label: "UX & Design", items: ["Product Design", "UX Research"] }],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("Skills ");
+ expect(html).toContain("UX & Design: Product Design, UX Research");
+ });
+
+ it("renders multiple strength groups as separate lines in the given order", () => {
+ // Arrange
+ const profile: ProfileData = {
+ strengthGroups: [
+ { label: "UX & Design", items: ["Product Design"] },
+ { label: "Tools", items: ["Figma", "Claude Code"] },
+ { label: "Technical", items: ["NextJS", "Ruby"] },
+ ],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ const uxIndex = html.indexOf("UX & Design: ");
+ const toolsIndex = html.indexOf("Tools: ");
+ const techIndex = html.indexOf("Technical: ");
+ expect([uxIndex, toolsIndex, techIndex].every((i) => i > -1)).toBe(true);
+ expect(toolsIndex).toBeGreaterThan(uxIndex);
+ expect(techIndex).toBeGreaterThan(toolsIndex);
+ expect(html).toContain("Figma, Claude Code");
+ });
+
+ it("prefers strengthGroups over the flat strengths list when both are present", () => {
+ // Arrange
+ const profile: ProfileData = {
+ strengths: ["Old flat strength"],
+ strengthGroups: [{ label: "Tools", items: ["Figma"] }],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("Tools: Figma");
+ expect(html).not.toContain("Old flat strength");
+ });
+
+ it("falls back to the flat strengths list under a Strengths label when strengthGroups is absent", () => {
+ // Arrange
+ const profile: ProfileData = { strengths: ["Design Systems", "DesignOps"] };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("Strengths: Design Systems, DesignOps");
+ });
+
+ it("escapes HTML-significant characters in strength group labels and items", () => {
+ // Arrange
+ const profile: ProfileData = {
+ strengthGroups: [{ label: "R&D", items: [""] }],
+ };
+
+ // Act
+ const html = buildResumeHtml(profile, candidate);
+
+ // Assert
+ expect(html).toContain("R&D");
+ expect(html).not.toContain("