Files
Reactive-Resume/packages/import/src/reactive-resume-v4-json.tsx
T
Amruth Pillai afd734dd61 refactor(import): add v4section/v4url aliases, inline clamp wrappers, replace classes with fns
Finding 5: Introduce V4Url + V4Section<T> 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
2026-07-04 21:02:17 +02:00

650 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
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);
if (!parsed) return "rgba(0, 0, 0, 1)";
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 isValidEmail = (email: string): boolean => {
if (!email) return false;
return z.email().safeParse(email).success;
};
const sanitizeEmail = (email: string | undefined): string => {
if (!email) return "";
return isValidEmail(email) ? email : "";
};
type FontWeight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
const FONT_WEIGHT_MAP: Record<string, FontWeight> = {
regular: "400",
italic: "400",
"100": "100",
"200": "200",
"300": "300",
"400": "400",
"500": "500",
"600": "600",
"700": "700",
"800": "800",
"900": "900",
bold: "700",
"bold-italic": "700",
};
const convertFontVariantToWeight = (variant: string, defaultWeight: FontWeight = "400"): FontWeight =>
FONT_WEIGHT_MAP[variant.toLowerCase()] ?? defaultWeight;
const convertFontVariants = (variants: string[] | undefined, defaultWeight: FontWeight = "400"): FontWeight[] => {
if (!variants || variants.length === 0) return [defaultWeight];
return variants.map((v) => convertFontVariantToWeight(v, defaultWeight));
};
const convertFontVariantsForHeading = (variants: string[] | undefined): FontWeight[] => {
const weights = convertFontVariants(variants, "600");
const filtered = weights.filter((w) => Number.parseInt(w, 10) >= 600);
return filtered.length > 0 ? filtered : ["600"];
};
// ponytail: V4Url + V4Section<T> collapse 11× {label,href} and 13× 5-field section header into aliases
type V4Url = { label: string; href: string };
type V4Section<T> = {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<T>;
};
type V4ResumeData = {
basics: {
name: string;
headline: string;
email: string;
phone: string;
location: 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 };
};
};
sections: {
summary: { name: string; columns: number; separateLinks: boolean; visible: boolean; id: string; content: string };
awards: V4Section<{
id: string;
visible: boolean;
title?: string;
awarder?: string;
date?: string;
summary?: string;
url?: V4Url;
}>;
certifications: V4Section<{
id: string;
visible: boolean;
name?: string;
issuer?: string;
date?: string;
summary?: string;
url?: V4Url;
}>;
education: V4Section<{
id: string;
visible: boolean;
institution?: string;
studyType?: string;
area?: string;
score?: string;
date?: string;
summary?: string;
url?: V4Url;
}>;
experience: V4Section<{
id: string;
visible: boolean;
company?: string;
position?: string;
location?: string;
date?: string;
summary?: string;
url?: V4Url;
}>;
volunteer: V4Section<{
id: string;
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;
visible: boolean;
network?: string;
username?: string;
icon?: string;
url?: V4Url;
}>;
projects: V4Section<{
id: string;
visible: boolean;
name?: string;
description?: string;
date?: string;
summary?: string;
keywords?: string[];
url?: V4Url;
}>;
publications: V4Section<{
id: string;
visible: boolean;
name?: string;
publisher?: string;
date?: string;
summary?: string;
url?: V4Url;
}>;
references: V4Section<{
id: string;
visible: boolean;
name?: string;
description?: string;
summary?: string;
url?: V4Url;
}>;
skills: V4Section<{
id: string;
visible: boolean;
name?: string;
description?: string;
level?: number;
keywords?: string[];
}>;
custom?: Record<
string,
V4Section<{
id: string;
visible: boolean;
name?: string;
description?: string;
date?: string;
location?: string;
summary?: string;
keywords?: string[];
url?: V4Url;
}>
>;
};
metadata: {
template: string;
layout: Array<Array<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 };
lineHeight: number;
hideIcons: boolean;
underlineLinks: boolean;
};
notes: string;
};
};
// Transform layout section ID from V4 format to new format
// V4 uses "custom.{id}" for custom sections, new format just uses "{id}"
const transformLayoutSectionId = (id: string): string => {
if (id.startsWith("custom.")) return id.slice(7);
return id;
};
// Transform layout column by stripping "custom." prefix from section IDs
const transformLayoutColumn = (column: string[]): string[] => {
return column
.filter((id) => id !== "summary") // Summary is handled separately
.map(transformLayoutSectionId);
};
// 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: 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 ?? "",
},
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.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(),
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,
};
}),
},
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");
}
}
return resumeDataSchema.parse(transformed);
} catch (error: unknown) {
if (error instanceof ZodError) rethrowAsImportError(error);
throw error;
}
}