From bad431b2fc0a45e226c4457ebc5ca945c40b58e7 Mon Sep 17 00:00:00 2001 From: Kaushik N <145863267+MrTig-afk@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:01:06 +1000 Subject: [PATCH] fix(import): auto-detect JSON format and show readable errors (#1) (#3296) * fix(import): auto-detect JSON format and show readable errors (#1) Readable import errors, a fail-soft v4 parser, and auto-detect of the JSON format so uploads just work. The format dropdown becomes an optional override. PDF and DOCX (AI) paths are untouched. * fix(import): address review feedback on the v4 guard and error message - reactive-resume-v4-json.tsx: reject arrays in isRecord so array-valued basics, sections, or metadata no longer pass the v4 shape guard. - reactive-resume-v4-json.tsx: reuse the guard's error instance in the catch arm instead of allocating a duplicate NOT_V4_MESSAGE. - error.ts: use a singular "Problem" label for root-level Zod issues so the message stays grammatical. - add a regression test for array-valued v4 branches. * fix(import): preserve selected JSON format * test(import): cover selected JSON parser --------- Co-authored-by: MrTig-afk Co-authored-by: Amruth Pillai --- apps/web/src/dialogs/resume/import.test.ts | 1 + apps/web/src/dialogs/resume/import.tsx | 21 +++++-------- .../web/src/dialogs/resume/parse-json.test.ts | 8 +++++ apps/web/src/dialogs/resume/parse-json.ts | 12 +++++++ packages/import/src/error.test.ts | 31 +++++++++++++++++++ packages/import/src/error.ts | 30 ++++++++++++++++-- .../src/reactive-resume-v4-json.test.ts | 30 ++++++++++++++++++ .../import/src/reactive-resume-v4-json.tsx | 24 ++++++++++++-- 8 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/dialogs/resume/parse-json.test.ts create mode 100644 apps/web/src/dialogs/resume/parse-json.ts create mode 100644 packages/import/src/error.test.ts diff --git a/apps/web/src/dialogs/resume/import.test.ts b/apps/web/src/dialogs/resume/import.test.ts index 36d1c9db1..f5b0a665a 100644 --- a/apps/web/src/dialogs/resume/import.test.ts +++ b/apps/web/src/dialogs/resume/import.test.ts @@ -19,6 +19,7 @@ describe("detectJsonImportType", () => { }); it("returns an empty string for unrecognized shapes", () => { + expect(detectJsonImportType({})).toBe(""); expect(detectJsonImportType({ foo: "bar" })).toBe(""); expect(detectJsonImportType(null)).toBe(""); expect(detectJsonImportType("nope")).toBe(""); diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx index 9afb5f43f..b13f68825 100644 --- a/apps/web/src/dialogs/resume/import.tsx +++ b/apps/web/src/dialogs/resume/import.tsx @@ -1,6 +1,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { DialogProps } from "../store"; import type { ImportType } from "./import.utils"; +import type { ResumeJsonFormat } from "./parse-json"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react"; @@ -10,9 +11,6 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { toast } from "sonner"; import z from "zod"; -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 { @@ -34,6 +32,7 @@ import { client, orpc } from "@/libs/orpc/client"; import { useAppForm } from "@/libs/tanstack-form"; import { useDialogStore } from "../store"; import { detectJsonImportType } from "./import.utils"; +import { parseResumeJson } from "./parse-json"; const formSchema = z.discriminatedUnion("type", [ z.object({ @@ -149,16 +148,12 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { try { let data: ResumeData | undefined; - if (value.type === "json-resume-json") { - data = parseJSONResume(await value.file.text()); - } - - if (value.type === "reactive-resume-json") { - data = parseReactiveResumeJSON(await value.file.text()); - } - - if (value.type === "reactive-resume-v4-json") { - data = parseReactiveResumeV4JSON(await value.file.text()); + if ( + value.type === "json-resume-json" || + value.type === "reactive-resume-json" || + value.type === "reactive-resume-v4-json" + ) { + data = parseResumeJson(await value.file.text(), value.type as ResumeJsonFormat); } if (value.type === "pdf") { diff --git a/apps/web/src/dialogs/resume/parse-json.test.ts b/apps/web/src/dialogs/resume/parse-json.test.ts new file mode 100644 index 000000000..c28933ead --- /dev/null +++ b/apps/web/src/dialogs/resume/parse-json.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { parseResumeJson } from "./parse-json"; + +describe("parseResumeJson", () => { + it("keeps a selected v4 import from falling back to JSON Resume", () => { + expect(() => parseResumeJson("{}", "reactive-resume-v4-json")).toThrow(/v4/i); + }); +}); diff --git a/apps/web/src/dialogs/resume/parse-json.ts b/apps/web/src/dialogs/resume/parse-json.ts new file mode 100644 index 000000000..ec03b429f --- /dev/null +++ b/apps/web/src/dialogs/resume/parse-json.ts @@ -0,0 +1,12 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +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"; + +export type ResumeJsonFormat = "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json"; + +export function parseResumeJson(text: string, format: ResumeJsonFormat): ResumeData { + if (format === "reactive-resume-json") return parseReactiveResumeJSON(text); + if (format === "reactive-resume-v4-json") return parseReactiveResumeV4JSON(text); + return parseJSONResume(text); +} diff --git a/packages/import/src/error.test.ts b/packages/import/src/error.test.ts new file mode 100644 index 000000000..10172402b --- /dev/null +++ b/packages/import/src/error.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { rethrowAsImportError } from "./error"; + +const makeZodError = () => { + const result = z.object({ name: z.string() }).safeParse({ name: 123 }); + if (result.success) throw new Error("expected the schema to fail"); + return result.error; +}; + +describe("rethrowAsImportError", () => { + it("turns a ZodError into a readable sentence instead of raw JSON", () => { + let caught: Error | undefined; + try { + rethrowAsImportError(makeZodError()); + } catch (error) { + caught = error as Error; + } + + expect(caught).toBeInstanceOf(Error); + // Not a raw JSON.stringify(flattenError(...)) dump. + expect(caught?.message.startsWith("{")).toBe(false); + expect(caught?.message).toContain("name"); + expect(caught?.message.toLowerCase()).toContain("resume"); + }); + + it("re-throws non-Zod errors unchanged", () => { + const original = new Error("boom"); + expect(() => rethrowAsImportError(original)).toThrow(original); + }); +}); diff --git a/packages/import/src/error.ts b/packages/import/src/error.ts index 50fc68992..29167dd4a 100644 --- a/packages/import/src/error.ts +++ b/packages/import/src/error.ts @@ -1,9 +1,33 @@ -import { flattenError, ZodError } from "zod"; +import { ZodError } from "zod"; -/** Rethrows a ZodError as a serialized string error; re-throws other errors as-is. */ +const MAX_LISTED_ISSUES = 3; + +const describeIssuePath = (path: ReadonlyArray): string => + path.length > 0 ? path.map((segment) => String(segment)).join(".") : "the document"; + +/** Builds a short, human-readable summary from a ZodError instead of a raw JSON dump. */ +const summarizeZodError = (error: ZodError): string => { + if (error.issues.length === 0) return "The file could not be read as a valid resume."; + + const listed = error.issues + .slice(0, MAX_LISTED_ISSUES) + .map((issue) => `${describeIssuePath(issue.path)} (${issue.message})`) + .join(", "); + + const remaining = error.issues.length - MAX_LISTED_ISSUES; + const suffix = remaining > 0 ? `, and ${remaining} more field${remaining === 1 ? "" : "s"}` : ""; + + // Root-level issues have an empty path (rendered as "the document"); use a + // singular label for them so the sentence stays grammatical. + const allAtRoot = error.issues.slice(0, MAX_LISTED_ISSUES).every((issue) => issue.path.length === 0); + const prefix = allAtRoot ? "Problem" : "Problem fields"; + return `The file could not be read as a valid resume. ${prefix}: ${listed}${suffix}.`; +}; + +/** Rethrows a ZodError as a human-readable import 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 new Error(summarizeZodError(error)); } throw error; } diff --git a/packages/import/src/reactive-resume-v4-json.test.ts b/packages/import/src/reactive-resume-v4-json.test.ts index ef9827ce3..1d3466c5b 100644 --- a/packages/import/src/reactive-resume-v4-json.test.ts +++ b/packages/import/src/reactive-resume-v4-json.test.ts @@ -394,3 +394,33 @@ describe("parseReactiveResumeV4JSON – language level scaling (v4: 0-10 → v5: expect(result.sections.languages.items[0]?.level).toBe(3); }); }); + +// ─── Guard: non-v4 JSON must fail with a readable error, not a raw TypeError ─── + +describe("parseReactiveResumeV4JSON – rejects non-v4 input gracefully", () => { + it("throws a readable error (not a TypeError) for an empty object", () => { + expect(() => parseReactiveResumeV4JSON("{}")).toThrow(/v4/i); + }); + + it("throws for a JSON Resume shaped file (basics but no sections/metadata)", () => { + expect(() => parseReactiveResumeV4JSON(JSON.stringify({ basics: { name: "Jane" } }))).toThrow(/v4/i); + }); + + it("throws for a top-level JSON array", () => { + expect(() => parseReactiveResumeV4JSON("[]")).toThrow(/v4/i); + }); + + it("throws for a current-format export whose metadata.layout is not a v4 array", () => { + const currentFormat = { + basics: { name: "Jane" }, + sections: {}, + metadata: { layout: { sidebarWidth: 35, pages: [] } }, + }; + expect(() => parseReactiveResumeV4JSON(JSON.stringify(currentFormat))).toThrow(/v4/i); + }); + + it("throws when basics, sections, or metadata are arrays rather than objects", () => { + const arrayBranches = JSON.stringify({ basics: [], sections: [], metadata: [] }); + expect(() => parseReactiveResumeV4JSON(arrayBranches)).toThrow(/v4/i); + }); +}); diff --git a/packages/import/src/reactive-resume-v4-json.tsx b/packages/import/src/reactive-resume-v4-json.tsx index ac0e3a2df..0f5cc01f0 100644 --- a/packages/import/src/reactive-resume-v4-json.tsx +++ b/packages/import/src/reactive-resume-v4-json.tsx @@ -227,10 +227,23 @@ const transformLayoutColumn = (column: string[]): string[] => { .map(transformLayoutSectionId); }; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// A genuine v4 export always carries object-typed basics, sections, and metadata. +// Checking this up front turns "not a v4 file" into a clear error instead of the +// raw TypeError the transform below would throw when it dereferences a missing branch. +const hasV4Shape = (value: unknown): value is V4ResumeData => + isRecord(value) && isRecord(value.basics) && isRecord(value.sections) && isRecord(value.metadata); + +const NOT_V4_MESSAGE = "This file doesn't look like a Reactive Resume v4 export."; + // ponytail: stateless single-method class → plain function export function parseReactiveResumeV4JSON(json: string): ResumeData { try { - const v4Data = JSON.parse(json) as V4ResumeData; + const parsed: unknown = JSON.parse(json); + if (!hasV4Shape(parsed)) throw new Error(NOT_V4_MESSAGE); + const v4Data = parsed; const transformed: ResumeData = { picture: { @@ -644,6 +657,13 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { return resumeDataSchema.parse(transformed); } catch (error: unknown) { if (error instanceof ZodError) rethrowAsImportError(error); - throw error; + // Surface malformed JSON as-is; treat any other transform failure (a shape + // mismatch that slipped past the guard) as "not a valid v4 export" rather + // than leaking a raw TypeError to the user. + if (error instanceof SyntaxError) throw error; + // The hasV4Shape guard above already throws NOT_V4_MESSAGE; reuse that + // instance instead of allocating a duplicate. + if (error instanceof Error && error.message === NOT_V4_MESSAGE) throw error; + throw new Error(NOT_V4_MESSAGE); } }