From afd734dd61e5d8866820f7b07153d70732e78aa9 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 4 Jul 2026 21:02:17 +0200 Subject: [PATCH] refactor(import): add v4section/v4url aliases, inline clamp wrappers, replace classes with fns Finding 5: Introduce V4Url + V4Section type aliases in reactive-resume-v4-json.tsx; collapse 310-line V4ResumeData type (13x 5-field section header, 11x {label,href}) into typed aliases. Inferred shape is structurally identical. Finding 6: Remove 9 single-caller clamp/pxToPt wrappers; replace compile-time constants (rotation=0, sidebarWidth=35, shadowWidth=0, gapX=4, gapY=6) with literals; inline dynamic calls (clamp(x, 32, 512) etc.) at the two body/heading typography call sites. Finding 7: Convert three stateless single-method importer classes to plain functions. Extract shared rethrowAsImportError() to error.ts, replacing triplicated ZodError catch blocks. Update web import dialog + all in-package test files to use function API. Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa --- apps/web/src/dialogs/resume/import.tsx | 18 +- packages/import/src/error.ts | 9 + packages/import/src/json-resume.test.ts | 28 +- packages/import/src/json-resume.tsx | 524 ++++---- .../import/src/reactive-resume-json.test.ts | 18 +- packages/import/src/reactive-resume-json.tsx | 24 +- .../src/reactive-resume-v4-json.broad.test.ts | 10 +- .../src/reactive-resume-v4-json.test.ts | 42 +- .../import/src/reactive-resume-v4-json.tsx | 1187 +++++++---------- 9 files changed, 834 insertions(+), 1026 deletions(-) create mode 100644 packages/import/src/error.ts 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; } }