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