mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-19 21:11:45 +10:00
* 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:
co-authored by
MrTig-afk
Amruth Pillai
parent
9f13638eab
commit
bad431b2fc
@@ -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("");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user