diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx
index 7d8d03ac8..718a13cac 100644
--- a/apps/web/src/dialogs/resume/import.tsx
+++ b/apps/web/src/dialogs/resume/import.tsx
@@ -9,9 +9,9 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
-import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
-import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json";
-import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json";
+import { parseJSONResume } from "@reactive-resume/import/json-resume";
+import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
+import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import {
@@ -167,21 +167,15 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
let data: ResumeData | undefined;
if (value.type === "json-resume-json") {
- const json = await value.file.text();
- const importer = new JSONResumeImporter();
- data = importer.parse(json);
+ data = parseJSONResume(await value.file.text());
}
if (value.type === "reactive-resume-json") {
- const json = await value.file.text();
- const importer = new ReactiveResumeJSONImporter();
- data = importer.parse(json);
+ data = parseReactiveResumeJSON(await value.file.text());
}
if (value.type === "reactive-resume-v4-json") {
- const json = await value.file.text();
- const importer = new ReactiveResumeV4JSONImporter();
- data = importer.parse(json);
+ data = parseReactiveResumeV4JSON(await value.file.text());
}
if (value.type === "pdf") {
diff --git a/packages/import/src/error.ts b/packages/import/src/error.ts
new file mode 100644
index 000000000..50fc68992
--- /dev/null
+++ b/packages/import/src/error.ts
@@ -0,0 +1,9 @@
+import { flattenError, ZodError } from "zod";
+
+/** Rethrows a ZodError as a serialized string error; re-throws other errors as-is. */
+export function rethrowAsImportError(error: unknown): never {
+ if (error instanceof ZodError) {
+ throw new Error(JSON.stringify(flattenError(error)));
+ }
+ throw error;
+}
diff --git a/packages/import/src/json-resume.test.ts b/packages/import/src/json-resume.test.ts
index 46ed4e91f..3e8a12019 100644
--- a/packages/import/src/json-resume.test.ts
+++ b/packages/import/src/json-resume.test.ts
@@ -1,22 +1,20 @@
// biome-ignore-all lint/style/noNonNullAssertion: These tests assert imported section lengths before inspecting the first item.
import { describe, expect, it } from "vitest";
-import { JSONResumeImporter } from "./json-resume";
+import { parseJSONResume } from "./json-resume";
-const importer = new JSONResumeImporter();
-
-describe("JSONResumeImporter.parse", () => {
+describe("parseJSONResume", () => {
it("throws when input is not valid JSON", () => {
- expect(() => importer.parse("not json")).toThrow();
+ expect(() => parseJSONResume("not json")).toThrow();
});
it("throws a serialized validation error for an obviously invalid shape", () => {
// Email field is validated; a non-email string should fail the loose schema.
const invalid = JSON.stringify({ basics: { email: "not-an-email" } });
- expect(() => importer.parse(invalid)).toThrow();
+ expect(() => parseJSONResume(invalid)).toThrow();
});
it("imports an empty JSON Resume into a baseline ResumeData", () => {
- const result = importer.parse("{}");
+ const result = parseJSONResume("{}");
// Defaults preserve a name field even when unset by input.
expect(typeof result.basics.name).toBe("string");
});
@@ -34,7 +32,7 @@ describe("JSONResumeImporter.parse", () => {
},
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
expect(result.basics.name).toBe("Jane Doe");
expect(result.basics.headline).toBe("Engineer");
expect(result.basics.email).toBe("jane@example.com");
@@ -62,7 +60,7 @@ describe("JSONResumeImporter.parse", () => {
],
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
expect(result.sections.experience.items).toHaveLength(1);
const item = result.sections.experience.items[0]!;
@@ -89,7 +87,7 @@ describe("JSONResumeImporter.parse", () => {
],
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
expect(result.sections.education.items).toHaveLength(1);
const edu = result.sections.education.items[0]!;
@@ -103,7 +101,7 @@ describe("JSONResumeImporter.parse", () => {
skills: [{ name: "TypeScript", level: "Master", keywords: ["node", "react"] }],
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
const skill = result.sections.skills.items[0]!;
expect(skill.name).toBe("TypeScript");
@@ -121,7 +119,7 @@ describe("JSONResumeImporter.parse", () => {
},
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
expect(result.sections.profiles.items).toHaveLength(1);
const profile = result.sections.profiles.items[0]!;
@@ -134,13 +132,13 @@ describe("JSONResumeImporter.parse", () => {
basics: { image: "https://example.com/pic.jpg" },
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
expect(result.picture.url).toBe("https://example.com/pic.jpg");
expect(result.picture.hidden).toBe(false);
});
it("leaves the summary content empty when basics.summary is absent", () => {
- const result = importer.parse(JSON.stringify({ basics: { name: "Jane" } }));
+ const result = parseJSONResume(JSON.stringify({ basics: { name: "Jane" } }));
expect(result.summary.content).toBe("");
});
@@ -159,7 +157,7 @@ describe("JSONResumeImporter.parse", () => {
],
});
- const result = importer.parse(json);
+ const result = parseJSONResume(json);
expect(result.sections.projects.items).toHaveLength(1);
const project = result.sections.projects.items[0]!;
diff --git a/packages/import/src/json-resume.tsx b/packages/import/src/json-resume.tsx
index dd3b5f8f7..9b050cdc9 100644
--- a/packages/import/src/json-resume.tsx
+++ b/packages/import/src/json-resume.tsx
@@ -1,11 +1,12 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
-import { flattenError, ZodError, z } from "zod";
+import { ZodError, z } from "zod";
import { getNetworkIcon } from "@reactive-resume/resume/icons";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { createUrl } from "@reactive-resume/utils/url";
import { formatPeriod, formatSingleDate } from "./date";
+import { rethrowAsImportError } from "./error";
import { arrayToHtmlList, toHtmlDescription } from "./html";
import { parseLevel } from "./level";
@@ -175,276 +176,271 @@ function formatLocation(location?: JSONResumeLocation): string {
return parts.join(", ");
}
-export class JSONResumeImporter {
- convert(jsonResume: JSONResume): ResumeData {
- const result: ResumeData = {
- ...defaultResumeData,
+// ponytail: stateless two-method class → two plain functions
+function convertJSONResume(jsonResume: JSONResume): ResumeData {
+ const result: ResumeData = {
+ ...defaultResumeData,
+ };
+
+ // Map basics
+ if (jsonResume.basics) {
+ const basics = jsonResume.basics;
+ result.basics = {
+ name: basics.name || "",
+ headline: basics.label || "",
+ email: basics.email || "",
+ phone: basics.phone || "",
+ location: basics.location ? formatLocation(basics.location) : "",
+ website: createUrl(basics.url),
+ customFields: [],
};
- // Map basics
- if (jsonResume.basics) {
- const basics = jsonResume.basics;
- result.basics = {
- name: basics.name || "",
- headline: basics.label || "",
- email: basics.email || "",
- phone: basics.phone || "",
- location: basics.location ? formatLocation(basics.location) : "",
- website: createUrl(basics.url),
- customFields: [],
- };
-
- // Map image to picture
- if (basics.image) {
- result.picture = {
- ...defaultResumeData.picture,
- url: basics.image,
- hidden: false,
- };
- }
- }
-
- // Map summary
- if (jsonResume.basics?.summary) {
- result.summary = {
- ...defaultResumeData.summary,
- content: `
${jsonResume.basics.summary}
`,
+ // Map image to picture
+ if (basics.image) {
+ result.picture = {
+ ...defaultResumeData.picture,
+ url: basics.image,
hidden: false,
};
}
-
- // Map work to experience
- if (jsonResume.work && jsonResume.work.length > 0) {
- result.sections.experience = {
- ...defaultResumeData.sections.experience,
- items: jsonResume.work
- .filter((work) => work.name || work.position)
- .map((work) => ({
- id: generateId(),
- hidden: false,
- company: work.name || "",
- position: work.position || "",
- location: work.location || "",
- period: formatPeriod(work.startDate, work.endDate),
- website: createItemWebsite(work.url),
- roles: [],
- description: toHtmlDescription(work.summary, work.highlights),
- })),
- };
- }
-
- // Map education
- if (jsonResume.education && jsonResume.education.length > 0) {
- result.sections.education = {
- ...defaultResumeData.sections.education,
- items: jsonResume.education
- .filter((edu) => edu.institution)
- .map((edu) => ({
- id: generateId(),
- hidden: false,
- school: edu.institution || "",
- degree: [edu.studyType, edu.area].filter(Boolean).join(" in ") || "",
- area: edu.area || "",
- grade: edu.score || "",
- location: "",
- period: formatPeriod(edu.startDate, edu.endDate),
- website: createItemWebsite(edu.url),
- description: edu.courses && edu.courses.length > 0 ? arrayToHtmlList(edu.courses) : "",
- })),
- };
- }
-
- // Map projects
- if (jsonResume.projects && jsonResume.projects.length > 0) {
- result.sections.projects = {
- ...defaultResumeData.sections.projects,
- items: jsonResume.projects
- .filter((project) => project.name)
- .map((project) => ({
- id: generateId(),
- hidden: false,
- name: project.name || "",
- period: formatPeriod(project.startDate, project.endDate),
- website: createItemWebsite(project.url),
- description: toHtmlDescription(project.description, project.highlights),
- })),
- };
- }
-
- // Map skills
- if (jsonResume.skills && jsonResume.skills.length > 0) {
- result.sections.skills = {
- ...defaultResumeData.sections.skills,
- items: jsonResume.skills
- .filter((skill) => skill.name)
- .map((skill) => ({
- id: generateId(),
- hidden: false,
- icon: "star",
- iconColor: "",
- name: skill.name || "",
- proficiency: skill.level || "",
- level: parseLevel(skill.level),
- keywords: skill.keywords || [],
- })),
- };
- }
-
- // Map languages
- if (jsonResume.languages && jsonResume.languages.length > 0) {
- result.sections.languages = {
- ...defaultResumeData.sections.languages,
- items: jsonResume.languages
- .filter((lang) => lang.language)
- .map((lang) => ({
- id: generateId(),
- hidden: false,
- language: lang.language || "",
- fluency: lang.fluency || "",
- level: parseLevel(lang.fluency),
- })),
- };
- }
-
- // Map interests
- if (jsonResume.interests && jsonResume.interests.length > 0) {
- result.sections.interests = {
- ...defaultResumeData.sections.interests,
- items: jsonResume.interests
- .filter((interest) => interest.name)
- .map((interest) => ({
- id: generateId(),
- hidden: false,
- icon: "star",
- iconColor: "",
- name: interest.name || "",
- keywords: interest.keywords || [],
- })),
- };
- }
-
- // Map awards
- if (jsonResume.awards && jsonResume.awards.length > 0) {
- result.sections.awards = {
- ...defaultResumeData.sections.awards,
- items: jsonResume.awards
- .filter((award) => award.title)
- .map((award) => ({
- id: generateId(),
- hidden: false,
- title: award.title || "",
- awarder: award.awarder || "",
- date: formatSingleDate(award.date),
- website: createItemWebsite(),
- description: award.summary ? `${award.summary}
` : "",
- })),
- };
- }
-
- // Map certificates
- if (jsonResume.certificates && jsonResume.certificates.length > 0) {
- result.sections.certifications = {
- ...defaultResumeData.sections.certifications,
- items: jsonResume.certificates
- .filter((cert) => cert.name)
- .map((cert) => ({
- id: generateId(),
- hidden: false,
- title: cert.name || "",
- issuer: cert.issuer || "",
- date: formatSingleDate(cert.date),
- website: createItemWebsite(cert.url),
- description: "",
- })),
- };
- }
-
- // Map publications
- if (jsonResume.publications && jsonResume.publications.length > 0) {
- result.sections.publications = {
- ...defaultResumeData.sections.publications,
- items: jsonResume.publications
- .filter((pub) => pub.name)
- .map((pub) => ({
- id: generateId(),
- hidden: false,
- title: pub.name || "",
- publisher: pub.publisher || "",
- date: formatSingleDate(pub.releaseDate),
- website: createItemWebsite(pub.url),
- description: pub.summary ? `${pub.summary}
` : "",
- })),
- };
- }
-
- // Map volunteer
- if (jsonResume.volunteer && jsonResume.volunteer.length > 0) {
- result.sections.volunteer = {
- ...defaultResumeData.sections.volunteer,
- items: jsonResume.volunteer
- .filter((vol) => vol.organization)
- .map((vol) => ({
- id: generateId(),
- hidden: false,
- organization: vol.organization || "",
- location: "",
- period: formatPeriod(vol.startDate, vol.endDate),
- website: createItemWebsite(vol.url),
- description: toHtmlDescription(vol.summary, vol.highlights),
- })),
- };
- }
-
- // Map references
- if (jsonResume.references && jsonResume.references.length > 0) {
- result.sections.references = {
- ...defaultResumeData.sections.references,
- items: jsonResume.references
- .filter((ref) => ref.name || ref.reference)
- .map((ref) => ({
- id: generateId(),
- hidden: false,
- name: ref.name || "",
- position: "",
- website: createItemWebsite(),
- phone: "",
- description: ref.reference ? `${ref.reference}
` : "",
- })),
- };
- }
-
- // Map profiles (from basics.profiles) to profiles section
- if (jsonResume.basics?.profiles && jsonResume.basics.profiles.length > 0) {
- result.sections.profiles = {
- ...defaultResumeData.sections.profiles,
- items: jsonResume.basics.profiles
- .filter((profile) => profile.network)
- .map((profile) => ({
- id: generateId(),
- hidden: false,
- icon: getNetworkIcon(profile.network),
- iconColor: "",
- network: profile.network || "",
- username: profile.username || "",
- website: createItemWebsite(profile.url, profile.username || profile.network),
- })),
- };
- }
-
- return resumeDataSchema.parse(result);
}
- parse(json: string): ResumeData {
- try {
- const jsonResume = jsonResumeSchema.parse(JSON.parse(json));
- return this.convert(jsonResume);
- } catch (error) {
- if (error instanceof ZodError) {
- const errors = flattenError(error);
- throw new Error(JSON.stringify(errors));
- }
+ // Map summary
+ if (jsonResume.basics?.summary) {
+ result.summary = {
+ ...defaultResumeData.summary,
+ content: `${jsonResume.basics.summary}
`,
+ hidden: false,
+ };
+ }
- throw error;
- }
+ // Map work to experience
+ if (jsonResume.work && jsonResume.work.length > 0) {
+ result.sections.experience = {
+ ...defaultResumeData.sections.experience,
+ items: jsonResume.work
+ .filter((work) => work.name || work.position)
+ .map((work) => ({
+ id: generateId(),
+ hidden: false,
+ company: work.name || "",
+ position: work.position || "",
+ location: work.location || "",
+ period: formatPeriod(work.startDate, work.endDate),
+ website: createItemWebsite(work.url),
+ roles: [],
+ description: toHtmlDescription(work.summary, work.highlights),
+ })),
+ };
+ }
+
+ // Map education
+ if (jsonResume.education && jsonResume.education.length > 0) {
+ result.sections.education = {
+ ...defaultResumeData.sections.education,
+ items: jsonResume.education
+ .filter((edu) => edu.institution)
+ .map((edu) => ({
+ id: generateId(),
+ hidden: false,
+ school: edu.institution || "",
+ degree: [edu.studyType, edu.area].filter(Boolean).join(" in ") || "",
+ area: edu.area || "",
+ grade: edu.score || "",
+ location: "",
+ period: formatPeriod(edu.startDate, edu.endDate),
+ website: createItemWebsite(edu.url),
+ description: edu.courses && edu.courses.length > 0 ? arrayToHtmlList(edu.courses) : "",
+ })),
+ };
+ }
+
+ // Map projects
+ if (jsonResume.projects && jsonResume.projects.length > 0) {
+ result.sections.projects = {
+ ...defaultResumeData.sections.projects,
+ items: jsonResume.projects
+ .filter((project) => project.name)
+ .map((project) => ({
+ id: generateId(),
+ hidden: false,
+ name: project.name || "",
+ period: formatPeriod(project.startDate, project.endDate),
+ website: createItemWebsite(project.url),
+ description: toHtmlDescription(project.description, project.highlights),
+ })),
+ };
+ }
+
+ // Map skills
+ if (jsonResume.skills && jsonResume.skills.length > 0) {
+ result.sections.skills = {
+ ...defaultResumeData.sections.skills,
+ items: jsonResume.skills
+ .filter((skill) => skill.name)
+ .map((skill) => ({
+ id: generateId(),
+ hidden: false,
+ icon: "star",
+ iconColor: "",
+ name: skill.name || "",
+ proficiency: skill.level || "",
+ level: parseLevel(skill.level),
+ keywords: skill.keywords || [],
+ })),
+ };
+ }
+
+ // Map languages
+ if (jsonResume.languages && jsonResume.languages.length > 0) {
+ result.sections.languages = {
+ ...defaultResumeData.sections.languages,
+ items: jsonResume.languages
+ .filter((lang) => lang.language)
+ .map((lang) => ({
+ id: generateId(),
+ hidden: false,
+ language: lang.language || "",
+ fluency: lang.fluency || "",
+ level: parseLevel(lang.fluency),
+ })),
+ };
+ }
+
+ // Map interests
+ if (jsonResume.interests && jsonResume.interests.length > 0) {
+ result.sections.interests = {
+ ...defaultResumeData.sections.interests,
+ items: jsonResume.interests
+ .filter((interest) => interest.name)
+ .map((interest) => ({
+ id: generateId(),
+ hidden: false,
+ icon: "star",
+ iconColor: "",
+ name: interest.name || "",
+ keywords: interest.keywords || [],
+ })),
+ };
+ }
+
+ // Map awards
+ if (jsonResume.awards && jsonResume.awards.length > 0) {
+ result.sections.awards = {
+ ...defaultResumeData.sections.awards,
+ items: jsonResume.awards
+ .filter((award) => award.title)
+ .map((award) => ({
+ id: generateId(),
+ hidden: false,
+ title: award.title || "",
+ awarder: award.awarder || "",
+ date: formatSingleDate(award.date),
+ website: createItemWebsite(),
+ description: award.summary ? `${award.summary}
` : "",
+ })),
+ };
+ }
+
+ // Map certificates
+ if (jsonResume.certificates && jsonResume.certificates.length > 0) {
+ result.sections.certifications = {
+ ...defaultResumeData.sections.certifications,
+ items: jsonResume.certificates
+ .filter((cert) => cert.name)
+ .map((cert) => ({
+ id: generateId(),
+ hidden: false,
+ title: cert.name || "",
+ issuer: cert.issuer || "",
+ date: formatSingleDate(cert.date),
+ website: createItemWebsite(cert.url),
+ description: "",
+ })),
+ };
+ }
+
+ // Map publications
+ if (jsonResume.publications && jsonResume.publications.length > 0) {
+ result.sections.publications = {
+ ...defaultResumeData.sections.publications,
+ items: jsonResume.publications
+ .filter((pub) => pub.name)
+ .map((pub) => ({
+ id: generateId(),
+ hidden: false,
+ title: pub.name || "",
+ publisher: pub.publisher || "",
+ date: formatSingleDate(pub.releaseDate),
+ website: createItemWebsite(pub.url),
+ description: pub.summary ? `${pub.summary}
` : "",
+ })),
+ };
+ }
+
+ // Map volunteer
+ if (jsonResume.volunteer && jsonResume.volunteer.length > 0) {
+ result.sections.volunteer = {
+ ...defaultResumeData.sections.volunteer,
+ items: jsonResume.volunteer
+ .filter((vol) => vol.organization)
+ .map((vol) => ({
+ id: generateId(),
+ hidden: false,
+ organization: vol.organization || "",
+ location: "",
+ period: formatPeriod(vol.startDate, vol.endDate),
+ website: createItemWebsite(vol.url),
+ description: toHtmlDescription(vol.summary, vol.highlights),
+ })),
+ };
+ }
+
+ // Map references
+ if (jsonResume.references && jsonResume.references.length > 0) {
+ result.sections.references = {
+ ...defaultResumeData.sections.references,
+ items: jsonResume.references
+ .filter((ref) => ref.name || ref.reference)
+ .map((ref) => ({
+ id: generateId(),
+ hidden: false,
+ name: ref.name || "",
+ position: "",
+ website: createItemWebsite(),
+ phone: "",
+ description: ref.reference ? `${ref.reference}
` : "",
+ })),
+ };
+ }
+
+ // Map profiles (from basics.profiles) to profiles section
+ if (jsonResume.basics?.profiles && jsonResume.basics.profiles.length > 0) {
+ result.sections.profiles = {
+ ...defaultResumeData.sections.profiles,
+ items: jsonResume.basics.profiles
+ .filter((profile) => profile.network)
+ .map((profile) => ({
+ id: generateId(),
+ hidden: false,
+ icon: getNetworkIcon(profile.network),
+ iconColor: "",
+ network: profile.network || "",
+ username: profile.username || "",
+ website: createItemWebsite(profile.url, profile.username || profile.network),
+ })),
+ };
+ }
+
+ return resumeDataSchema.parse(result);
+}
+
+export function parseJSONResume(json: string): ResumeData {
+ try {
+ const jsonResume = jsonResumeSchema.parse(JSON.parse(json));
+ return convertJSONResume(jsonResume);
+ } catch (error) {
+ if (error instanceof ZodError) rethrowAsImportError(error);
+ throw error;
}
}
diff --git a/packages/import/src/reactive-resume-json.test.ts b/packages/import/src/reactive-resume-json.test.ts
index 8e9884874..dd8f1ce96 100644
--- a/packages/import/src/reactive-resume-json.test.ts
+++ b/packages/import/src/reactive-resume-json.test.ts
@@ -1,29 +1,27 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
-import { ReactiveResumeJSONImporter } from "./reactive-resume-json";
+import { parseReactiveResumeJSON } from "./reactive-resume-json";
-const importer = new ReactiveResumeJSONImporter();
-
-describe("ReactiveResumeJSONImporter", () => {
+describe("parseReactiveResumeJSON", () => {
it("round-trips the default resume data", () => {
- const result = importer.parse(JSON.stringify(defaultResumeData));
+ const result = parseReactiveResumeJSON(JSON.stringify(defaultResumeData));
expect(result.basics.name).toBe(defaultResumeData.basics.name);
});
it("throws a JSON-serialised validation error for an invalid object", () => {
// Missing required top-level fields.
- expect(() => importer.parse(JSON.stringify({ foo: "bar" }))).toThrow();
+ expect(() => parseReactiveResumeJSON(JSON.stringify({ foo: "bar" }))).toThrow();
});
it("throws when the input is not valid JSON", () => {
- expect(() => importer.parse("not-json")).toThrow();
+ expect(() => parseReactiveResumeJSON("not-json")).toThrow();
});
it("creates a default layout page when the imported data has no pages", () => {
const data = structuredClone(defaultResumeData);
data.metadata.layout.pages = [];
- const result = importer.parse(JSON.stringify(data));
+ const result = parseReactiveResumeJSON(JSON.stringify(data));
expect(result.metadata.layout.pages.length).toBe(1);
expect(result.metadata.layout.pages[0]?.main.length).toBeGreaterThan(0);
});
@@ -38,7 +36,7 @@ describe("ReactiveResumeJSONImporter", () => {
},
];
- const result = importer.parse(JSON.stringify(data));
+ const result = parseReactiveResumeJSON(JSON.stringify(data));
const firstPage = result.metadata.layout.pages[0];
expect(firstPage).toBeDefined();
const allIds = new Set([...(firstPage?.main ?? []), ...(firstPage?.sidebar ?? [])]);
@@ -53,7 +51,7 @@ describe("ReactiveResumeJSONImporter", () => {
});
it("does not modify the layout when every built-in section is already placed", () => {
- const result = importer.parse(JSON.stringify(defaultResumeData));
+ const result = parseReactiveResumeJSON(JSON.stringify(defaultResumeData));
expect(result.metadata.layout.pages.length).toBe(defaultResumeData.metadata.layout.pages.length);
});
});
diff --git a/packages/import/src/reactive-resume-json.tsx b/packages/import/src/reactive-resume-json.tsx
index 5a81e0044..1a6ac3523 100644
--- a/packages/import/src/reactive-resume-json.tsx
+++ b/packages/import/src/reactive-resume-json.tsx
@@ -1,6 +1,7 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
-import { flattenError, ZodError } from "zod";
+import { ZodError } from "zod";
import { resumeDataSchema, sectionTypeSchema } from "@reactive-resume/schema/resume/data";
+import { rethrowAsImportError } from "./error";
const BUILT_IN_LAYOUT_SECTION_IDS = sectionTypeSchema.options.filter((section) => section !== "cover-letter");
@@ -62,18 +63,13 @@ function normalizeBuiltInSectionsInLayout(data: ResumeData): ResumeData {
};
}
-export class ReactiveResumeJSONImporter {
- parse(json: string): ResumeData {
- try {
- const parsed = resumeDataSchema.parse(JSON.parse(json));
- return resumeDataSchema.parse(normalizeBuiltInSectionsInLayout(parsed));
- } catch (error) {
- if (error instanceof ZodError) {
- const errors = flattenError(error);
- throw new Error(JSON.stringify(errors));
- }
-
- throw error;
- }
+// ponytail: stateless single-method class → plain function
+export function parseReactiveResumeJSON(json: string): ResumeData {
+ try {
+ const parsed = resumeDataSchema.parse(JSON.parse(json));
+ return resumeDataSchema.parse(normalizeBuiltInSectionsInLayout(parsed));
+ } catch (error) {
+ if (error instanceof ZodError) rethrowAsImportError(error);
+ throw error;
}
}
diff --git a/packages/import/src/reactive-resume-v4-json.broad.test.ts b/packages/import/src/reactive-resume-v4-json.broad.test.ts
index 038703c00..64fc155d5 100644
--- a/packages/import/src/reactive-resume-v4-json.broad.test.ts
+++ b/packages/import/src/reactive-resume-v4-json.broad.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { ReactiveResumeV4JSONImporter } from "./reactive-resume-v4-json";
+import { parseReactiveResumeV4JSON } from "./reactive-resume-v4-json";
const baseV4 = () => ({
basics: {
@@ -205,11 +205,9 @@ const baseV4 = () => ({
},
});
-const importer = new ReactiveResumeV4JSONImporter();
-
-describe("ReactiveResumeV4JSONImporter — broad section mapping", () => {
+describe("parseReactiveResumeV4JSON — broad section mapping", () => {
const v4 = baseV4();
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
it("maps basics fields (name, headline, contact, website, customFields)", () => {
expect(result.basics.name).toBe("Jane Doe");
@@ -303,6 +301,6 @@ describe("ReactiveResumeV4JSONImporter — broad section mapping", () => {
});
it("invalid JSON throws", () => {
- expect(() => importer.parse("not json")).toThrow();
+ expect(() => parseReactiveResumeV4JSON("not json")).toThrow();
});
});
diff --git a/packages/import/src/reactive-resume-v4-json.test.ts b/packages/import/src/reactive-resume-v4-json.test.ts
index ad227cc04..ef9827ce3 100644
--- a/packages/import/src/reactive-resume-v4-json.test.ts
+++ b/packages/import/src/reactive-resume-v4-json.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { ReactiveResumeV4JSONImporter } from "./reactive-resume-v4-json";
+import { parseReactiveResumeV4JSON } from "./reactive-resume-v4-json";
// Minimal v4 resume JSON to use as base in all tests.
// V4 layout is Array>: [ [mainColumn, sidebarColumn] ]
@@ -124,11 +124,9 @@ function makeV4Base(overrides: Record = {}) {
};
}
-const importer = new ReactiveResumeV4JSONImporter();
-
// ─── Bug #2726-A: Custom section items without a name ("description-only") ───
-describe("ReactiveResumeV4JSONImporter – custom section description-only items", () => {
+describe("parseReactiveResumeV4JSON – custom section description-only items", () => {
it("puts description in the body (not the title) when item has no name", () => {
const v4 = makeV4Base({
custom: {
@@ -150,7 +148,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
},
});
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const section = result.customSections[0];
expect(section).toBeDefined();
@@ -184,7 +182,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
},
});
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const section = result.customSections[0];
const item = section?.items[0] as { company: string; position: string; description: string };
@@ -214,7 +212,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
},
});
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const item = result.customSections[0]?.items[0] as { description: string };
// summary takes priority over description
expect(item.description).toBe("Rich HTML content
");
@@ -241,7 +239,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
},
});
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const item = result.customSections[0]?.items[0] as { company: string; position: string; description: string };
expect(item.company).toBe("#1");
expect(item.position).toBe("");
@@ -268,7 +266,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
},
});
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const item = result.customSections[0]?.items[0] as { hidden: boolean };
// Should default to visible (hidden=false), consistent with all other sections
expect(item.hidden).toBe(false);
@@ -291,7 +289,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
},
});
- const result = importer.parse(JSON.stringify(v4));
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const section = result.customSections[0];
// Both items must migrate — hidden items must NOT be dropped
expect(section?.items).toHaveLength(2);
@@ -304,7 +302,7 @@ describe("ReactiveResumeV4JSONImporter – custom section description-only items
// ─── Bug #2726-B: Skill / Language levels incorrectly clamped to max ──────────
-describe("ReactiveResumeV4JSONImporter – skill level scaling (v4: 0-10 → v5: 0-5)", () => {
+describe("parseReactiveResumeV4JSON – skill level scaling (v4: 0-10 → v5: 0-5)", () => {
function makeWithSkills(items: Array<{ id: string; visible: boolean; name: string; level: number }>) {
return makeV4Base({
skills: {
@@ -319,54 +317,56 @@ describe("ReactiveResumeV4JSONImporter – skill level scaling (v4: 0-10 → v5:
}
it("scales level 10 → 5", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s1", visible: true, name: "TypeScript", level: 10 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(5);
});
it("scales level 8 → 4 (not 5)", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s2", visible: true, name: "React", level: 8 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(4);
});
it("scales level 6 → 3 (not 5)", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s3", visible: true, name: "GraphQL", level: 6 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(3);
});
it("scales level 5 → 3 (rounds 2.5 up)", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s4", visible: true, name: "Node", level: 5 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(3);
});
it("keeps level 0 → 0 (hides the visual indicator)", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s5", visible: true, name: "Rust", level: 0 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(0);
});
it("clamps negative level to 0", () => {
- const result = importer.parse(JSON.stringify(makeWithSkills([{ id: "s6", visible: true, name: "Go", level: -3 }])));
+ const result = parseReactiveResumeV4JSON(
+ JSON.stringify(makeWithSkills([{ id: "s6", visible: true, name: "Go", level: -3 }])),
+ );
expect(result.sections.skills.items[0]?.level).toBe(0);
});
it("clamps level above 10 to 5", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s7", visible: true, name: "C++", level: 15 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(5);
});
});
-describe("ReactiveResumeV4JSONImporter – language level scaling (v4: 0-10 → v5: 0-5)", () => {
+describe("parseReactiveResumeV4JSON – language level scaling (v4: 0-10 → v5: 0-5)", () => {
function makeWithLanguages(items: Array<{ id: string; visible: boolean; name: string; level: number }>) {
return makeV4Base({
languages: {
@@ -381,14 +381,14 @@ describe("ReactiveResumeV4JSONImporter – language level scaling (v4: 0-10 →
}
it("scales level 10 → 5", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithLanguages([{ id: "l1", visible: true, name: "Spanish", level: 10 }])),
);
expect(result.sections.languages.items[0]?.level).toBe(5);
});
it("scales level 6 → 3 (not 5)", () => {
- const result = importer.parse(
+ const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithLanguages([{ id: "l2", visible: true, name: "French", level: 6 }])),
);
expect(result.sections.languages.items[0]?.level).toBe(3);
diff --git a/packages/import/src/reactive-resume-v4-json.tsx b/packages/import/src/reactive-resume-v4-json.tsx
index 39db8ecb9..ac0e3a2df 100644
--- a/packages/import/src/reactive-resume-v4-json.tsx
+++ b/packages/import/src/reactive-resume-v4-json.tsx
@@ -1,10 +1,11 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
-import z, { flattenError, ZodError } from "zod";
+import z, { ZodError } from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { templateSchema } from "@reactive-resume/schema/templates";
import { parseColorString } from "@reactive-resume/utils/color";
import { generateId } from "@reactive-resume/utils/string";
+import { rethrowAsImportError } from "./error";
function colorToRgba(color: string): string {
const parsed = parseColorString(color);
@@ -12,28 +13,9 @@ function colorToRgba(color: string): string {
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${parsed.a})`;
}
+// ponytail: inlined single-caller wrappers; compile-time constants (rotation=0, sidebarWidth=35, shadow=0) replaced with literals
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
-const nonNegative = (value: number): number => Math.max(0, value);
-
-const pxToPt = (px: number): number => px * 0.75;
-
-const clampPictureSize = (size: number): number => clamp(size, 32, 512);
-
-const clampRotation = (rotation: number): number => clamp(rotation, 0, 360);
-
-const clampAspectRatio = (ratio: number): number => clamp(ratio, 0.5, 2.5);
-
-const clampBorderRadius = (radius: number): number => clamp(radius, 0, 100);
-
-const clampFontSize = (size: number): number => clamp(size, 6, 24);
-
-const clampLineHeight = (height: number): number => clamp(height, 0.5, 4);
-
-const clampSidebarWidth = (width: number): number => clamp(width, 10, 50);
-
-const convertAndClampFontSize = (px: number): number => clampFontSize(pxToPt(px));
-
const isValidEmail = (email: string): boolean => {
if (!email) return false;
return z.email().safeParse(email).success;
@@ -76,6 +58,17 @@ const convertFontVariantsForHeading = (variants: string[] | undefined): FontWeig
return filtered.length > 0 ? filtered : ["600"];
};
+// ponytail: V4Url + V4Section collapse 11× {label,href} and 13× 5-field section header into aliases
+type V4Url = { label: string; href: string };
+type V4Section = {
+ name: string;
+ columns: number;
+ separateLinks: boolean;
+ visible: boolean;
+ id: string;
+ items: Array;
+};
+
type V4ResumeData = {
basics: {
name: string;
@@ -83,304 +76,135 @@ type V4ResumeData = {
email: string;
phone: string;
location: string;
- url: {
- label: string;
- href: string;
- };
- customFields: Array<{
- id?: string;
- icon?: string;
- text?: string;
- }>;
+ url: V4Url;
+ customFields: Array<{ id?: string; icon?: string; text?: string }>;
picture: {
url: string;
size: number;
aspectRatio: number;
borderRadius: number;
- effects: {
- hidden: boolean;
- border: boolean;
- grayscale: boolean;
- };
+ effects: { hidden: boolean; border: boolean; grayscale: boolean };
};
};
sections: {
- summary: {
- name: string;
- columns: number;
- separateLinks: boolean;
- visible: boolean;
+ summary: { name: string; columns: number; separateLinks: boolean; visible: boolean; id: string; content: string };
+ awards: V4Section<{
id: string;
- content: string;
- };
- awards: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ title?: string;
+ awarder?: string;
+ date?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ certifications: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- title?: string;
- awarder?: string;
- date?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- certifications: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ name?: string;
+ issuer?: string;
+ date?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ education: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- issuer?: string;
- date?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- education: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ institution?: string;
+ studyType?: string;
+ area?: string;
+ score?: string;
+ date?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ experience: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- institution?: string;
- studyType?: string;
- area?: string;
- score?: string;
- date?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- experience: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ company?: string;
+ position?: string;
+ location?: string;
+ date?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ volunteer: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- company?: string;
- position?: string;
- location?: string;
- date?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- volunteer: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ organization?: string;
+ position?: string;
+ location?: string;
+ date?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ interests: V4Section<{ id: string; visible: boolean; name?: string; keywords?: string[] }>;
+ languages: V4Section<{ id: string; visible: boolean; name?: string; description?: string; level?: number }>;
+ profiles: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- organization?: string;
- position?: string;
- location?: string;
- date?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- interests: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ network?: string;
+ username?: string;
+ icon?: string;
+ url?: V4Url;
+ }>;
+ projects: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- keywords?: string[];
- }>;
- };
- languages: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ name?: string;
+ description?: string;
+ date?: string;
+ summary?: string;
+ keywords?: string[];
+ url?: V4Url;
+ }>;
+ publications: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- description?: string;
- level?: number;
- }>;
- };
- profiles: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ name?: string;
+ publisher?: string;
+ date?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ references: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- network?: string;
- username?: string;
- icon?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- projects: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
+ name?: string;
+ description?: string;
+ summary?: string;
+ url?: V4Url;
+ }>;
+ skills: V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- description?: string;
- date?: string;
- summary?: string;
- keywords?: string[];
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- publications: {
- name: string;
- columns: number;
- separateLinks: boolean;
visible: boolean;
- id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- publisher?: string;
- date?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- references: {
- name: string;
- columns: number;
- separateLinks: boolean;
- visible: boolean;
- id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- description?: string;
- summary?: string;
- url?: {
- label: string;
- href: string;
- };
- }>;
- };
- skills: {
- name: string;
- columns: number;
- separateLinks: boolean;
- visible: boolean;
- id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- description?: string;
- level?: number;
- keywords?: string[];
- }>;
- };
+ name?: string;
+ description?: string;
+ level?: number;
+ keywords?: string[];
+ }>;
custom?: Record<
string,
- {
- name: string;
- columns: number;
- separateLinks: boolean;
- visible: boolean;
+ V4Section<{
id: string;
- items: Array<{
- id: string;
- visible: boolean;
- name?: string;
- description?: string;
- date?: string;
- location?: string;
- summary?: string;
- keywords?: string[];
- url?: {
- label: string;
- href: string;
- };
- }>;
- }
+ visible: boolean;
+ name?: string;
+ description?: string;
+ date?: string;
+ location?: string;
+ summary?: string;
+ keywords?: string[];
+ url?: V4Url;
+ }>
>;
};
metadata: {
template: string;
layout: Array>;
- css: {
- value: string;
- visible: boolean;
- };
- page: {
- margin: number;
- format: "a4" | "letter";
- options: {
- breakLine: boolean;
- pageNumbers: boolean;
- };
- };
- theme: {
- background: string;
- text: string;
- primary: string;
- };
+ css: { value: string; visible: boolean };
+ page: { margin: number; format: "a4" | "letter"; options: { breakLine: boolean; pageNumbers: boolean } };
+ theme: { background: string; text: string; primary: string };
typography: {
- font: {
- family: string;
- subset: string;
- variants: string[];
- size: number;
- };
+ font: { family: string; subset: string; variants: string[]; size: number };
lineHeight: number;
hideIcons: boolean;
underlineLinks: boolean;
@@ -403,338 +227,88 @@ const transformLayoutColumn = (column: string[]): string[] => {
.map(transformLayoutSectionId);
};
-export class ReactiveResumeV4JSONImporter {
- parse(json: string): ResumeData {
- try {
- const v4Data = JSON.parse(json) as V4ResumeData;
+// ponytail: stateless single-method class → plain function
+export function parseReactiveResumeV4JSON(json: string): ResumeData {
+ try {
+ const v4Data = JSON.parse(json) as V4ResumeData;
- const transformed: ResumeData = {
- picture: {
- hidden: v4Data.basics.picture?.effects?.hidden ?? false,
- url: v4Data.basics.picture?.url ?? "",
- size: clampPictureSize(v4Data.basics.picture?.size ?? 80),
- rotation: clampRotation(0),
- aspectRatio: clampAspectRatio(v4Data.basics.picture?.aspectRatio ?? 1),
- borderRadius: clampBorderRadius(v4Data.basics.picture?.borderRadius ?? 0),
- borderColor: v4Data.basics.picture?.effects?.border ? "rgba(0, 0, 0, 0.5)" : "rgba(0, 0, 0, 0)",
- borderWidth: nonNegative(v4Data.basics.picture?.effects?.border ? 1 : 0),
- shadowColor: "rgba(0, 0, 0, 0.5)",
- shadowWidth: nonNegative(0),
+ const transformed: ResumeData = {
+ picture: {
+ hidden: v4Data.basics.picture?.effects?.hidden ?? false,
+ url: v4Data.basics.picture?.url ?? "",
+ size: clamp(v4Data.basics.picture?.size ?? 80, 32, 512),
+ rotation: 0,
+ aspectRatio: clamp(v4Data.basics.picture?.aspectRatio ?? 1, 0.5, 2.5),
+ borderRadius: clamp(v4Data.basics.picture?.borderRadius ?? 0, 0, 100),
+ borderColor: v4Data.basics.picture?.effects?.border ? "rgba(0, 0, 0, 0.5)" : "rgba(0, 0, 0, 0)",
+ borderWidth: v4Data.basics.picture?.effects?.border ? 1 : 0,
+ shadowColor: "rgba(0, 0, 0, 0.5)",
+ shadowWidth: 0,
+ },
+ basics: {
+ name: v4Data.basics.name ?? "",
+ headline: v4Data.basics.headline ?? "",
+ email: sanitizeEmail(v4Data.basics.email),
+ phone: v4Data.basics.phone ?? "",
+ location: v4Data.basics.location ?? "",
+ website: {
+ url: v4Data.basics.url?.href ?? "",
+ label: v4Data.basics.url?.label ?? "",
},
- basics: {
- name: v4Data.basics.name ?? "",
- headline: v4Data.basics.headline ?? "",
- email: sanitizeEmail(v4Data.basics.email),
- phone: v4Data.basics.phone ?? "",
- location: v4Data.basics.location ?? "",
- website: {
- url: v4Data.basics.url?.href ?? "",
- label: v4Data.basics.url?.label ?? "",
- },
- customFields: (v4Data.basics.customFields ?? []).map((field) => ({
- id: field.id ?? generateId(),
- icon: field.icon ?? "",
- text: field.text ?? "",
- link: "",
- })),
- },
- summary: {
- title: v4Data.sections.summary?.name ?? "",
+ customFields: (v4Data.basics.customFields ?? []).map((field) => ({
+ id: field.id ?? generateId(),
+ icon: field.icon ?? "",
+ text: field.text ?? "",
+ link: "",
+ })),
+ },
+ summary: {
+ title: v4Data.sections.summary?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.summary?.columns ?? 1,
+ hidden: !(v4Data.sections.summary?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ content: v4Data.sections.summary?.content ?? "",
+ },
+ sections: {
+ profiles: {
+ title: v4Data.sections.profiles?.name ?? "",
icon: "",
- columns: v4Data.sections.summary?.columns ?? 1,
- hidden: !(v4Data.sections.summary?.visible ?? true),
+ columns: v4Data.sections.profiles?.columns ?? 1,
+ hidden: !(v4Data.sections.profiles?.visible ?? true),
keepTogether: false,
startOnNewPage: false,
- content: v4Data.sections.summary?.content ?? "",
- },
- sections: {
- profiles: {
- title: v4Data.sections.profiles?.name ?? "",
- icon: "",
- columns: v4Data.sections.profiles?.columns ?? 1,
- hidden: !(v4Data.sections.profiles?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.profiles?.items ?? [])
- .filter((item) => item.network && item.network.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- icon: item.icon ?? "",
- iconColor: "",
- network: item.network ?? "",
- username: item.username ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- })),
- },
- experience: {
- title: v4Data.sections.experience?.name ?? "",
- icon: "",
- columns: v4Data.sections.experience?.columns ?? 1,
- hidden: !(v4Data.sections.experience?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.experience?.items ?? [])
- .filter((item) => item.company && item.company.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- company: item.company ?? "",
- position: item.position ?? "",
- location: item.location ?? "",
- period: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- roles: [],
- description: item.summary ?? "",
- })),
- },
- education: {
- title: v4Data.sections.education?.name ?? "",
- icon: "",
- columns: v4Data.sections.education?.columns ?? 1,
- hidden: !(v4Data.sections.education?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.education?.items ?? [])
- .filter((item) => item.institution && item.institution.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- school: item.institution ?? "",
- degree: item.studyType ?? "",
- area: item.area ?? "",
- grade: item.score ?? "",
- location: "",
- period: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? "",
- })),
- },
- projects: {
- title: v4Data.sections.projects?.name ?? "",
- icon: "",
- columns: v4Data.sections.projects?.columns ?? 1,
- hidden: !(v4Data.sections.projects?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.projects?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- name: item.name ?? "",
- period: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? item.description ?? "",
- })),
- },
- skills: {
- title: v4Data.sections.skills?.name ?? "",
- icon: "",
- columns: v4Data.sections.skills?.columns ?? 1,
- hidden: !(v4Data.sections.skills?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.skills?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- icon: "",
- iconColor: "",
- name: item.name ?? "",
- proficiency: item.description ?? "",
- // v4 stored skill level as 0-10; scale down to v5's 0-5 range
- level: Math.round(clamp(item.level ?? 0, 0, 10) / 2),
- keywords: item.keywords ?? [],
- })),
- },
- languages: {
- title: v4Data.sections.languages?.name ?? "",
- icon: "",
- columns: v4Data.sections.languages?.columns ?? 1,
- hidden: !(v4Data.sections.languages?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.languages?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- language: item.name ?? "",
- fluency: item.description ?? "",
- // v4 stored language level as 0-10; scale down to v5's 0-5 range
- level: Math.round(clamp(item.level ?? 0, 0, 10) / 2),
- })),
- },
- interests: {
- title: v4Data.sections.interests?.name ?? "",
- icon: "",
- columns: v4Data.sections.interests?.columns ?? 1,
- hidden: !(v4Data.sections.interests?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.interests?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- icon: "",
- iconColor: "",
- name: item.name ?? "",
- keywords: item.keywords ?? [],
- })),
- },
- awards: {
- title: v4Data.sections.awards?.name ?? "",
- icon: "",
- columns: v4Data.sections.awards?.columns ?? 1,
- hidden: !(v4Data.sections.awards?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.awards?.items ?? [])
- .filter((item) => item.title && item.title.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- title: item.title ?? "",
- awarder: item.awarder ?? "",
- date: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? "",
- })),
- },
- certifications: {
- title: v4Data.sections.certifications?.name ?? "",
- icon: "",
- columns: v4Data.sections.certifications?.columns ?? 1,
- hidden: !(v4Data.sections.certifications?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.certifications?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- title: item.name ?? "",
- issuer: item.issuer ?? "",
- date: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? "",
- })),
- },
- publications: {
- title: v4Data.sections.publications?.name ?? "",
- icon: "",
- columns: v4Data.sections.publications?.columns ?? 1,
- hidden: !(v4Data.sections.publications?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.publications?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- title: item.name ?? "",
- publisher: item.publisher ?? "",
- date: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? "",
- })),
- },
- volunteer: {
- title: v4Data.sections.volunteer?.name ?? "",
- icon: "",
- columns: v4Data.sections.volunteer?.columns ?? 1,
- hidden: !(v4Data.sections.volunteer?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.volunteer?.items ?? [])
- .filter((item) => item.organization && item.organization.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- organization: item.organization ?? "",
- location: item.location ?? "",
- period: item.date ?? "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? "",
- })),
- },
- references: {
- title: v4Data.sections.references?.name ?? "",
- icon: "",
- columns: v4Data.sections.references?.columns ?? 1,
- hidden: !(v4Data.sections.references?.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: (v4Data.sections.references?.items ?? [])
- .filter((item) => item.name && item.name.length > 0)
- .map((item) => ({
- id: item.id ?? generateId(),
- hidden: !(item.visible ?? true),
- name: item.name ?? "",
- position: item.description ?? "",
- phone: "",
- website: {
- url: item.url?.href ?? "",
- label: item.url?.label ?? "",
- inlineLink: false,
- },
- description: item.summary ?? "",
- })),
- },
- },
- customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => ({
- id: section.id || sectionId,
- title: section.name ?? "",
- icon: "",
- type: "experience" as const, // Default to experience type as it has the most compatible fields
- columns: section.columns ?? 1,
- hidden: !(section.visible ?? true),
- keepTogether: false,
- startOnNewPage: false,
- items: section.items.map((item, index) => {
- const hasName = Boolean(item.name?.trim());
- return {
- id: item.id || generateId(),
+ items: (v4Data.sections.profiles?.items ?? [])
+ .filter((item) => item.network && item.network.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
- company: item.name?.trim() || `#${index + 1}`,
- // Only use description as subtitle when item has a name;
- // otherwise description IS the primary content and goes to the body below
- position: hasName ? (item.description ?? "") : "",
+ icon: item.icon ?? "",
+ iconColor: "",
+ network: item.network ?? "",
+ username: item.username ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ })),
+ },
+ experience: {
+ title: v4Data.sections.experience?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.experience?.columns ?? 1,
+ hidden: !(v4Data.sections.experience?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.experience?.items ?? [])
+ .filter((item) => item.company && item.company.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ company: item.company ?? "",
+ position: item.position ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
@@ -743,88 +317,333 @@ export class ReactiveResumeV4JSONImporter {
inlineLink: false,
},
roles: [],
- // Prefer HTML summary; fall back to plain description
- // (for description-only items, description IS the body content)
+ description: item.summary ?? "",
+ })),
+ },
+ education: {
+ title: v4Data.sections.education?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.education?.columns ?? 1,
+ hidden: !(v4Data.sections.education?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.education?.items ?? [])
+ .filter((item) => item.institution && item.institution.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ school: item.institution ?? "",
+ degree: item.studyType ?? "",
+ area: item.area ?? "",
+ grade: item.score ?? "",
+ location: "",
+ period: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ description: item.summary ?? "",
+ })),
+ },
+ projects: {
+ title: v4Data.sections.projects?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.projects?.columns ?? 1,
+ hidden: !(v4Data.sections.projects?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.projects?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ name: item.name ?? "",
+ period: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
description: item.summary ?? item.description ?? "",
+ })),
+ },
+ skills: {
+ title: v4Data.sections.skills?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.skills?.columns ?? 1,
+ hidden: !(v4Data.sections.skills?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.skills?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ icon: "",
+ iconColor: "",
+ name: item.name ?? "",
+ proficiency: item.description ?? "",
+ // v4 stored skill level as 0-10; scale down to v5's 0-5 range
+ level: Math.round(clamp(item.level ?? 0, 0, 10) / 2),
+ keywords: item.keywords ?? [],
+ })),
+ },
+ languages: {
+ title: v4Data.sections.languages?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.languages?.columns ?? 1,
+ hidden: !(v4Data.sections.languages?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.languages?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ language: item.name ?? "",
+ fluency: item.description ?? "",
+ // v4 stored language level as 0-10; scale down to v5's 0-5 range
+ level: Math.round(clamp(item.level ?? 0, 0, 10) / 2),
+ })),
+ },
+ interests: {
+ title: v4Data.sections.interests?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.interests?.columns ?? 1,
+ hidden: !(v4Data.sections.interests?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.interests?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ icon: "",
+ iconColor: "",
+ name: item.name ?? "",
+ keywords: item.keywords ?? [],
+ })),
+ },
+ awards: {
+ title: v4Data.sections.awards?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.awards?.columns ?? 1,
+ hidden: !(v4Data.sections.awards?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.awards?.items ?? [])
+ .filter((item) => item.title && item.title.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ title: item.title ?? "",
+ awarder: item.awarder ?? "",
+ date: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ description: item.summary ?? "",
+ })),
+ },
+ certifications: {
+ title: v4Data.sections.certifications?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.certifications?.columns ?? 1,
+ hidden: !(v4Data.sections.certifications?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.certifications?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ title: item.name ?? "",
+ issuer: item.issuer ?? "",
+ date: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ description: item.summary ?? "",
+ })),
+ },
+ publications: {
+ title: v4Data.sections.publications?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.publications?.columns ?? 1,
+ hidden: !(v4Data.sections.publications?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.publications?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ title: item.name ?? "",
+ publisher: item.publisher ?? "",
+ date: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ description: item.summary ?? "",
+ })),
+ },
+ volunteer: {
+ title: v4Data.sections.volunteer?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.volunteer?.columns ?? 1,
+ hidden: !(v4Data.sections.volunteer?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.volunteer?.items ?? [])
+ .filter((item) => item.organization && item.organization.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ organization: item.organization ?? "",
+ location: item.location ?? "",
+ period: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ description: item.summary ?? "",
+ })),
+ },
+ references: {
+ title: v4Data.sections.references?.name ?? "",
+ icon: "",
+ columns: v4Data.sections.references?.columns ?? 1,
+ hidden: !(v4Data.sections.references?.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: (v4Data.sections.references?.items ?? [])
+ .filter((item) => item.name && item.name.length > 0)
+ .map((item) => ({
+ id: item.id ?? generateId(),
+ hidden: !(item.visible ?? true),
+ name: item.name ?? "",
+ position: item.description ?? "",
+ phone: "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ description: item.summary ?? "",
+ })),
+ },
+ },
+ customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => ({
+ id: section.id || sectionId,
+ title: section.name ?? "",
+ icon: "",
+ type: "experience" as const, // Default to experience type as it has the most compatible fields
+ columns: section.columns ?? 1,
+ hidden: !(section.visible ?? true),
+ keepTogether: false,
+ startOnNewPage: false,
+ items: section.items.map((item, index) => {
+ const hasName = Boolean(item.name?.trim());
+ return {
+ id: item.id || generateId(),
+ hidden: !(item.visible ?? true),
+ company: item.name?.trim() || `#${index + 1}`,
+ // Only use description as subtitle when item has a name;
+ // otherwise description IS the primary content and goes to the body below
+ position: hasName ? (item.description ?? "") : "",
+ location: item.location ?? "",
+ period: item.date ?? "",
+ website: {
+ url: item.url?.href ?? "",
+ label: item.url?.label ?? "",
+ inlineLink: false,
+ },
+ roles: [],
+ // Prefer HTML summary; fall back to plain description
+ // (for description-only items, description IS the body content)
+ description: item.summary ?? item.description ?? "",
+ };
+ }),
+ })),
+ metadata: {
+ template: (templateSchema.safeParse(v4Data.metadata.template).success
+ ? v4Data.metadata.template
+ : "onyx") as Template,
+ layout: {
+ sidebarWidth: 35,
+ pages: (v4Data.metadata.layout ?? []).map((page) => {
+ const main = transformLayoutColumn(page[0] ?? []);
+ const sidebar = transformLayoutColumn(page[1] ?? []);
+ return {
+ fullWidth: sidebar.length === 0,
+ main,
+ sidebar,
};
}),
- })),
- metadata: {
- template: (templateSchema.safeParse(v4Data.metadata.template).success
- ? v4Data.metadata.template
- : "onyx") as Template,
- layout: {
- sidebarWidth: clampSidebarWidth(35),
- pages: (v4Data.metadata.layout ?? []).map((page) => {
- const main = transformLayoutColumn(page[0] ?? []);
- const sidebar = transformLayoutColumn(page[1] ?? []);
- return {
- fullWidth: sidebar.length === 0,
- main,
- sidebar,
- };
- }),
- },
- page: {
- gapX: nonNegative(4),
- gapY: nonNegative(6),
- marginX: nonNegative(v4Data.metadata.page?.margin ?? 14),
- marginY: nonNegative(v4Data.metadata.page?.margin ?? 14),
- format: v4Data.metadata.page?.format ?? "a4",
- locale: "en-US",
- hideLinkUnderline: v4Data.metadata.typography?.underlineLinks === false,
- hideIcons: v4Data.metadata.typography?.hideIcons ?? false,
- hideSectionIcons: true,
- },
- design: {
- colors: {
- primary: v4Data.metadata.theme?.primary
- ? colorToRgba(v4Data.metadata.theme.primary)
- : "rgba(220, 38, 38, 1)",
- text: v4Data.metadata.theme?.text ? colorToRgba(v4Data.metadata.theme.text) : "rgba(0, 0, 0, 1)",
- background: v4Data.metadata.theme?.background
- ? colorToRgba(v4Data.metadata.theme.background)
- : "rgba(255, 255, 255, 1)",
- },
- level: {
- icon: "star",
- type: "circle",
- },
- },
- typography: {
- body: {
- fontFamily: v4Data.metadata.typography?.font?.family ?? "IBM Plex Serif",
- fontWeights: convertFontVariants(v4Data.metadata.typography?.font?.variants),
- fontSize: convertAndClampFontSize(v4Data.metadata.typography?.font?.size ?? 14.67),
- lineHeight: clampLineHeight(v4Data.metadata.typography?.lineHeight ?? 1.5),
- },
- heading: {
- fontFamily: v4Data.metadata.typography?.font?.family ?? "IBM Plex Serif",
- fontWeights: convertFontVariantsForHeading(v4Data.metadata.typography?.font?.variants),
- fontSize: clampFontSize(convertAndClampFontSize(v4Data.metadata.typography?.font?.size ?? 14.67) + 3),
- lineHeight: clampLineHeight(v4Data.metadata.typography?.lineHeight ?? 1.5),
- },
- },
- notes: v4Data.metadata.notes ?? "",
- styleRules: [],
},
- };
+ page: {
+ gapX: 4,
+ gapY: 6,
+ marginX: Math.max(0, v4Data.metadata.page?.margin ?? 14),
+ marginY: Math.max(0, v4Data.metadata.page?.margin ?? 14),
+ format: v4Data.metadata.page?.format ?? "a4",
+ locale: "en-US",
+ hideLinkUnderline: v4Data.metadata.typography?.underlineLinks === false,
+ hideIcons: v4Data.metadata.typography?.hideIcons ?? false,
+ hideSectionIcons: true,
+ },
+ design: {
+ colors: {
+ primary: v4Data.metadata.theme?.primary
+ ? colorToRgba(v4Data.metadata.theme.primary)
+ : "rgba(220, 38, 38, 1)",
+ text: v4Data.metadata.theme?.text ? colorToRgba(v4Data.metadata.theme.text) : "rgba(0, 0, 0, 1)",
+ background: v4Data.metadata.theme?.background
+ ? colorToRgba(v4Data.metadata.theme.background)
+ : "rgba(255, 255, 255, 1)",
+ },
+ level: {
+ icon: "star",
+ type: "circle",
+ },
+ },
+ typography: {
+ body: {
+ fontFamily: v4Data.metadata.typography?.font?.family ?? "IBM Plex Serif",
+ fontWeights: convertFontVariants(v4Data.metadata.typography?.font?.variants),
+ fontSize: clamp((v4Data.metadata.typography?.font?.size ?? 14.67) * 0.75, 6, 24),
+ lineHeight: clamp(v4Data.metadata.typography?.lineHeight ?? 1.5, 0.5, 4),
+ },
+ heading: {
+ fontFamily: v4Data.metadata.typography?.font?.family ?? "IBM Plex Serif",
+ fontWeights: convertFontVariantsForHeading(v4Data.metadata.typography?.font?.variants),
+ fontSize: clamp(clamp((v4Data.metadata.typography?.font?.size ?? 14.67) * 0.75, 6, 24) + 3, 6, 24),
+ lineHeight: clamp(v4Data.metadata.typography?.lineHeight ?? 1.5, 0.5, 4),
+ },
+ },
+ notes: v4Data.metadata.notes ?? "",
+ styleRules: [],
+ },
+ };
- if (v4Data.sections.summary?.visible && v4Data.sections.summary?.content) {
- const firstPage = transformed.metadata.layout.pages[0];
- if (firstPage) {
- firstPage.main.unshift("summary");
- }
+ if (v4Data.sections.summary?.visible && v4Data.sections.summary?.content) {
+ const firstPage = transformed.metadata.layout.pages[0];
+ if (firstPage) {
+ firstPage.main.unshift("summary");
}
-
- return resumeDataSchema.parse(transformed);
- } catch (error: unknown) {
- if (error instanceof ZodError) {
- const errors = flattenError(error);
- throw new Error(JSON.stringify(errors));
- }
-
- throw error;
}
+
+ return resumeDataSchema.parse(transformed);
+ } catch (error: unknown) {
+ if (error instanceof ZodError) rethrowAsImportError(error);
+ throw error;
}
}