mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
a4999c04af
- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code) - delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt) - delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool, schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers - inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough) - deduplicate template color helpers into shared/color-helpers - unexport 50+ internal-only symbols, remove dead export-map entries - replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
267 lines
7.9 KiB
TypeScript
267 lines
7.9 KiB
TypeScript
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
|
import { deepmergeCustom } from "deepmerge-ts";
|
|
import { jsonrepair } from "jsonrepair";
|
|
import { flattenError, ZodError } from "zod";
|
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
|
import { generateId } from "@reactive-resume/utils/string";
|
|
import { buildAiExtractionTemplate } from "./extraction-template";
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
const aiExtractionTemplate = buildAiExtractionTemplate();
|
|
|
|
type SectionKey = keyof typeof sectionRequiredFieldMap;
|
|
|
|
type CoercionEntry = {
|
|
path: string;
|
|
fromType: string;
|
|
toType: string;
|
|
};
|
|
|
|
type DroppedSectionItemEntry = {
|
|
section: SectionKey;
|
|
index: number;
|
|
reason: string;
|
|
};
|
|
|
|
type ResumeSanitizationDiagnostics = {
|
|
coercions: CoercionEntry[];
|
|
droppedSectionItems: DroppedSectionItemEntry[];
|
|
salvageApplied: boolean;
|
|
};
|
|
|
|
type ResumeSanitizationResult = {
|
|
data: ResumeData;
|
|
diagnostics: ResumeSanitizationDiagnostics;
|
|
};
|
|
|
|
const mergeDefaultsDeep = deepmergeCustom({
|
|
filterValues: (values) => values.filter((value) => value !== undefined && value !== null),
|
|
mergeArrays: false,
|
|
});
|
|
|
|
const sectionRequiredFieldMap = {
|
|
profiles: "network",
|
|
experience: "company",
|
|
education: "school",
|
|
projects: "name",
|
|
skills: "name",
|
|
languages: "language",
|
|
interests: "name",
|
|
awards: "title",
|
|
certifications: "title",
|
|
publications: "title",
|
|
volunteer: "organization",
|
|
references: "name",
|
|
} as const;
|
|
|
|
function getValueType(value: unknown): string {
|
|
if (value === null) return "null";
|
|
if (Array.isArray(value)) return "array";
|
|
return typeof value;
|
|
}
|
|
|
|
function mergeDefaults<T extends Record<string, unknown>, S extends Record<string, unknown>>(
|
|
target: T,
|
|
source: S,
|
|
): T & S {
|
|
if (!isObject(target) || !isObject(source)) {
|
|
return (source !== undefined && source !== null ? source : target) as T & S;
|
|
}
|
|
|
|
return mergeDefaultsDeep(target, source) as T & S;
|
|
}
|
|
|
|
function coerceBoolean(value: unknown): boolean | undefined {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "number") {
|
|
if (value === 1) return true;
|
|
if (value === 0) return false;
|
|
return;
|
|
}
|
|
if (typeof value !== "string") return;
|
|
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return true;
|
|
if (normalized === "false" || normalized === "0") return false;
|
|
return;
|
|
}
|
|
|
|
function coerceNumber(value: unknown): number | undefined {
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
if (typeof value !== "string") return;
|
|
|
|
const normalized = value.trim();
|
|
if (normalized.length === 0) return;
|
|
|
|
const coerced = Number(normalized);
|
|
return Number.isFinite(coerced) ? coerced : undefined;
|
|
}
|
|
|
|
function coerceValueAgainstTemplate(
|
|
value: unknown,
|
|
template: unknown,
|
|
path: string,
|
|
diagnostics: ResumeSanitizationDiagnostics,
|
|
): unknown {
|
|
if (value === undefined || value === null) return value;
|
|
if (template === undefined || template === null) return value;
|
|
|
|
if (typeof template === "boolean") {
|
|
const coerced = coerceBoolean(value);
|
|
if (coerced === undefined) return value;
|
|
if (coerced === value) return value;
|
|
|
|
diagnostics.coercions.push({ path, fromType: getValueType(value), toType: "boolean" });
|
|
return coerced;
|
|
}
|
|
|
|
if (typeof template === "number") {
|
|
const coerced = coerceNumber(value);
|
|
if (coerced === undefined) return value;
|
|
if (coerced === value) return value;
|
|
|
|
diagnostics.coercions.push({ path, fromType: getValueType(value), toType: "number" });
|
|
return coerced;
|
|
}
|
|
|
|
if (Array.isArray(template) && Array.isArray(value)) {
|
|
const itemTemplate = template[0];
|
|
if (itemTemplate === undefined) return value;
|
|
|
|
return value.map((entry, index) =>
|
|
coerceValueAgainstTemplate(entry, itemTemplate, `${path}[${index}]`, diagnostics),
|
|
);
|
|
}
|
|
|
|
if (!isObject(template) || !isObject(value)) return value;
|
|
|
|
const output: Record<string, unknown> = { ...value };
|
|
|
|
for (const key of Object.keys(template)) {
|
|
if (!(key in output)) continue;
|
|
|
|
const nextPath = path.length > 0 ? `${path}.${key}` : key;
|
|
output[key] = coerceValueAgainstTemplate(output[key], template[key], nextPath, diagnostics);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function getJsonBoundaryIndices(value: string): { first: number; last: number } {
|
|
const firstCurly = value.indexOf("{");
|
|
const firstSquare = value.indexOf("[");
|
|
const lastCurly = value.lastIndexOf("}");
|
|
const lastSquare = value.lastIndexOf("]");
|
|
|
|
let first = -1;
|
|
if (firstCurly !== -1 && firstSquare !== -1) {
|
|
first = Math.min(firstCurly, firstSquare);
|
|
} else {
|
|
first = Math.max(firstCurly, firstSquare);
|
|
}
|
|
|
|
return { first, last: Math.max(lastCurly, lastSquare) };
|
|
}
|
|
|
|
function normalizeResumeDataForSchema(data: Record<string, unknown>, diagnostics: ResumeSanitizationDiagnostics) {
|
|
if (!isObject(data)) return data;
|
|
if (!isObject(data.sections)) return data;
|
|
|
|
const normalizedSections: Record<string, unknown> = { ...data.sections };
|
|
|
|
for (const sectionKey of Object.keys(sectionRequiredFieldMap) as SectionKey[]) {
|
|
const section = normalizedSections[sectionKey];
|
|
if (!isObject(section)) continue;
|
|
if (!Array.isArray(section.items)) continue;
|
|
|
|
const itemTemplate = aiExtractionTemplate.sections[sectionKey].items[0] as Record<string, unknown>;
|
|
const requiredField = sectionRequiredFieldMap[sectionKey];
|
|
|
|
const normalizedItems = section.items
|
|
.filter((item): item is Record<string, unknown> => isObject(item))
|
|
.map((item) => {
|
|
const mergedItem = mergeDefaults(itemTemplate, item);
|
|
return coerceValueAgainstTemplate(
|
|
mergedItem,
|
|
itemTemplate,
|
|
`sections.${sectionKey}.items`,
|
|
diagnostics,
|
|
) as Record<string, unknown>;
|
|
})
|
|
.filter((item, index) => {
|
|
const requiredValue = item[requiredField];
|
|
if (typeof requiredValue !== "string" || requiredValue.trim().length === 0) {
|
|
diagnostics.salvageApplied = true;
|
|
diagnostics.droppedSectionItems.push({
|
|
section: sectionKey,
|
|
index,
|
|
reason: `missing required "${requiredField}"`,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map((item) => {
|
|
const normalizedItem = { ...item };
|
|
if (typeof normalizedItem.id !== "string" || normalizedItem.id.trim().length === 0) {
|
|
diagnostics.salvageApplied = true;
|
|
normalizedItem.id = generateId();
|
|
}
|
|
if (typeof normalizedItem.hidden !== "boolean") {
|
|
diagnostics.salvageApplied = true;
|
|
normalizedItem.hidden = false;
|
|
}
|
|
|
|
return normalizedItem;
|
|
});
|
|
|
|
normalizedSections[sectionKey] = { ...section, items: normalizedItems };
|
|
}
|
|
|
|
return { ...data, sections: normalizedSections };
|
|
}
|
|
|
|
export function sanitizeAndParseResumeJson(resultText: string): ResumeSanitizationResult {
|
|
let jsonString = resultText;
|
|
const { first, last } = getJsonBoundaryIndices(jsonString);
|
|
if (first !== -1 && last !== -1 && last >= first) {
|
|
jsonString = jsonString.substring(first, last + 1);
|
|
}
|
|
|
|
try {
|
|
const diagnostics: ResumeSanitizationDiagnostics = {
|
|
coercions: [],
|
|
droppedSectionItems: [],
|
|
salvageApplied: false,
|
|
};
|
|
|
|
const repairedJson = jsonrepair(jsonString);
|
|
const parsedJson = JSON.parse(repairedJson);
|
|
const mergedData = mergeDefaults(defaultResumeData, parsedJson);
|
|
const coercedData = coerceValueAgainstTemplate(mergedData, defaultResumeData, "", diagnostics);
|
|
const normalizedData = normalizeResumeDataForSchema(coercedData as Record<string, unknown>, diagnostics);
|
|
|
|
const data = resumeDataSchema.parse({
|
|
...normalizedData,
|
|
customSections: [],
|
|
picture: defaultResumeData.picture,
|
|
metadata: defaultResumeData.metadata,
|
|
});
|
|
|
|
return { data, diagnostics };
|
|
} catch (error: unknown) {
|
|
if (error instanceof ZodError) {
|
|
console.error("Zod validation failed during resume parsing:", flattenError(error));
|
|
throw error;
|
|
}
|
|
|
|
console.error("Unknown error during resume data validation:", error);
|
|
throw new Error("An unknown error occurred while validating the merged resume data.");
|
|
}
|
|
}
|