mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNetworkIcon } from "./network-icons";
|
||||
|
||||
describe("getNetworkIcon", () => {
|
||||
it("returns 'star' default when network is undefined", () => {
|
||||
expect(getNetworkIcon()).toBe("star");
|
||||
});
|
||||
|
||||
it("returns 'star' default when network is empty string", () => {
|
||||
expect(getNetworkIcon("")).toBe("star");
|
||||
});
|
||||
|
||||
it("returns 'star' default when no match found", () => {
|
||||
expect(getNetworkIcon("unknown-network")).toBe("star");
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
expect(getNetworkIcon("GitHub")).toBe("github-logo");
|
||||
expect(getNetworkIcon("LINKEDIN")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("matches GitHub", () => {
|
||||
expect(getNetworkIcon("github")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("matches LinkedIn", () => {
|
||||
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("matches Twitter and X variants", () => {
|
||||
expect(getNetworkIcon("twitter")).toBe("twitter-logo");
|
||||
// "x" alone matches, including in unrelated words
|
||||
expect(getNetworkIcon("x")).toBe("twitter-logo");
|
||||
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
|
||||
});
|
||||
|
||||
it("matches Facebook", () => {
|
||||
expect(getNetworkIcon("facebook")).toBe("facebook-logo");
|
||||
});
|
||||
|
||||
it("matches Instagram", () => {
|
||||
expect(getNetworkIcon("instagram")).toBe("instagram-logo");
|
||||
});
|
||||
|
||||
it("matches YouTube", () => {
|
||||
expect(getNetworkIcon("youtube")).toBe("youtube-logo");
|
||||
});
|
||||
|
||||
it("matches Stack Overflow variants", () => {
|
||||
expect(getNetworkIcon("stackoverflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("stack-overflow")).toBe("stack-overflow-logo");
|
||||
});
|
||||
|
||||
it("matches Medium", () => {
|
||||
expect(getNetworkIcon("medium")).toBe("medium-logo");
|
||||
});
|
||||
|
||||
it("matches Dev.to variants to 'code'", () => {
|
||||
expect(getNetworkIcon("dev.to")).toBe("code");
|
||||
expect(getNetworkIcon("devto")).toBe("code");
|
||||
});
|
||||
|
||||
it("matches Dribbble", () => {
|
||||
expect(getNetworkIcon("dribbble")).toBe("dribbble-logo");
|
||||
});
|
||||
|
||||
it("matches Behance", () => {
|
||||
expect(getNetworkIcon("behance")).toBe("behance-logo");
|
||||
});
|
||||
|
||||
it("matches GitLab to 'git-branch'", () => {
|
||||
expect(getNetworkIcon("gitlab")).toBe("git-branch");
|
||||
});
|
||||
|
||||
it("matches Bitbucket and CodePen to 'code'", () => {
|
||||
expect(getNetworkIcon("bitbucket")).toBe("code");
|
||||
expect(getNetworkIcon("codepen")).toBe("code");
|
||||
});
|
||||
|
||||
it("matches when keyword is substring of input", () => {
|
||||
expect(getNetworkIcon("My GitHub Profile")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("returns first matching icon (priority order)", () => {
|
||||
// "github" comes before "x" in the map, so github wins
|
||||
// (and this is also how it should behave for ambiguous strings)
|
||||
expect(getNetworkIcon("xgithub")).toBe("github-logo");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { IconName } from "@reactive-resume/schema/icons";
|
||||
|
||||
const NETWORK_ICON_MAP: Array<{ match: string[]; icon: IconName }> = [
|
||||
{ match: ["github"], icon: "github-logo" },
|
||||
{ match: ["linkedin"], icon: "linkedin-logo" },
|
||||
{ match: ["twitter", "x", "x.com"], icon: "twitter-logo" },
|
||||
{ match: ["facebook"], icon: "facebook-logo" },
|
||||
{ match: ["instagram"], icon: "instagram-logo" },
|
||||
{ match: ["youtube"], icon: "youtube-logo" },
|
||||
{ match: ["stackoverflow", "stack-overflow"], icon: "stack-overflow-logo" },
|
||||
{ match: ["medium"], icon: "medium-logo" },
|
||||
{ match: ["dev.to", "devto"], icon: "code" },
|
||||
{ match: ["dribbble"], icon: "dribbble-logo" },
|
||||
{ match: ["behance"], icon: "behance-logo" },
|
||||
{ match: ["gitlab"], icon: "git-branch" },
|
||||
{ match: ["bitbucket", "codepen"], icon: "code" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a social network name to a Phosphor icon name.
|
||||
* Returns "star" as default if no match is found.
|
||||
*/
|
||||
export function getNetworkIcon(network?: string): IconName {
|
||||
if (!network) return "star";
|
||||
|
||||
const networkLower = network.toLowerCase();
|
||||
|
||||
for (const entry of NETWORK_ICON_MAP) {
|
||||
if (entry.match.some((keyword) => networkLower.includes(keyword))) {
|
||||
return entry.icon;
|
||||
}
|
||||
}
|
||||
|
||||
return "star";
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Document } from "docx";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { buildDocument } from "./builder";
|
||||
|
||||
describe("buildDocument", () => {
|
||||
it("returns a docx Document instance for the default resume", () => {
|
||||
const doc = buildDocument(defaultResumeData);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("returns a docx Document instance for the sample resume", () => {
|
||||
const doc = buildDocument(sampleResumeData);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("handles a resume with no layout pages without throwing", () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.layout.pages = [];
|
||||
|
||||
expect(() => buildDocument(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it("supports both a4 and letter page formats", () => {
|
||||
const a4Data = structuredClone(defaultResumeData);
|
||||
a4Data.metadata.page.format = "a4";
|
||||
expect(buildDocument(a4Data)).toBeInstanceOf(Document);
|
||||
|
||||
const letterData = structuredClone(defaultResumeData);
|
||||
letterData.metadata.page.format = "letter";
|
||||
expect(buildDocument(letterData)).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("handles a full-width single page layout", () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.layout.pages = [
|
||||
{
|
||||
fullWidth: true,
|
||||
main: ["experience", "education"],
|
||||
sidebar: [],
|
||||
},
|
||||
];
|
||||
expect(() => buildDocument(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles a sidebar layout (main + sidebar split)", () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.layout.pages = [
|
||||
{
|
||||
fullWidth: false,
|
||||
main: ["experience", "education"],
|
||||
sidebar: ["skills", "languages"],
|
||||
},
|
||||
];
|
||||
expect(() => buildDocument(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to default colors when the primary color is unparseable", () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.design.colors.primary = "not-a-color";
|
||||
expect(() => buildDocument(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to a default font when fontFamily is empty", () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.typography.body.fontFamily = "";
|
||||
data.metadata.typography.heading.fontFamily = "";
|
||||
expect(() => buildDocument(data)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,526 +0,0 @@
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import {
|
||||
BorderStyle,
|
||||
convertMillimetersToTwip,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import { parseColorString } from "@reactive-resume/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) ---
|
||||
|
||||
interface PageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE: PageSize = { width: 210, height: 297 };
|
||||
|
||||
const PAGE_SIZES = {
|
||||
a4: DEFAULT_PAGE_SIZE,
|
||||
letter: { width: 215.9, height: 279.4 },
|
||||
} satisfies Record<string, PageSize>;
|
||||
|
||||
type DocxPageFormat = keyof typeof PAGE_SIZES;
|
||||
|
||||
const resolveDocxPageFormat = (format: ResumeData["metadata"]["page"]["format"]): DocxPageFormat => {
|
||||
if (format === "letter") return "letter";
|
||||
|
||||
return "a4";
|
||||
};
|
||||
|
||||
// --- 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" | "none";
|
||||
/** 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<Template, 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" },
|
||||
meowth: { sidebarSide: "left", 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" },
|
||||
scizor: { sidebarSide: "left", 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" | "none",
|
||||
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 margins: { right?: number; left?: number } = {};
|
||||
|
||||
if (sidebarSide === "left") {
|
||||
margins.right = gapXTwips;
|
||||
} else if (sidebarSide === "right") {
|
||||
margins.left = gapXTwips;
|
||||
}
|
||||
|
||||
const sidebarCell = new TableCell({
|
||||
width: { size: sidebarWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins,
|
||||
children: sidebarChildren,
|
||||
...(sidebarShading ? { shading: sidebarShading } : {}),
|
||||
});
|
||||
|
||||
const mainCell = new TableCell({
|
||||
width: { size: mainWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins,
|
||||
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 fixed DOCX page format from `metadata.page`; free-form exports as A4
|
||||
* - 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[resolveDocxPageFormat(page.format)];
|
||||
// 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(bgColorHex !== "FFFFFF" ? { background: { color: bgColorHex } } : {}),
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
size: {
|
||||
width: convertMillimetersToTwip(pageSize.width),
|
||||
height: convertMillimetersToTwip(pageSize.height),
|
||||
},
|
||||
margin: {
|
||||
top: marginYTwips,
|
||||
bottom: marginYTwips,
|
||||
left: marginXTwips,
|
||||
right: marginXTwips,
|
||||
},
|
||||
},
|
||||
},
|
||||
children: documentChildren,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlToParagraphs } from "./html-to-docx";
|
||||
|
||||
describe("htmlToParagraphs", () => {
|
||||
it("returns [] for empty / whitespace-only input", () => {
|
||||
expect(htmlToParagraphs("")).toEqual([]);
|
||||
expect(htmlToParagraphs(" \n ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns at least one paragraph for a simple <p> element", () => {
|
||||
const result = htmlToParagraphs("<p>Hello world</p>");
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("converts plain text nodes at the body root into a paragraph", () => {
|
||||
const result = htmlToParagraphs("Just some plain text");
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits separate paragraphs for multiple top-level blocks", () => {
|
||||
const result = htmlToParagraphs("<p>One</p><p>Two</p><p>Three</p>");
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("treats <h1>..<h6> as block-level paragraphs (one per heading)", () => {
|
||||
const result = htmlToParagraphs("<h1>A</h1><h2>B</h2><h3>C</h3>");
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders nested <strong>, <em>, <u>, <s> inline styling without throwing", () => {
|
||||
const result = htmlToParagraphs("<p><strong>Bold</strong> <em>italic</em> <u>under</u> <s>strike</s></p>");
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders links and lists without throwing", () => {
|
||||
const html = '<p><a href="https://example.com">link</a></p><ul><li>One</li><li>Two</li></ul>';
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("accepts custom font + size config without throwing", () => {
|
||||
const result = htmlToParagraphs("<p>Hello</p>", {
|
||||
font: "Roboto",
|
||||
size: 22,
|
||||
color: "111111",
|
||||
linkColor: "0563C1",
|
||||
});
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores HTML comments and script/style tags at the root", () => {
|
||||
// Comments at root and script tags don't add paragraph output.
|
||||
const result = htmlToParagraphs("<!--c--><script>x</script><p>Hello</p>");
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
import type { IShadingAttributesProperties, ISpacingProperties } from "docx";
|
||||
import { ExternalHyperlink, HeadingLevel, Paragraph, TextRun } from "docx";
|
||||
import { parseColorString } from "@reactive-resume/utils/color";
|
||||
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;
|
||||
type NumberingOptions = { reference: string; level: number; instance?: number };
|
||||
|
||||
/** 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 toDocxColorValue(value: string) {
|
||||
const rgba = parseColorString(value);
|
||||
if (!rgba) return null;
|
||||
|
||||
return [rgba.r, rgba.g, rgba.b].map((channel) => channel.toString(16).padStart(2, "0").toUpperCase()).join("");
|
||||
}
|
||||
|
||||
function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): 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;
|
||||
}
|
||||
|
||||
const colorValue = (element as HTMLElement | undefined)?.style.color;
|
||||
if (colorValue) {
|
||||
const color = toDocxColorValue(colorValue);
|
||||
if (color) next.color = color;
|
||||
}
|
||||
|
||||
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 HTMLElement;
|
||||
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, el);
|
||||
children.push(...collectInlineChildren(el, merged));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function getNumberingOptions(reference: string, level: number, instance?: number): NumberingOptions {
|
||||
return instance === undefined ? { reference, level } : { reference, level, instance };
|
||||
}
|
||||
|
||||
function processBlockElement(
|
||||
el: HTMLElement,
|
||||
style: InlineStyle,
|
||||
paragraphs: Paragraph[],
|
||||
listLevel?: number,
|
||||
numberingRef?: string,
|
||||
listIndex?: number,
|
||||
): void {
|
||||
const tag = el.tagName;
|
||||
const mergedStyle = mergeStyle(style, tag, el);
|
||||
|
||||
if (HEADING_MAP[tag]) {
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "P" || tag === "DIV") {
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(listLevel != null && numberingRef
|
||||
? { numbering: getNumberingOptions(numberingRef, listLevel, 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, ...mergedStyle })],
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: getNumberingOptions(numberingRef, level, listIndex) }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level, numberingRef, listIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inlineChildren = collectInlineChildren(li, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: getNumberingOptions(numberingRef, level, listIndex) }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "BLOCKQUOTE") {
|
||||
const indent: ISpacingProperties = {};
|
||||
const inlineChildren = collectInlineChildren(el, { ...mergedStyle, 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", ...mergedStyle })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "HR") {
|
||||
paragraphs.push(new Paragraph({ children: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "LI") {
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
bullet: { level: listLevel ?? 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: treat as inline container
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
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?.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 HTMLElement, baseStyle, paragraphs);
|
||||
}
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { buildDocx } from "./index";
|
||||
|
||||
describe("buildDocx", () => {
|
||||
it("returns a Blob for the default resume", async () => {
|
||||
const blob = await buildDocx(defaultResumeData);
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("produces a non-empty document for a populated resume", async () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.basics.name = "Jane Doe";
|
||||
data.basics.headline = "Engineer";
|
||||
data.basics.email = "jane@example.com";
|
||||
|
||||
const blob = await buildDocx(data);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { Packer } from "docx";
|
||||
import { buildDocument } from "./builder";
|
||||
|
||||
/** Builds a DOCX file from resume data and returns it as a Blob. */
|
||||
export async function buildDocx(data: ResumeData): Promise<Blob> {
|
||||
const doc = buildDocument(data);
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
describe("toSafeDocxLink", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(toSafeDocxLink("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(toSafeDocxLink(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("accepts http URLs", () => {
|
||||
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("preserves path and query", () => {
|
||||
expect(toSafeDocxLink("https://example.com/p?a=1")).toBe("https://example.com/p?a=1");
|
||||
});
|
||||
|
||||
it("returns mailto: for valid email", () => {
|
||||
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("returns null for empty mailto:", () => {
|
||||
expect(toSafeDocxLink("mailto:")).toBeNull();
|
||||
expect(toSafeDocxLink("mailto: ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims email body within mailto:", () => {
|
||||
expect(toSafeDocxLink("mailto: user@example.com ")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("rejects javascript: protocol", () => {
|
||||
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects data: protocol", () => {
|
||||
expect(toSafeDocxLink("data:text/html,<script>alert(1)</script>")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects file: protocol", () => {
|
||||
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(toSafeDocxLink("not a url at all")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects ftp: protocol", () => {
|
||||
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { CustomSection, ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
||||
|
||||
const baseConfig = {
|
||||
headingFont: "Inter",
|
||||
headingSizeHalfPt: 28,
|
||||
bodyFont: "Inter",
|
||||
bodySizeHalfPt: 20,
|
||||
textColorHex: "111111",
|
||||
primaryColorHex: "0563C1",
|
||||
};
|
||||
|
||||
setRenderConfig(baseConfig);
|
||||
|
||||
const HEX = "#0563C1";
|
||||
|
||||
describe("renderSummary", () => {
|
||||
it("returns [] when the section is hidden", () => {
|
||||
const summary: ResumeData["summary"] = {
|
||||
title: "Summary",
|
||||
content: "<p>Hello</p>",
|
||||
hidden: true,
|
||||
columns: 1,
|
||||
};
|
||||
expect(renderSummary(summary, HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when content is empty", () => {
|
||||
const summary: ResumeData["summary"] = {
|
||||
title: "Summary",
|
||||
content: "",
|
||||
hidden: false,
|
||||
columns: 1,
|
||||
};
|
||||
expect(renderSummary(summary, HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes a heading paragraph when both title and content are present", () => {
|
||||
const summary: ResumeData["summary"] = {
|
||||
title: "Summary",
|
||||
content: "<p>Hello world</p>",
|
||||
hidden: false,
|
||||
columns: 1,
|
||||
};
|
||||
const paragraphs = renderSummary(summary, HEX);
|
||||
// One heading + the htmlToParagraphs output for one <p>.
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("omits the heading when title is empty but still renders content", () => {
|
||||
const summary: ResumeData["summary"] = {
|
||||
title: "",
|
||||
content: "<p>Hello world</p>",
|
||||
hidden: false,
|
||||
columns: 1,
|
||||
};
|
||||
const paragraphs = renderSummary(summary, HEX);
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
const emptySection = <T extends SectionType>(_type: T): ResumeData["sections"][T] =>
|
||||
({
|
||||
title: "Section",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
}) as ResumeData["sections"][T];
|
||||
|
||||
describe("renderBuiltInSection", () => {
|
||||
it("returns [] when the section has no items", () => {
|
||||
expect(renderBuiltInSection("experience", emptySection("experience"), HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when section.hidden is true", () => {
|
||||
const section = { ...emptySection("experience"), hidden: true };
|
||||
expect(renderBuiltInSection("experience", section, HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for an unknown section type", () => {
|
||||
expect(renderBuiltInSection("not-a-section" as never, emptySection("experience"), HEX)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCustomSection", () => {
|
||||
const baseCustom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "summary",
|
||||
title: "Notes",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
it("returns [] when the custom section is hidden", () => {
|
||||
expect(renderCustomSection({ ...baseCustom, hidden: true }, HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when all items are hidden or empty", () => {
|
||||
const section: CustomSection = {
|
||||
...baseCustom,
|
||||
items: [{ id: "x", hidden: true } as never],
|
||||
};
|
||||
expect(renderCustomSection(section, HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for an unknown custom section type", () => {
|
||||
const section: CustomSection = {
|
||||
...baseCustom,
|
||||
type: "no-such-type" as never,
|
||||
items: [{ id: "x", hidden: false, content: "<p>x</p>" } as never],
|
||||
};
|
||||
expect(renderCustomSection(section, HEX)).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders content for a summary-type custom section", () => {
|
||||
const section: CustomSection = {
|
||||
...baseCustom,
|
||||
type: "summary",
|
||||
items: [{ id: "x", hidden: false, content: "<p>Hello</p>" } as never],
|
||||
};
|
||||
const paragraphs = renderCustomSection(section, HEX);
|
||||
// 1 heading + at least one paragraph for the HTML
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders recipient + content for a cover-letter custom section", () => {
|
||||
const section: CustomSection = {
|
||||
...baseCustom,
|
||||
type: "cover-letter",
|
||||
items: [
|
||||
{
|
||||
id: "x",
|
||||
hidden: false,
|
||||
recipient: "<p>Dear Jane,</p>",
|
||||
content: "<p>Body</p>",
|
||||
} as never,
|
||||
],
|
||||
};
|
||||
const paragraphs = renderCustomSection(section, HEX);
|
||||
// 1 heading + at least two paragraphs (recipient + body)
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRenderConfig", () => {
|
||||
it("can be called repeatedly with a different config (no throw)", () => {
|
||||
expect(() => setRenderConfig({ ...baseConfig, headingFont: "Roboto", primaryColorHex: "ff0000" })).not.toThrow();
|
||||
|
||||
// Restore for any later tests in the file
|
||||
setRenderConfig(baseConfig);
|
||||
});
|
||||
});
|
||||
@@ -1,559 +0,0 @@
|
||||
import type { CustomSection, CustomSectionType, ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { HtmlStyleConfig } from "./html-to-docx";
|
||||
import { BorderStyle, ExternalHyperlink, HeadingLevel, Paragraph, TabStopPosition, TabStopType, TextRun } from "docx";
|
||||
import { 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 [];
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { applyResumePatches, createResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
|
||||
|
||||
describe("jsonPatchOperationSchema", () => {
|
||||
it("validates add op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo", value: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires value for add", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates remove op without value", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "remove", path: "/foo" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates replace op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "replace", path: "/foo", value: 2 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates move op with from", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a", from: "/b" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires from for move", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates copy op with from", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "copy", path: "/a", from: "/b" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates test op with value", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "test", path: "/a", value: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "swap", path: "/a", value: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("permits any value type for add (unknown)", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/x", value: { nested: { array: [1] } } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResumePatches", () => {
|
||||
it("returns empty array when documents are equal", () => {
|
||||
const patches = createResumePatches(defaultResumeData, defaultResumeData);
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a replace op when a scalar field changes", () => {
|
||||
const next = { ...defaultResumeData, basics: { ...defaultResumeData.basics, name: "Alice" } };
|
||||
const patches = createResumePatches(defaultResumeData, next);
|
||||
|
||||
expect(patches.some((p) => p.op === "replace" && p.path === "/basics/name")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures multiple changes in distinct operations", () => {
|
||||
const next = {
|
||||
...defaultResumeData,
|
||||
basics: { ...defaultResumeData.basics, name: "Alice", email: "alice@example.com" },
|
||||
};
|
||||
const patches = createResumePatches(defaultResumeData, next);
|
||||
|
||||
expect(patches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResumePatches", () => {
|
||||
it("applies a single replace op", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
// Original is preserved (not mutated)
|
||||
expect(defaultResumeData.basics.name).toBe("");
|
||||
});
|
||||
|
||||
it("does not mutate the input data", () => {
|
||||
const before = JSON.stringify(defaultResumeData);
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Bob" }]);
|
||||
expect(JSON.stringify(defaultResumeData)).toBe(before);
|
||||
});
|
||||
|
||||
it("applies multiple ops in sequence", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [
|
||||
{ op: "replace", path: "/basics/name", value: "Alice" },
|
||||
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
expect(result.basics.email).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for unresolvable path", () => {
|
||||
expect(() =>
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/does/not/exist", value: "x" }]),
|
||||
).toThrow(ResumePatchError);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for failed test op", () => {
|
||||
try {
|
||||
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong-value" }]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResumePatchError);
|
||||
const patchError = error as ResumePatchError;
|
||||
expect(patchError.code).toBe("TEST_OPERATION_FAILED");
|
||||
expect(patchError.index).toBe(0);
|
||||
expect(patchError.message).toContain("Test operation failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the failing operation in the error", () => {
|
||||
try {
|
||||
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong" }]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as ResumePatchError).operation).toEqual({
|
||||
op: "test",
|
||||
path: "/basics/name",
|
||||
value: "wrong",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back if patched document fails schema validation", () => {
|
||||
// Setting picture.size to a value outside schema bounds (min 32, max 512)
|
||||
expect(() =>
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/picture/size", value: 9999 }]),
|
||||
).toThrow(/Patch produced invalid resume data/);
|
||||
});
|
||||
|
||||
it("supports adding to arrays", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [
|
||||
{
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0]?.name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("supports remove operation on existing path", () => {
|
||||
// First add an item, then remove it
|
||||
const withItem = applyResumePatches(defaultResumeData, [
|
||||
{
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const removed = applyResumePatches(withItem, [{ op: "remove", path: "/sections/skills/items/0" }]);
|
||||
expect(removed.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResumePatchError", () => {
|
||||
it("captures all expected properties", () => {
|
||||
const error = new ResumePatchError("CODE", "msg", 2, { op: "test", path: "/x", value: 1 });
|
||||
expect(error.name).toBe("ResumePatchError");
|
||||
expect(error.code).toBe("CODE");
|
||||
expect(error.message).toBe("msg");
|
||||
expect(error.index).toBe(2);
|
||||
expect(error.operation).toEqual({ op: "test", path: "/x", value: 1 });
|
||||
});
|
||||
|
||||
it("is instanceof Error", () => {
|
||||
const error = new ResumePatchError("X", "y", 0, { op: "remove", path: "/x" });
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import jsonpatch, { type JsonPatchError, type Operation } from "fast-json-patch";
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
/**
|
||||
* A Zod schema that models JSON Patch (RFC 6902) operations as a discriminated union on `op`.
|
||||
* This ensures required fields (`value` for add/replace/test, `from` for move/copy) are
|
||||
* validated at the request boundary rather than failing later at the `fast-json-patch` layer.
|
||||
*/
|
||||
export const jsonPatchOperationSchema = z.discriminatedUnion("op", [
|
||||
z.object({ op: z.literal("add"), path: z.string(), value: z.unknown() }),
|
||||
z.object({ op: z.literal("remove"), path: z.string() }),
|
||||
z.object({ op: z.literal("replace"), path: z.string(), value: z.unknown() }),
|
||||
z.object({ op: z.literal("move"), path: z.string(), from: z.string() }),
|
||||
z.object({ op: z.literal("copy"), path: z.string(), from: z.string() }),
|
||||
z.object({ op: z.literal("test"), path: z.string(), value: z.unknown() }),
|
||||
]);
|
||||
|
||||
export type JsonPatchOperation = z.infer<typeof jsonPatchOperationSchema>;
|
||||
|
||||
export function createResumePatches(previous: ResumeData, next: ResumeData): JsonPatchOperation[] {
|
||||
return z.array(jsonPatchOperationSchema).parse(jsonpatch.compare(previous, next));
|
||||
}
|
||||
|
||||
/**
|
||||
* A structured error thrown when a JSON Patch operation fails.
|
||||
* Contains only the relevant details -- never the full document tree.
|
||||
*/
|
||||
export class ResumePatchError extends Error {
|
||||
/** The error code from `fast-json-patch`, e.g. `TEST_OPERATION_FAILED`. */
|
||||
code: string;
|
||||
/** The zero-based index of the failing operation in the operations array. */
|
||||
index: number;
|
||||
/** The operation object that caused the failure. */
|
||||
operation: Operation;
|
||||
|
||||
constructor(code: string, message: string, index: number, operation: Operation) {
|
||||
super(message);
|
||||
this.name = "ResumePatchError";
|
||||
this.code = code;
|
||||
this.index = index;
|
||||
this.operation = operation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable messages for each `fast-json-patch` error code.
|
||||
* These are returned to the API consumer instead of the raw library output.
|
||||
*/
|
||||
const patchErrorMessages: Record<string, string> = {
|
||||
SEQUENCE_NOT_AN_ARRAY: "Patch sequence must be an array.",
|
||||
OPERATION_NOT_AN_OBJECT: "Operation is not an object.",
|
||||
OPERATION_OP_INVALID: "Operation `op` property is not one of the operations defined in RFC 6902.",
|
||||
OPERATION_PATH_INVALID: "Operation `path` property is not a valid JSON Pointer string.",
|
||||
OPERATION_FROM_REQUIRED: "Operation `from` property is required for `move` and `copy` operations.",
|
||||
OPERATION_VALUE_REQUIRED: "Operation `value` property is required for `add`, `replace`, and `test` operations.",
|
||||
OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED:
|
||||
"Operation `value` contains an `undefined` value, which is not valid in JSON.",
|
||||
OPERATION_PATH_CANNOT_ADD: "Cannot perform an `add` operation at the desired path.",
|
||||
OPERATION_PATH_UNRESOLVABLE: "Cannot perform the operation at a path that does not exist.",
|
||||
OPERATION_FROM_UNRESOLVABLE: "Cannot perform the operation from a path that does not exist.",
|
||||
OPERATION_PATH_ILLEGAL_ARRAY_INDEX: "Array index in path must be an unsigned base-10 integer.",
|
||||
OPERATION_VALUE_OUT_OF_BOUNDS: "The specified array index is greater than the number of elements in the array.",
|
||||
TEST_OPERATION_FAILED: "Test operation failed -- the value at the given path did not match the expected value.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether an error is a `JsonPatchError` from `fast-json-patch`.
|
||||
* The library doesn't export the class directly, so we duck-type it.
|
||||
*/
|
||||
function isJsonPatchError(error: unknown): error is JsonPatchError {
|
||||
return error instanceof Error && "index" in error && "operation" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a `JsonPatchError` into a clean `ResumePatchError` that omits the document tree.
|
||||
*/
|
||||
function toResumePatchError(error: JsonPatchError): ResumePatchError {
|
||||
const code = error.name;
|
||||
const message = patchErrorMessages[code] ?? error.message;
|
||||
const index = error.index ?? 0;
|
||||
const operation = error.operation as Operation;
|
||||
|
||||
return new ResumePatchError(code, message, index, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an array of JSON Patch (RFC 6902) operations to a `ResumeData` object.
|
||||
*
|
||||
* This function validates the operations before applying them, then validates the
|
||||
* resulting document against the `resumeDataSchema` to ensure the patched data is
|
||||
* still a valid resume.
|
||||
*
|
||||
* The original `data` object is not mutated; a deep clone is created internally.
|
||||
*
|
||||
* @see https://docs.rxresu.me/guides/using-the-patch-api — for usage examples and API details.
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6902 — JSON Patch specification.
|
||||
*
|
||||
* @param data - The current resume data to patch.
|
||||
* @param operations - An array of JSON Patch operations to apply.
|
||||
* @returns The patched and validated `ResumeData` object.
|
||||
* @throws {ResumePatchError} If the operations are structurally invalid or target non-existent paths.
|
||||
* @throws {ResumePatchError} If a `test` operation does not match.
|
||||
* @throws {Error} If the patched document does not conform to the `ResumeData` schema.
|
||||
*/
|
||||
export function applyResumePatches(data: ResumeData, operations: Operation[]): ResumeData {
|
||||
// Validate operations structurally before applying.
|
||||
const validationError = jsonpatch.validate(operations, data);
|
||||
if (validationError) throw toResumePatchError(validationError);
|
||||
|
||||
// Apply operations. applyPatch throws on `test` failures.
|
||||
let patched: ResumeData;
|
||||
|
||||
try {
|
||||
const result = jsonpatch.applyPatch(data, operations, false, false);
|
||||
patched = result.newDocument;
|
||||
} catch (error: unknown) {
|
||||
if (isJsonPatchError(error)) throw toResumePatchError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate the result still conforms to ResumeData.
|
||||
const parsed = resumeDataSchema.safeParse(patched);
|
||||
if (!parsed.success) throw new Error(`Patch produced invalid resume data: ${parsed.error.message}`);
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -320,53 +320,58 @@ describe("isAllowedExternalUrl", () => {
|
||||
|
||||
describe("isAllowedOAuthRedirectUri", () => {
|
||||
const trustedOrigins = ["https://app.example.com"];
|
||||
const allowed = new Set(["api.example.com"]);
|
||||
|
||||
it("returns false for malformed URI", () => {
|
||||
expect(isAllowedOAuthRedirectUri("nope", trustedOrigins, allowed)).toBe(false);
|
||||
expect(isAllowedOAuthRedirectUri("nope", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when credentials present", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://u:p@app.example.com", trustedOrigins, allowed)).toBe(false);
|
||||
expect(isAllowedOAuthRedirectUri("https://u:p@app.example.com", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when fragment present", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb#x", trustedOrigins, allowed)).toBe(false);
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb#x", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows http for loopback (localhost)", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://localhost:3000/cb", trustedOrigins, allowed)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("http://localhost:3000/cb", trustedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows http for 127.0.0.1", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://127.0.0.1/cb", trustedOrigins, allowed)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("http://127.0.0.1/cb", trustedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows http for IPv6 loopback", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://[::1]/cb", trustedOrigins, allowed)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("http://[::1]/cb", trustedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http for non-loopback hosts", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://example.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
expect(isAllowedOAuthRedirectUri("http://example.com/cb", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-https/non-http protocols", () => {
|
||||
expect(isAllowedOAuthRedirectUri("ftp://example.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
expect(isAllowedOAuthRedirectUri("ftp://example.com/cb", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects https with private/loopback host", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://192.168.1.1/cb", trustedOrigins, allowed)).toBe(false);
|
||||
expect(isAllowedOAuthRedirectUri("https://192.168.1.1/cb", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches trusted origins", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb", trustedOrigins, allowed)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb", trustedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against allowed origins set", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://api.example.com/cb", trustedOrigins, allowed)).toBe(true);
|
||||
it("rejects public https hosts outside trusted origins", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://api.example.com/cb", trustedOrigins)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-allowed public host", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://evil.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
it("allows any parseable URI when unsafe mode is enabled", () => {
|
||||
const options = { allowUnsafe: true };
|
||||
|
||||
expect(isAllowedOAuthRedirectUri("myapp://callback", trustedOrigins, options)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("http://example.com/cb", trustedOrigins, options)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("https://192.168.1.1/cb", trustedOrigins, options)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("https://u:p@app.example.com/cb#x", trustedOrigins, options)).toBe(true);
|
||||
expect(isAllowedOAuthRedirectUri("not a url", trustedOrigins, options)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,9 +205,14 @@ export function isAllowedExternalUrl(input: string, allowedHosts: Set<string>) {
|
||||
return allowedHosts.has(origin);
|
||||
}
|
||||
|
||||
export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[], allowedHosts: Set<string>) {
|
||||
type OAuthRedirectUriOptions = {
|
||||
allowUnsafe?: boolean;
|
||||
};
|
||||
|
||||
export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[], options?: OAuthRedirectUriOptions) {
|
||||
const parsed = parseUrl(input);
|
||||
if (!parsed) return false;
|
||||
if (options?.allowUnsafe) return true;
|
||||
if (parsed.username || parsed.password) return false;
|
||||
if (parsed.hash) return false;
|
||||
|
||||
@@ -218,8 +223,5 @@ export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (isPrivateOrLoopbackHost(hostname)) return false;
|
||||
|
||||
if (trustedOrigins.includes(origin)) return true;
|
||||
if (allowedHosts.has(origin)) return true;
|
||||
|
||||
return allowedHosts.has(hostname);
|
||||
return trustedOrigins.includes(origin);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user