refactor(validation): migrate BBCode and TextStyle validation to Zod schemas
- commit
- b74deb49dc234abec275190748a98af8b7c0cfb0
- author
- bnema · · 2025-12-15 22:11 UTC
- parent
- 85bf2ff
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
3 files · +145 -116
M src/lib/utils/text-formatter.ts +8 -61
| @@ -5,6 +5,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | |||
| 5 | 5 | import rehypeStringify from 'rehype-stringify'; | |
| 6 | 6 | import type { TextStyle } from '../types/text-formatting'; | |
| 7 | 7 | import { validateTextStyle } from '../types/text-formatting'; | |
| 8 | + | import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode'; | |
| 8 | 9 | ||
| 9 | 10 | // Define allowed HTML tags and attributes for security | |
| 10 | 11 | const ALLOWED_SCHEMA = { | |
| @@ -25,60 +26,6 @@ const ALLOWED_SCHEMA = { | |||
| 25 | 26 | } | |
| 26 | 27 | }; | |
| 27 | 28 | ||
| 28 | - | // Validation functions for BBCode values | |
| 29 | - | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | |
| 30 | - | const NAMED_COLORS = new Set([ | |
| 31 | - | 'red', | |
| 32 | - | 'blue', | |
| 33 | - | 'green', | |
| 34 | - | 'yellow', | |
| 35 | - | 'orange', | |
| 36 | - | 'purple', | |
| 37 | - | 'pink', | |
| 38 | - | 'black', | |
| 39 | - | 'white', | |
| 40 | - | 'gray', | |
| 41 | - | 'grey', | |
| 42 | - | 'brown', | |
| 43 | - | 'cyan', | |
| 44 | - | 'magenta', | |
| 45 | - | 'lime', | |
| 46 | - | 'navy', | |
| 47 | - | 'teal', | |
| 48 | - | 'maroon' | |
| 49 | - | ]); | |
| 50 | - | const ALLOWED_FONTS = new Set([ | |
| 51 | - | 'arial', | |
| 52 | - | 'helvetica', | |
| 53 | - | 'verdana', | |
| 54 | - | 'georgia', | |
| 55 | - | 'times', | |
| 56 | - | 'courier', | |
| 57 | - | 'comic sans ms', | |
| 58 | - | 'impact', | |
| 59 | - | 'trebuchet ms', | |
| 60 | - | 'lucida console', | |
| 61 | - | 'monospace', | |
| 62 | - | 'sans-serif', | |
| 63 | - | 'serif' | |
| 64 | - | ]); | |
| 65 | - | const MIN_FONT_SIZE = 8; | |
| 66 | - | const MAX_FONT_SIZE = 24; | |
| 67 | - | ||
| 68 | - | function isValidColor(color: string): boolean { | |
| 69 | - | const lower = color.toLowerCase(); | |
| 70 | - | return HEX_COLOR_REGEX.test(color) || NAMED_COLORS.has(lower); | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | function isValidFontSize(size: string): boolean { | |
| 74 | - | const num = parseInt(size, 10); | |
| 75 | - | return !isNaN(num) && num >= MIN_FONT_SIZE && num <= MAX_FONT_SIZE; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | function isValidFont(font: string): boolean { | |
| 79 | - | return ALLOWED_FONTS.has(font.toLowerCase().trim()); | |
| 80 | - | } | |
| 81 | - | ||
| 82 | 29 | // BBCode-style formatting patterns (simple ones with static replacement) | |
| 83 | 30 | const BB_CODE_SIMPLE_PATTERNS: Array<{ | |
| 84 | 31 | pattern: RegExp; | |
| @@ -97,29 +44,29 @@ const BB_CODE_SIMPLE_PATTERNS: Array<{ | |||
| 97 | 44 | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' } | |
| 98 | 45 | ]; | |
| 99 | 46 | ||
| 100 | - | // BBCode patterns that require validation | |
| 47 | + | // BBCode patterns that require validation (using Zod schemas) | |
| 101 | 48 | function processBBCodeWithValidation(text: string): string { | |
| 102 | 49 | let result = text; | |
| 103 | 50 | ||
| 104 | - | // Color - validate hex or named colors | |
| 51 | + | // Color - validate hex or named colors using Zod schema | |
| 105 | 52 | result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => { | |
| 106 | - | if (isValidColor(color)) { | |
| 53 | + | if (isValidBBCodeColor(color)) { | |
| 107 | 54 | return `<span style="color: ${color}">${content}</span>`; | |
| 108 | 55 | } | |
| 109 | 56 | return content; // Strip invalid color, keep content | |
| 110 | 57 | }); | |
| 111 | 58 | ||
| 112 | - | // Size - validate range 8-24px | |
| 59 | + | // Size - validate range 8-24px using Zod schema | |
| 113 | 60 | result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => { | |
| 114 | - | if (isValidFontSize(size)) { | |
| 61 | + | if (isValidBBCodeFontSize(size)) { | |
| 115 | 62 | return `<span style="font-size: ${size}px">${content}</span>`; | |
| 116 | 63 | } | |
| 117 | 64 | return content; // Strip invalid size, keep content | |
| 118 | 65 | }); | |
| 119 | 66 | ||
| 120 | - | // Font - validate against allowed fonts | |
| 67 | + | // Font - validate against allowed fonts using Zod schema | |
| 121 | 68 | result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => { | |
| 122 | - | if (isValidFont(font)) { | |
| 69 | + | if (isValidBBCodeFont(font)) { | |
| 123 | 70 | return `<span style="font-family: ${font}">${content}</span>`; | |
| 124 | 71 | } | |
| 125 | 72 | return content; // Strip invalid font, keep content | |
| @@ -5,6 +5,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | |||||
| 5 | import rehypeStringify from 'rehype-stringify'; | 5 | import rehypeStringify from 'rehype-stringify'; | ||
| 6 | import type { TextStyle } from '../types/text-formatting'; | 6 | import type { TextStyle } from '../types/text-formatting'; | ||
| 7 | import { validateTextStyle } from '../types/text-formatting'; | 7 | import { validateTextStyle } from '../types/text-formatting'; | ||
| 8 | + | import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode'; | |||
| 8 | 9 | ||||
| 9 | // Define allowed HTML tags and attributes for security | 10 | // Define allowed HTML tags and attributes for security | ||
| 10 | const ALLOWED_SCHEMA = { | 11 | const ALLOWED_SCHEMA = { | ||
| @@ -25,60 +26,6 @@ const ALLOWED_SCHEMA = { | |||||
| 25 | } | 26 | } | ||
| 26 | }; | 27 | }; | ||
| 27 | 28 | ||||
| 28 | - | // Validation functions for BBCode values | |||
| 29 | - | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | |||
| 30 | - | const NAMED_COLORS = new Set([ | |||
| 31 | - | 'red', | |||
| 32 | - | 'blue', | |||
| 33 | - | 'green', | |||
| 34 | - | 'yellow', | |||
| 35 | - | 'orange', | |||
| 36 | - | 'purple', | |||
| 37 | - | 'pink', | |||
| 38 | - | 'black', | |||
| 39 | - | 'white', | |||
| 40 | - | 'gray', | |||
| 41 | - | 'grey', | |||
| 42 | - | 'brown', | |||
| 43 | - | 'cyan', | |||
| 44 | - | 'magenta', | |||
| 45 | - | 'lime', | |||
| 46 | - | 'navy', | |||
| 47 | - | 'teal', | |||
| 48 | - | 'maroon' | |||
| 49 | - | ]); | |||
| 50 | - | const ALLOWED_FONTS = new Set([ | |||
| 51 | - | 'arial', | |||
| 52 | - | 'helvetica', | |||
| 53 | - | 'verdana', | |||
| 54 | - | 'georgia', | |||
| 55 | - | 'times', | |||
| 56 | - | 'courier', | |||
| 57 | - | 'comic sans ms', | |||
| 58 | - | 'impact', | |||
| 59 | - | 'trebuchet ms', | |||
| 60 | - | 'lucida console', | |||
| 61 | - | 'monospace', | |||
| 62 | - | 'sans-serif', | |||
| 63 | - | 'serif' | |||
| 64 | - | ]); | |||
| 65 | - | const MIN_FONT_SIZE = 8; | |||
| 66 | - | const MAX_FONT_SIZE = 24; | |||
| 67 | - | ||||
| 68 | - | function isValidColor(color: string): boolean { | |||
| 69 | - | const lower = color.toLowerCase(); | |||
| 70 | - | return HEX_COLOR_REGEX.test(color) || NAMED_COLORS.has(lower); | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | function isValidFontSize(size: string): boolean { | |||
| 74 | - | const num = parseInt(size, 10); | |||
| 75 | - | return !isNaN(num) && num >= MIN_FONT_SIZE && num <= MAX_FONT_SIZE; | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | function isValidFont(font: string): boolean { | |||
| 79 | - | return ALLOWED_FONTS.has(font.toLowerCase().trim()); | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | // BBCode-style formatting patterns (simple ones with static replacement) | 29 | // BBCode-style formatting patterns (simple ones with static replacement) | ||
| 83 | const BB_CODE_SIMPLE_PATTERNS: Array<{ | 30 | const BB_CODE_SIMPLE_PATTERNS: Array<{ | ||
| 84 | pattern: RegExp; | 31 | pattern: RegExp; | ||
| @@ -97,29 +44,29 @@ const BB_CODE_SIMPLE_PATTERNS: Array<{ | |||||
| 97 | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' } | 44 | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' } | ||
| 98 | ]; | 45 | ]; | ||
| 99 | 46 | ||||
| 100 | - | // BBCode patterns that require validation | 47 | + | // BBCode patterns that require validation (using Zod schemas) |
| 101 | function processBBCodeWithValidation(text: string): string { | 48 | function processBBCodeWithValidation(text: string): string { | ||
| 102 | let result = text; | 49 | let result = text; | ||
| 103 | 50 | ||||
| 104 | - | // Color - validate hex or named colors | 51 | + | // Color - validate hex or named colors using Zod schema |
| 105 | result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => { | 52 | result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => { | ||
| 106 | - | if (isValidColor(color)) { | 53 | + | if (isValidBBCodeColor(color)) { |
| 107 | return `<span style="color: ${color}">${content}</span>`; | 54 | return `<span style="color: ${color}">${content}</span>`; | ||
| 108 | } | 55 | } | ||
| 109 | return content; // Strip invalid color, keep content | 56 | return content; // Strip invalid color, keep content | ||
| 110 | }); | 57 | }); | ||
| 111 | 58 | ||||
| 112 | - | // Size - validate range 8-24px | 59 | + | // Size - validate range 8-24px using Zod schema |
| 113 | result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => { | 60 | result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => { | ||
| 114 | - | if (isValidFontSize(size)) { | 61 | + | if (isValidBBCodeFontSize(size)) { |
| 115 | return `<span style="font-size: ${size}px">${content}</span>`; | 62 | return `<span style="font-size: ${size}px">${content}</span>`; | ||
| 116 | } | 63 | } | ||
| 117 | return content; // Strip invalid size, keep content | 64 | return content; // Strip invalid size, keep content | ||
| 118 | }); | 65 | }); | ||
| 119 | 66 | ||||
| 120 | - | // Font - validate against allowed fonts | 67 | + | // Font - validate against allowed fonts using Zod schema |
| 121 | result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => { | 68 | result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => { | ||
| 122 | - | if (isValidFont(font)) { | 69 | + | if (isValidBBCodeFont(font)) { |
| 123 | return `<span style="font-family: ${font}">${content}</span>`; | 70 | return `<span style="font-family: ${font}">${content}</span>`; | ||
| 124 | } | 71 | } | ||
| 125 | return content; // Strip invalid font, keep content | 72 | return content; // Strip invalid font, keep content | ||
A src/lib/validation/bbcode.ts +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | import { z } from 'zod'; | |
| 2 | + | import { RETRO_FONTS } from '$lib/types/text-formatting'; | |
| 3 | + | ||
| 4 | + | // Build allowed fonts Set from RETRO_FONTS - includes both keys and display names | |
| 5 | + | const ALLOWED_FONTS_SET = new Set<string>(); | |
| 6 | + | for (const [key, value] of Object.entries(RETRO_FONTS)) { | |
| 7 | + | // Add the key (e.g., 'comicsans') | |
| 8 | + | ALLOWED_FONTS_SET.add(key.toLowerCase()); | |
| 9 | + | // Add the display name (e.g., 'comic sans ms') | |
| 10 | + | ALLOWED_FONTS_SET.add(value.name.toLowerCase()); | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // BBCode constraints | |
| 14 | + | export const BBCODE_CONSTRAINTS = { | |
| 15 | + | minFontSize: 8, | |
| 16 | + | maxFontSize: 24, | |
| 17 | + | namedColors: [ | |
| 18 | + | 'red', | |
| 19 | + | 'blue', | |
| 20 | + | 'green', | |
| 21 | + | 'yellow', | |
| 22 | + | 'orange', | |
| 23 | + | 'purple', | |
| 24 | + | 'pink', | |
| 25 | + | 'black', | |
| 26 | + | 'white', | |
| 27 | + | 'gray', | |
| 28 | + | 'grey', | |
| 29 | + | 'brown', | |
| 30 | + | 'cyan', | |
| 31 | + | 'magenta', | |
| 32 | + | 'lime', | |
| 33 | + | 'navy', | |
| 34 | + | 'teal', | |
| 35 | + | 'maroon' | |
| 36 | + | ] as const | |
| 37 | + | }; | |
| 38 | + | ||
| 39 | + | // Hex color schema - validates 6-digit hex colors | |
| 40 | + | const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format'); | |
| 41 | + | ||
| 42 | + | // Named color schema - validates against predefined list | |
| 43 | + | const namedColorSchema = z.enum(BBCODE_CONSTRAINTS.namedColors); | |
| 44 | + | ||
| 45 | + | // Combined color schema - accepts either hex or named color | |
| 46 | + | export const bbcodeColorSchema = z.union([hexColorSchema, namedColorSchema]); | |
| 47 | + | ||
| 48 | + | // Font size schema - validates range 8-24 | |
| 49 | + | export const bbcodeFontSizeSchema = z.coerce | |
| 50 | + | .number() | |
| 51 | + | .int('Font size must be an integer') | |
| 52 | + | .min( | |
| 53 | + | BBCODE_CONSTRAINTS.minFontSize, | |
| 54 | + | `Font size must be at least ${BBCODE_CONSTRAINTS.minFontSize}px` | |
| 55 | + | ) | |
| 56 | + | .max( | |
| 57 | + | BBCODE_CONSTRAINTS.maxFontSize, | |
| 58 | + | `Font size cannot exceed ${BBCODE_CONSTRAINTS.maxFontSize}px` | |
| 59 | + | ); | |
| 60 | + | ||
| 61 | + | // Font family schema - validates against RETRO_FONTS (both keys and names) | |
| 62 | + | export const bbcodeFontFamilySchema = z | |
| 63 | + | .string() | |
| 64 | + | .transform((val) => val.toLowerCase().trim()) | |
| 65 | + | .refine((val) => ALLOWED_FONTS_SET.has(val), { | |
| 66 | + | message: 'Invalid font family' | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | // Validation helper functions that return boolean (for use in text-formatter) | |
| 70 | + | export function isValidBBCodeColor(color: string): boolean { | |
| 71 | + | return bbcodeColorSchema.safeParse(color.toLowerCase()).success; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | export function isValidBBCodeFontSize(size: string): boolean { | |
| 75 | + | return bbcodeFontSizeSchema.safeParse(size).success; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | export function isValidBBCodeFont(font: string): boolean { | |
| 79 | + | return ALLOWED_FONTS_SET.has(font.toLowerCase().trim()); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Type exports for use elsewhere | |
| 83 | + | export type BBCodeColor = z.infer<typeof bbcodeColorSchema>; | |
| 84 | + | export type BBCodeFontSize = z.infer<typeof bbcodeFontSizeSchema>; | |
| 85 | + | export type BBCodeFontFamily = z.infer<typeof bbcodeFontFamilySchema>; | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | import { z } from 'zod'; | |||
| 2 | + | import { RETRO_FONTS } from '$lib/types/text-formatting'; | |||
| 3 | + | ||||
| 4 | + | // Build allowed fonts Set from RETRO_FONTS - includes both keys and display names | |||
| 5 | + | const ALLOWED_FONTS_SET = new Set<string>(); | |||
| 6 | + | for (const [key, value] of Object.entries(RETRO_FONTS)) { | |||
| 7 | + | // Add the key (e.g., 'comicsans') | |||
| 8 | + | ALLOWED_FONTS_SET.add(key.toLowerCase()); | |||
| 9 | + | // Add the display name (e.g., 'comic sans ms') | |||
| 10 | + | ALLOWED_FONTS_SET.add(value.name.toLowerCase()); | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | // BBCode constraints | |||
| 14 | + | export const BBCODE_CONSTRAINTS = { | |||
| 15 | + | minFontSize: 8, | |||
| 16 | + | maxFontSize: 24, | |||
| 17 | + | namedColors: [ | |||
| 18 | + | 'red', | |||
| 19 | + | 'blue', | |||
| 20 | + | 'green', | |||
| 21 | + | 'yellow', | |||
| 22 | + | 'orange', | |||
| 23 | + | 'purple', | |||
| 24 | + | 'pink', | |||
| 25 | + | 'black', | |||
| 26 | + | 'white', | |||
| 27 | + | 'gray', | |||
| 28 | + | 'grey', | |||
| 29 | + | 'brown', | |||
| 30 | + | 'cyan', | |||
| 31 | + | 'magenta', | |||
| 32 | + | 'lime', | |||
| 33 | + | 'navy', | |||
| 34 | + | 'teal', | |||
| 35 | + | 'maroon' | |||
| 36 | + | ] as const | |||
| 37 | + | }; | |||
| 38 | + | ||||
| 39 | + | // Hex color schema - validates 6-digit hex colors | |||
| 40 | + | const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format'); | |||
| 41 | + | ||||
| 42 | + | // Named color schema - validates against predefined list | |||
| 43 | + | const namedColorSchema = z.enum(BBCODE_CONSTRAINTS.namedColors); | |||
| 44 | + | ||||
| 45 | + | // Combined color schema - accepts either hex or named color | |||
| 46 | + | export const bbcodeColorSchema = z.union([hexColorSchema, namedColorSchema]); | |||
| 47 | + | ||||
| 48 | + | // Font size schema - validates range 8-24 | |||
| 49 | + | export const bbcodeFontSizeSchema = z.coerce | |||
| 50 | + | .number() | |||
| 51 | + | .int('Font size must be an integer') | |||
| 52 | + | .min( | |||
| 53 | + | BBCODE_CONSTRAINTS.minFontSize, | |||
| 54 | + | `Font size must be at least ${BBCODE_CONSTRAINTS.minFontSize}px` | |||
| 55 | + | ) | |||
| 56 | + | .max( | |||
| 57 | + | BBCODE_CONSTRAINTS.maxFontSize, | |||
| 58 | + | `Font size cannot exceed ${BBCODE_CONSTRAINTS.maxFontSize}px` | |||
| 59 | + | ); | |||
| 60 | + | ||||
| 61 | + | // Font family schema - validates against RETRO_FONTS (both keys and names) | |||
| 62 | + | export const bbcodeFontFamilySchema = z | |||
| 63 | + | .string() | |||
| 64 | + | .transform((val) => val.toLowerCase().trim()) | |||
| 65 | + | .refine((val) => ALLOWED_FONTS_SET.has(val), { | |||
| 66 | + | message: 'Invalid font family' | |||
| 67 | + | }); | |||
| 68 | + | ||||
| 69 | + | // Validation helper functions that return boolean (for use in text-formatter) | |||
| 70 | + | export function isValidBBCodeColor(color: string): boolean { | |||
| 71 | + | return bbcodeColorSchema.safeParse(color.toLowerCase()).success; | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | export function isValidBBCodeFontSize(size: string): boolean { | |||
| 75 | + | return bbcodeFontSizeSchema.safeParse(size).success; | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | export function isValidBBCodeFont(font: string): boolean { | |||
| 79 | + | return ALLOWED_FONTS_SET.has(font.toLowerCase().trim()); | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Type exports for use elsewhere | |||
| 83 | + | export type BBCodeColor = z.infer<typeof bbcodeColorSchema>; | |||
| 84 | + | export type BBCodeFontSize = z.infer<typeof bbcodeFontSizeSchema>; | |||
| 85 | + | export type BBCodeFontFamily = z.infer<typeof bbcodeFontFamilySchema>; | |||
M src/lib/validation/text-formatting.ts +52 -55
| @@ -1,76 +1,74 @@ | |||
| 1 | + | import { z } from 'zod'; | |
| 1 | 2 | import type { TextStyle } from '$lib/types/text-formatting'; | |
| 2 | 3 | import { RETRO_FONTS } from '$lib/types/text-formatting'; | |
| 3 | 4 | ||
| 4 | - | const MIN_FONT_SIZE = 8; | |
| 5 | - | const MAX_FONT_SIZE = 18; | |
| 6 | - | const MAX_GRADIENT_COLORS = 3; | |
| 7 | - | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | |
| 5 | + | // Text style constraints (different from BBCode - style picker has smaller max) | |
| 6 | + | export const TEXT_STYLE_CONSTRAINTS = { | |
| 7 | + | minFontSize: 8, | |
| 8 | + | maxFontSize: 18, | |
| 9 | + | maxGradientColors: 3 | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | // Hex color schema | |
| 13 | + | const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format'); | |
| 14 | + | ||
| 15 | + | // Font family schema - validates against RETRO_FONTS keys | |
| 16 | + | const fontFamilyKeys = Object.keys(RETRO_FONTS) as [string, ...string[]]; | |
| 17 | + | const fontFamilySchema = z.enum(fontFamilyKeys); | |
| 18 | + | ||
| 19 | + | // Font size schema with clamping | |
| 20 | + | const fontSizeSchema = z.coerce | |
| 21 | + | .number() | |
| 22 | + | .int() | |
| 23 | + | .transform((val) => | |
| 24 | + | Math.max(TEXT_STYLE_CONSTRAINTS.minFontSize, Math.min(TEXT_STYLE_CONSTRAINTS.maxFontSize, val)) | |
| 25 | + | ); | |
| 8 | 26 | ||
| 27 | + | // Gradient schema - array of 1-3 hex colors | |
| 28 | + | const gradientSchema = z | |
| 29 | + | .array(hexColorSchema) | |
| 30 | + | .min(1) | |
| 31 | + | .max(TEXT_STYLE_CONSTRAINTS.maxGradientColors) | |
| 32 | + | .optional(); | |
| 33 | + | ||
| 34 | + | // Full TextStyle schema | |
| 35 | + | const textStyleSchema = z.object({ | |
| 36 | + | fontFamily: fontFamilySchema.default('pixelated'), | |
| 37 | + | fontSize: fontSizeSchema.default(14), | |
| 38 | + | color: hexColorSchema.optional(), | |
| 39 | + | gradient: gradientSchema, | |
| 40 | + | bold: z.boolean().default(false), | |
| 41 | + | italic: z.boolean().default(false), | |
| 42 | + | underline: z.boolean().default(false), | |
| 43 | + | strikethrough: z.boolean().default(false) | |
| 44 | + | }); | |
| 45 | + | ||
| 46 | + | // Legacy helper functions (for backwards compatibility) | |
| 9 | 47 | export function validateHexColor(color: string): boolean { | |
| 10 | - | return HEX_COLOR_REGEX.test(color); | |
| 48 | + | return hexColorSchema.safeParse(color).success; | |
| 11 | 49 | } | |
| 12 | 50 | ||
| 13 | 51 | export function validateFontFamily(fontFamily: string): boolean { | |
| 14 | - | return fontFamily in RETRO_FONTS; | |
| 52 | + | return fontFamilySchema.safeParse(fontFamily).success; | |
| 15 | 53 | } | |
| 16 | 54 | ||
| 17 | 55 | export function validateFontSize(size: number): number { | |
| 18 | - | return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); | |
| 56 | + | const result = fontSizeSchema.safeParse(size); | |
| 57 | + | return result.success ? result.data : 14; | |
| 19 | 58 | } | |
| 20 | 59 | ||
| 21 | 60 | export function validateGradient(gradient: unknown): string[] | undefined { | |
| 22 | - | if (!Array.isArray(gradient)) return undefined; | |
| 23 | - | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; | |
| 24 | - | ||
| 25 | - | const validColors = gradient.filter( | |
| 26 | - | (color) => typeof color === 'string' && validateHexColor(color) | |
| 27 | - | ); | |
| 28 | - | ||
| 29 | - | return validColors.length === gradient.length ? validColors : undefined; | |
| 61 | + | const result = gradientSchema.safeParse(gradient); | |
| 62 | + | return result.success ? result.data : undefined; | |
| 30 | 63 | } | |
| 31 | 64 | ||
| 32 | 65 | export function validateTextStyle(style: unknown): TextStyle | null { | |
| 33 | 66 | if (!style || typeof style !== 'object') return null; | |
| 34 | 67 | ||
| 35 | - | const rawStyle = style as Record<string, unknown>; | |
| 36 | - | ||
| 37 | - | const validated: TextStyle = { | |
| 38 | - | fontFamily: 'pixelated', | |
| 39 | - | fontSize: 14, | |
| 40 | - | bold: false, | |
| 41 | - | italic: false, | |
| 42 | - | underline: false, | |
| 43 | - | strikethrough: false | |
| 44 | - | }; | |
| 45 | - | ||
| 46 | - | // Validate font family | |
| 47 | - | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { | |
| 48 | - | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // Validate font size | |
| 52 | - | if (typeof rawStyle.fontSize === 'number') { | |
| 53 | - | validated.fontSize = validateFontSize(rawStyle.fontSize); | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // Validate color | |
| 57 | - | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { | |
| 58 | - | validated.color = rawStyle.color; | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Validate gradient | |
| 62 | - | const validatedGradient = validateGradient(rawStyle.gradient); | |
| 63 | - | if (validatedGradient) { | |
| 64 | - | validated.gradient = validatedGradient; | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // Validate boolean properties | |
| 68 | - | validated.bold = Boolean(rawStyle.bold); | |
| 69 | - | validated.italic = Boolean(rawStyle.italic); | |
| 70 | - | validated.underline = Boolean(rawStyle.underline); | |
| 71 | - | validated.strikethrough = Boolean(rawStyle.strikethrough); | |
| 68 | + | const result = textStyleSchema.safeParse(style); | |
| 69 | + | if (!result.success) return null; | |
| 72 | 70 | ||
| 73 | - | return validated; | |
| 71 | + | return result.data as TextStyle; | |
| 74 | 72 | } | |
| 75 | 73 | ||
| 76 | 74 | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | |
| @@ -81,8 +79,7 @@ export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | |||
| 81 | 79 | if (typeof styleData === 'string') { | |
| 82 | 80 | try { | |
| 83 | 81 | parsedData = JSON.parse(styleData); | |
| 84 | - | } catch (error) { | |
| 85 | - | console.warn('Failed to parse styleData JSON:', error); | |
| 82 | + | } catch { | |
| 86 | 83 | return undefined; | |
| 87 | 84 | } | |
| 88 | 85 | } | |
| @@ -1,76 +1,74 @@ | |||||
| 1 | + | import { z } from 'zod'; | |||
| 1 | import type { TextStyle } from '$lib/types/text-formatting'; | 2 | import type { TextStyle } from '$lib/types/text-formatting'; | ||
| 2 | import { RETRO_FONTS } from '$lib/types/text-formatting'; | 3 | import { RETRO_FONTS } from '$lib/types/text-formatting'; | ||
| 3 | 4 | ||||
| 4 | - | const MIN_FONT_SIZE = 8; | 5 | + | // Text style constraints (different from BBCode - style picker has smaller max) |
| 5 | - | const MAX_FONT_SIZE = 18; | 6 | + | export const TEXT_STYLE_CONSTRAINTS = { |
| 6 | - | const MAX_GRADIENT_COLORS = 3; | 7 | + | minFontSize: 8, |
| 7 | - | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | 8 | + | maxFontSize: 18, |
| 9 | + | maxGradientColors: 3 | |||
| 10 | + | }; | |||
| 11 | + | ||||
| 12 | + | // Hex color schema | |||
| 13 | + | const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format'); | |||
| 14 | + | ||||
| 15 | + | // Font family schema - validates against RETRO_FONTS keys | |||
| 16 | + | const fontFamilyKeys = Object.keys(RETRO_FONTS) as [string, ...string[]]; | |||
| 17 | + | const fontFamilySchema = z.enum(fontFamilyKeys); | |||
| 18 | + | ||||
| 19 | + | // Font size schema with clamping | |||
| 20 | + | const fontSizeSchema = z.coerce | |||
| 21 | + | .number() | |||
| 22 | + | .int() | |||
| 23 | + | .transform((val) => | |||
| 24 | + | Math.max(TEXT_STYLE_CONSTRAINTS.minFontSize, Math.min(TEXT_STYLE_CONSTRAINTS.maxFontSize, val)) | |||
| 25 | + | ); | |||
| 8 | 26 | ||||
| 27 | + | // Gradient schema - array of 1-3 hex colors | |||
| 28 | + | const gradientSchema = z | |||
| 29 | + | .array(hexColorSchema) | |||
| 30 | + | .min(1) | |||
| 31 | + | .max(TEXT_STYLE_CONSTRAINTS.maxGradientColors) | |||
| 32 | + | .optional(); | |||
| 33 | + | ||||
| 34 | + | // Full TextStyle schema | |||
| 35 | + | const textStyleSchema = z.object({ | |||
| 36 | + | fontFamily: fontFamilySchema.default('pixelated'), | |||
| 37 | + | fontSize: fontSizeSchema.default(14), | |||
| 38 | + | color: hexColorSchema.optional(), | |||
| 39 | + | gradient: gradientSchema, | |||
| 40 | + | bold: z.boolean().default(false), | |||
| 41 | + | italic: z.boolean().default(false), | |||
| 42 | + | underline: z.boolean().default(false), | |||
| 43 | + | strikethrough: z.boolean().default(false) | |||
| 44 | + | }); | |||
| 45 | + | ||||
| 46 | + | // Legacy helper functions (for backwards compatibility) | |||
| 9 | export function validateHexColor(color: string): boolean { | 47 | export function validateHexColor(color: string): boolean { | ||
| 10 | - | return HEX_COLOR_REGEX.test(color); | 48 | + | return hexColorSchema.safeParse(color).success; |
| 11 | } | 49 | } | ||
| 12 | 50 | ||||
| 13 | export function validateFontFamily(fontFamily: string): boolean { | 51 | export function validateFontFamily(fontFamily: string): boolean { | ||
| 14 | - | return fontFamily in RETRO_FONTS; | 52 | + | return fontFamilySchema.safeParse(fontFamily).success; |
| 15 | } | 53 | } | ||
| 16 | 54 | ||||
| 17 | export function validateFontSize(size: number): number { | 55 | export function validateFontSize(size: number): number { | ||
| 18 | - | return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); | 56 | + | const result = fontSizeSchema.safeParse(size); |
| 57 | + | return result.success ? result.data : 14; | |||
| 19 | } | 58 | } | ||
| 20 | 59 | ||||
| 21 | export function validateGradient(gradient: unknown): string[] | undefined { | 60 | export function validateGradient(gradient: unknown): string[] | undefined { | ||
| 22 | - | if (!Array.isArray(gradient)) return undefined; | 61 | + | const result = gradientSchema.safeParse(gradient); |
| 23 | - | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; | 62 | + | return result.success ? result.data : undefined; |
| 24 | - | ||||
| 25 | - | const validColors = gradient.filter( | |||
| 26 | - | (color) => typeof color === 'string' && validateHexColor(color) | |||
| 27 | - | ); | |||
| 28 | - | ||||
| 29 | - | return validColors.length === gradient.length ? validColors : undefined; | |||
| 30 | } | 63 | } | ||
| 31 | 64 | ||||
| 32 | export function validateTextStyle(style: unknown): TextStyle | null { | 65 | export function validateTextStyle(style: unknown): TextStyle | null { | ||
| 33 | if (!style || typeof style !== 'object') return null; | 66 | if (!style || typeof style !== 'object') return null; | ||
| 34 | 67 | ||||
| 35 | - | const rawStyle = style as Record<string, unknown>; | 68 | + | const result = textStyleSchema.safeParse(style); |
| 36 | - | 69 | + | if (!result.success) return null; | |
| 37 | - | const validated: TextStyle = { | |||
| 38 | - | fontFamily: 'pixelated', | |||
| 39 | - | fontSize: 14, | |||
| 40 | - | bold: false, | |||
| 41 | - | italic: false, | |||
| 42 | - | underline: false, | |||
| 43 | - | strikethrough: false | |||
| 44 | - | }; | |||
| 45 | - | ||||
| 46 | - | // Validate font family | |||
| 47 | - | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { | |||
| 48 | - | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // Validate font size | |||
| 52 | - | if (typeof rawStyle.fontSize === 'number') { | |||
| 53 | - | validated.fontSize = validateFontSize(rawStyle.fontSize); | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // Validate color | |||
| 57 | - | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { | |||
| 58 | - | validated.color = rawStyle.color; | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | // Validate gradient | |||
| 62 | - | const validatedGradient = validateGradient(rawStyle.gradient); | |||
| 63 | - | if (validatedGradient) { | |||
| 64 | - | validated.gradient = validatedGradient; | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // Validate boolean properties | |||
| 68 | - | validated.bold = Boolean(rawStyle.bold); | |||
| 69 | - | validated.italic = Boolean(rawStyle.italic); | |||
| 70 | - | validated.underline = Boolean(rawStyle.underline); | |||
| 71 | - | validated.strikethrough = Boolean(rawStyle.strikethrough); | |||
| 72 | 70 | ||||
| 73 | - | return validated; | 71 | + | return result.data as TextStyle; |
| 74 | } | 72 | } | ||
| 75 | 73 | ||||
| 76 | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | 74 | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | ||
| @@ -81,8 +79,7 @@ export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | |||||
| 81 | if (typeof styleData === 'string') { | 79 | if (typeof styleData === 'string') { | ||
| 82 | try { | 80 | try { | ||
| 83 | parsedData = JSON.parse(styleData); | 81 | parsedData = JSON.parse(styleData); | ||
| 84 | - | } catch (error) { | 82 | + | } catch { |
| 85 | - | console.warn('Failed to parse styleData JSON:', error); | |||
| 86 | return undefined; | 83 | return undefined; | ||
| 87 | } | 84 | } | ||
| 88 | } | 85 | } | ||