Alex Severyanov  ·  2026-03-25

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, '&amp;')
95		.replace(/</g, '&lt;')
96		.replace(/>/g, '&gt;')
97		.replace(/"/g, '&quot;');
98}