c637db9741
- Added `highlightMagicKeywords(text: string, resetTo?: string): string` and chained ultrathink, orchestrate, workflow glow. - Updated ultrathink, orchestrate, and workflow matching to lowercase whitespace-delimited patterns with prose-only checks. - Added `maskNonProse` and `keywordInProse` to skip fenced/inline code and HTML/XML segments while highlighting keywords. - Added `KeywordHighlighter` with optional `resetTo`, and updated gradient highlighting to use masked match slicing.
88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
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);
|
|
};
|
|
}
|