import { maskNonProse } from "./markdown-prose"; import { theme } from "./theme/theme"; /** A gradient keyword highlighter. `resetTo` is the SGR foreground sequence * re-emitted after each painted keyword so surrounding text keeps its color; * it defaults to a plain foreground reset (editor / default-colored text). */ export type KeywordHighlighter = (text: string, resetTo?: string) => string; const FG_RESET = "\x1b[39m"; /** Declarative spec for {@link createGradientHighlighter}. */ export interface GradientHighlightSpec { /** Cheap, stateless presence probe used to skip the boundary regex on most lines. Must be non-global. */ probe: RegExp; /** Global, word-bounded match regex walked by `.replace`. */ highlight: RegExp; /** Number of color stops swept across the gradient. */ stops: number; /** Maps a normalized position `t` in [0, 1) to an HSL hue in degrees. */ hue: (t: number) => number; /** HSL saturation percentage. Default 90. */ saturation?: number; /** HSL lightness percentage. Default 62. */ lightness?: number; } /** * Build a stateless highlighter that paints each standalone match of `highlight` * with a smooth HSL gradient for editor display. The returned function adds only * zero-width SGR escapes — the visible width is unchanged — and returns the input * untouched when `probe` does not match. The palette is compiled lazily and * memoized per active color mode. */ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordHighlighter { const { probe, highlight, stops, hue, saturation = 90, lightness = 62 } = spec; let cachedMode: string | undefined; let cachedPalette: readonly string[] | undefined; /** Gradient foreground escapes for the active color mode, compiled once per mode. */ const palette = (): readonly string[] => { const mode = theme.getColorMode(); if (cachedPalette && cachedMode === mode) return cachedPalette; const format = mode === "truecolor" ? "ansi-16m" : "ansi-256"; const next: string[] = []; for (let i = 0; i < stops; i++) { next.push(Bun.color(`hsl(${Math.round(hue(i / stops))}, ${saturation}%, ${lightness}%)`, format) ?? ""); } cachedMode = mode; cachedPalette = next; return next; }; /** Paint each character of `word` with the next gradient stop, restoring `resetTo` after. */ const paint = (word: string, resetTo: string): string => { const stopsArr = palette(); const n = word.length; let out = ""; let prev = ""; for (let i = 0; i < n; i++) { const color = stopsArr[Math.floor((i / n) * stopsArr.length)] ?? stopsArr[0] ?? ""; // Coalesce consecutive characters that resolve to the same stop. if (color !== prev) { out += color; prev = color; } out += word[i]; } return `${out}${resetTo}`; }; return (text: string, resetTo: string = FG_RESET): string => { if (!probe.test(text)) return text; // Match against a code/markup-masked copy so keywords inside code spans, // fenced blocks, or XML sections never paint; indices still address `text`. const masked = maskNonProse(text); let out = ""; let last = 0; for (const m of masked.matchAll(highlight)) { const start = m.index ?? 0; const end = start + m[0].length; out += text.slice(last, start) + paint(text.slice(start, end), resetTo); last = end; } return out + text.slice(last); }; }