/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { isValidColor, resolveColor, interpolateColor, getThemeTypeFromBackgroundColor, } from './color-utils.js'; // Type for syntax highlighter theme styles // These come from react-syntax-highlighter and include additional properties interface HighlightJSStyle { color?: string; background?: string; backgroundColor?: string; display?: string; fontStyle?: string; fontWeight?: string; textDecoration?: string; textTransform?: string; borderRight?: string; paddingRight?: string; paddingLeft?: string; overflowX?: string; padding?: string; [key: string]: string | undefined; // Allow other CSS properties } export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom'; export interface ColorsTheme { type: ThemeType; Background: string; Foreground: string; LightBlue: string; AccentBlue: string; AccentPurple: string; AccentCyan: string; AccentGreen: string; AccentYellow: string; AccentRed: string; Warning?: string; DiffAdded: string; DiffRemoved: string; // Optional diff colour fields with fallbacks DiffAddedBackground?: string; DiffAddedForeground?: string; DiffRemovedBackground?: string; DiffRemovedForeground?: string; Comment: string; DimComment: string; Gray: string; DarkGray: string; GradientColors?: string[]; } export interface SemanticColors { text: { primary: string; secondary: string; link: string; accent: string; response: string; }; background: { primary: string; diff: { added: string; removed: string; }; }; border: { default: string; focused: string; }; ui: { comment: string; symbol: string; dark: string; gradient: string[] | undefined; }; status: { error: string; success: string; warning: string; }; } export interface CustomTheme { type: 'custom'; name: string; text?: { primary?: string; secondary?: string; link?: string; accent?: string; response?: string; }; background?: { primary?: string; diff?: { added?: string; removed?: string; }; }; border?: { default?: string; focused?: string; }; ui?: { comment?: string; symbol?: string; gradient?: string[]; }; status?: { error?: string; success?: string; warning?: string; }; // Legacy properties (all optional) Background?: string; Foreground?: string; LightBlue?: string; AccentBlue?: string; AccentPurple?: string; AccentCyan?: string; AccentGreen?: string; AccentYellow?: string; AccentRed?: string; DiffAdded?: string; DiffRemoved?: string; Comment?: string; DimComment?: string; Gray?: string; DarkGray?: string; GradientColors?: string[]; } export const lightTheme: ColorsTheme = { type: 'light', Background: '#FAFAFA', Foreground: '', LightBlue: '#89BDCD', AccentBlue: '#3B82F6', AccentPurple: '#8B5CF6', AccentCyan: '#06B6D4', AccentGreen: '#3CA84B', AccentYellow: '#D5A40A', Warning: '#D5A40A', AccentRed: '#DD4C4C', DiffAdded: '#C6EAD8', DiffRemoved: '#FFCCCC', Comment: '#008000', DimComment: '#006000', Gray: '#97a0b0', DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5), GradientColors: ['#4796E4', '#847ACE', '#C3677F'], }; export const darkTheme: ColorsTheme = { type: 'dark', Background: '#1E1E2E', Foreground: '', LightBlue: '#ADD8E6', AccentBlue: '#89B4FA', AccentPurple: '#CBA6F7', AccentCyan: '#89DCEB', AccentGreen: '#A6E3A1', AccentYellow: '#F9E2AF', Warning: '#F9E2AF', AccentRed: '#F38BA8', DiffAdded: '#28350B', DiffRemoved: '#430000', Comment: '#6C7086', DimComment: '#4A4D5E', Gray: '#6C7086', DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5), GradientColors: ['#4796E4', '#847ACE', '#C3677F'], }; export const ansiTheme: ColorsTheme = { type: 'ansi', Background: 'black', Foreground: '', LightBlue: 'blue', AccentBlue: 'blue', AccentPurple: 'magenta', AccentCyan: 'cyan', AccentGreen: 'green', AccentYellow: 'yellow', Warning: 'yellow', AccentRed: 'red', DiffAdded: 'green', DiffRemoved: 'red', Comment: 'gray', DimComment: '#5a5a5a', Gray: 'gray', DarkGray: 'gray', }; export class Theme { /** * The default foreground color for text when no specific highlight rule applies. * This is an Ink-compatible color string (hex or name). */ readonly defaultColor: string; /** * Stores the mapping from highlight.js class names (e.g., 'hljs-keyword') * to Ink-compatible color strings (hex or name). */ protected readonly _colorMap: Readonly>; readonly semanticColors: SemanticColors; /** * Creates a new Theme instance. * @param name The name of the theme. * @param rawMappings The raw CSSProperties mappings from a react-syntax-highlighter theme object. */ constructor( readonly name: string, readonly type: ThemeType, rawMappings: Record, readonly colors: ColorsTheme, semanticColors?: SemanticColors, ) { this.semanticColors = semanticColors ?? { text: { primary: this.colors.Foreground, secondary: this.colors.Gray, link: this.colors.AccentBlue, accent: this.colors.AccentPurple, response: this.colors.Foreground, }, background: { primary: this.colors.Background, diff: { added: this.colors.DiffAdded, removed: this.colors.DiffRemoved, }, }, border: { default: this.colors.Gray, focused: this.colors.AccentBlue, }, ui: { comment: this.colors.Comment, symbol: this.colors.Gray, dark: this.colors.DarkGray, gradient: this.colors.GradientColors, }, status: { error: this.colors.AccentRed, success: this.colors.AccentGreen, warning: this.colors.AccentYellow, }, }; this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map // Determine the default foreground color const rawDefaultColor = rawMappings['hljs'].color; this.defaultColor = (rawDefaultColor ? Theme._resolveColor(rawDefaultColor) : undefined) ?? ''; // Default to empty string if not found or resolvable } /** * Gets the Ink-compatible color string for a given highlight.js class name. * @param hljsClass The highlight.js class name (e.g., 'hljs-keyword', 'hljs-string'). * @returns The corresponding Ink color string (hex or name) if it exists. */ getInkColor(hljsClass: string): string | undefined { return this._colorMap[hljsClass]; } /** * Resolves a CSS color value (name or hex) into an Ink-compatible color string. * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki'). * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable. */ private static _resolveColor(colorValue: string): string | undefined { return resolveColor(colorValue); } /** * Builds the internal map from highlight.js class names to Ink-compatible color strings. * This method is protected and primarily intended for use by the constructor. * @param hljsTheme The raw CSSProperties mappings from a react-syntax-highlighter theme object. * @returns An Ink-compatible theme map (Record). */ protected _buildColorMap( hljsTheme: Record, ): Record { const inkTheme: Record = {}; for (const key in hljsTheme) { // Ensure the key starts with 'hljs-' or is 'hljs' for the base style if (!key.startsWith('hljs-') && key !== 'hljs') { continue; // Skip keys not related to highlighting classes } const style = hljsTheme[key]; if (style.color) { const resolvedColor = Theme._resolveColor(style.color); if (resolvedColor !== undefined) { // Use the original key from the hljsTheme (e.g., 'hljs-keyword') inkTheme[key] = resolvedColor; } // If color is not resolvable, it's omitted from the map, // this enables falling back to the default foreground color. } // We currently only care about the 'color' property for Ink rendering. // Other properties like background, fontStyle, etc., are ignored. } return inkTheme; } } /** * Creates a Theme instance from a custom theme configuration. * @param customTheme The custom theme configuration. * @returns A new Theme instance. */ function resolveField( preferred: string | undefined, fallback: string | undefined, ): string { return preferred ?? fallback ?? ''; } function buildColorsFromCustomTheme(customTheme: CustomTheme): ColorsTheme { const DarkGray = customTheme.DarkGray ?? interpolateColor( resolveField(customTheme.text?.secondary, customTheme.Gray), resolveField(customTheme.background?.primary, customTheme.Background), 0.5, ); return { type: 'custom', Background: resolveField( customTheme.background?.primary, customTheme.Background, ), Foreground: resolveField(customTheme.text?.primary, customTheme.Foreground), LightBlue: resolveField(customTheme.text?.link, customTheme.LightBlue), AccentBlue: resolveField(customTheme.text?.link, customTheme.AccentBlue), AccentPurple: resolveField( customTheme.text?.accent, customTheme.AccentPurple, ), AccentCyan: resolveField(customTheme.text?.link, customTheme.AccentCyan), AccentGreen: resolveField( customTheme.status?.success, customTheme.AccentGreen, ), AccentYellow: resolveField( customTheme.status?.warning, customTheme.AccentYellow, ), Warning: resolveField( customTheme.status?.warning, customTheme.AccentYellow, ), AccentRed: resolveField(customTheme.status?.error, customTheme.AccentRed), DiffAdded: resolveField( customTheme.background?.diff?.added, customTheme.DiffAdded, ), DiffRemoved: resolveField( customTheme.background?.diff?.removed, customTheme.DiffRemoved, ), Comment: resolveField(customTheme.ui?.comment, customTheme.Comment), DimComment: resolveField(customTheme.ui?.comment, customTheme.DimComment), Gray: resolveField(customTheme.text?.secondary, customTheme.Gray), DarkGray, GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors, }; } function buildRawMappingsFromCustomTheme( customTheme: CustomTheme, ): Record { return { hljs: { display: 'block', overflowX: 'auto', padding: '0.5em', background: customTheme.Background, color: customTheme.Foreground, }, 'hljs-keyword': { color: customTheme.AccentBlue }, 'hljs-literal': { color: customTheme.AccentBlue }, 'hljs-symbol': { color: customTheme.AccentBlue }, 'hljs-name': { color: customTheme.AccentBlue }, 'hljs-link': { color: customTheme.AccentBlue, textDecoration: 'underline', }, 'hljs-built_in': { color: customTheme.AccentCyan }, 'hljs-type': { color: customTheme.AccentCyan }, 'hljs-number': { color: customTheme.AccentGreen }, 'hljs-class': { color: customTheme.AccentGreen }, 'hljs-string': { color: customTheme.AccentYellow }, 'hljs-meta-string': { color: customTheme.AccentYellow }, 'hljs-regexp': { color: customTheme.AccentRed }, 'hljs-template-tag': { color: customTheme.AccentRed }, 'hljs-subst': { color: customTheme.Foreground }, 'hljs-function': { color: customTheme.Foreground }, 'hljs-title': { color: customTheme.Foreground }, 'hljs-params': { color: customTheme.Foreground }, 'hljs-formula': { color: customTheme.Foreground }, 'hljs-comment': { color: customTheme.Comment, fontStyle: 'italic' }, 'hljs-quote': { color: customTheme.Comment, fontStyle: 'italic' }, 'hljs-doctag': { color: customTheme.Comment }, 'hljs-meta': { color: customTheme.Gray }, 'hljs-meta-keyword': { color: customTheme.Gray }, 'hljs-tag': { color: customTheme.Gray }, 'hljs-variable': { color: customTheme.AccentPurple }, 'hljs-template-variable': { color: customTheme.AccentPurple }, 'hljs-attr': { color: customTheme.LightBlue }, 'hljs-attribute': { color: customTheme.LightBlue }, 'hljs-builtin-name': { color: customTheme.LightBlue }, 'hljs-section': { color: customTheme.AccentYellow }, 'hljs-emphasis': { fontStyle: 'italic' }, 'hljs-strong': { fontWeight: 'bold' }, 'hljs-bullet': { color: customTheme.AccentYellow }, 'hljs-selector-tag': { color: customTheme.AccentYellow }, 'hljs-selector-id': { color: customTheme.AccentYellow }, 'hljs-selector-class': { color: customTheme.AccentYellow }, 'hljs-selector-attr': { color: customTheme.AccentYellow }, 'hljs-selector-pseudo': { color: customTheme.AccentYellow }, 'hljs-addition': { backgroundColor: customTheme.AccentGreen, display: 'inline-block', width: '100%', }, 'hljs-deletion': { backgroundColor: customTheme.AccentRed, display: 'inline-block', width: '100%', }, }; } function buildSemanticColorsFromCustomTheme( customTheme: CustomTheme, colors: ColorsTheme, ): SemanticColors { return { text: buildSemanticText(customTheme, colors), background: buildSemanticBackground(customTheme, colors), border: buildSemanticBorder(customTheme, colors), ui: buildSemanticUi(customTheme, colors), status: buildSemanticStatus(customTheme, colors), }; } function buildSemanticText( customTheme: CustomTheme, colors: ColorsTheme, ): SemanticColors['text'] { return { primary: customTheme.text?.primary ?? colors.Foreground, secondary: customTheme.text?.secondary ?? colors.Gray, link: customTheme.text?.link ?? colors.AccentBlue, accent: customTheme.text?.accent ?? colors.AccentPurple, response: customTheme.text?.response ?? customTheme.text?.primary ?? colors.Foreground, }; } function buildSemanticBackground( customTheme: CustomTheme, colors: ColorsTheme, ): SemanticColors['background'] { return { primary: customTheme.background?.primary ?? colors.Background, diff: { added: customTheme.background?.diff?.added ?? colors.DiffAdded, removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved, }, }; } function buildSemanticBorder( customTheme: CustomTheme, colors: ColorsTheme, ): SemanticColors['border'] { return { default: customTheme.border?.default ?? colors.Gray, focused: customTheme.border?.focused ?? colors.AccentBlue, }; } function buildSemanticUi( customTheme: CustomTheme, colors: ColorsTheme, ): SemanticColors['ui'] { return { comment: customTheme.ui?.comment ?? colors.Comment, symbol: customTheme.ui?.symbol ?? colors.Gray, dark: colors.DarkGray, gradient: customTheme.ui?.gradient ?? colors.GradientColors, }; } function buildSemanticStatus( customTheme: CustomTheme, colors: ColorsTheme, ): SemanticColors['status'] { return { error: customTheme.status?.error ?? colors.AccentRed, success: customTheme.status?.success ?? colors.AccentGreen, warning: customTheme.status?.warning ?? colors.AccentYellow, }; } export function createCustomTheme(customTheme: CustomTheme): Theme { const colors = buildColorsFromCustomTheme(customTheme); const rawMappings = buildRawMappingsFromCustomTheme(customTheme); const semanticColors = buildSemanticColorsFromCustomTheme( customTheme, colors, ); return new Theme( customTheme.name, 'custom', rawMappings, colors, semanticColors, ); } /** * Validates a custom theme configuration. * @param customTheme The custom theme to validate. * @returns An object with isValid boolean and error message if invalid. */ export function validateCustomTheme(customTheme: Partial): { isValid: boolean; error?: string; warning?: string; } { // Check required fields const requiredFields: Array = [ 'name', 'Background', 'Foreground', 'LightBlue', 'AccentBlue', 'AccentPurple', 'AccentCyan', 'AccentGreen', 'AccentYellow', 'AccentRed', // 'DiffAdded' and 'DiffRemoved' are not required as they were added after // the theme format was defined. 'Comment', 'Gray', ]; const recommendedFields: Array = [ 'DiffAdded', 'DiffRemoved', ]; for (const field of requiredFields) { if (customTheme[field] == null || customTheme[field] === '') { return { isValid: false, error: `Missing required field: ${field}`, }; } } const missingFields: string[] = []; for (const field of recommendedFields) { if (customTheme[field] == null || customTheme[field] === '') { missingFields.push(field); } } // Validate color format (basic hex validation) const colorFields: Array = [ 'Background', 'Foreground', 'LightBlue', 'AccentBlue', 'AccentPurple', 'AccentCyan', 'AccentGreen', 'AccentYellow', 'AccentRed', 'DiffAdded', 'DiffRemoved', 'Comment', 'Gray', ]; for (const field of colorFields) { const color = customTheme[field] as string | undefined; if (color !== undefined && !isValidColor(color)) { return { isValid: false, error: `Invalid color format for ${field}: ${color}`, }; } } // Validate theme name if (customTheme.name && !isValidThemeName(customTheme.name)) { return { isValid: false, error: `Invalid theme name: ${customTheme.name}`, }; } return { isValid: true, warning: missingFields.length > 0 ? `Missing field(s) ${missingFields.join(', ')}` : undefined, }; } /** * Checks if a theme name is valid. * @param name The theme name to validate. * @returns True if the theme name is valid. */ function isValidThemeName(name: string): boolean { // Theme name should be non-empty and not contain invalid characters return name.trim().length > 0 && name.trim().length <= 50; } /** * Picks a default theme name based on terminal background color * Falls back to fallbackTheme if no match found * @param terminalBackgroundColor The hex color string of the terminal background * @param availableThemes A list of available themes to search through * @param fallbackTheme The name of the fallback dark theme * @param fallbackLightTheme The name of the fallback light theme * @returns The name of the chosen theme */ export function pickDefaultThemeName( terminalBackgroundColor: string | undefined, availableThemes: ReadonlyArray<{ name: string; type: string; colors?: { Background?: string }; }>, fallbackTheme: string, fallbackLightTheme?: string, ): string { const terminalThemeType = getThemeTypeFromBackgroundColor( terminalBackgroundColor, ); if (!terminalThemeType) { return fallbackTheme; } // Prefer the designated fallback themes when they match the terminal type const fallbackForType = terminalThemeType === 'light' ? fallbackLightTheme : fallbackTheme; const preferredFallback = availableThemes.find( (t) => t.name === fallbackForType && t.type === terminalThemeType, ); if (preferredFallback) { return preferredFallback.name; } // Otherwise pick the first theme matching terminal type const matchingTheme = availableThemes.find( (t) => t.type === terminalThemeType, ); if (matchingTheme) { return matchingTheme.name; } // Fallback to light theme if terminal is light if (terminalThemeType === 'light' && fallbackLightTheme) { return fallbackLightTheme; } return fallbackTheme; }