detached b74deb4

refactor(validation): migrate BBCode and TextStyle validation to Zod schemas

commit
b74deb49dc234abec275190748a98af8b7c0cfb0
author
bnema · · 2025-12-15 22:11 UTC
parent
85bf2ff
signature
GPG Unverified2B82207D370127A0 The 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';
55 import rehypeStringify from 'rehype-stringify';
66 import type { TextStyle } from '../types/text-formatting';
77 import { validateTextStyle } from '../types/text-formatting';
8+import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';
89
910 // Define allowed HTML tags and attributes for security
1011 const ALLOWED_SCHEMA = {
@@ -25,60 +26,6 @@ const ALLOWED_SCHEMA = {
2526 }
2627 };
2728
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-
8229 // BBCode-style formatting patterns (simple ones with static replacement)
8330 const BB_CODE_SIMPLE_PATTERNS: Array<{
8431 pattern: RegExp;
@@ -97,29 +44,29 @@ const BB_CODE_SIMPLE_PATTERNS: Array<{
9744 { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }
9845 ];
9946
100-// BBCode patterns that require validation
47+// BBCode patterns that require validation (using Zod schemas)
10148 function processBBCodeWithValidation(text: string): string {
10249 let result = text;
10350
104- // Color - validate hex or named colors
51+ // Color - validate hex or named colors using Zod schema
10552 result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => {
106- if (isValidColor(color)) {
53+ if (isValidBBCodeColor(color)) {
10754 return `<span style="color: ${color}">${content}</span>`;
10855 }
10956 return content; // Strip invalid color, keep content
11057 });
11158
112- // Size - validate range 8-24px
59+ // Size - validate range 8-24px using Zod schema
11360 result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => {
114- if (isValidFontSize(size)) {
61+ if (isValidBBCodeFontSize(size)) {
11562 return `<span style="font-size: ${size}px">${content}</span>`;
11663 }
11764 return content; // Strip invalid size, keep content
11865 });
11966
120- // Font - validate against allowed fonts
67+ // Font - validate against allowed fonts using Zod schema
12168 result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => {
122- if (isValidFont(font)) {
69+ if (isValidBBCodeFont(font)) {
12370 return `<span style="font-family: ${font}">${content}</span>`;
12471 }
12572 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 security10 // 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 validation47+// 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 colors51+ // 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 content56 return content; // Strip invalid color, keep content
110 });57 });
111 58
112- // Size - validate range 8-24px59+ // 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 content64 return content; // Strip invalid size, keep content
118 });65 });
119 66
120- // Font - validate against allowed fonts67+ // 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 content72 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';
12 import type { TextStyle } from '$lib/types/text-formatting';
23 import { RETRO_FONTS } from '$lib/types/text-formatting';
34
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+ );
826
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)
947 export function validateHexColor(color: string): boolean {
10- return HEX_COLOR_REGEX.test(color);
48+ return hexColorSchema.safeParse(color).success;
1149 }
1250
1351 export function validateFontFamily(fontFamily: string): boolean {
14- return fontFamily in RETRO_FONTS;
52+ return fontFamilySchema.safeParse(fontFamily).success;
1553 }
1654
1755 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;
1958 }
2059
2160 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;
3063 }
3164
3265 export function validateTextStyle(style: unknown): TextStyle | null {
3366 if (!style || typeof style !== 'object') return null;
3467
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;
7270
73- return validated;
71+ return result.data as TextStyle;
7472 }
7573
7674 export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
@@ -81,8 +79,7 @@ export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
8179 if (typeof styleData === 'string') {
8280 try {
8381 parsedData = JSON.parse(styleData);
84- } catch (error) {
85- console.warn('Failed to parse styleData JSON:', error);
82+ } catch {
8683 return undefined;
8784 }
8885 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close