highlighter.ts
1import { createHighlighter, type Highlighter, type BundledLanguage } from 'shiki';
2
3let highlighter: Highlighter | null = null;
4
5async function getHighlighter(): Promise<Highlighter> {
6 if (!highlighter) {
7 highlighter = await createHighlighter({
8 themes: ['github-dark', 'github-light'],
9 langs: [] // Start empty, load on demand
10 });
11 }
12 return highlighter;
13}
14
15/**
16 * Highlight a full file and return a map of line number → HTML string.
17 * Line numbers are 1-indexed.
18 * Output uses CSS custom properties for dual-theme support:
19 * <span class="st" style="--sd:#dark;--sl:#light">text</span>
20 */
21export async function highlightLines(
22 code: string,
23 language: string
24): Promise<Map<number, string>> {
25 const lineMap = new Map<number, string>();
26
27 if (language === 'text' || !code.trim()) {
28 return lineMap;
29 }
30
31 try {
32 const hl = await getHighlighter();
33
34 // Load language on demand if not already loaded
35 const loadedLangs = hl.getLoadedLanguages();
36 if (!loadedLangs.includes(language as BundledLanguage)) {
37 try {
38 await hl.loadLanguage(language as BundledLanguage);
39 } catch {
40 // Language not supported by Shiki — return unhighlighted
41 return lineMap;
42 }
43 }
44
45 const darkResult = hl.codeToTokens(code, {
46 lang: language as BundledLanguage,
47 theme: 'github-dark'
48 });
49
50 const lightResult = hl.codeToTokens(code, {
51 lang: language as BundledLanguage,
52 theme: 'github-light'
53 });
54
55 for (let i = 0; i < darkResult.tokens.length; i++) {
56 const darkTokens = darkResult.tokens[i];
57 const lightTokens = lightResult.tokens[i];
58 let html = '';
59
60 if (lightTokens && darkTokens.length === lightTokens.length) {
61 // Both themes have matching tokens — use dual-theme spans
62 for (let j = 0; j < darkTokens.length; j++) {
63 const escaped = escapeHtml(darkTokens[j].content);
64 const dc = darkTokens[j].color;
65 const lc = lightTokens[j].color;
66 if (dc || lc) {
67 html += `<span class="st" style="--sd:${dc || 'inherit'};--sl:${lc || 'inherit'}">${escaped}</span>`;
68 } else {
69 html += escaped;
70 }
71 }
72 } else {
73 // Fallback: dark-only tokens (shouldn't normally happen)
74 for (const token of darkTokens) {
75 const escaped = escapeHtml(token.content);
76 if (token.color) {
77 html += `<span class="st" style="--sd:${token.color};--sl:${token.color}">${escaped}</span>`;
78 } else {
79 html += escaped;
80 }
81 }
82 }
83 lineMap.set(i + 1, html);
84 }
85 } catch {
86 // Fallback: no highlighting
87 }
88
89 return lineMap;
90}
91
92function escapeHtml(text: string): string {
93 return text
94 .replace(/&/g, '&')
95 .replace(/</g, '<')
96 .replace(/>/g, '>')
97 .replace(/"/g, '"');
98}