pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/lib/shiki.ts

132 lines4,550 bytesCodeBlame
1import { type HighlighterCore, createHighlighterCore } from "shiki/core";
2import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3
4import type { FileDiff } from "@g1t/contracts";
5
6import type { HighlightedFile, HighlightedLine } from "./diff";
7
8/**
9 * Syntax highlighting, shared by the server and the browser. Grammars are
10 * imported on first use, so a page that highlights nothing loads none.
11 */
12
13/** File extension (or whole file name) to Shiki language. */
14const LANGUAGES: Record<string, string> = {
15 rs: "rust",
16 ts: "typescript",
17 tsx: "tsx",
18 js: "javascript",
19 mjs: "javascript",
20 jsx: "jsx",
21 json: "json",
22 jsonc: "jsonc",
23 toml: "toml",
24 yaml: "yaml",
25 yml: "yaml",
26 md: "markdown",
27 css: "css",
28 html: "html",
29 sql: "sql",
30 sh: "shellscript",
31 bash: "shellscript",
32 py: "python",
33 go: "go",
34 dockerfile: "docker",
35};
36
37export const THEME = "vitesse-dark";
38
39/** The language of a file, from its name, if it is one g1t highlights. */
40export function languageOf(path: string): string | null {
41 const name = path.split("/").pop()!.toLowerCase();
42 return LANGUAGES[name.includes(".") ? name.split(".").pop()! : name] ?? null;
43}
44
45/** The language a fenced block names (`rust`, `ts`, `sh`…), if g1t highlights it. */
46export function languageNamed(name: string): string | null {
47 const wanted = name.toLowerCase();
48 if (Object.values(LANGUAGES).includes(wanted)) return wanted;
49 return LANGUAGES[wanted] ?? (wanted === "shell" || wanted === "console" ? "shellscript" : null);
50}
51
52let highlighter: Promise<HighlighterCore> | undefined;
53
54export function getHighlighter(): Promise<HighlighterCore> {
55 highlighter ??= createHighlighterCore({
56 themes: [import("shiki/themes/vitesse-dark.mjs")],
57 langs: [
58 import("shiki/langs/rust.mjs"),
59 import("shiki/langs/typescript.mjs"),
60 import("shiki/langs/tsx.mjs"),
61 import("shiki/langs/javascript.mjs"),
62 import("shiki/langs/jsx.mjs"),
63 import("shiki/langs/json.mjs"),
64 import("shiki/langs/jsonc.mjs"),
65 import("shiki/langs/toml.mjs"),
66 import("shiki/langs/yaml.mjs"),
67 import("shiki/langs/markdown.mjs"),
68 import("shiki/langs/css.mjs"),
69 import("shiki/langs/html.mjs"),
70 import("shiki/langs/sql.mjs"),
71 import("shiki/langs/shellscript.mjs"),
72 import("shiki/langs/python.mjs"),
73 import("shiki/langs/go.mjs"),
74 import("shiki/langs/docker.mjs"),
75 ],
76 engine: createJavaScriptRegexEngine(),
77 });
78 return highlighter;
79}
80
81function escape(text: string): string {
82 return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83}
84
85/** Each line of `text` as HTML, coloured. */
86export function linesToHtml(core: HighlighterCore, text: string, lang: string): string[] {
87 const { tokens } = core.codeToTokens(text, { lang, theme: THEME });
88 return tokens.map((row) =>
89 row
90 .map((token) =>
91 token.color ? `<span style="color:${token.color}">${escape(token.content)}</span>` : escape(token.content),
92 )
93 .join(""),
94 );
95}
96
97/** Larger files in a diff are left plain. */
98const MAX_FILE_DIFF_CHARS = 60_000;
99
100/**
101 * A file's diff with each line's text highlighted, or null when its
102 * language is unknown or it is too large. Each hunk is highlighted twice,
103 * as it was and as it is, so a line's colours come from the side it is on.
104 */
105export async function highlightFile(file: FileDiff): Promise<HighlightedFile | null> {
106 const lang = languageOf(file.path);
107 if (!lang || file.binary) return null;
108 const size = file.hunks.reduce((sum, hunk) => sum + hunk.lines.reduce((n, line) => n + line.text.length, 0), 0);
109 if (size > MAX_FILE_DIFF_CHARS) return null;
110 const core = await getHighlighter();
111 try {
112 const hunks = file.hunks.map((hunk) => {
113 const lines: HighlightedLine[] = hunk.lines.map((line) => ({ ...line }));
114 for (const side of ["old", "new"] as const) {
115 const indexes = lines
116 .map((line, index) => (line.kind === (side === "old" ? "add" : "delete") ? -1 : index))
117 .filter((index) => index >= 0);
118 const html = linesToHtml(core, indexes.map((index) => lines[index]!.text).join("\n"), lang);
119 html.forEach((row, n) => {
120 const line = lines[indexes[n]!];
121 // Context lines are the same on both sides; the new side wins.
122 if (!line || (side === "old" && line.kind === "context")) return;
123 line.html = row;
124 });
125 }
126 return { lines };
127 });
128 return { ...file, hunks };
129 } catch {
130 return null;
131 }
132}