diff --git a/package-lock.json b/package-lock.json index 6a33fe6c..4d525869 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1073,6 +1073,15 @@ "@types/tinycolor2": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1113,6 +1122,12 @@ "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -2300,6 +2315,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -3516,6 +3553,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -4378,6 +4424,21 @@ "dev": true, "license": "MIT" }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -6511,11 +6572,13 @@ "diff": "^7.0.0", "dotenv": "^16.4.7", "fast-glob": "^3.3.3", + "highlight.js": "^11.11.1", "ink": "^5.2.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "lowlight": "^3.3.0", "react": "^18.3.1", "yargs": "^17.7.2" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 2f53c609..abd0e819 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,11 +27,13 @@ "diff": "^7.0.0", "dotenv": "^16.4.7", "fast-glob": "^3.3.3", + "highlight.js": "^11.11.1", "ink": "^5.2.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "lowlight": "^3.3.0", "react": "^18.3.1", "yargs": "^17.7.2" }, diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts new file mode 100644 index 00000000..e083575c --- /dev/null +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VS2015 } from './vs2015.js'; +import { Theme } from './theme.js'; + +class ThemeManager { + private static readonly DEFAULT_THEME: Theme = VS2015; + private readonly availableThemes: Theme[]; + private activeTheme: Theme; + + constructor() { + this.availableThemes = [VS2015]; + this.activeTheme = ThemeManager.DEFAULT_THEME; + } + + /** + * Returns the currently active theme object. + */ + getActiveTheme(): Theme { + return this.activeTheme; + } +} + +// Export an instance of the ThemeManager +export const themeManager = new ThemeManager(); diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts new file mode 100644 index 00000000..f7bd1cd0 --- /dev/null +++ b/packages/cli/src/ui/themes/theme.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CSSProperties } from 'react'; + +export class Theme { + /** + * The user-facing name of the theme. + */ + readonly name: string; + + /** + * 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>; + + // --- Static Helper Data --- + + // Mapping from common CSS color names (lowercase) to hex codes (lowercase) + // Excludes names directly supported by Ink + private static readonly cssNameToHexMap: Readonly> = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + blanchedalmond: '#ffebcd', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgrey: '#a9a9a9', + darkgreen: '#006400', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + greenyellow: '#adff2f', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', + lightgrey: '#d3d3d3', + lightgreen: '#90ee90', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370db', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#db7093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + rebeccapurple: '#663399', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + slategrey: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + whitesmoke: '#f5f5f5', + yellowgreen: '#9acd32', + }; + + // Define the set of Ink's named colors for quick lookup + private static readonly inkSupportedNames = new Set([ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'cyan', + 'magenta', + 'white', + 'gray', + 'grey', + 'blackbright', + 'redbright', + 'greenbright', + 'yellowbright', + 'bluebright', + 'cyanbright', + 'magentabright', + 'whitebright', + ]); + + /** + * 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(name: string, rawMappings: Record) { + this.name = name; + 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 { + const lowerColor = colorValue.toLowerCase(); + + // 1. Check if it's already a hex code + if (lowerColor.startsWith('#')) { + return lowerColor; // Use hex directly + } + // 2. Check if it's an Ink supported name (lowercase) + else if (Theme.inkSupportedNames.has(lowerColor)) { + return lowerColor; // Use Ink name directly + } + // 3. Check if it's a known CSS name we can map to hex + else if (Theme.cssNameToHexMap[lowerColor]) { + return Theme.cssNameToHexMap[lowerColor]; // Use mapped hex + } + + // 4. Could not resolve + console.warn( + `[Theme] Could not resolve color "${colorValue}" to an Ink-compatible format.`, + ); + return undefined; + } + + /** + * 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, + // allowing fallback 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; + } +} diff --git a/packages/cli/src/ui/themes/vs2015.ts b/packages/cli/src/ui/themes/vs2015.ts new file mode 100644 index 00000000..fa4ec1ee --- /dev/null +++ b/packages/cli/src/ui/themes/vs2015.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Theme } from './theme.js'; + +export const VS2015: Theme = new Theme('VS2015', { + hljs: { + display: 'block', + overflowX: 'auto', + padding: '0.5em', + background: '#1E1E1E', + color: '#DCDCDC', + }, + 'hljs-keyword': { + color: '#569CD6', + }, + 'hljs-literal': { + color: '#569CD6', + }, + 'hljs-symbol': { + color: '#569CD6', + }, + 'hljs-name': { + color: '#569CD6', + }, + 'hljs-link': { + color: '#569CD6', + textDecoration: 'underline', + }, + 'hljs-built_in': { + color: '#4EC9B0', + }, + 'hljs-type': { + color: '#4EC9B0', + }, + 'hljs-number': { + color: '#B8D7A3', + }, + 'hljs-class': { + color: '#B8D7A3', + }, + 'hljs-string': { + color: '#D69D85', + }, + 'hljs-meta-string': { + color: '#D69D85', + }, + 'hljs-regexp': { + color: '#9A5334', + }, + 'hljs-template-tag': { + color: '#9A5334', + }, + 'hljs-subst': { + color: '#DCDCDC', + }, + 'hljs-function': { + color: '#DCDCDC', + }, + 'hljs-title': { + color: '#DCDCDC', + }, + 'hljs-params': { + color: '#DCDCDC', + }, + 'hljs-formula': { + color: '#DCDCDC', + }, + 'hljs-comment': { + color: '#57A64A', + fontStyle: 'italic', + }, + 'hljs-quote': { + color: '#57A64A', + fontStyle: 'italic', + }, + 'hljs-doctag': { + color: '#608B4E', + }, + 'hljs-meta': { + color: '#9B9B9B', + }, + 'hljs-meta-keyword': { + color: '#9B9B9B', + }, + 'hljs-tag': { + color: '#9B9B9B', + }, + 'hljs-variable': { + color: '#BD63C5', + }, + 'hljs-template-variable': { + color: '#BD63C5', + }, + 'hljs-attr': { + color: '#9CDCFE', + }, + 'hljs-attribute': { + color: '#9CDCFE', + }, + 'hljs-builtin-name': { + color: '#9CDCFE', + }, + 'hljs-section': { + color: 'gold', + }, + 'hljs-emphasis': { + fontStyle: 'italic', + }, + 'hljs-strong': { + fontWeight: 'bold', + }, + 'hljs-bullet': { + color: '#D7BA7D', + }, + 'hljs-selector-tag': { + color: '#D7BA7D', + }, + 'hljs-selector-id': { + color: '#D7BA7D', + }, + 'hljs-selector-class': { + color: '#D7BA7D', + }, + 'hljs-selector-attr': { + color: '#D7BA7D', + }, + 'hljs-selector-pseudo': { + color: '#D7BA7D', + }, + 'hljs-addition': { + backgroundColor: '#144212', + display: 'inline-block', + width: '100%', + }, + 'hljs-deletion': { + backgroundColor: '#600', + display: 'inline-block', + width: '100%', + }, +}); diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx new file mode 100644 index 00000000..1916ff50 --- /dev/null +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import { common, createLowlight } from 'lowlight'; +import type { + Root, + Element, + Text as HastText, + ElementContent, + RootContent, +} from 'hast'; +import { themeManager } from '../themes/theme-manager.js'; +import { Theme } from '../themes/theme.js'; + +// Configure themeing and parsing utilities. +const lowlight = createLowlight(common); + +function renderHastNode( + node: Root | Element | HastText | RootContent, + theme: Theme, + inheritedColor: string | undefined, +): React.ReactNode { + if (node.type === 'text') { + // Use the color passed down from parent element, if any + return {node.value}; + } + + // Handle Element Nodes: Determine color and pass it down, don't wrap + if (node.type === 'element') { + const nodeClasses: string[] = + (node.properties?.className as string[]) || []; + let elementColor: string | undefined = undefined; + + // Find color defined specifically for this element's class + for (let i = nodeClasses.length - 1; i >= 0; i--) { + const color = theme.getInkColor(nodeClasses[i]); + if (color) { + elementColor = color; + break; + } + } + + // Determine the color to pass down: Use this element's specific color + // if found, otherwise, continue passing down the already inherited color. + const colorToPassDown = elementColor || inheritedColor; + + // Recursively render children, passing the determined color down + // Ensure child type matches expected HAST structure (ElementContent is common) + const children = node.children?.map( + (child: ElementContent, index: number) => ( + + {renderHastNode(child, theme, colorToPassDown)} + + ), + ); + + // Element nodes now only group children; color is applied by Text nodes. + // Use a React Fragment to avoid adding unnecessary elements. + return {children}; + } + + // Handle Root Node: Start recursion with initial inherited color + if (node.type === 'root') { + // Pass down the initial inheritedColor (likely undefined from the top call) + // Ensure child type matches expected HAST structure (RootContent is common) + return node.children?.map((child: RootContent, index: number) => ( + + {renderHastNode(child, theme, inheritedColor)} + + )); + } + + // Handle unknown or unsupported node types + return null; +} + +/** + * Renders syntax-highlighted code for Ink applications using a selected theme. + * + * @param code The code string to highlight. + * @param language The language identifier (e.g., 'javascript', 'css', 'html') + * @returns A React.ReactNode containing Ink elements for the highlighted code. + */ +export function colorizeCode( + code: string, + language: string | null, +): React.ReactNode { + const codeToHighlight = code.replace(/\n$/, ''); + const activeTheme = themeManager.getActiveTheme(); + + try { + const hastTree = + !language || !lowlight.registered(language) + ? lowlight.highlightAuto(codeToHighlight) + : lowlight.highlight(language, codeToHighlight); + + // Render the HAST tree using the adapted theme + // Apply the theme's default foreground color to the top-level Text element + return ( + + {renderHastNode(hastTree, activeTheme, undefined)} + + ); + } catch (error) { + console.error( + `[colorizeCode] Error highlighting code for language "${language}":`, + error, + ); + // Fallback to plain text with default color on error + return {codeToHighlight}; + } +} diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx index 680d7407..c9053728 100644 --- a/packages/cli/src/ui/utils/MarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/MarkdownRenderer.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { Text, Box } from 'ink'; import { Colors } from '../colors.js'; +import { colorizeCode } from './CodeColorizer.js'; /** * A utility class to render a subset of Markdown into Ink components. @@ -155,21 +156,12 @@ export class MarkdownRenderer { content: string[], lang: string | null, ): React.ReactNode { - // Basic styling for code block + const fullContent = content.join('\n'); + const colorizedCode = colorizeCode(fullContent, lang); + return ( - - {lang && {lang}} - {/* Render each line preserving whitespace (within Text component) */} - {content.map((line, idx) => ( - {line} - ))} + + {colorizedCode} ); }