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 config from "../config.ts";
11import { BINARY_DETECT_BYTES, 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
74export function hasBinaryContent(buf: Buffer): boolean {
75 return buf.subarray(0, BINARY_DETECT_BYTES).includes(0);
76}
77
78export function detectLang(filename: string): string {
79 if (!extToLangId || !filenameToLangId) return "text";
80 const base = filename.split("/").pop() ?? filename;
81 const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : "";
82 return (
83 filenameToLangId.get(base) ??
84 (ext ? extToLangId.get(ext) : undefined) ??
85 "text"
86 );
87}
88
89export type FileView =
90 | { type: "inline"; html: string; lines: number }
91 | { type: "download"; size: number }
92 | { type: "binary"; size: number }
93 | { type: "media"; mimeType: string; size: number };
94
95const fileCache = new Map<string, FileView>();
96
97function shikiHtmlToTable(html: string): string {
98 const styleMatch = html.match(/<pre[^>]*style="([^"]*)"/);
99 const rawStyle = styleMatch?.[1] ?? "";
100 // Convert Shiki's direct background-color/color to CSS custom properties so
101 // the CSS (not the inline style) controls the background in dark mode.
102 const lightBg =
103 rawStyle.match(/(?:^|;)background-color:([^;]+)/)?.[1] ?? "#fff";
104 const lightColor = rawStyle.match(/(?:^|;)color:([^;]+)/)?.[1] ?? "";
105 const darkBg = rawStyle.match(/--shiki-dark-bg:([^;]+)/)?.[1] ?? "";
106 const darkColor = rawStyle.match(/--shiki-dark:([^;]+)/)?.[1] ?? "";
107 const varParts = [`--shiki-light-bg:${lightBg}`];
108 if (lightColor) varParts.push(`--shiki-light:${lightColor}`);
109 if (darkBg) varParts.push(`--shiki-dark-bg:${darkBg}`);
110 if (darkColor) varParts.push(`--shiki-dark:${darkColor}`);
111 const tableStyle = varParts.join(";");
112
113 const codeMatch = html.match(/<code>([\s\S]*?)<\/code>/);
114 const codeContent = codeMatch?.[1] ?? "";
115 const rawLines = codeContent.split("\n");
116 // Shiki adds a trailing newline, so drop the last empty entry
117 const lines =
118 rawLines.length > 0 && rawLines[rawLines.length - 1] === ""
119 ? rawLines.slice(0, -1)
120 : rawLines;
121 const rows = lines
122 .map((line, i) => {
123 const n = i + 1;
124 return `<tr id="L${n}"><td class="blob-ln"><a href="#L${n}">${n}</a></td><td class="blob-code">${line}</td></tr>`;
125 })
126 .join("");
127 return `<table class="blob-table" style="${tableStyle}"><tbody>${rows}</tbody></table>`;
128}
129
130function plainTextToTable(text: string): string {
131 const rawLines = text.split("\n");
132 const lines =
133 rawLines.length > 0 && rawLines[rawLines.length - 1] === ""
134 ? rawLines.slice(0, -1)
135 : rawLines;
136 const rows = lines
137 .map((line, i) => {
138 const n = i + 1;
139 const escaped = line
140 .replace(/&/g, "&amp;")
141 .replace(/</g, "&lt;")
142 .replace(/>/g, "&gt;");
143 return `<tr id="L${n}"><td class="blob-ln"><a href="#L${n}">${n}</a></td><td class="blob-code">${escaped}</td></tr>`;
144 })
145 .join("");
146 return `<table class="blob-table"><tbody>${rows}</tbody></table>`;
147}
148
149export async function serveFile(
150 content: Buffer,
151 filename: string,
152 cacheKey: string,
153): Promise<FileView> {
154 const cached = fileCache.get(cacheKey);
155 if (cached) return cached;
156
157 const fileType = await fileTypeFromBuffer(content);
158 if (fileType) {
159 const group = fileType.mime.split("/")[0];
160 if (group === "image" || group === "audio" || group === "video") {
161 return {
162 type: "media",
163 mimeType: fileType.mime,
164 size: content.length,
165 };
166 }
167 }
168
169 const isBinary = hasBinaryContent(content);
170 if (isBinary) {
171 return { type: "binary", size: content.length };
172 }
173 if (content.length > config.INLINE_MAX_BYTES) {
174 return { type: "download", size: content.length };
175 }
176 const text = content.toString("utf-8");
177 const lineCount = text.split("\n").length;
178 let view: FileView;
179 try {
180 const h = getHighlighter();
181 const lang = detectLang(filename);
182 const shikiHtml = h.codeToHtml(text, {
183 lang,
184 themes: { light: "github-light", dark: "github-dark" },
185 });
186 view = {
187 type: "inline",
188 html: shikiHtmlToTable(shikiHtml),
189 lines: lineCount,
190 };
191 } catch {
192 view = {
193 type: "inline",
194 html: plainTextToTable(text),
195 lines: lineCount,
196 };
197 }
198 if (cacheKey) {
199 if (fileCache.size >= MAX_FILE_CACHE)
200 fileCache.delete(fileCache.keys().next().value!);
201 fileCache.set(cacheKey, view);
202 }
203 return view;
204}
205