highlight.ts
Raw
1import type { Language } from "linguist-languages";
2import * as linguistLangs from "linguist-languages";
3import {
4 bundledLanguages,
5 bundledLanguagesInfo,
6 createHighlighter,
7 type Highlighter,
8} from "shiki";
9import { INLINE_MAX_BYTES } from "../config.ts";
10import { MAX_FILE_CACHE } from "../constants.ts";
11
12let highlighter: Highlighter | null = null;
13let extToLangId: Map<string, string> | null = null;
14let filenameToLangId: Map<string, string> | null = null;
15
16export async function highlightStartup(): Promise<void> {
17 console.log(
18 "[highlight] initializing highlighter and language detection maps...",
19 );
20
21 // Index linguist languages by lowercase name and aliases for precise lookup.
22 // Many shiki languages share the same tmScope (e.g. source.js has 31 entries),
23 // so scope-based matching is unreliable — name/alias matching is used instead.
24 const linguistByName = new Map<string, Language>();
25 for (const lang of Object.values(
26 linguistLangs as Record<string, Language>,
27 )) {
28 linguistByName.set(lang.name.toLowerCase(), lang);
29 for (const alias of lang.aliases ?? []) {
30 if (!linguistByName.has(alias.toLowerCase())) {
31 linguistByName.set(alias.toLowerCase(), lang);
32 }
33 }
34 }
35
36 const extMap = new Map<string, string>();
37 const fnMap = new Map<string, string>();
38
39 for (const { id, aliases } of bundledLanguagesInfo) {
40 // Match shiki lang → linguist language by ID then aliases (no scope fallback)
41 const linguistLang =
42 linguistByName.get(id) ??
43 aliases?.reduce<Language | undefined>(
44 (found, a) => found ?? linguistByName.get(a.toLowerCase()),
45 undefined,
46 );
47 if (!linguistLang) continue;
48 for (const ext of linguistLang.extensions ?? []) {
49 if (!extMap.has(ext)) extMap.set(ext, id);
50 }
51 for (const fn of linguistLang.filenames ?? []) {
52 if (!fnMap.has(fn)) fnMap.set(fn, id);
53 }
54 }
55
56 extToLangId = extMap;
57 filenameToLangId = fnMap;
58 highlighter = await createHighlighter({
59 themes: ["github-light", "github-dark"],
60 langs: Object.keys(bundledLanguages),
61 });
62
63 console.log(
64 `[highlight] ready: ${extMap.size} extensions, ${fnMap.size} filenames`,
65 );
66}
67
68export function getHighlighter(): Highlighter {
69 if (!highlighter) throw new Error("highlightStartup() has not been called");
70 return highlighter;
71}
72
73// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional binary content detection
74const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
75
76export function hasBinaryContent(buf: Buffer): boolean {
77 const sample = buf.subarray(0, 8000);
78 return BINARY_RE.test(sample.toString("binary"));
79}
80
81export function detectLang(filename: string): string {
82 if (!extToLangId || !filenameToLangId) return "text";
83 const base = filename.split("/").pop() ?? filename;
84 const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : "";
85 return (
86 filenameToLangId.get(base) ??
87 (ext ? extToLangId.get(ext) : undefined) ??
88 "text"
89 );
90}
91
92export type FileView =
93 | { type: "inline"; html: string; lines: number }
94 | { type: "download"; size: number }
95 | { type: "binary"; size: number };
96
97const fileCache = new Map<string, FileView>();
98
99function shikiHtmlToTable(html: string): string {
100 const styleMatch = html.match(/<pre[^>]*style="([^"]*)"/);
101 const rawStyle = styleMatch?.[1] ?? "";
102 // Convert Shiki's direct background-color/color to CSS custom properties so
103 // the CSS (not the inline style) controls the background in dark mode.
104 const lightBg =
105 rawStyle.match(/(?:^|;)background-color:([^;]+)/)?.[1] ?? "#fff";
106 const lightColor = rawStyle.match(/(?:^|;)color:([^;]+)/)?.[1] ?? "";
107 const darkBg = rawStyle.match(/--shiki-dark-bg:([^;]+)/)?.[1] ?? "";
108 const darkColor = rawStyle.match(/--shiki-dark:([^;]+)/)?.[1] ?? "";
109 const varParts = [`--shiki-light-bg:${lightBg}`];
110 if (lightColor) varParts.push(`--shiki-light:${lightColor}`);
111 if (darkBg) varParts.push(`--shiki-dark-bg:${darkBg}`);
112 if (darkColor) varParts.push(`--shiki-dark:${darkColor}`);
113 const tableStyle = varParts.join(";");
114
115 const codeMatch = html.match(/<code>([\s\S]*?)<\/code>/);
116 const codeContent = codeMatch?.[1] ?? "";
117 const rawLines = codeContent.split("\n");
118 // Shiki adds a trailing newline, so drop the last empty entry
119 const lines =
120 rawLines.length > 0 && rawLines[rawLines.length - 1] === ""
121 ? rawLines.slice(0, -1)
122 : rawLines;
123 const rows = lines
124 .map((line, i) => {
125 const n = i + 1;
126 return `<tr id="L${n}"><td class="blob-ln"><a href="#L${n}">${n}</a></td><td class="blob-code">${line}</td></tr>`;
127 })
128 .join("");
129 return `<table class="blob-table" style="${tableStyle}"><tbody>${rows}</tbody></table>`;
130}
131
132function plainTextToTable(text: string): string {
133 const rawLines = text.split("\n");
134 const lines =
135 rawLines.length > 0 && rawLines[rawLines.length - 1] === ""
136 ? rawLines.slice(0, -1)
137 : rawLines;
138 const rows = lines
139 .map((line, i) => {
140 const n = i + 1;
141 const escaped = line
142 .replace(/&/g, "&amp;")
143 .replace(/</g, "&lt;")
144 .replace(/>/g, "&gt;");
145 return `<tr id="L${n}"><td class="blob-ln"><a href="#L${n}">${n}</a></td><td class="blob-code">${escaped}</td></tr>`;
146 })
147 .join("");
148 return `<table class="blob-table"><tbody>${rows}</tbody></table>`;
149}
150
151export async function serveFile(
152 content: Buffer,
153 filename: string,
154 cacheKey: string,
155): Promise<FileView> {
156 const cached = fileCache.get(cacheKey);
157 if (cached) return cached;
158
159 const isBinary = hasBinaryContent(content);
160 if (isBinary) {
161 return { type: "binary", size: content.length };
162 }
163 if (content.length > INLINE_MAX_BYTES) {
164 return { type: "download", size: content.length };
165 }
166 const text = content.toString("utf-8");
167 const lineCount = text.split("\n").length;
168 let view: FileView;
169 try {
170 const h = getHighlighter();
171 const lang = detectLang(filename);
172 const shikiHtml = h.codeToHtml(text, {
173 lang,
174 themes: { light: "github-light", dark: "github-dark" },
175 });
176 view = {
177 type: "inline",
178 html: shikiHtmlToTable(shikiHtml),
179 lines: lineCount,
180 };
181 } catch {
182 view = {
183 type: "inline",
184 html: plainTextToTable(text),
185 lines: lineCount,
186 };
187 }
188 if (cacheKey) {
189 if (fileCache.size >= MAX_FILE_CACHE)
190 fileCache.delete(fileCache.keys().next().value!);
191 fileCache.set(cacheKey, view);
192 }
193 return view;
194}
195