| 1 | import { type HighlighterCore, createHighlighterCore } from "shiki/core"; |
| 2 | import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; |
| 3 | |
| 4 | import type { FileDiff } from "@g1t/contracts"; |
| 5 | |
| 6 | import 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. */ |
| 14 | const 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 | */ |
| 43 | export 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. */ |
| 46 | export 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. */ |
| 52 | export 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 | |
| 58 | let highlighter: Promise<HighlighterCore> | undefined; |
| 59 | |
| 60 | export 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 | |
| 87 | function escape(text: string): string { |
| 88 | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 89 | } |
| 90 | |
| 91 | /** A piece of a line and its colour, a `light-dark()` pair, if it has one. */ |
| 92 | export type CodeToken = { content: string; color?: string }; |
| 93 | |
| 94 | /** Each line of `text` as coloured tokens, for both themes. */ |
| 95 | export 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. */ |
| 107 | export 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. */ |
| 118 | const 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 | */ |
| 125 | export 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 | } |