highlight.ts
1 import { createHighlighter, type Highlighter, type ShikiTransformer } from 'shiki'; 2 import { CODE_THEMES } from './constants'; 3 import { contentHasDiffMarkers } from './diff-lines'; 4 import type { ReviewGuide } from './types'; 5 6 let highlighterInstance: Highlighter | null = null; 7 8 async function getHighlighter(): Promise<Highlighter> { 9 if (!highlighterInstance) { 10 highlighterInstance = await createHighlighter({ 11 themes: CODE_THEMES.map((t) => t.id), 12 langs: [ 13 'typescript', 14 'javascript', 15 'tsx', 16 'jsx', 17 'python', 18 'go', 19 'rust', 20 'java', 21 'kotlin', 22 'csharp', 23 'cpp', 24 'c', 25 'css', 26 'scss', 27 'html', 28 'json', 29 'yaml', 30 'toml', 31 'bash', 32 'shell', 33 'sql', 34 'markdown', 35 'diff', 36 'swift', 37 'ruby', 38 'php', 39 ], 40 }); 41 } 42 return highlighterInstance; 43 } 44 45 const SUPPORTED_LANGUAGES = new Set([ 46 'typescript', 47 'javascript', 48 'tsx', 49 'jsx', 50 'python', 51 'go', 52 'rust', 53 'java', 54 'kotlin', 55 'csharp', 56 'cpp', 57 'c', 58 'css', 59 'scss', 60 'html', 61 'json', 62 'yaml', 63 'toml', 64 'bash', 65 'shell', 66 'sql', 67 'markdown', 68 'swift', 69 'ruby', 70 'php', 71 ]); 72 73 /** 74 * Render a diff hunk using Shiki with a position-based diff transformer. 75 * 76 * Instead of appending text annotations (// [!code ++]) which break when the 77 * code line contains multi-span comment syntax (e.g. C# XML doc comments), 78 * we track the diff type for each line by position and apply classes directly 79 * via a custom ShikiTransformer. This works for every language. 80 */ 81 export async function renderDiffHunk( 82 content: string, 83 language: string, 84 theme = 'aurora-x', 85 hunkHeader?: string 86 ): Promise<string> { 87 const highlighter = await getHighlighter(); 88 const lang = SUPPORTED_LANGUAGES.has(language) ? language : 'text'; 89 90 type DiffType = 'add' | 'remove' | 'context'; 91 const diffTypes: DiffType[] = []; 92 const codeLines: string[] = []; 93 94 const hasMarkers = contentHasDiffMarkers(content); 95 96 // When markers are absent, infer diff type from hunk header 97 let fallbackType: DiffType = 'context'; 98 if (!hasMarkers && hunkHeader) { 99 const m = hunkHeader.match(/@@ -(\d+)(?:,(\d+))? \+/); 100 if (m) { 101 const oldStart = parseInt(m[1], 10); 102 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- regex optional group can be undefined at runtime 103 const oldCount = m[2] !== undefined ? parseInt(m[2], 10) : 1; 104 if (oldStart === 0 && oldCount === 0) fallbackType = 'add'; 105 } 106 } 107 108 for (const line of content.split('\n')) { 109 if (line === '') continue; 110 if (hasMarkers) { 111 const prefix = line[0]; 112 diffTypes.push(prefix === '+' ? 'add' : prefix === '-' ? 'remove' : 'context'); 113 codeLines.push(line.slice(1)); 114 } else { 115 diffTypes.push(fallbackType); 116 codeLines.push(line); 117 } 118 } 119 120 // Empty content: return minimal HTML to avoid Shiki producing a single empty line span 121 if (codeLines.length === 0) { 122 return '<pre class="shiki"><code></code></pre>'; 123 } 124 125 const hasDiff = diffTypes.some((t) => t !== 'context'); 126 127 const diffTransformer: ShikiTransformer = { 128 name: 'pr-review-diff', 129 pre(node) { 130 if (hasDiff) this.addClassToHast(node, 'has-diff'); 131 }, 132 line(node, lineNumber) { 133 // lineNumber is 1-indexed 134 const type = diffTypes[lineNumber - 1]; 135 if (type === 'add') this.addClassToHast(node, ['diff', 'add']); 136 else if (type === 'remove') this.addClassToHast(node, ['diff', 'remove']); 137 }, 138 }; 139 140 return highlighter.codeToHtml(codeLines.join('\n'), { 141 lang, 142 theme, 143 transformers: [diffTransformer], 144 }); 145 } 146 147 export function inferLanguage(filePath: string): string { 148 const ext = filePath.split('.').pop()?.toLowerCase(); 149 const map: Record<string, string> = { 150 ts: 'typescript', 151 tsx: 'tsx', 152 js: 'javascript', 153 jsx: 'jsx', 154 py: 'python', 155 go: 'go', 156 rs: 'rust', 157 java: 'java', 158 kt: 'kotlin', 159 cs: 'csharp', 160 cpp: 'cpp', 161 cc: 'cpp', 162 cxx: 'cpp', 163 c: 'c', 164 h: 'c', 165 css: 'css', 166 scss: 'scss', 167 html: 'html', 168 htm: 'html', 169 json: 'json', 170 yaml: 'yaml', 171 yml: 'yaml', 172 toml: 'toml', 173 sh: 'bash', 174 bash: 'bash', 175 sql: 'sql', 176 md: 'markdown', 177 swift: 'swift', 178 rb: 'ruby', 179 php: 'php', 180 }; 181 return map[ext ?? ''] ?? 'text'; 182 } 183 184 export async function reRenderAllHunks(review: ReviewGuide, theme: string): Promise<void> { 185 for (const slide of review.slides) { 186 for (const hunk of slide.diffHunks) { 187 try { 188 hunk.renderedHtml = await renderDiffHunk(hunk.content, hunk.language, theme, hunk.hunkHeader); 189 } catch (err) { 190 console.warn(`[highlight] Failed to re-render hunk for ${hunk.filePath}:`, err); 191 } 192 } 193 } 194 }