|
| 1 | +/** |
| 2 | + * Custom syntax highlighting component that doesn't leak memory |
| 3 | + * Replaces rehype-highlight to avoid memory accumulation |
| 4 | + */ |
| 5 | + |
| 6 | +import type React from 'react' |
| 7 | +import { useMemo } from 'react' |
| 8 | + |
| 9 | +// Simple regex-based syntax highlighting patterns |
| 10 | +const HIGHLIGHT_PATTERNS = { |
| 11 | + javascript: [ |
| 12 | + { |
| 13 | + pattern: /\b(function|const|let|var|return|if|else|for|while|class|import|export)\b/g, |
| 14 | + className: 'hljs-keyword', |
| 15 | + }, |
| 16 | + { pattern: /\b(true|false|null|undefined)\b/g, className: 'hljs-literal' }, |
| 17 | + { pattern: /"([^"\\]|\\.)*"/g, className: 'hljs-string' }, |
| 18 | + { pattern: /'([^'\\]|\\.)*'/g, className: 'hljs-string' }, |
| 19 | + { pattern: /`([^`\\]|\\.)*`/g, className: 'hljs-string' }, |
| 20 | + { pattern: /\/\/.*$/gm, className: 'hljs-comment' }, |
| 21 | + { pattern: /\/\*[\s\S]*?\*\//g, className: 'hljs-comment' }, |
| 22 | + { pattern: /\b\d+(\.\d+)?\b/g, className: 'hljs-number' }, |
| 23 | + ], |
| 24 | + typescript: [ |
| 25 | + { |
| 26 | + pattern: |
| 27 | + /\b(function|const|let|var|return|if|else|for|while|class|import|export|interface|type|enum)\b/g, |
| 28 | + className: 'hljs-keyword', |
| 29 | + }, |
| 30 | + { pattern: /\b(string|number|boolean|any|void|never)\b/g, className: 'hljs-type' }, |
| 31 | + { pattern: /\b(true|false|null|undefined)\b/g, className: 'hljs-literal' }, |
| 32 | + { pattern: /"([^"\\]|\\.)*"/g, className: 'hljs-string' }, |
| 33 | + { pattern: /'([^'\\]|\\.)*'/g, className: 'hljs-string' }, |
| 34 | + { pattern: /`([^`\\]|\\.)*`/g, className: 'hljs-string' }, |
| 35 | + { pattern: /\/\/.*$/gm, className: 'hljs-comment' }, |
| 36 | + { pattern: /\/\*[\s\S]*?\*\//g, className: 'hljs-comment' }, |
| 37 | + { pattern: /\b\d+(\.\d+)?\b/g, className: 'hljs-number' }, |
| 38 | + ], |
| 39 | + python: [ |
| 40 | + { |
| 41 | + pattern: /\b(def|class|import|from|return|if|else|elif|for|while|try|except|with|as)\b/g, |
| 42 | + className: 'hljs-keyword', |
| 43 | + }, |
| 44 | + { pattern: /\b(True|False|None)\b/g, className: 'hljs-literal' }, |
| 45 | + { pattern: /"([^"\\]|\\.)*"/g, className: 'hljs-string' }, |
| 46 | + { pattern: /'([^'\\]|\\.)*'/g, className: 'hljs-string' }, |
| 47 | + { pattern: /#.*$/gm, className: 'hljs-comment' }, |
| 48 | + { pattern: /\b\d+(\.\d+)?\b/g, className: 'hljs-number' }, |
| 49 | + ], |
| 50 | + go: [ |
| 51 | + { |
| 52 | + pattern: /\b(func|var|const|type|import|package|return|if|else|for|range|switch|case)\b/g, |
| 53 | + className: 'hljs-keyword', |
| 54 | + }, |
| 55 | + { pattern: /\b(true|false|nil)\b/g, className: 'hljs-literal' }, |
| 56 | + { pattern: /"([^"\\]|\\.)*"/g, className: 'hljs-string' }, |
| 57 | + { pattern: /`([^`\\]|\\.)*`/g, className: 'hljs-string' }, |
| 58 | + { pattern: /\/\/.*$/gm, className: 'hljs-comment' }, |
| 59 | + { pattern: /\/\*[\s\S]*?\*\//g, className: 'hljs-comment' }, |
| 60 | + { pattern: /\b\d+(\.\d+)?\b/g, className: 'hljs-number' }, |
| 61 | + ], |
| 62 | +} |
| 63 | + |
| 64 | +const DEFAULT_PATTERNS = [ |
| 65 | + { pattern: /"([^"\\]|\\.)*"/g, className: 'hljs-string' }, |
| 66 | + { pattern: /'([^'\\]|\\.)*'/g, className: 'hljs-string' }, |
| 67 | + { pattern: /\b\d+(\.\d+)?\b/g, className: 'hljs-number' }, |
| 68 | +] |
| 69 | + |
| 70 | +function highlightCode(code: string, language?: string): string { |
| 71 | + if (!code) return '' |
| 72 | + |
| 73 | + const patterns = HIGHLIGHT_PATTERNS[language as keyof typeof HIGHLIGHT_PATTERNS] || DEFAULT_PATTERNS |
| 74 | + |
| 75 | + // Escape HTML first |
| 76 | + const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
| 77 | + |
| 78 | + // Find all matches for all patterns |
| 79 | + const matches: Array<{ start: number; end: number; className: string; text: string }> = [] |
| 80 | + |
| 81 | + for (const { pattern, className } of patterns) { |
| 82 | + pattern.lastIndex = 0 // Reset regex |
| 83 | + let match = pattern.exec(escaped) |
| 84 | + while (match !== null) { |
| 85 | + matches.push({ |
| 86 | + start: match.index, |
| 87 | + end: match.index + match[0].length, |
| 88 | + className, |
| 89 | + text: match[0], |
| 90 | + }) |
| 91 | + if (!pattern.global) break |
| 92 | + match = pattern.exec(escaped) |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + // Sort matches by position and filter out overlaps |
| 97 | + matches.sort((a, b) => a.start - b.start) |
| 98 | + const filteredMatches = [] |
| 99 | + let lastEnd = 0 |
| 100 | + |
| 101 | + for (const match of matches) { |
| 102 | + if (match.start >= lastEnd) { |
| 103 | + filteredMatches.push(match) |
| 104 | + lastEnd = match.end |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + // Build the final highlighted string |
| 109 | + let result = '' |
| 110 | + let currentIndex = 0 |
| 111 | + |
| 112 | + for (const match of filteredMatches) { |
| 113 | + // Add text before match |
| 114 | + if (match.start > currentIndex) { |
| 115 | + result += escaped.slice(currentIndex, match.start) |
| 116 | + } |
| 117 | + // Add highlighted match |
| 118 | + result += `<span class="${match.className}">${match.text}</span>` |
| 119 | + currentIndex = match.end |
| 120 | + } |
| 121 | + |
| 122 | + // Add remaining text |
| 123 | + if (currentIndex < escaped.length) { |
| 124 | + result += escaped.slice(currentIndex) |
| 125 | + } |
| 126 | + |
| 127 | + return result |
| 128 | +} |
| 129 | + |
| 130 | +export const CustomHTMLHighlighter: React.FC<{ |
| 131 | + code: string |
| 132 | + language?: string |
| 133 | + className?: string |
| 134 | +}> = ({ code, language, className }) => { |
| 135 | + const highlightedCode = useMemo(() => { |
| 136 | + return highlightCode(code, language) |
| 137 | + }, [code, language]) |
| 138 | + |
| 139 | + return ( |
| 140 | + <pre className={`hljs ${className || ''}`}> |
| 141 | + <code |
| 142 | + className={language ? `language-${language}` : ''} |
| 143 | + // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> |
| 144 | + dangerouslySetInnerHTML={{ __html: highlightedCode }} |
| 145 | + /> |
| 146 | + </pre> |
| 147 | + ) |
| 148 | +} |
0 commit comments