geulah 3a9591fb48 feat(templates): add color theme support and template selection step
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
2025-10-14 22:39:36 +01:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// URL validation and normalization
export const normalizeUrl = (url: string): string => {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
}
return url;
};