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:
Amruth Pillai
2026-05-19 13:14:21 +02:00
parent 5b1297fa2b
commit 62f8270b3e
518 changed files with 29398 additions and 26871 deletions
-89
View File
@@ -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");
});
});
-35
View File
@@ -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();
});
});
-526
View File
@@ -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);
});
});
-9
View File
@@ -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 [];
}
-205
View File
@@ -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);
});
});
-128
View File
@@ -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;
}
+20 -15
View File
@@ -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);
});
});
+7 -5
View File
@@ -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);
}