main f6d8405

text-formatter.ts RawHistoryFolder

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, '&amp;')
207		.replace(/</g, '&lt;')
208		.replace(/>/g, '&gt;')
209		.replace(/"/g, '&quot;')
210		.replace(/'/g, '&#039;');
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 '&nbsp;'; // 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}

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