1import { unified } from 'unified';
2import remarkParse from 'remark-parse';
3import remarkRehype from 'remark-rehype';
4import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
5import rehypeStringify from 'rehype-stringify';
6import type { TextStyle } from '../types/text-formatting';
7import { validateTextStyle } from '../types/text-formatting';
8import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';
9import { highlightMentionsHtml } from './mention';
10
11// Define allowed HTML tags and attributes for security
12const ALLOWED_SCHEMA = {
13 ...defaultSchema,
14 tagNames: [...(defaultSchema.tagNames || []), 'span', 'strong', 'em', 'u', 's', 'mark'],
15 attributes: {
16 ...defaultSchema.attributes,
17 span: ['style', 'class', 'data-gradient'],
18 strong: ['style', 'class'],
19 em: ['style', 'class'],
20 u: ['style', 'class'],
21 s: ['style', 'class'],
22 mark: ['style', 'class']
23 },
24 protocols: {
25 ...defaultSchema.protocols,
26 style: []
27 }
28};
29
30// BBCode-style formatting patterns (simple ones with static replacement)
31const BB_CODE_SIMPLE_PATTERNS: Array<{
32 pattern: RegExp;
33 replacement: string;
34}> = [
35 // Bold
36 { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' },
37
38 // Italic
39 { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' },
40
41 // Underline
42 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
43
44 // Strikethrough
45 { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }
46];
47
48// BBCode patterns that require validation (using Zod schemas)
49function processBBCodeWithValidation(text: string): string {
50 let result = text;
51
52 // Color - validate hex or named colors using Zod schema
53 result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => {
54 if (isValidBBCodeColor(color)) {
55 return `<span style="color: ${color}">${content}</span>`;
56 }
57 return content; // Strip invalid color, keep content
58 });
59
60 // Size - validate range 8-24px using Zod schema
61 result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => {
62 if (isValidBBCodeFontSize(size)) {
63 return `<span style="font-size: ${size}px">${content}</span>`;
64 }
65 return content; // Strip invalid size, keep content
66 });
67
68 // Font - validate against allowed fonts using Zod schema
69 result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => {
70 if (isValidBBCodeFont(font)) {
71 return `<span style="font-family: ${font}">${content}</span>`;
72 }
73 return content; // Strip invalid font, keep content
74 });
75
76 return result;
77}
78
79// Markdown-style formatting patterns
80const MARKDOWN_PATTERNS: Array<{
81 pattern: RegExp;
82 replacement: string;
83}> = [
84 // Bold (double asterisk or double underscore)
85 { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' },
86 { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' },
87
88 // Italic (single asterisk or single underscore)
89 { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' },
90 { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' },
91
92 // Strikethrough
93 { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' },
94
95 // Inline code
96 { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' }
97];
98
99// Preprocess text to convert BBCode and Markdown to HTML
100function preprocessFormatting(text: string): string {
101 let processedText = text;
102
103 // Apply simple BBCode patterns first (bold, italic, underline, strikethrough)
104 BB_CODE_SIMPLE_PATTERNS.forEach(({ pattern, replacement }) => {
105 processedText = processedText.replace(pattern, replacement);
106 });
107
108 // Apply BBCode patterns that require validation (color, size, font)
109 processedText = processBBCodeWithValidation(processedText);
110
111 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
112 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
113 // Only apply if not already inside HTML tags
114 processedText = processedText.replace(pattern, (match, content) => {
115 // Simple check to avoid replacing inside existing HTML tags
116 const beforeMatch = processedText.substring(0, processedText.indexOf(match));
117 const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length;
118 const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length;
119
120 // If we're inside an unclosed tag, don't apply markdown
121 if (openTags > closeTags) {
122 return match;
123 }
124
125 return replacement.replace('$1', content);
126 });
127 });
128
129 // Note: Mention highlighting is applied after sanitization in formatText
130 // to ensure it's not stripped by the sanitizer
131
132 return processedText;
133}
134
135// Main text formatting function
136export async function formatText(
137 text: string,
138 userStyle?: Partial<TextStyle>,
139 allowRichFormatting: boolean = true
140): Promise<string> {
141 try {
142 // If text is empty, return empty string
143 if (!text) return '';
144
145 // Validate user style (ensures safe values)
146 validateTextStyle(userStyle || {});
147
148 // Preprocess the text for formatting if allowed
149 let processedText = text;
150 if (allowRichFormatting) {
151 processedText = preprocessFormatting(text);
152 }
153
154 // If no rich formatting, just return escaped text
155 if (!allowRichFormatting && !processedText.includes('<')) {
156 return escapeHtml(text);
157 }
158
159 // Create the unified processor
160 const processor = unified()
161 .use(remarkParse, {
162 fragment: true // Parse as fragment, not full document
163 })
164 .use(remarkRehype, {
165 allowDangerousHtml: true // Allow preprocessed HTML to pass through
166 })
167 .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML
168 .use(rehypeStringify);
169
170 // Process the text
171 const result = await processor.process(processedText);
172 let formattedHtml = String(result).trim();
173
174 // If we got an empty result, return the escaped text
175 if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') {
176 return escapeHtml(text);
177 }
178
179 // Remove wrapping <p> tags if it's just a single paragraph
180 if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) {
181 const pCount = (formattedHtml.match(/<p>/g) || []).length;
182 if (pCount === 1) {
183 formattedHtml = formattedHtml.slice(3, -4).trim();
184 }
185 }
186
187 // Final check - if we ended up with empty content, return escaped text
188 if (!formattedHtml) {
189 return escapeHtml(text);
190 }
191
192 // Apply mention highlighting after sanitization
193 formattedHtml = highlightMentionsHtml(formattedHtml);
194
195 return formattedHtml;
196 } catch (error) {
197 console.error('Text formatting error:', error);
198 // Return escaped plain text as fallback
199 return escapeHtml(text);
200 }
201}
202
203// Utility function to escape HTML (isomorphic - works in SSR and browser)
204export function escapeHtml(text: string): string {
205 return text
206 .replace(/&/g, '&')
207 .replace(/</g, '<')
208 .replace(/>/g, '>')
209 .replace(/"/g, '"')
210 .replace(/'/g, ''');
211}
212
213// Generate CSS style string excluding font-family (which should be handled by classes)
214function generateCSSStyleWithoutFont(style: TextStyle): string {
215 const styles: string[] = [];
216
217 // Font size
218 styles.push(`font-size: ${style.fontSize}px`);
219
220 // Color (not for gradients)
221 if (style.color && !style.gradient) {
222 styles.push(`color: ${style.color}`);
223 }
224
225 // Font weight
226 if (style.bold) {
227 styles.push(`font-weight: bold`);
228 }
229
230 // Font style
231 if (style.italic) {
232 styles.push(`font-style: italic`);
233 }
234
235 // Text decoration
236 const decorations: string[] = [];
237 if (style.underline) decorations.push('underline');
238 if (style.strikethrough) decorations.push('line-through');
239 if (decorations.length > 0) {
240 styles.push(`text-decoration: ${decorations.join(' ')}`);
241 }
242
243 return styles.join('; ');
244}
245
246// Create gradient text effect
247export function createGradientText(
248 text: string,
249 colors: string[],
250 className: string = 'gradient-text',
251 style?: TextStyle
252): string {
253 if (colors.length < 2) {
254 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
255 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';
256 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
257 return result;
258 }
259
260 const chars = Array.from(text);
261 const totalChars = chars.length;
262
263 if (totalChars === 0) return '';
264
265 // Create spans for each character with interpolated colors
266 const gradientSpans = chars.map((char, index) => {
267 if (char === ' ') {
268 return ' '; // Use non-breaking space to preserve spacing
269 }
270
271 // Calculate color position (0 to 1)
272 const position = totalChars === 1 ? 0 : index / (totalChars - 1);
273
274 // Find the two colors to interpolate between
275 const colorIndex = position * (colors.length - 1);
276 const lowerIndex = Math.floor(colorIndex);
277 const upperIndex = Math.min(lowerIndex + 1, colors.length - 1);
278 const t = colorIndex - lowerIndex;
279
280 // Interpolate between colors
281 const color1 = hexToRgb(colors[lowerIndex]);
282 const color2 = hexToRgb(colors[upperIndex]);
283
284 if (!color1 || !color2) {
285 const fallbackStyle = style
286 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined })
287 : '';
288 return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`;
289 }
290
291 const r = Math.round(color1.r + t * (color2.r - color1.r));
292 const g = Math.round(color1.g + t * (color2.g - color1.g));
293 const b = Math.round(color1.b + t * (color2.b - color1.b));
294
295 const interpolatedColor = `rgb(${r}, ${g}, ${b})`;
296
297 // Combine interpolated color with other styles
298 let inlineStyle = `color: ${interpolatedColor}`;
299 if (style) {
300 // Get styles without font-family and color
301 if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`;
302 if (style.bold) inlineStyle += `; font-weight: bold`;
303 if (style.italic) inlineStyle += `; font-style: italic`;
304 const decorations: string[] = [];
305 if (style.underline) decorations.push('underline');
306 if (style.strikethrough) decorations.push('line-through');
307 if (decorations.length > 0) {
308 inlineStyle += `; text-decoration: ${decorations.join(' ')}`;
309 }
310 }
311
312 return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`;
313 });
314
315 // Wrap all spans in a container - use inline display to match text baseline
316 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
317 const containerStyle = style
318 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })
319 : '';
320 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;
321 return result;
322}
323
324// Helper function to convert hex to RGB
325function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
326 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
327 return result
328 ? {
329 r: parseInt(result[1], 16),
330 g: parseInt(result[2], 16),
331 b: parseInt(result[3], 16)
332 }
333 : null;
334}
335
336// Validate message content for security
337export function validateMessageContent(content: string): {
338 isValid: boolean;
339 sanitizedContent: string;
340 errors: string[];
341} {
342 const errors: string[] = [];
343 let sanitizedContent = content;
344
345 // Check length
346 if (content.length > 2000) {
347 errors.push('Message too long (max 2000 characters)');
348 sanitizedContent = content.substring(0, 2000);
349 }
350
351 // Check for potential XSS patterns
352 const dangerousPatterns = [
353 /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
354 /javascript:/gi,
355 /vbscript:/gi,
356 /onload\s*=/gi,
357 /onerror\s*=/gi,
358 /onclick\s*=/gi,
359 /onmouseover\s*=/gi
360 ];
361
362 dangerousPatterns.forEach((pattern) => {
363 if (pattern.test(content)) {
364 errors.push('Potentially dangerous content detected');
365 sanitizedContent = sanitizedContent.replace(pattern, '');
366 }
367 });
368
369 return {
370 isValid: errors.length === 0,
371 sanitizedContent,
372 errors
373 };
374}