Implement color theme system with 9 color options and default theme Add template selection as first step in CV creation flow Update all templates to support dynamic color theming Create ThemeProvider component to apply theme styles Add template thumbnails with color theme variations Extend CV schema with colorTheme field Update store to handle template selection and color theme state
158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
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<typeof CvSchema>;
|
|
|
|
// 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 = <K extends keyof CV>(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, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
// URL validation and normalization
|
|
export const normalizeUrl = (url: string): string => {
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
return `https://${url}`;
|
|
}
|
|
return url;
|
|
}; |