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
99export async function serveFile(
100 content: Buffer,
101 filename: string,
102 cacheKey: string,
103): Promise<FileView> {
104 const cached = fileCache.get(cacheKey);
105 if (cached) return cached;
106
107 const isBinary = hasBinaryContent(content);
108 if (isBinary) {
109 return { type: "binary", size: content.length };
110 }
111 if (content.length > INLINE_MAX_BYTES) {
112 return { type: "download", size: content.length };
113 }
114 const text = content.toString("utf-8");
115 const lines = text.split("\n").length;
116 let view: FileView;
117 try {
118 const h = getHighlighter();
119 const lang = detectLang(filename);
120 const html = h.codeToHtml(text, {
121 lang,
122 themes: { light: "github-light", dark: "github-dark" },
123 });
124 view = { type: "inline", html, lines };
125 } catch {
126 // fallback: plain pre
127 const escaped = text
128 .replace(/&/g, "&amp;")
129 .replace(/</g, "&lt;")
130 .replace(/>/g, "&gt;");
131 view = {
132 type: "inline",
133 html: `<pre class="code-plain"><code>${escaped}</code></pre>`,
134 lines,
135 };
136 }
137 if (cacheKey) {
138 if (fileCache.size >= MAX_FILE_CACHE)
139 fileCache.delete(fileCache.keys().next().value!);
140 fileCache.set(cacheKey, view);
141 }
142 return view;
143}
144