mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 14:57:00 +10:00
feat(jobs): add job listings with AI-powered resume tailoring (#2788)
* feat: add job listings feature with JSearch API integration, resume tailoring, and per-user rate limiting * feat(jobs): add search filters UI, filter helper functions with tests, and job_search_quota DB migration * feat(jobs): add pagination with 30 results per page and prev/next navigation * refactor(job-detail): Adjust sheet width and scroll area height * feat(ai): Add resume tailoring feature and prompt * refactor(ai): Revise tailoring prompts and schema for full skill rewrite * feat(ai): Add reference tailoring and output sanitization * feat(testing): Add Vitest testing framework * fix: address PR review - atomic rate limiting, calendar-month quota, skill sync warning, gitignore routeTree.gen.ts * feat(jobs): Add location filter to job listings * feat(job-listings): Add DOCX document generation * feat(job-listings): Enable search by location and on Enter key * feat(job-listings): Split location filter into city, state, and country * feat(jobs): Implement job search adapter and JSearch * Update 'locale/' directory * feat(resume): Simplify filename generation and add tests * fix(JSearch): reduce JSearch API usage to 1 request per search to prevent quota exhaustion * fix(JSearch): Displayed quota amounts on Job Search functionality and settings fixed to pull from RapidAPI/JSearch response * fix(internal rate limit): Removed internal rate limit and .env.example addition, cloud based implementation handles. * style(job-filters): Adjust layout of switch filters * fix(typecheck): Fixed typecheck issues introduced to sync with origin * feat(jobs): Enhance tailor dialog with apply link and tags * feat(locale files): updated locale files with the latest build * feat(jobs): Add job search provider and integrate testing functionality - Introduced `createJobSearchProvider` function to instantiate a JSearchProvider. - Enhanced job search provider with methods for searching jobs, retrieving job details, and testing connection. - Updated `vite.config.ts` to include new testing configurations and plugins. - Added new dependencies in `package.json` for testing and document generation. - Removed obsolete `vitest.config.ts` file. - Improved job search provider tests for better coverage and reliability. * refactor: Update job search routes and remove obsolete test configurations - Removed the test configuration from `vite.config.ts`. - Updated localization files to reflect changes in job search routes, renaming references from `jobs` to `job-search` across multiple languages. - Adjusted autofix workflow to run formatting without the `--fix` flag for better control over code style adjustments. * chore: Update dependencies and improve animation performance - Added `jsdom` as a new dependency in `package.json`. - Updated `vite-plus` and `vitest` to the latest versions for better compatibility. - Enhanced animation components with `willChange` styles to optimize rendering performance. - Adjusted various UI components to improve responsiveness and visual effects. - Removed obsolete job details functionality from the job search provider and related tests. * chore(locales): Update localization files for job search improvements - Modified job search related strings to remove references to "this month" for a more concise format. - Updated file references in localization entries to reflect changes in the job search component structure. - Added new strings for API usage, quota remaining, and job fetching error messages across multiple languages. - Removed obsolete "Monthly Usage" string from localization files. * chore(dependencies): Update @typescript/native-preview to version 7.0.0-dev.20260319.1 --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generateFilename } from "./file";
|
||||
|
||||
describe("generateFilename", () => {
|
||||
it("should return name with extension", () => {
|
||||
expect(generateFilename("My Resume", "docx")).toBe("my-resume.docx");
|
||||
});
|
||||
|
||||
it("should return name without extension when none provided", () => {
|
||||
expect(generateFilename("My Resume")).toBe("my-resume");
|
||||
});
|
||||
|
||||
it("should preserve the exact resume name with special characters", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "docx")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.docx",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with pdf extension", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "pdf")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with json extension", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "json")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-14
@@ -1,21 +1,8 @@
|
||||
import { slugify } from "./string";
|
||||
|
||||
function getReadableTimestamp(now: Date) {
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const min = String(now.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${y}${m}${d}_${h}${min}`;
|
||||
}
|
||||
|
||||
export function generateFilename(prefix: string, extension?: string) {
|
||||
const now = new Date();
|
||||
const name = slugify(prefix);
|
||||
const timestamp = getReadableTimestamp(now);
|
||||
|
||||
return `${name}_${timestamp}${extension ? `.${extension}` : ""}`;
|
||||
return `${name}${extension ? `.${extension}` : ""}`;
|
||||
}
|
||||
|
||||
export function downloadWithAnchor(blob: Blob, filename: string) {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { Document } from "docx";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { buildDocument } from "./builder";
|
||||
|
||||
function makeResumeData(overrides?: Partial<ResumeData>): ResumeData {
|
||||
return { ...defaultResumeData, ...overrides };
|
||||
}
|
||||
|
||||
describe("buildDocument", () => {
|
||||
it("produces a valid Document from default resume data", () => {
|
||||
const doc = buildDocument(defaultResumeData);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("includes name in header when present", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "John Doe" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("includes contact info in header", () => {
|
||||
const data = makeResumeData({
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
phone: "+1-555-1234",
|
||||
location: "San Francisco, CA",
|
||||
website: { url: "https://jane.dev", label: "jane.dev" },
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders summary section when visible", () => {
|
||||
const data = makeResumeData({
|
||||
summary: {
|
||||
title: "About Me",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
content: "<p>I am a software engineer.</p>",
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("omits summary when hidden", () => {
|
||||
const data = makeResumeData({
|
||||
summary: {
|
||||
title: "About Me",
|
||||
columns: 1,
|
||||
hidden: true,
|
||||
content: "<p>Should not appear.</p>",
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders experience items", () => {
|
||||
const data = makeResumeData({
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
experience: {
|
||||
title: "Experience",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Acme Corp",
|
||||
position: "Developer",
|
||||
location: "Remote",
|
||||
period: "2020-2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Built features</p>",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("filters hidden items from experience", () => {
|
||||
const data = makeResumeData({
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
experience: {
|
||||
title: "Experience",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: true,
|
||||
company: "Hidden Corp",
|
||||
position: "Ghost",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// Should produce a document but the experience section should be omitted
|
||||
// (all items hidden → section heading omitted)
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders skills with keywords", () => {
|
||||
const data = makeResumeData({
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
skills: {
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["React", "Node.js", "Vite"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("follows section order from layout metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{
|
||||
fullWidth: false,
|
||||
main: ["experience", "education"],
|
||||
sidebar: ["skills"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders custom sections by type", () => {
|
||||
const data = makeResumeData({
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-1",
|
||||
title: "Side Projects",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
type: "projects",
|
||||
items: [
|
||||
{
|
||||
id: "proj-1",
|
||||
hidden: false,
|
||||
name: "My App",
|
||||
period: "2023",
|
||||
website: { url: "https://myapp.dev", label: "myapp.dev" },
|
||||
description: "<p>A cool app.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{
|
||||
fullWidth: false,
|
||||
main: ["custom-1"],
|
||||
sidebar: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("extracts primary color from metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
design: {
|
||||
...defaultResumeData.metadata.design,
|
||||
colors: {
|
||||
primary: "rgba(59, 130, 246, 1)",
|
||||
text: "rgba(0, 0, 0, 1)",
|
||||
background: "rgba(255, 255, 255, 1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Should not throw — the color is used for section headings
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders full-width layout when sidebar is empty", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{
|
||||
fullWidth: true,
|
||||
main: ["experience"],
|
||||
sidebar: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("applies page format and margins from metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
page: {
|
||||
...defaultResumeData.metadata.page,
|
||||
format: "letter",
|
||||
marginX: 20,
|
||||
marginY: 15,
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("applies typography settings from metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
typography: {
|
||||
body: {
|
||||
fontFamily: "Arial",
|
||||
fontWeights: ["400"],
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
heading: {
|
||||
fontFamily: "Georgia",
|
||||
fontWeights: ["700"],
|
||||
fontSize: 16,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("produces a valid DOCX blob via Packer", async () => {
|
||||
const { Packer } = await import("docx");
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Test User" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
const blob = await Packer.toBlob(doc);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
|
||||
// DOCX files are ZIP archives — first 2 bytes are "PK" (0x50, 0x4B)
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const view = new Uint8Array(buffer);
|
||||
expect(view[0]).toBe(0x50);
|
||||
expect(view[1]).toBe(0x4b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import {
|
||||
BorderStyle,
|
||||
convertMillimetersToTwip,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
|
||||
import type { ResumeData, SectionType } from "@/schema/resume/data";
|
||||
|
||||
import { parseColorString } from "@/utils/color";
|
||||
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
||||
|
||||
// --- Color helpers ---
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function getColorHex(rgba: string, fallback: string): string {
|
||||
const parsed = parseColorString(rgba);
|
||||
if (!parsed) return fallback;
|
||||
return rgbToHex(parsed.r, parsed.g, parsed.b);
|
||||
}
|
||||
|
||||
// --- Unit conversion helpers ---
|
||||
|
||||
/** Convert pt font size to docx half-points (docx uses half-pt units). */
|
||||
function ptToHalfPt(pt: number): number {
|
||||
return Math.round(pt * 2);
|
||||
}
|
||||
|
||||
/** Convert pt spacing to twips (1pt = 20 twips). */
|
||||
function ptToTwips(pt: number): number {
|
||||
return Math.round(pt * 20);
|
||||
}
|
||||
|
||||
// --- Page size constants (in mm) ---
|
||||
|
||||
const PAGE_SIZES: Record<string, { width: number; height: number }> = {
|
||||
a4: { width: 210, height: 297 },
|
||||
letter: { width: 215.9, height: 279.4 },
|
||||
};
|
||||
|
||||
// --- Invisible border preset for table cells ---
|
||||
|
||||
const NO_BORDERS = {
|
||||
top: { style: BorderStyle.NONE, size: 0 },
|
||||
bottom: { style: BorderStyle.NONE, size: 0 },
|
||||
left: { style: BorderStyle.NONE, size: 0 },
|
||||
right: { style: BorderStyle.NONE, size: 0 },
|
||||
} as const;
|
||||
|
||||
// --- Template layout config ---
|
||||
|
||||
interface TemplateConfig {
|
||||
/** Which side the sidebar appears on */
|
||||
sidebarSide: "left" | "right";
|
||||
/** Sidebar background: "solid" = full primary color, "tint" = 20% opacity, "none" = no background */
|
||||
sidebarBackground: "solid" | "tint" | "none";
|
||||
/** Where the header is rendered */
|
||||
headerPosition: "full-width" | "main-only" | "sidebar-only";
|
||||
}
|
||||
|
||||
const TEMPLATE_CONFIGS: Record<string, TemplateConfig> = {
|
||||
azurill: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
bronzor: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
chikorita: { sidebarSide: "right", sidebarBackground: "solid", headerPosition: "main-only" },
|
||||
ditgar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
ditto: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
gengar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
glalie: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
kakuna: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
lapras: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
leafish: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
onyx: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
pikachu: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "main-only" },
|
||||
rhyhorn: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
};
|
||||
|
||||
const DEFAULT_TEMPLATE_CONFIG: TemplateConfig = {
|
||||
sidebarSide: "left",
|
||||
sidebarBackground: "none",
|
||||
headerPosition: "full-width",
|
||||
};
|
||||
|
||||
/**
|
||||
* Blends a hex color toward white at the given opacity (0-1).
|
||||
* Used to approximate CSS `background-color: rgba(r,g,b, 0.2)` on a white background.
|
||||
*/
|
||||
function blendWithWhite(hex: string, opacity: number): string {
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16);
|
||||
const blend = (c: number) => Math.round(c * opacity + 255 * (1 - opacity));
|
||||
return [blend(r), blend(g), blend(b)].map((c) => c.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// --- Section rendering dispatch ---
|
||||
|
||||
const BUILT_IN_SECTIONS = new Set<string>([
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
]);
|
||||
|
||||
function renderSection(sectionId: string, data: ResumeData, colorHex: string): Paragraph[] {
|
||||
if (sectionId === "summary") {
|
||||
return renderSummary(data.summary, colorHex);
|
||||
}
|
||||
|
||||
if (BUILT_IN_SECTIONS.has(sectionId)) {
|
||||
const section = data.sections[sectionId as SectionType];
|
||||
if (section) {
|
||||
return renderBuiltInSection(sectionId as SectionType, section, colorHex);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const customSection = data.customSections.find((cs) => cs.id === sectionId);
|
||||
if (customSection) {
|
||||
return renderCustomSection(customSection, colorHex);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Header ---
|
||||
|
||||
function buildHeader(data: ResumeData, colorHex: string, textColorHex: string): Paragraph[] {
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const { basics } = data;
|
||||
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
||||
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
||||
// h2 = calc(var(--page-heading-font-size) * 1.25pt)
|
||||
const nameSize = ptToHalfPt(data.metadata.typography.heading.fontSize * 1.25);
|
||||
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
||||
|
||||
if (basics.name) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.name,
|
||||
bold: true,
|
||||
size: nameSize,
|
||||
font: headingFont,
|
||||
color: colorHex,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (basics.headline) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.headline,
|
||||
italics: true,
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
color: textColorHex,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const contactParts: (TextRun | ExternalHyperlink)[] = [];
|
||||
const addSeparator = () => {
|
||||
if (contactParts.length > 0) {
|
||||
contactParts.push(new TextRun({ text: " | ", color: "999999", size: bodySize, font: bodyFont }));
|
||||
}
|
||||
};
|
||||
|
||||
if (basics.email) {
|
||||
const mailtoLink = toSafeDocxLink(`mailto:${basics.email}`);
|
||||
if (mailtoLink) {
|
||||
addSeparator();
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: mailtoLink,
|
||||
children: [
|
||||
new TextRun({ text: basics.email, color: colorHex, underline: {}, size: bodySize, font: bodyFont }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (basics.phone) {
|
||||
addSeparator();
|
||||
contactParts.push(new TextRun({ text: basics.phone, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
|
||||
if (basics.location) {
|
||||
addSeparator();
|
||||
contactParts.push(new TextRun({ text: basics.location, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
|
||||
if (basics.website.url) {
|
||||
const websiteLink = toSafeDocxLink(basics.website.url);
|
||||
if (websiteLink) {
|
||||
addSeparator();
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: websiteLink,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.website.label || websiteLink,
|
||||
color: colorHex,
|
||||
underline: {},
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of basics.customFields) {
|
||||
if (!field.text) continue;
|
||||
addSeparator();
|
||||
if (field.link) {
|
||||
const customLink = toSafeDocxLink(field.link);
|
||||
if (customLink) {
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: customLink,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: field.text,
|
||||
color: colorHex,
|
||||
underline: {},
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
} else {
|
||||
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
}
|
||||
|
||||
if (contactParts.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: contactParts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// --- Two-column table layout ---
|
||||
|
||||
function buildTwoColumnTable(
|
||||
mainParagraphs: Paragraph[],
|
||||
sidebarParagraphs: Paragraph[],
|
||||
sidebarWidthPct: number,
|
||||
gapXTwips: number,
|
||||
sidebarSide: "left" | "right",
|
||||
sidebarShadingHex?: string,
|
||||
): Table {
|
||||
const mainWidthPct = 100 - sidebarWidthPct;
|
||||
|
||||
// DOCX table cells require at least one child
|
||||
const mainChildren = mainParagraphs.length > 0 ? mainParagraphs : [new Paragraph({})];
|
||||
const sidebarChildren = sidebarParagraphs.length > 0 ? sidebarParagraphs : [new Paragraph({})];
|
||||
|
||||
const sidebarShading = sidebarShadingHex
|
||||
? { fill: sidebarShadingHex, type: ShadingType.CLEAR, color: "auto" }
|
||||
: undefined;
|
||||
|
||||
const sidebarCell = new TableCell({
|
||||
width: { size: sidebarWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins: sidebarSide === "left" ? { right: gapXTwips } : { left: gapXTwips },
|
||||
children: sidebarChildren,
|
||||
...(sidebarShading ? { shading: sidebarShading } : {}),
|
||||
});
|
||||
|
||||
const mainCell = new TableCell({
|
||||
width: { size: mainWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins: sidebarSide === "left" ? { left: gapXTwips } : { right: gapXTwips },
|
||||
children: mainChildren,
|
||||
});
|
||||
|
||||
const cells = sidebarSide === "left" ? [sidebarCell, mainCell] : [mainCell, sidebarCell];
|
||||
|
||||
return new Table({
|
||||
rows: [new TableRow({ children: cells })],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0 },
|
||||
bottom: { style: BorderStyle.NONE, size: 0 },
|
||||
left: { style: BorderStyle.NONE, size: 0 },
|
||||
right: { style: BorderStyle.NONE, size: 0 },
|
||||
insideHorizontal: { style: BorderStyle.NONE, size: 0 },
|
||||
insideVertical: { style: BorderStyle.NONE, size: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete docx Document from resume data.
|
||||
*
|
||||
* Mirrors the resume builder's visual layout:
|
||||
* - Header with name, headline, and contact info (centered, full-width)
|
||||
* - Two-column table layout (main + sidebar) matching `metadata.layout`
|
||||
* - Typography (font family, size, line height) from `metadata.typography`
|
||||
* - Page margins and format (A4/Letter) from `metadata.page`
|
||||
* - Primary, text, and background colors from `metadata.design.colors`
|
||||
*/
|
||||
export function buildDocument(data: ResumeData): Document {
|
||||
const colorHex = getColorHex(data.metadata.design.colors.primary, "DC2626");
|
||||
const textColorHex = getColorHex(data.metadata.design.colors.text, "000000");
|
||||
const bgColorHex = getColorHex(data.metadata.design.colors.background, "FFFFFF");
|
||||
|
||||
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
||||
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
||||
const lineSpacing = Math.round(data.metadata.typography.body.lineHeight * 240);
|
||||
|
||||
const { page } = data.metadata;
|
||||
const pageSize = PAGE_SIZES[page.format] ?? PAGE_SIZES.a4;
|
||||
// Margins and gaps are defined in points (pt), not mm
|
||||
const marginXTwips = ptToTwips(page.marginX);
|
||||
const marginYTwips = ptToTwips(page.marginY);
|
||||
const gapXTwips = ptToTwips(page.gapX);
|
||||
|
||||
const sidebarWidth = data.metadata.layout.sidebarWidth;
|
||||
|
||||
// Template-aware layout config
|
||||
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template] ?? DEFAULT_TEMPLATE_CONFIG;
|
||||
|
||||
// Compute sidebar background shading hex
|
||||
let sidebarShadingHex: string | undefined;
|
||||
if (templateConfig.sidebarBackground === "solid") {
|
||||
sidebarShadingHex = colorHex;
|
||||
} else if (templateConfig.sidebarBackground === "tint") {
|
||||
sidebarShadingHex = blendWithWhite(colorHex, 0.2);
|
||||
}
|
||||
|
||||
// Determine sidebar text colors — inverted when sidebar has a solid background
|
||||
const sidebarTextColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : textColorHex;
|
||||
const sidebarHeadingColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : colorHex;
|
||||
|
||||
// Configure heading typography for section renderers
|
||||
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
||||
const headingSize = ptToHalfPt(data.metadata.typography.heading.fontSize);
|
||||
|
||||
const documentChildren: (Paragraph | Table)[] = [];
|
||||
|
||||
// Header placement depends on template
|
||||
if (templateConfig.headerPosition === "full-width") {
|
||||
// Configure colors for main content
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
documentChildren.push(...buildHeader(data, colorHex, textColorHex));
|
||||
}
|
||||
|
||||
// Process each page in the layout
|
||||
for (const layoutPage of data.metadata.layout.pages) {
|
||||
const isFullWidth = layoutPage.fullWidth || layoutPage.sidebar.length === 0;
|
||||
|
||||
if (isFullWidth) {
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
for (const sectionId of [...layoutPage.main, ...layoutPage.sidebar]) {
|
||||
documentChildren.push(...renderSection(sectionId, data, colorHex));
|
||||
}
|
||||
} else {
|
||||
// Render main sections with normal colors
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
|
||||
const mainParagraphs: Paragraph[] = [];
|
||||
if (templateConfig.headerPosition === "main-only") {
|
||||
mainParagraphs.push(...buildHeader(data, colorHex, textColorHex));
|
||||
}
|
||||
for (const sectionId of layoutPage.main) {
|
||||
mainParagraphs.push(...renderSection(sectionId, data, colorHex));
|
||||
}
|
||||
|
||||
// Render sidebar sections with potentially inverted colors
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex: sidebarTextColorHex,
|
||||
primaryColorHex: sidebarHeadingColorHex,
|
||||
});
|
||||
|
||||
const sidebarParagraphs: Paragraph[] = [];
|
||||
if (templateConfig.headerPosition === "sidebar-only") {
|
||||
sidebarParagraphs.push(...buildHeader(data, sidebarHeadingColorHex, sidebarTextColorHex));
|
||||
}
|
||||
for (const sectionId of layoutPage.sidebar) {
|
||||
sidebarParagraphs.push(...renderSection(sectionId, data, sidebarHeadingColorHex));
|
||||
}
|
||||
|
||||
if (mainParagraphs.length > 0 || sidebarParagraphs.length > 0) {
|
||||
documentChildren.push(
|
||||
buildTwoColumnTable(
|
||||
mainParagraphs,
|
||||
sidebarParagraphs,
|
||||
sidebarWidth,
|
||||
gapXTwips,
|
||||
templateConfig.sidebarSide,
|
||||
sidebarShadingHex,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
font: bodyFont,
|
||||
size: bodySize,
|
||||
color: textColorHex,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: { line: lineSpacing },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
background: bgColorHex !== "FFFFFF" ? { color: bgColorHex } : undefined,
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
size: {
|
||||
width: convertMillimetersToTwip(pageSize.width),
|
||||
height: convertMillimetersToTwip(pageSize.height),
|
||||
},
|
||||
margin: {
|
||||
top: marginYTwips,
|
||||
bottom: marginYTwips,
|
||||
left: marginXTwips,
|
||||
right: marginXTwips,
|
||||
},
|
||||
},
|
||||
},
|
||||
children: documentChildren,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { ExternalHyperlink, Paragraph, TextRun } from "docx";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { htmlToParagraphs } from "./html-to-docx";
|
||||
|
||||
// Helper to extract the internal children array from a Paragraph.
|
||||
// docx stores children in the `root` property's internal array.
|
||||
function getChildren(paragraph: Paragraph): unknown[] {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing internal docx structure for testing
|
||||
const root = (paragraph as any).root;
|
||||
if (Array.isArray(root)) return root;
|
||||
return [];
|
||||
}
|
||||
|
||||
describe("htmlToParagraphs", () => {
|
||||
it("returns empty array for empty string", () => {
|
||||
expect(htmlToParagraphs("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for whitespace-only string", () => {
|
||||
expect(htmlToParagraphs(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a plain paragraph", () => {
|
||||
const result = htmlToParagraphs("<p>Hello</p>");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("parses bold text", () => {
|
||||
const result = htmlToParagraphs("<p><strong>Bold</strong></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const textRuns = children.filter((c) => c instanceof TextRun);
|
||||
expect(textRuns.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("parses italic text", () => {
|
||||
const result = htmlToParagraphs("<p><em>Italic</em></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const textRuns = children.filter((c) => c instanceof TextRun);
|
||||
expect(textRuns.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("parses nested bold and italic", () => {
|
||||
const result = htmlToParagraphs("<p><strong><em>Both</em></strong></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses unordered list", () => {
|
||||
const result = htmlToParagraphs("<ul><li>A</li><li>B</li></ul>");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses ordered list", () => {
|
||||
const result = htmlToParagraphs("<ol><li>A</li><li>B</li></ol>");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses hyperlink", () => {
|
||||
const result = htmlToParagraphs('<p><a href="https://example.com">Link</a></p>');
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("does not create hyperlink for unsafe javascript links", () => {
|
||||
const result = htmlToParagraphs('<p><a href="javascript:alert(1)">Click me</a></p>');
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not create hyperlink for unsafe data links", () => {
|
||||
const result = htmlToParagraphs('<p><a href="data:text/html;base64,PHNjcmlwdD4=">Click me</a></p>');
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses mixed inline formatting", () => {
|
||||
const result = htmlToParagraphs("<p>Normal <strong>bold</strong> end</p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
// Should have multiple TextRuns: "Normal ", bold "bold", " end"
|
||||
const textRuns = children.filter((c) => c instanceof TextRun);
|
||||
expect(textRuns.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("parses multiple paragraphs", () => {
|
||||
const result = htmlToParagraphs("<p>A</p><p>B</p>");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses line break within paragraph", () => {
|
||||
const result = htmlToParagraphs("<p>Line1<br>Line2</p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
// Should contain TextRuns with a break between
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
expect(children.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("handles empty paragraph gracefully", () => {
|
||||
const result = htmlToParagraphs("<p></p>");
|
||||
// Empty paragraphs may be skipped
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses strikethrough text", () => {
|
||||
const result = htmlToParagraphs("<p><s>struck</s></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses underline text", () => {
|
||||
const result = htmlToParagraphs("<p><u>under</u></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
type IShadingAttributesProperties,
|
||||
type ISpacingProperties,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
} from "docx";
|
||||
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
export interface HtmlStyleConfig {
|
||||
font?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
linkColor?: string;
|
||||
}
|
||||
|
||||
interface InlineStyle {
|
||||
bold?: boolean;
|
||||
italics?: boolean;
|
||||
underline?: Record<string, never>;
|
||||
strike?: boolean;
|
||||
font?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
shading?: IShadingAttributesProperties;
|
||||
}
|
||||
|
||||
type InlineChild = TextRun | ExternalHyperlink;
|
||||
|
||||
/** Module-level link color, set per htmlToParagraphs invocation. */
|
||||
let currentLinkColor = "0563C1";
|
||||
|
||||
const HEADING_MAP: Record<string, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
H1: HeadingLevel.HEADING_1,
|
||||
H2: HeadingLevel.HEADING_2,
|
||||
H3: HeadingLevel.HEADING_3,
|
||||
H4: HeadingLevel.HEADING_4,
|
||||
H5: HeadingLevel.HEADING_5,
|
||||
H6: HeadingLevel.HEADING_6,
|
||||
};
|
||||
|
||||
function mergeStyle(parent: InlineStyle, tag: string): InlineStyle {
|
||||
const next = { ...parent };
|
||||
|
||||
switch (tag) {
|
||||
case "STRONG":
|
||||
case "B":
|
||||
next.bold = true;
|
||||
break;
|
||||
case "EM":
|
||||
case "I":
|
||||
next.italics = true;
|
||||
break;
|
||||
case "U":
|
||||
next.underline = {};
|
||||
break;
|
||||
case "S":
|
||||
case "STRIKE":
|
||||
next.strike = true;
|
||||
break;
|
||||
case "CODE":
|
||||
next.font = "Courier New";
|
||||
break;
|
||||
case "MARK":
|
||||
next.shading = { fill: "FFFF00" };
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
|
||||
const children: InlineChild[] = [];
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent ?? "";
|
||||
if (text) {
|
||||
children.push(new TextRun({ text, ...style }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as Element;
|
||||
const tag = el.tagName;
|
||||
|
||||
if (tag === "BR") {
|
||||
children.push(new TextRun({ break: 1 }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag === "A") {
|
||||
const href = toSafeDocxLink(el.getAttribute("href") ?? "");
|
||||
const linkChildren = collectInlineChildren(el, { ...style, color: currentLinkColor, underline: {} });
|
||||
if (linkChildren.length > 0 && href) {
|
||||
children.push(new ExternalHyperlink({ link: href, children: linkChildren as TextRun[] }));
|
||||
} else {
|
||||
children.push(...linkChildren);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const merged = mergeStyle(style, tag);
|
||||
children.push(...collectInlineChildren(el, merged));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function processBlockElement(
|
||||
el: Element,
|
||||
style: InlineStyle,
|
||||
paragraphs: Paragraph[],
|
||||
listLevel?: number,
|
||||
numberingRef?: string,
|
||||
listIndex?: number,
|
||||
): void {
|
||||
const tag = el.tagName;
|
||||
|
||||
if (HEADING_MAP[tag]) {
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "P" || tag === "DIV") {
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(listLevel != null && numberingRef
|
||||
? { numbering: { reference: numberingRef, level: listLevel, instance: listIndex } }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "UL" || tag === "OL") {
|
||||
const isOrdered = tag === "OL";
|
||||
const level = listLevel != null ? listLevel + 1 : 0;
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== "LI") continue;
|
||||
|
||||
const hasNestedBlocks = Array.from(li.children).some(
|
||||
(c) => c.tagName === "UL" || c.tagName === "OL" || c.tagName === "P",
|
||||
);
|
||||
|
||||
if (hasNestedBlocks) {
|
||||
for (const liChild of li.childNodes) {
|
||||
if (liChild.nodeType === Node.TEXT_NODE) {
|
||||
const text = (liChild.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text, ...style })],
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: { reference: numberingRef, level, instance: listIndex } }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(liChild as Element, style, paragraphs, level, numberingRef, listIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inlineChildren = collectInlineChildren(li, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: { reference: numberingRef, level, instance: listIndex } }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "BLOCKQUOTE") {
|
||||
const indent: ISpacingProperties = {};
|
||||
const inlineChildren = collectInlineChildren(el, { ...style, italics: true });
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
indent: { left: 720 },
|
||||
spacing: indent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "PRE") {
|
||||
const text = el.textContent ?? "";
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text, font: "Courier New", ...style })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "HR") {
|
||||
paragraphs.push(new Paragraph({ children: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "LI") {
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
bullet: { level: listLevel ?? 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: treat as inline container
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ children: inlineChildren }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HTML string (from TipTap rich text editor) into an array of docx Paragraphs.
|
||||
* Uses the browser's DOMParser to parse HTML, then walks the DOM tree to produce
|
||||
* structured docx content with proper formatting (bold, italic, lists, links, etc.).
|
||||
*
|
||||
* @param html - The HTML string to convert
|
||||
* @param styleConfig - Optional base typography (font, size, color) to apply to all text runs
|
||||
*/
|
||||
export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): Paragraph[] {
|
||||
if (!html || !html.trim()) return [];
|
||||
|
||||
currentLinkColor = styleConfig?.linkColor ?? "0563C1";
|
||||
|
||||
const baseStyle: InlineStyle = {};
|
||||
if (styleConfig?.font) baseStyle.font = styleConfig.font;
|
||||
if (styleConfig?.size) baseStyle.size = styleConfig.size;
|
||||
if (styleConfig?.color) baseStyle.color = styleConfig.color;
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
for (const child of doc.body.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...baseStyle })] }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(child as Element, baseStyle, paragraphs);
|
||||
}
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
/**
|
||||
* Builds a DOCX file from resume data and returns it as a Blob.
|
||||
*
|
||||
* Uses dynamic imports to lazy-load the `docx` package (~200KB gzipped)
|
||||
* so it's only downloaded when the user actually clicks the DOCX export button.
|
||||
*/
|
||||
export async function buildDocx(data: ResumeData): Promise<Blob> {
|
||||
const { buildDocument } = await import("./builder");
|
||||
const { Packer } = await import("docx");
|
||||
const doc = buildDocument(data);
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const SAFE_DOCX_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
|
||||
|
||||
export function toSafeDocxLink(value: string): string | null {
|
||||
const input = value.trim();
|
||||
if (!input) return null;
|
||||
|
||||
if (input.startsWith("mailto:")) {
|
||||
const email = input.slice("mailto:".length).trim();
|
||||
if (!email) return null;
|
||||
return `mailto:${email}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
if (!SAFE_DOCX_LINK_SCHEMES.has(url.protocol)) return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { BorderStyle, ExternalHyperlink, HeadingLevel, Paragraph, TabStopPosition, TabStopType, TextRun } from "docx";
|
||||
|
||||
import type { CustomSection, CustomSectionType, ResumeData, SectionType } from "@/schema/resume/data";
|
||||
|
||||
import { type HtmlStyleConfig, htmlToParagraphs } from "./html-to-docx";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
type Sections = ResumeData["sections"];
|
||||
|
||||
/** Module-level typography/color config, set by the builder before rendering. */
|
||||
let headingFont: string | undefined;
|
||||
let headingSize: number | undefined;
|
||||
let bodyFont: string | undefined;
|
||||
let bodySize: number | undefined;
|
||||
let textColor: string | undefined;
|
||||
let primaryColor: string | undefined;
|
||||
|
||||
/**
|
||||
* Configures the typography and colors used by all section renderers.
|
||||
* Must be called before any render functions.
|
||||
*/
|
||||
export function setRenderConfig(config: {
|
||||
headingFont: string;
|
||||
headingSizeHalfPt: number;
|
||||
bodyFont: string;
|
||||
bodySizeHalfPt: number;
|
||||
textColorHex: string;
|
||||
primaryColorHex: string;
|
||||
}): void {
|
||||
headingFont = config.headingFont;
|
||||
headingSize = config.headingSizeHalfPt;
|
||||
bodyFont = config.bodyFont;
|
||||
bodySize = config.bodySizeHalfPt;
|
||||
textColor = config.textColorHex;
|
||||
primaryColor = config.primaryColorHex;
|
||||
}
|
||||
|
||||
function getHtmlStyle(): HtmlStyleConfig {
|
||||
return {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
...(primaryColor ? { linkColor: primaryColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a section heading paragraph with primary color and bottom border.
|
||||
* Uses the heading typography set via `setRenderConfig`.
|
||||
*/
|
||||
function sectionHeading(title: string, colorHex: string): Paragraph {
|
||||
// Section headings use <h6> in templates: calc(var(--page-heading-font-size) * 0.75pt)
|
||||
const h6Size = headingSize ? Math.round(headingSize * 0.75) : 16;
|
||||
|
||||
return new Paragraph({
|
||||
heading: HeadingLevel.HEADING_6,
|
||||
spacing: { before: 240, after: 120 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: colorHex.replace("#", "") },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: title,
|
||||
bold: true,
|
||||
color: colorHex.replace("#", ""),
|
||||
size: h6Size,
|
||||
...(headingFont ? { font: headingFont } : {}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function titleAndSubtitle(primary: string, secondary: string, rightText?: string): Paragraph {
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: primary, bold: true, ...baseRun })];
|
||||
|
||||
if (secondary) {
|
||||
children.push(new TextRun({ text: ` — ${secondary}`, ...baseRun }));
|
||||
}
|
||||
|
||||
if (rightText) {
|
||||
children.push(new TextRun({ text: `\t${rightText}`, italics: true, ...baseRun }));
|
||||
}
|
||||
|
||||
return new Paragraph({
|
||||
tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
|
||||
spacing: { before: 120 },
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
function locationAndPeriod(location: string, period: string): Paragraph | null {
|
||||
const parts = [location, period].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: parts.join(" | "),
|
||||
italics: true,
|
||||
color: textColor ?? "666666",
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
}),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
});
|
||||
}
|
||||
|
||||
function websiteParagraph(url: string, label: string): Paragraph | null {
|
||||
const safeUrl = toSafeDocxLink(url);
|
||||
if (!safeUrl) return null;
|
||||
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new ExternalHyperlink({
|
||||
link: safeUrl,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: label || safeUrl,
|
||||
color: primaryColor ?? "0563C1",
|
||||
underline: {},
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Section-specific renderers ---
|
||||
|
||||
export function renderSummary(summary: ResumeData["summary"], colorHex: string): Paragraph[] {
|
||||
if (summary.hidden || !summary.content) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [];
|
||||
if (summary.title) {
|
||||
paragraphs.push(sectionHeading(summary.title, colorHex));
|
||||
}
|
||||
paragraphs.push(...htmlToParagraphs(summary.content, getHtmlStyle()));
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderExperience(section: Sections["experience"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.roles && item.roles.length > 0) {
|
||||
paragraphs.push(titleAndSubtitle(item.company, "", item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
for (const role of item.roles) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 80 },
|
||||
children: [
|
||||
new TextRun({ text: role.position, bold: true, italics: true, ...baseRun }),
|
||||
...(role.period ? [new TextRun({ text: ` — ${role.period}`, italics: true, ...baseRun })] : []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (role.description) {
|
||||
paragraphs.push(...htmlToParagraphs(role.description, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paragraphs.push(titleAndSubtitle(item.company, item.position, item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderEducation(section: Sections["education"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const degreeArea = [item.degree, item.area].filter(Boolean).join(", ");
|
||||
paragraphs.push(titleAndSubtitle(item.school, degreeArea, item.period));
|
||||
|
||||
if (item.grade) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `Grade: ${item.grade}`, italics: true, ...baseRun })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderProjects(section: Sections["projects"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.name, "", item.period));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderSkills(section: Sections["skills"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.proficiency) {
|
||||
children.push(new TextRun({ text: ` — ${item.proficiency}`, ...baseRun }));
|
||||
}
|
||||
|
||||
if (item.keywords.length > 0) {
|
||||
children.push(new TextRun({ text: `: ${item.keywords.join(", ")}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderLanguages(section: Sections["languages"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.language, bold: true, ...baseRun })];
|
||||
|
||||
if (item.fluency) {
|
||||
children.push(new TextRun({ text: ` — ${item.fluency}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderInterests(section: Sections["interests"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.keywords.length > 0) {
|
||||
children.push(new TextRun({ text: `: ${item.keywords.join(", ")}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderAwards(section: Sections["awards"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.awarder, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderCertifications(section: Sections["certifications"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.issuer, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderPublications(section: Sections["publications"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.publisher, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderVolunteer(section: Sections["volunteer"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.organization, "", item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderReferences(section: Sections["references"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.position) {
|
||||
children.push(new TextRun({ text: ` — ${item.position}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 120 }, children }));
|
||||
|
||||
if (item.phone) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: item.phone, ...baseRun })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderProfiles(section: Sections["profiles"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: item.network, bold: true, ...baseRun })];
|
||||
|
||||
if (item.username) {
|
||||
children.push(new TextRun({ text: ` — ${item.username}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from built-in section type to its renderer.
|
||||
*/
|
||||
const sectionRenderers: Record<SectionType, (section: Sections[SectionType], colorHex: string) => Paragraph[]> = {
|
||||
experience: renderExperience as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
education: renderEducation as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
projects: renderProjects as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
skills: renderSkills as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
languages: renderLanguages as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
interests: renderInterests as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
awards: renderAwards as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
certifications: renderCertifications as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
publications: renderPublications as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
volunteer: renderVolunteer as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
references: renderReferences as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
profiles: renderProfiles as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a built-in section by type.
|
||||
*/
|
||||
export function renderBuiltInSection(type: SectionType, section: Sections[SectionType], colorHex: string): Paragraph[] {
|
||||
const renderer = sectionRenderers[type];
|
||||
if (!renderer) return [];
|
||||
return renderer(section, colorHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a custom section by dispatching to the matching built-in renderer based on its type.
|
||||
*/
|
||||
export function renderCustomSection(section: CustomSection, colorHex: string): Paragraph[] {
|
||||
if (section.hidden) return [];
|
||||
|
||||
const visibleItems = section.items.filter((item) => !item.hidden);
|
||||
if (visibleItems.length === 0) return [];
|
||||
|
||||
const sectionType = section.type as CustomSectionType;
|
||||
|
||||
// Summary-type custom sections render HTML content
|
||||
if (sectionType === "summary") {
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
for (const item of visibleItems) {
|
||||
if ("content" in item && item.content) {
|
||||
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// Cover letter type — render recipient + content
|
||||
if (sectionType === "cover-letter") {
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
for (const item of visibleItems) {
|
||||
if ("recipient" in item && item.recipient) {
|
||||
paragraphs.push(...htmlToParagraphs(item.recipient, getHtmlStyle()));
|
||||
}
|
||||
if ("content" in item && item.content) {
|
||||
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// Build a synthetic section matching the built-in type for the renderer
|
||||
const sectionKey = sectionType as SectionType;
|
||||
if (sectionKey in sectionRenderers) {
|
||||
const syntheticSection = {
|
||||
title: section.title,
|
||||
columns: section.columns,
|
||||
hidden: false,
|
||||
items: visibleItems,
|
||||
} as Sections[SectionType];
|
||||
|
||||
return renderBuiltInSection(sectionKey, syntheticSection, colorHex);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import type { TailorOutput } from "@/schema/tailor";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import {
|
||||
buildSkillSyncOperations,
|
||||
type JsonPatchOperation,
|
||||
sanitizeText,
|
||||
tailorOutputToPatches,
|
||||
validateTailorOutput,
|
||||
} from "./tailor";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test helper needs dynamic access to operation values
|
||||
function opValue(op: JsonPatchOperation | undefined): any {
|
||||
if (op && "value" in op) return op.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to create resume data with experience, skills, and references
|
||||
function makeResumeData(overrides?: {
|
||||
experiences?: ResumeData["sections"]["experience"]["items"];
|
||||
skills?: ResumeData["sections"]["skills"]["items"];
|
||||
references?: ResumeData["sections"]["references"]["items"];
|
||||
}): ResumeData {
|
||||
return {
|
||||
...defaultResumeData,
|
||||
summary: { ...defaultResumeData.summary, content: "<p>Original summary</p>" },
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
experience: {
|
||||
...defaultResumeData.sections.experience,
|
||||
items: overrides?.experiences ?? [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "TechCorp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020-2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Original description</p>",
|
||||
roles: [
|
||||
{ id: "role-1", position: "Senior", period: "2022-2024", description: "<p>Senior role</p>" },
|
||||
{ id: "role-2", position: "Junior", period: "2020-2022", description: "<p>Junior role</p>" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "exp-2",
|
||||
hidden: false,
|
||||
company: "StartupInc",
|
||||
position: "Developer",
|
||||
location: "Remote",
|
||||
period: "2018-2020",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Startup description</p>",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
...defaultResumeData.sections.skills,
|
||||
items: overrides?.skills ?? [
|
||||
{
|
||||
id: "skill-0",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "JavaScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["React", "Node.js"],
|
||||
},
|
||||
{
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "Python",
|
||||
proficiency: "Intermediate",
|
||||
level: 3,
|
||||
keywords: ["Django"],
|
||||
},
|
||||
{
|
||||
id: "skill-2",
|
||||
hidden: true,
|
||||
icon: "",
|
||||
name: "Photography",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
references: {
|
||||
...defaultResumeData.sections.references,
|
||||
items: overrides?.references ?? [
|
||||
{
|
||||
id: "ref-1",
|
||||
hidden: false,
|
||||
name: "Jane Smith",
|
||||
position: "Engineering Manager",
|
||||
website: { url: "", label: "" },
|
||||
phone: "555-1234",
|
||||
description: "<p>Jane was my direct manager at TechCorp.</p>",
|
||||
},
|
||||
{
|
||||
id: "ref-2",
|
||||
hidden: false,
|
||||
name: "Bob Jones",
|
||||
position: "CTO at StartupInc",
|
||||
website: { url: "", label: "" },
|
||||
phone: "555-5678",
|
||||
description: "<p>Bob can speak to my contributions.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyTailorOutput: TailorOutput = {
|
||||
summary: { content: "" },
|
||||
experiences: [],
|
||||
references: [],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
// --- sanitizeText ---
|
||||
|
||||
describe("sanitizeText", () => {
|
||||
it("replaces emdash and endash with hyphen", () => {
|
||||
expect(sanitizeText("2020\u20132024")).toBe("2020-2024");
|
||||
expect(sanitizeText("skills \u2014 leadership")).toBe("skills - leadership");
|
||||
});
|
||||
|
||||
it("replaces curly quotes with straight quotes", () => {
|
||||
expect(sanitizeText("\u201CHello\u201D")).toBe('"Hello"');
|
||||
expect(sanitizeText("\u2018world\u2019")).toBe("'world'");
|
||||
});
|
||||
|
||||
it("replaces ellipsis character with three dots", () => {
|
||||
expect(sanitizeText("and more\u2026")).toBe("and more...");
|
||||
});
|
||||
|
||||
it("replaces non-breaking and special whitespace with standard space", () => {
|
||||
expect(sanitizeText("hello\u00A0world")).toBe("hello world");
|
||||
expect(sanitizeText("thin\u2009space")).toBe("thin space");
|
||||
});
|
||||
|
||||
it("removes Unicode bullet characters", () => {
|
||||
expect(sanitizeText("\u2022 item one")).toBe(" item one");
|
||||
expect(sanitizeText("\u2219 item two")).toBe(" item two");
|
||||
});
|
||||
|
||||
it("leaves normal ASCII text unchanged", () => {
|
||||
expect(sanitizeText("<p>Normal text - with hyphens.</p>")).toBe("<p>Normal text - with hyphens.</p>");
|
||||
});
|
||||
});
|
||||
|
||||
// --- tailorOutputToPatches ---
|
||||
|
||||
describe("tailorOutputToPatches", () => {
|
||||
it("generates summary replace patch", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
summary: { content: "<p>Tailored summary</p>" },
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/summary/content",
|
||||
value: "<p>Tailored summary</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes summary content", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
summary: { content: "<p>Expert \u2014 Full Stack Developer</p>" },
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const summaryOp = operations.find((op) => op.path === "/summary/content");
|
||||
expect(opValue(summaryOp)).toBe("<p>Expert - Full Stack Developer</p>");
|
||||
});
|
||||
|
||||
it("skips summary patch when content is empty", () => {
|
||||
const { operations } = tailorOutputToPatches(emptyTailorOutput, makeResumeData());
|
||||
|
||||
const summaryOps = operations.filter((op) => op.path === "/summary/content");
|
||||
expect(summaryOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("generates experience description patches", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Tailored exp</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/description",
|
||||
value: "<p>Tailored exp</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes experience descriptions", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Led team \u2013 built \u201Cgreat\u201D things</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const expOp = operations.find((op) => op.path === "/sections/experience/items/0/description");
|
||||
expect(opValue(expOp)).toBe('<p>Led team - built "great" things</p>');
|
||||
});
|
||||
|
||||
it("handles role progression with nested role patches", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [
|
||||
{
|
||||
index: 0,
|
||||
description: "<p>Main desc</p>",
|
||||
roles: [
|
||||
{ index: 0, description: "<p>Tailored senior</p>" },
|
||||
{ index: 1, description: "<p>Tailored junior</p>" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/roles/0/description",
|
||||
value: "<p>Tailored senior</p>",
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/roles/1/description",
|
||||
value: "<p>Tailored junior</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates reference description patches", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [
|
||||
{ index: 0, description: "<p>Jane supervised my engineering work and can speak to my technical skills.</p>" },
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/references/items/0/description",
|
||||
value: "<p>Jane supervised my engineering work and can speak to my technical skills.</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates patches for multiple references", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [
|
||||
{ index: 0, description: "<p>Ref 0 tailored</p>" },
|
||||
{ index: 1, description: "<p>Ref 1 tailored</p>" },
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/references/items/0/description",
|
||||
value: "<p>Ref 0 tailored</p>",
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/references/items/1/description",
|
||||
value: "<p>Ref 1 tailored</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes reference descriptions", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 0, description: "<p>Jane\u2019s team \u2014 excellent work</p>" }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const refOp = operations.find((op) => op.path === "/sections/references/items/0/description");
|
||||
expect(opValue(refOp)).toBe("<p>Jane's team - excellent work</p>");
|
||||
});
|
||||
|
||||
it("ignores out-of-bounds reference indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 99, description: "<p>Bad ref</p>" }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const refOps = operations.filter((op) => op.path.includes("/references/"));
|
||||
expect(refOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hides all existing skills and adds curated skills", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "React", keywords: ["Hooks", "JSX"], proficiency: "Developer", icon: "code", isNew: false }],
|
||||
};
|
||||
|
||||
const resumeData = makeResumeData();
|
||||
const { operations } = tailorOutputToPatches(output, resumeData);
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/skills/items/0/hidden",
|
||||
value: true,
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/skills/items/1/hidden",
|
||||
value: true,
|
||||
});
|
||||
|
||||
// Already-hidden skill (index 2) should not get a redundant hide op
|
||||
const hideSkill2 = operations.find((op) => op.path === "/sections/skills/items/2/hidden");
|
||||
expect(hideSkill2).toBeUndefined();
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(addOp).toBeDefined();
|
||||
expect(opValue(addOp)).toMatchObject({
|
||||
name: "React",
|
||||
keywords: ["Hooks", "JSX"],
|
||||
proficiency: "Developer",
|
||||
icon: "code",
|
||||
hidden: false,
|
||||
level: 0,
|
||||
});
|
||||
expect(opValue(addOp).id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sanitizes skill names and keywords", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [
|
||||
{
|
||||
name: "Full\u2013Stack Development",
|
||||
keywords: ["React \u2014 Frontend", "Node\u2019s Server"],
|
||||
proficiency: "\u201CAdvanced\u201D",
|
||||
icon: "code",
|
||||
isNew: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(opValue(addOp).name).toBe("Full-Stack Development");
|
||||
expect(opValue(addOp).keywords).toEqual(["React - Frontend", "Node's Server"]);
|
||||
expect(opValue(addOp).proficiency).toBe('"Advanced"');
|
||||
});
|
||||
|
||||
it("returns newSkills only for skills marked isNew", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [
|
||||
{ name: "React", keywords: ["Hooks"], proficiency: "Developer", icon: "code", isNew: false },
|
||||
{ name: "Docker", keywords: ["K8s"], proficiency: "Intermediate", icon: "cloud", isNew: true },
|
||||
{ name: "AWS", keywords: ["EC2", "S3"], proficiency: "Advanced", icon: "cloud", isNew: true },
|
||||
],
|
||||
};
|
||||
|
||||
const { newSkills } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(newSkills).toEqual([
|
||||
{ name: "Docker", keywords: ["K8s"], proficiency: "Intermediate" },
|
||||
{ name: "AWS", keywords: ["EC2", "S3"], proficiency: "Advanced" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return newSkills when all skills are existing", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "React", keywords: ["Hooks"], proficiency: "Developer", icon: "code", isNew: false }],
|
||||
};
|
||||
|
||||
const { newSkills } = tailorOutputToPatches(output, makeResumeData());
|
||||
expect(newSkills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty skills array without modifying existing skills", () => {
|
||||
const { operations } = tailorOutputToPatches(emptyTailorOutput, makeResumeData());
|
||||
|
||||
const skillOps = operations.filter((op) => op.path.includes("/skills/"));
|
||||
expect(skillOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty experiences, references, and skills gracefully", () => {
|
||||
const { operations, newSkills } = tailorOutputToPatches(emptyTailorOutput, makeResumeData());
|
||||
|
||||
expect(operations).toHaveLength(0);
|
||||
expect(newSkills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores out-of-bounds experience indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 99, description: "<p>Bad</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const expOps = operations.filter((op) => op.path.includes("/experience/"));
|
||||
expect(expOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores out-of-bounds role indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Ok</p>", roles: [{ index: 99, description: "<p>Bad role</p>" }] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const roleOps = operations.filter((op) => op.path.includes("/roles/"));
|
||||
expect(roleOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("generates multiple experience patches at different indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [
|
||||
{ index: 0, description: "<p>First</p>", roles: [] },
|
||||
{ index: 1, description: "<p>Second</p>", roles: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/description",
|
||||
value: "<p>First</p>",
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/1/description",
|
||||
value: "<p>Second</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses skill icon from AI output", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "Cloud", keywords: ["AWS"], proficiency: "Advanced", icon: "cloud", isNew: false }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(opValue(addOp).icon).toBe("cloud");
|
||||
});
|
||||
|
||||
it("defaults empty icon to empty string", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "Cloud", keywords: ["AWS"], proficiency: "Advanced", icon: "", isNew: false }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(opValue(addOp).icon).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// --- validateTailorOutput ---
|
||||
|
||||
describe("validateTailorOutput", () => {
|
||||
it("returns empty array for valid output", () => {
|
||||
const output: TailorOutput = {
|
||||
summary: { content: "<p>Summary</p>" },
|
||||
experiences: [{ index: 0, description: "<p>Desc</p>", roles: [] }],
|
||||
references: [{ index: 0, description: "<p>Ref desc</p>" }],
|
||||
skills: [{ name: "React", keywords: ["Hooks"], proficiency: "Advanced", icon: "code", isNew: false }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects out-of-bounds experience index", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 10, description: "<p>Bad</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toContain("Experience index 10 out of bounds");
|
||||
});
|
||||
|
||||
it("detects out-of-bounds role index", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Ok</p>", roles: [{ index: 5, description: "<p>Bad role</p>" }] }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toContain("Role index 5");
|
||||
});
|
||||
|
||||
it("detects out-of-bounds reference index", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 50, description: "<p>Bad ref</p>" }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toContain("Reference index 50 out of bounds");
|
||||
});
|
||||
|
||||
it("handles resume with no items gracefully", () => {
|
||||
const emptyResume = makeResumeData({ experiences: [], skills: [], references: [] });
|
||||
|
||||
const output: TailorOutput = {
|
||||
summary: { content: "<p>Summary</p>" },
|
||||
experiences: [],
|
||||
references: [],
|
||||
skills: [{ name: "New Skill", keywords: [], proficiency: "", icon: "", isNew: true }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, emptyResume);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects negative indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: -1, description: "<p>Negative</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects negative reference indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: -1, description: "<p>Negative ref</p>" }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- buildSkillSyncOperations ---
|
||||
|
||||
describe("buildSkillSyncOperations", () => {
|
||||
it("generates add operations for each skill", () => {
|
||||
const skills = [
|
||||
{ name: "React", keywords: ["Hooks", "JSX"], proficiency: "Advanced" },
|
||||
{ name: "Go", keywords: ["Goroutines"], proficiency: "" },
|
||||
];
|
||||
|
||||
const operations = buildSkillSyncOperations(skills);
|
||||
|
||||
expect(operations).toHaveLength(2);
|
||||
expect(operations[0].op).toBe("add");
|
||||
expect(operations[0].path).toBe("/sections/skills/items/-");
|
||||
expect(opValue(operations[0])).toMatchObject({
|
||||
name: "React",
|
||||
keywords: ["Hooks", "JSX"],
|
||||
proficiency: "Advanced",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
level: 0,
|
||||
});
|
||||
expect(opValue(operations[0]).id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("generates unique IDs for each skill", () => {
|
||||
const skills = [
|
||||
{ name: "A", keywords: [], proficiency: "" },
|
||||
{ name: "B", keywords: [], proficiency: "" },
|
||||
];
|
||||
|
||||
const operations = buildSkillSyncOperations(skills);
|
||||
|
||||
expect(opValue(operations[0]).id).not.toBe(opValue(operations[1]).id);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
const operations = buildSkillSyncOperations([]);
|
||||
expect(operations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import type { NewSkillInfo, TailorOutput } from "@/schema/tailor";
|
||||
import type { jsonPatchOperationSchema } from "@/utils/resume/patch";
|
||||
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
export type JsonPatchOperation = z.infer<typeof jsonPatchOperationSchema>;
|
||||
|
||||
/**
|
||||
* Sanitizes text output from AI to ensure consistent character formatting.
|
||||
* Replaces smart quotes, emdashes, endashes, special whitespace, and
|
||||
* other Unicode characters with their standard ASCII equivalents.
|
||||
*/
|
||||
export function sanitizeText(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Emdashes and endashes → hyphen
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
// Curly single quotes → straight
|
||||
.replace(/[\u2018\u2019\u201A]/g, "'")
|
||||
// Curly double quotes → straight
|
||||
.replace(/[\u201C\u201D\u201E]/g, '"')
|
||||
// Ellipsis character → three dots
|
||||
.replace(/\u2026/g, "...")
|
||||
// Non-breaking space and other special whitespace → standard space
|
||||
.replace(/[\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F]/g, " ")
|
||||
// Unicode bullet → empty (should use HTML <li> instead)
|
||||
.replace(/[\u2022\u2023\u25E6\u2043\u2219]/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a TailorOutput from the AI into JSON Patch (RFC 6902) operations
|
||||
* that can be applied to a resume's data.
|
||||
*
|
||||
* Returns both the patch operations and metadata about newly added skills
|
||||
* (for the skill sync confirmation dialog).
|
||||
*/
|
||||
export function tailorOutputToPatches(
|
||||
output: TailorOutput,
|
||||
resumeData: ResumeData,
|
||||
): { operations: JsonPatchOperation[]; newSkills: NewSkillInfo[] } {
|
||||
const operations: JsonPatchOperation[] = [];
|
||||
|
||||
// 1. Summary
|
||||
if (output.summary?.content) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: "/summary/content",
|
||||
value: sanitizeText(output.summary.content),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Experience descriptions
|
||||
for (const exp of output.experiences) {
|
||||
if (exp.index < 0 || exp.index >= resumeData.sections.experience.items.length) continue;
|
||||
|
||||
const basePath = `/sections/experience/items/${exp.index}`;
|
||||
|
||||
if (exp.description) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `${basePath}/description`,
|
||||
value: sanitizeText(exp.description),
|
||||
});
|
||||
}
|
||||
|
||||
if (exp.roles) {
|
||||
const rolesCount = resumeData.sections.experience.items[exp.index].roles?.length ?? 0;
|
||||
for (const role of exp.roles) {
|
||||
if (role.index < 0 || role.index >= rolesCount) continue;
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `${basePath}/roles/${role.index}/description`,
|
||||
value: sanitizeText(role.description),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reference descriptions
|
||||
for (const ref of output.references) {
|
||||
if (ref.index < 0 || ref.index >= resumeData.sections.references.items.length) continue;
|
||||
|
||||
if (ref.description) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `/sections/references/items/${ref.index}/description`,
|
||||
value: sanitizeText(ref.description),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Skills — full replacement approach
|
||||
// The AI provides the complete curated skills list.
|
||||
// First, hide ALL existing skills (they remain in the data but won't show).
|
||||
// Then add the curated set as new visible items.
|
||||
const newSkills: NewSkillInfo[] = [];
|
||||
|
||||
if (output.skills.length > 0) {
|
||||
// Hide all existing skills on the tailored copy
|
||||
for (let i = 0; i < resumeData.sections.skills.items.length; i++) {
|
||||
if (!resumeData.sections.skills.items[i].hidden) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `/sections/skills/items/${i}/hidden`,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the curated skills as new visible items
|
||||
for (const skill of output.skills) {
|
||||
operations.push({
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: skill.icon || "",
|
||||
name: sanitizeText(skill.name),
|
||||
proficiency: sanitizeText(skill.proficiency || ""),
|
||||
level: 0,
|
||||
keywords: skill.keywords.map(sanitizeText),
|
||||
},
|
||||
});
|
||||
|
||||
// Track newly inferred skills for sync-back to original resume
|
||||
if (skill.isNew) {
|
||||
newSkills.push({
|
||||
name: sanitizeText(skill.name),
|
||||
keywords: skill.keywords.map(sanitizeText),
|
||||
proficiency: sanitizeText(skill.proficiency || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { operations, newSkills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the AI-generated TailorOutput references valid indices
|
||||
* within the actual resume data. Returns an array of error messages.
|
||||
* An empty array means the output is valid.
|
||||
*/
|
||||
export function validateTailorOutput(output: TailorOutput, resumeData: ResumeData): string[] {
|
||||
const errors: string[] = [];
|
||||
const experienceCount = resumeData.sections.experience.items.length;
|
||||
const referencesCount = resumeData.sections.references.items.length;
|
||||
|
||||
for (const exp of output.experiences) {
|
||||
if (exp.index < 0 || exp.index >= experienceCount) {
|
||||
errors.push(`Experience index ${exp.index} out of bounds (max: ${experienceCount - 1})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exp.roles) {
|
||||
const rolesCount = resumeData.sections.experience.items[exp.index].roles?.length ?? 0;
|
||||
for (const role of exp.roles) {
|
||||
if (role.index < 0 || role.index >= rolesCount) {
|
||||
errors.push(`Role index ${role.index} in experience ${exp.index} out of bounds (max: ${rolesCount - 1})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of output.references) {
|
||||
if (ref.index < 0 || ref.index >= referencesCount) {
|
||||
errors.push(`Reference index ${ref.index} out of bounds (max: ${referencesCount - 1})`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds JSON Patch add operations for syncing new skills back to
|
||||
* the original (source) resume.
|
||||
*/
|
||||
export function buildSkillSyncOperations(skills: NewSkillInfo[]): JsonPatchOperation[] {
|
||||
return skills.map((skill) => ({
|
||||
op: "add" as const,
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: skill.name,
|
||||
proficiency: skill.proficiency,
|
||||
level: 0,
|
||||
keywords: skill.keywords,
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user