/** * CodePreview Component * * Displays code with syntax highlighting, scrollable preview, * and option to expand to full-screen Monaco editor. */ import { useState, useCallback, useEffect, lazy, Suspense } from 'react'; import { Copy, Check, Maximize2, X, FileText } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTheme } from './hooks/useTheme'; import hljs from 'highlight.js/lib/core'; // Register common languages import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import json from 'highlight.js/lib/languages/json'; import python from 'highlight.js/lib/languages/python'; import bash from 'highlight.js/lib/languages/bash'; import xml from 'highlight.js/lib/languages/xml'; import css from 'highlight.js/lib/languages/css'; import yaml from 'highlight.js/lib/languages/yaml'; import markdown from 'highlight.js/lib/languages/markdown'; import sql from 'highlight.js/lib/languages/sql'; import go from 'highlight.js/lib/languages/go'; import rust from 'highlight.js/lib/languages/rust'; hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('js', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('ts', typescript); hljs.registerLanguage('tsx', typescript); hljs.registerLanguage('jsx', javascript); hljs.registerLanguage('json', json); hljs.registerLanguage('python', python); hljs.registerLanguage('py', python); hljs.registerLanguage('bash', bash); hljs.registerLanguage('sh', bash); hljs.registerLanguage('shell', bash); hljs.registerLanguage('html', xml); hljs.registerLanguage('xml', xml); hljs.registerLanguage('css', css); hljs.registerLanguage('yaml', yaml); hljs.registerLanguage('yml', yaml); hljs.registerLanguage('markdown', markdown); hljs.registerLanguage('md', markdown); hljs.registerLanguage('sql', sql); hljs.registerLanguage('go', go); hljs.registerLanguage('rust', rust); hljs.registerLanguage('rs', rust); // Lazy load Monaco for full editor view const Editor = lazy(() => import('@monaco-editor/react')); interface CodePreviewProps { /** Code content to display */ content: string; /** File path for language detection and display */ filePath?: string; /** Override detected language */ language?: string; /** Maximum lines before showing "show more" (default: 10) */ maxLines?: number; /** Whether to show line numbers (default: true) */ showLineNumbers?: boolean; /** Maximum height in pixels for the preview (default: 200) */ maxHeight?: number; /** Optional title/label */ title?: string; /** Show icon before title */ showIcon?: boolean; /** Show header with title/actions (default: true) */ showHeader?: boolean; } // Map file extensions to hljs/monaco languages const EXT_TO_LANG: Record = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript', json: 'json', py: 'python', sh: 'bash', bash: 'bash', zsh: 'bash', html: 'html', htm: 'html', xml: 'xml', svg: 'xml', css: 'css', scss: 'css', less: 'css', yaml: 'yaml', yml: 'yaml', md: 'markdown', mdx: 'markdown', sql: 'sql', go: 'go', rs: 'rust', toml: 'yaml', ini: 'yaml', env: 'bash', dockerfile: 'bash', makefile: 'bash', }; function getLanguageFromPath(filePath: string): string { const ext = filePath.split('.').pop()?.toLowerCase() || ''; const filename = filePath.split('/').pop()?.toLowerCase() || ''; // Check special filenames if (filename === 'dockerfile') return 'bash'; if (filename === 'makefile') return 'bash'; if (filename.startsWith('.env')) return 'bash'; return EXT_TO_LANG[ext] || 'plaintext'; } function getShortPath(path: string): string { const parts = path.split('/').filter(Boolean); if (parts.length <= 2) return path; return `.../${parts.slice(-2).join('/')}`; } /** * Escape HTML entities to prevent XSS when using dangerouslySetInnerHTML */ function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function CodePreview({ content, filePath, language: overrideLanguage, maxLines = 10, showLineNumbers = true, maxHeight = 200, title, showIcon = true, showHeader = true, }: CodePreviewProps) { const [showAll, setShowAll] = useState(false); const [showFullScreen, setShowFullScreen] = useState(false); const [copied, setCopied] = useState(false); const { theme } = useTheme(); const language = overrideLanguage || (filePath ? getLanguageFromPath(filePath) : 'plaintext'); const lines = content.split('\n'); const shouldTruncate = lines.length > maxLines && !showAll; const displayContent = shouldTruncate ? lines.slice(0, maxLines).join('\n') : content; // Apply HTML escaping before syntax highlighting to prevent XSS let highlightedContent: string; try { if (language !== 'plaintext') { // Escape HTML entities first, then highlight the escaped content const escaped = escapeHtml(displayContent); const result = hljs.highlight(escaped, { language, ignoreIllegals: true }); highlightedContent = result.value; } else { // Plaintext - escape HTML entities highlightedContent = escapeHtml(displayContent); } } catch { // Highlight failed - escape HTML entities for safety highlightedContent = escapeHtml(displayContent); } const handleCopy = useCallback(async () => { await navigator.clipboard.writeText(content); setCopied(true); setTimeout(() => setCopied(false), 1500); }, [content]); // Handle escape key to close full screen modal useEffect(() => { if (!showFullScreen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setShowFullScreen(false); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [showFullScreen]); const displayTitle = title || (filePath ? getShortPath(filePath) : undefined); return ( <>
{/* Header */} {showHeader && (displayTitle || filePath) && (
{showIcon && } {displayTitle} {lines.length} lines
)} {/* Code preview */}
                            {showLineNumbers ? (
                                
                                    {(showAll ? content : displayContent)
                                        .split('\n')
                                        .map((line, i) => (
                                            
{i + 1} { const lineContent = line || ' '; // Always escape first to prevent XSS const escaped = escapeHtml(lineContent); try { if (language !== 'plaintext') { return hljs.highlight(escaped, { language, ignoreIllegals: true, }).value; } } catch { // fallback - already escaped above } return escaped; })(), }} />
))}
) : ( )}
{/* Show more button */} {shouldTruncate && ( )} {showAll && lines.length > maxLines && ( )}
{/* Full screen modal with Monaco */} {showFullScreen && (
{/* Modal header */}
{filePath || 'Code'} ({lines.length} lines)
{/* Monaco editor */}
Loading editor...
} >
{/* Click outside to close */}
setShowFullScreen(false)} />
)} ); }