2025-04-22 18:37:58 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { CSSProperties } from 'react';
|
2025-07-20 16:51:18 +09:00
|
|
|
import { isValidColor, resolveColor } from './color-utils.js';
|
2025-05-08 16:00:55 -07:00
|
|
|
|
2025-07-20 16:51:18 +09:00
|
|
|
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
|
2025-05-08 16:00:55 -07:00
|
|
|
|
2025-04-23 17:37:09 -07:00
|
|
|
export interface ColorsTheme {
|
2025-05-08 16:00:55 -07:00
|
|
|
type: ThemeType;
|
2025-04-23 17:37:09 -07:00
|
|
|
Background: string;
|
|
|
|
|
Foreground: string;
|
|
|
|
|
LightBlue: string;
|
|
|
|
|
AccentBlue: string;
|
|
|
|
|
AccentPurple: string;
|
|
|
|
|
AccentCyan: string;
|
|
|
|
|
AccentGreen: string;
|
|
|
|
|
AccentYellow: string;
|
|
|
|
|
AccentRed: string;
|
2025-06-05 14:35:47 -07:00
|
|
|
Comment: string;
|
2025-04-23 17:37:09 -07:00
|
|
|
Gray: string;
|
2025-04-24 11:56:23 -07:00
|
|
|
GradientColors?: string[];
|
2025-04-23 17:37:09 -07:00
|
|
|
}
|
|
|
|
|
|
2025-07-20 16:51:18 +09:00
|
|
|
export interface CustomTheme extends ColorsTheme {
|
|
|
|
|
type: 'custom';
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-23 17:37:09 -07:00
|
|
|
export const lightTheme: ColorsTheme = {
|
2025-05-08 16:00:55 -07:00
|
|
|
type: 'light',
|
2025-04-23 17:37:09 -07:00
|
|
|
Background: '#FAFAFA',
|
|
|
|
|
Foreground: '#3C3C43',
|
2025-06-04 10:41:03 -07:00
|
|
|
LightBlue: '#89BDCD',
|
2025-04-23 17:37:09 -07:00
|
|
|
AccentBlue: '#3B82F6',
|
|
|
|
|
AccentPurple: '#8B5CF6',
|
|
|
|
|
AccentCyan: '#06B6D4',
|
2025-06-04 10:41:03 -07:00
|
|
|
AccentGreen: '#3CA84B',
|
|
|
|
|
AccentYellow: '#D5A40A',
|
|
|
|
|
AccentRed: '#DD4C4C',
|
2025-06-05 14:35:47 -07:00
|
|
|
Comment: '#008000',
|
|
|
|
|
Gray: '#B7BECC',
|
2025-04-24 11:56:23 -07:00
|
|
|
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
2025-04-23 17:37:09 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const darkTheme: ColorsTheme = {
|
2025-05-08 16:00:55 -07:00
|
|
|
type: 'dark',
|
2025-04-23 17:37:09 -07:00
|
|
|
Background: '#1E1E2E',
|
|
|
|
|
Foreground: '#CDD6F4',
|
|
|
|
|
LightBlue: '#ADD8E6',
|
|
|
|
|
AccentBlue: '#89B4FA',
|
|
|
|
|
AccentPurple: '#CBA6F7',
|
|
|
|
|
AccentCyan: '#89DCEB',
|
|
|
|
|
AccentGreen: '#A6E3A1',
|
|
|
|
|
AccentYellow: '#F9E2AF',
|
|
|
|
|
AccentRed: '#F38BA8',
|
2025-06-05 14:35:47 -07:00
|
|
|
Comment: '#6C7086',
|
|
|
|
|
Gray: '#6C7086',
|
2025-04-24 11:56:23 -07:00
|
|
|
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ansiTheme: ColorsTheme = {
|
2025-05-08 16:00:55 -07:00
|
|
|
type: 'ansi',
|
2025-04-24 11:56:23 -07:00
|
|
|
Background: 'black',
|
|
|
|
|
Foreground: 'white',
|
|
|
|
|
LightBlue: 'blue',
|
2025-04-24 14:19:35 -07:00
|
|
|
AccentBlue: 'blue',
|
|
|
|
|
AccentPurple: 'magenta',
|
2025-05-31 11:10:52 -07:00
|
|
|
AccentCyan: 'cyan',
|
2025-04-24 14:19:35 -07:00
|
|
|
AccentGreen: 'green',
|
|
|
|
|
AccentYellow: 'yellow',
|
2025-04-24 11:56:23 -07:00
|
|
|
AccentRed: 'red',
|
2025-06-05 14:35:47 -07:00
|
|
|
Comment: 'gray',
|
2025-04-24 11:56:23 -07:00
|
|
|
Gray: 'gray',
|
2025-04-23 17:37:09 -07:00
|
|
|
};
|
2025-04-22 18:37:58 -07:00
|
|
|
|
|
|
|
|
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<Record<string, string>>;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2025-04-23 17:37:09 -07:00
|
|
|
constructor(
|
2025-05-02 09:31:18 -07:00
|
|
|
readonly name: string,
|
2025-05-08 16:00:55 -07:00
|
|
|
readonly type: ThemeType,
|
2025-04-23 17:37:09 -07:00
|
|
|
rawMappings: Record<string, CSSProperties>,
|
2025-05-02 09:31:18 -07:00
|
|
|
readonly colors: ColorsTheme,
|
2025-04-23 17:37:09 -07:00
|
|
|
) {
|
2025-04-22 18:37:58 -07:00
|
|
|
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 {
|
2025-07-20 16:51:18 +09:00
|
|
|
return resolveColor(colorValue);
|
2025-04-22 18:37:58 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<string, string>).
|
|
|
|
|
*/
|
|
|
|
|
protected _buildColorMap(
|
|
|
|
|
hljsTheme: Record<string, CSSProperties>,
|
|
|
|
|
): Record<string, string> {
|
|
|
|
|
const inkTheme: Record<string, string> = {};
|
|
|
|
|
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,
|
2025-07-21 17:54:44 -04:00
|
|
|
// this enables falling back to the default foreground color.
|
2025-04-22 18:37:58 -07:00
|
|
|
}
|
|
|
|
|
// We currently only care about the 'color' property for Ink rendering.
|
|
|
|
|
// Other properties like background, fontStyle, etc., are ignored.
|
|
|
|
|
}
|
|
|
|
|
return inkTheme;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-20 16:51:18 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a Theme instance from a custom theme configuration.
|
|
|
|
|
* @param customTheme The custom theme configuration.
|
|
|
|
|
* @returns A new Theme instance.
|
|
|
|
|
*/
|
|
|
|
|
export function createCustomTheme(customTheme: CustomTheme): Theme {
|
|
|
|
|
// Generate CSS properties mappings based on the custom theme colors
|
|
|
|
|
const rawMappings: Record<string, CSSProperties> = {
|
|
|
|
|
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%',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return new Theme(customTheme.name, 'custom', rawMappings, customTheme);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<CustomTheme>): {
|
|
|
|
|
isValid: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
} {
|
|
|
|
|
// Check required fields
|
|
|
|
|
const requiredFields: Array<keyof CustomTheme> = [
|
|
|
|
|
'name',
|
|
|
|
|
'Background',
|
|
|
|
|
'Foreground',
|
|
|
|
|
'LightBlue',
|
|
|
|
|
'AccentBlue',
|
|
|
|
|
'AccentPurple',
|
|
|
|
|
'AccentCyan',
|
|
|
|
|
'AccentGreen',
|
|
|
|
|
'AccentYellow',
|
|
|
|
|
'AccentRed',
|
|
|
|
|
'Comment',
|
|
|
|
|
'Gray',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const field of requiredFields) {
|
|
|
|
|
if (!customTheme[field]) {
|
|
|
|
|
return {
|
|
|
|
|
isValid: false,
|
|
|
|
|
error: `Missing required field: ${field}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate color format (basic hex validation)
|
|
|
|
|
const colorFields: Array<keyof CustomTheme> = [
|
|
|
|
|
'Background',
|
|
|
|
|
'Foreground',
|
|
|
|
|
'LightBlue',
|
|
|
|
|
'AccentBlue',
|
|
|
|
|
'AccentPurple',
|
|
|
|
|
'AccentCyan',
|
|
|
|
|
'AccentGreen',
|
|
|
|
|
'AccentYellow',
|
|
|
|
|
'AccentRed',
|
|
|
|
|
'Comment',
|
|
|
|
|
'Gray',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const field of colorFields) {
|
|
|
|
|
const color = customTheme[field] as string;
|
|
|
|
|
if (!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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|