/ lib / highlight.ts
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  }