* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
+450
View File
@@ -0,0 +1,450 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { flattenError, ZodError, z } from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { formatPeriod, formatSingleDate } from "@reactive-resume/utils/date";
import { arrayToHtmlList, toHtmlDescription } from "@reactive-resume/utils/html";
import { parseLevel } from "@reactive-resume/utils/level";
import { getNetworkIcon } from "@reactive-resume/utils/network-icons";
import { generateId } from "@reactive-resume/utils/string";
import { createUrl } from "@reactive-resume/utils/url";
const createItemWebsite = (url?: string, label?: string) => ({
...createUrl(url, label),
inlineLink: false,
});
// Custom ISO 8601 date pattern that allows partial dates (year only, year-month, or full date)
const iso8601 = z
.string()
.regex(
/^([1-2][0-9]{3}-[0-1][0-9]-[0-3][0-9]|[1-2][0-9]{3}-[0-1][0-9]|[1-2][0-9]{3})$/,
"Must be a valid ISO 8601 date (YYYY, YYYY-MM, or YYYY-MM-DD)",
);
const locationSchema = z.looseObject({
address: z.string().optional(),
postalCode: z.string().optional(),
city: z.string().optional(),
countryCode: z.string().optional(),
region: z.string().optional(),
});
const profileSchema = z.looseObject({
network: z.string().optional(),
username: z.string().optional(),
url: z.url().optional(),
});
const basicsSchema = z.looseObject({
name: z.string().optional(),
label: z.string().optional(),
image: z.string().optional(),
email: z.email().optional(),
phone: z.string().optional(),
url: z.url().optional(),
summary: z.string().optional(),
location: locationSchema.optional(),
profiles: z.array(profileSchema).optional(),
});
const workSchema = z.looseObject({
name: z.string().optional(),
location: z.string().optional(),
description: z.string().optional(),
position: z.string().optional(),
url: z.url().optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
summary: z.string().optional(),
highlights: z.array(z.string()).optional(),
});
const volunteerSchema = z.looseObject({
organization: z.string().optional(),
position: z.string().optional(),
url: z.url().optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
summary: z.string().optional(),
highlights: z.array(z.string()).optional(),
});
const educationSchema = z.looseObject({
institution: z.string().optional(),
url: z.url().optional(),
area: z.string().optional(),
studyType: z.string().optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
score: z.string().optional(),
courses: z.array(z.string()).optional(),
});
const awardSchema = z.looseObject({
title: z.string().optional(),
date: iso8601.optional(),
awarder: z.string().optional(),
summary: z.string().optional(),
});
const certificateSchema = z.looseObject({
name: z.string().optional(),
date: iso8601.optional(),
url: z.url().optional(),
issuer: z.string().optional(),
});
const publicationSchema = z.looseObject({
name: z.string().optional(),
publisher: z.string().optional(),
releaseDate: iso8601.optional(),
url: z.url().optional(),
summary: z.string().optional(),
});
const skillSchema = z.looseObject({
name: z.string().optional(),
level: z.string().optional(),
keywords: z.array(z.string()).optional(),
});
const languageSchema = z.looseObject({
language: z.string().optional(),
fluency: z.string().optional(),
});
const interestSchema = z.looseObject({
name: z.string().optional(),
keywords: z.array(z.string()).optional(),
});
const referenceSchema = z.looseObject({
name: z.string().optional(),
reference: z.string().optional(),
});
const projectSchema = z.looseObject({
name: z.string().optional(),
description: z.string().optional(),
highlights: z.array(z.string()).optional(),
keywords: z.array(z.string()).optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
url: z.url().optional(),
roles: z.array(z.string()).optional(),
entity: z.string().optional(),
type: z.string().optional(),
});
const metaSchema = z.looseObject({
canonical: z.url().optional(),
version: z.string().optional(),
lastModified: z.string().optional(),
});
const jsonResumeSchema = z.looseObject({
$schema: z.url().optional(),
basics: basicsSchema.optional(),
work: z.array(workSchema).optional(),
volunteer: z.array(volunteerSchema).optional(),
education: z.array(educationSchema).optional(),
awards: z.array(awardSchema).optional(),
certificates: z.array(certificateSchema).optional(),
publications: z.array(publicationSchema).optional(),
skills: z.array(skillSchema).optional(),
languages: z.array(languageSchema).optional(),
interests: z.array(interestSchema).optional(),
references: z.array(referenceSchema).optional(),
projects: z.array(projectSchema).optional(),
meta: metaSchema.optional(),
});
type JSONResume = z.infer<typeof jsonResumeSchema>;
type JSONResumeLocation = z.infer<typeof locationSchema>;
// Helper function to format location object to string
function formatLocation(location?: JSONResumeLocation): string {
if (!location) return "";
const parts: string[] = [];
if (location.city) parts.push(location.city);
if (location.region) parts.push(location.region);
if (location.countryCode) parts.push(location.countryCode);
return parts.join(", ");
}
export class JSONResumeImporter {
convert(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 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: `<p>${jsonResume.basics.summary}</p>`,
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 ? `<p>${award.summary}</p>` : "",
})),
};
}
// 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 ? `<p>${pub.summary}</p>` : "",
})),
};
}
// 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 ? `<p>${ref.reference}</p>` : "",
})),
};
}
// 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));
}
throw error;
}
}
}
@@ -0,0 +1,79 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { flattenError, ZodError } from "zod";
import { resumeDataSchema, sectionTypeSchema } from "@reactive-resume/schema/resume/data";
const BUILT_IN_LAYOUT_SECTION_IDS = sectionTypeSchema.options.filter((section) => section !== "cover-letter");
function normalizeBuiltInSectionsInLayout(data: ResumeData): ResumeData {
const pages = data.metadata.layout.pages;
// Some exported resumes can arrive without any persisted layout pages.
if (pages.length === 0) {
return {
...data,
metadata: {
...data.metadata,
layout: {
...data.metadata.layout,
pages: [
{
fullWidth: false,
main: [...BUILT_IN_LAYOUT_SECTION_IDS],
sidebar: [],
},
],
},
},
};
}
const existingSectionIds = new Set<string>();
// Preserve the imported layout and only compute which built-in IDs are missing entirely.
for (const page of pages) {
for (const sectionId of page.main) existingSectionIds.add(sectionId);
for (const sectionId of page.sidebar) existingSectionIds.add(sectionId);
}
const missingSectionIds = BUILT_IN_LAYOUT_SECTION_IDS.filter((sectionId) => !existingSectionIds.has(sectionId));
if (missingSectionIds.length === 0) return data;
const [firstPage, ...restPages] = pages;
if (!firstPage) return data;
return {
...data,
metadata: {
...data.metadata,
layout: {
...data.metadata.layout,
pages: [
{
fullWidth: firstPage.fullWidth ?? false,
sidebar: firstPage.sidebar ?? [],
// Recover missing built-in sections without reordering the imported layout.
main: [...firstPage.main, ...missingSectionIds],
},
...restPages,
],
},
},
};
}
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;
}
}
}
@@ -0,0 +1,780 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import z, { flattenError, 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";
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})`;
}
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 clampLevel = (level: number): number => clamp(level, 0, 5);
const convertAndClampFontSize = (px: number): number => clampFontSize(pxToPt(px));
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"];
};
type V4ResumeData = {
basics: {
name: string;
headline: string;
email: string;
phone: string;
location: string;
url: {
label: string;
href: string;
};
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: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
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;
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;
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;
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;
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;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
keywords?: string[];
}>;
};
languages: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
description?: string;
level?: number;
}>;
};
profiles: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
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;
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[];
}>;
};
custom?: Record<
string,
{
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
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;
};
}>;
}
>;
};
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);
};
export class ReactiveResumeV4JSONImporter {
parse(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),
},
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 ?? "",
columns: v4Data.sections.summary?.columns ?? 1,
hidden: !(v4Data.sections.summary?.visible ?? true),
content: v4Data.sections.summary?.content ?? "",
},
sections: {
profiles: {
title: v4Data.sections.profiles?.name ?? "",
columns: v4Data.sections.profiles?.columns ?? 1,
hidden: !(v4Data.sections.profiles?.visible ?? true),
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 ?? "",
columns: v4Data.sections.experience?.columns ?? 1,
hidden: !(v4Data.sections.experience?.visible ?? true),
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 ?? "",
columns: v4Data.sections.education?.columns ?? 1,
hidden: !(v4Data.sections.education?.visible ?? true),
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 ?? "",
columns: v4Data.sections.projects?.columns ?? 1,
hidden: !(v4Data.sections.projects?.visible ?? true),
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 ?? "",
columns: v4Data.sections.skills?.columns ?? 1,
hidden: !(v4Data.sections.skills?.visible ?? true),
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 ?? "",
level: clampLevel(item.level ?? 0),
keywords: item.keywords ?? [],
})),
},
languages: {
title: v4Data.sections.languages?.name ?? "",
columns: v4Data.sections.languages?.columns ?? 1,
hidden: !(v4Data.sections.languages?.visible ?? true),
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 ?? "",
level: clampLevel(item.level ?? 0),
})),
},
interests: {
title: v4Data.sections.interests?.name ?? "",
columns: v4Data.sections.interests?.columns ?? 1,
hidden: !(v4Data.sections.interests?.visible ?? true),
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 ?? "",
columns: v4Data.sections.awards?.columns ?? 1,
hidden: !(v4Data.sections.awards?.visible ?? true),
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 ?? "",
columns: v4Data.sections.certifications?.columns ?? 1,
hidden: !(v4Data.sections.certifications?.visible ?? true),
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 ?? "",
columns: v4Data.sections.publications?.columns ?? 1,
hidden: !(v4Data.sections.publications?.visible ?? true),
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 ?? "",
columns: v4Data.sections.volunteer?.columns ?? 1,
hidden: !(v4Data.sections.volunteer?.visible ?? true),
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 ?? "",
columns: v4Data.sections.references?.columns ?? 1,
hidden: !(v4Data.sections.references?.visible ?? true),
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 ?? "",
type: "experience" as const, // Default to experience type as it has the most compatible fields
columns: section.columns ?? 1,
hidden: !(section.visible ?? true),
items: section.items
.filter((item) => item.visible !== false)
.map((item, index) => ({
id: item.id || generateId(),
hidden: !item.visible,
company: item.name?.trim() || `#${index + 1}`,
position: item.description ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
roles: [],
description: item.summary ?? "",
})),
})),
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",
hideIcons: v4Data.metadata.typography?.hideIcons ?? false,
},
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 ?? "",
},
};
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;
}
}
}