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 <MrTig-afk@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Kaushik N
2026-08-13 23:01:06 +02:00
committed by GitHub
co-authored by MrTig-afk Amruth Pillai
parent 9f13638eab
commit bad431b2fc
8 changed files with 139 additions and 18 deletions
@@ -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("");
+8 -13
View File
@@ -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") {
@@ -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);
});
});
+12
View File
@@ -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);
}
+31
View File
@@ -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);
});
});
+27 -3
View File
@@ -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<PropertyKey>): 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;
}
@@ -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);
});
});
@@ -227,10 +227,23 @@ const transformLayoutColumn = (column: string[]): string[] => {
.map(transformLayoutSectionId);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
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);
}
}