Skip to content
152 linesCodeBlameRaw
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
37/**
38 * Code is coloured for both themes at once: each token's colour is
39 * `light-dark(light, dark)`, so the page's Appearance (lib/theme.ts)
40 * picks one with no second pass, and highlighted HTML kept in a cache is
41 * right in either.
42 */
43export const THEMES = { light: "vitesse-light", dark: "vitesse-dark" } as const;
44
45/** The language of a file, from its name, if it is one g1t highlights. */
46export function languageOf(path: string): string | null {
47 const name = path.split("/").pop()!.toLowerCase();
48 return LANGUAGES[name.includes(".") ? name.split(".").pop()! : name] ?? null;
49}
50
51/** The language a fenced block names (`rust`, `ts`, `sh`…), if g1t highlights it. */
52export function languageNamed(name: string): string | null {
53 const wanted = name.toLowerCase();
54 if (Object.values(LANGUAGES).includes(wanted)) return wanted;
55 return LANGUAGES[wanted] ?? (wanted === "shell" || wanted === "console" ? "shellscript" : null);
56}
57
58let highlighter: Promise<HighlighterCore> | undefined;
59
60export function getHighlighter(): Promise<HighlighterCore> {
61 highlighter ??= createHighlighterCore({
62 themes: [import("shiki/themes/vitesse-light.mjs"), import("shiki/themes/vitesse-dark.mjs")],
63 langs: [
64 import("shiki/langs/rust.mjs"),
65 import("shiki/langs/typescript.mjs"),
66 import("shiki/langs/tsx.mjs"),
67 import("shiki/langs/javascript.mjs"),
68 import("shiki/langs/jsx.mjs"),
69 import("shiki/langs/json.mjs"),
70 import("shiki/langs/jsonc.mjs"),
71 import("shiki/langs/toml.mjs"),
72 import("shiki/langs/yaml.mjs"),
73 import("shiki/langs/markdown.mjs"),
74 import("shiki/langs/css.mjs"),
75 import("shiki/langs/html.mjs"),
76 import("shiki/langs/sql.mjs"),
77 import("shiki/langs/shellscript.mjs"),
78 import("shiki/langs/python.mjs"),
79 import("shiki/langs/go.mjs"),
80 import("shiki/langs/docker.mjs"),
81 ],
82 engine: createJavaScriptRegexEngine(),
83 });
84 return highlighter;
85}
86
87function escape(text: string): string {
88 return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
89}
90
91/** A piece of a line and its colour, a `light-dark()` pair, if it has one. */
92export type CodeToken = { content: string; color?: string };
93
94/** Each line of `text` as coloured tokens, for both themes. */
95export function tokenRows(core: HighlighterCore, text: string, lang: string): CodeToken[][] {
96 return core.codeToTokensWithThemes(text, { lang, themes: THEMES }).map((row) =>
97 row.map((token) => {
98 const light = token.variants.light?.color;
99 const dark = token.variants.dark?.color;
100 const color = light && dark ? `light-dark(${light},${dark})` : (light ?? dark);
101 return color ? { content: token.content, color } : { content: token.content };
102 }),
103 );
104}
105
106/** Each line of `text` as HTML, coloured. */
107export function linesToHtml(core: HighlighterCore, text: string, lang: string): string[] {
108 return tokenRows(core, text, lang).map((row) =>
109 row
110 .map((token) =>
111 token.color ? `<span style="color:${token.color}">${escape(token.content)}</span>` : escape(token.content),
112 )
113 .join(""),
114 );
115}
116
117/** Larger files in a diff are left plain. */
118const MAX_FILE_DIFF_CHARS = 60_000;
119
120/**
121 * A file's diff with each line's text highlighted, or null when its
122 * language is unknown or it is too large. Each hunk is highlighted twice,
123 * as it was and as it is, so a line's colours come from the side it is on.
124 */
125export async function highlightFile(file: FileDiff): Promise<HighlightedFile | null> {
126 const lang = languageOf(file.path);
127 if (!lang || file.binary) return null;
128 const size = file.hunks.reduce((sum, hunk) => sum + hunk.lines.reduce((n, line) => n + line.text.length, 0), 0);
129 if (size > MAX_FILE_DIFF_CHARS) return null;
130 const core = await getHighlighter();
131 try {
132 const hunks = file.hunks.map((hunk) => {
133 const lines: HighlightedLine[] = hunk.lines.map((line) => ({ ...line }));
134 for (const side of ["old", "new"] as const) {
135 const indexes = lines
136 .map((line, index) => (line.kind === (side === "old" ? "add" : "delete") ? -1 : index))
137 .filter((index) => index >= 0);
138 const html = linesToHtml(core, indexes.map((index) => lines[index]!.text).join("\n"), lang);
139 html.forEach((row, n) => {
140 const line = lines[indexes[n]!];
141 // Context lines are the same on both sides; the new side wins.
142 if (!line || (side === "old" && line.kind === "context")) return;
143 line.html = row;
144 });
145 }
146 return { lines };
147 });
148 return { ...file, hunks };
149 } catch {
150 return null;
151 }
152}