import { z } from 'zod'; import DOMPurify from 'dompurify'; // Initialize link-safety hook: force rel/target and restrict href schemes // Note: Hooks may be registered multiple times during HMR; this hook is idempotent. DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node && node.nodeName && node.nodeName.toLowerCase() === 'a') { const href = node.getAttribute('href') || ''; const isSafe = /^(https?:\/\/|mailto:)/i.test(href); if (!isSafe) { node.removeAttribute('href'); } // Enforce safe link attributes node.setAttribute('rel', 'noopener noreferrer'); node.setAttribute('target', '_blank'); } }); // Helper function to generate unique IDs export const generateId = () => Math.random().toString(36).substring(2, 9); // CV Schema using Zod for validation export const CvSchema = z.object({ personal: z.object({ firstName: z.string().min(1, "First name is required"), lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), phone: z.string().optional(), photoUrl: z.string().optional().default(""), street: z.string().optional(), city: z.string().optional(), country: z.string().optional(), postcode: z.string().optional(), links: z.array( z.object({ id: z.string().default(() => generateId()), label: z.string(), url: z.string().url("Invalid URL format") }) ).optional().default([]), extras: z.array(z.string()).optional().default([]), }), summary: z.string().max(600, "Summary should be less than 600 characters").optional().default(""), work: z.array( z.object({ id: z.string().default(() => generateId()), title: z.string().min(1, "Job title is required"), company: z.string().min(1, "Company name is required"), location: z.string().optional(), startDate: z.string(), endDate: z.string().optional(), bullets: z.array(z.string().max(160, "Each bullet point should be less than 160 characters")).max(6, "Maximum 6 bullet points allowed"), employmentType: z.enum(['full_time', 'part_time', 'contract', 'intern', 'freelance']).optional(), }) ).default([]), education: z.array( z.object({ id: z.string().default(() => generateId()), degree: z.string().min(1, "Degree is required"), school: z.string().min(1, "School name is required"), startDate: z.string(), endDate: z.string().optional(), notes: z.string().optional(), }) ).default([]), skills: z.array( z.object({ id: z.string().default(() => generateId()), name: z.string(), level: z.enum(['Beginner', 'Intermediate', 'Advanced']).optional() }) ).default([]), languages: z.array( z.object({ id: z.string().default(() => generateId()), name: z.string(), level: z.enum(['Basic', 'Conversational', 'Fluent', 'Native']) }) ).optional().default([]), certifications: z.array( z.object({ id: z.string().default(() => generateId()), name: z.string(), issuer: z.string().optional(), date: z.string().optional() }) ).optional().default([]), // Optional ProseMirror JSON for summary rich content persistence summaryJson: z.any().optional(), templateId: z.string().default('ats'), colorTheme: z.enum(['gray', 'dark-gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'black'] as const).nullable().default(null), }); // Type inference from the schema export type CV = z.infer; // Create an empty CV with default values export const createEmptyCV = (): CV => ({ personal: { firstName: "", lastName: "", email: "", phone: "", photoUrl: "", street: "", city: "", country: "", postcode: "", links: [], extras: [], }, summary: "", work: [], education: [], skills: [], languages: [], certifications: [], summaryJson: undefined, templateId: "ats", colorTheme: null, }); // Helper functions for validation export const validateCV = (cv: CV) => { return CvSchema.safeParse(cv); }; export const validateSection = (section: K, data: CV[K]) => { const sectionSchema = CvSchema.shape[section]; return sectionSchema.safeParse(data); }; // Sanitization helpers export const sanitizeHtml = (html: string): string => { // Sanitize summary while preserving basic formatting elements return DOMPurify.sanitize(html, { ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'], ALLOWED_ATTR: ['href', 'rel', 'target'], ALLOW_UNKNOWN_PROTOCOLS: false }); }; export const escapeText = (text: string): string => { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; // URL validation and normalization export const normalizeUrl = (url: string): string => { if (!url.startsWith('http://') && !url.startsWith('https://')) { return `https://${url}`; } return url; };