/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { Text, Box } 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 type { Theme } from '../themes/theme.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT, } from '../components/shared/MaxSizedBox.js'; import type { LoadedSettings } from '../../config/settings.js'; import { debugLogger } from '@vybestack/llxprt-code-telemetry'; // Configure theming and parsing utilities. const lowlight = createLowlight(common); function getClassNames(node: Element): string[] { const className = node.properties.className; if (Array.isArray(className)) { return className.filter( (value): value is string => typeof value === 'string', ); } return typeof className === 'string' ? [className] : []; } 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 = getClassNames(node); 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 initially inherited color if (node.type === 'root') { // Check if children array is empty - this happens when lowlight can't detect language – fall back to plain text if (node.children.length === 0) { return null; } // 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; } function highlightAndRenderLine( line: string, language: string | null, theme: Theme, ): React.ReactNode { try { const getHighlightedLine = () => !language || !lowlight.registered(language) ? lowlight.highlightAuto(line) : lowlight.highlight(language, line); const renderedNode = renderHastNode( getHighlightedLine(), theme, theme.defaultColor, ); return renderedNode !== null ? renderedNode : line; } catch { return line; } } export function colorizeLine( line: string, language: string | null, theme?: Theme, overrideColor?: string, ): React.ReactNode { const activeTheme = theme ?? themeManager.getActiveTheme(); if (overrideColor) { // If there's an override color, we create a simple Text component // instead of going through syntax highlighting return {line}; } return highlightAndRenderLine(line, language, activeTheme); } function renderFallbackLines( codeToHighlight: string, availableHeight: number | undefined, maxWidth: number | undefined, activeTheme: Theme, showLineNumbers: boolean, ): React.ReactNode { const lines = codeToHighlight.split('\n'); const padWidth = String(lines.length).length; return ( {lines.map((line, index) => ( {showLineNumbers && ( {`${String(index + 1).padStart(padWidth, ' ')} `} )} {line} ))} ); } /** * Renders syntax-highlighted code for Ink applications using a selected theme. * * @param line The line of code to highlight. * @param language The language identifier (e.g., 'javascript', 'css', 'html') * @param theme Optional theme to use instead of the active theme * @param overrideColor Optional color to use instead of syntax highlighting * @returns A React.ReactNode containing Ink elements for the highlighted code. */ export function colorizeCode( code: string, language: string | null, availableHeight?: number, maxWidth?: number, theme?: Theme, settings?: LoadedSettings, hideLineNumbers = false, ): React.ReactNode { const codeToHighlight = code.replace(/\n$/, ''); const activeTheme = theme ?? themeManager.getActiveTheme(); const showLineNumbers = hideLineNumbers ? false : (settings?.merged.ui.showLineNumbers ?? true); try { let lines = codeToHighlight.split('\n'); const padWidth = String(lines.length).length; let hiddenLinesCount = 0; if (availableHeight !== undefined) { availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT); if (lines.length > availableHeight) { const sliceIndex = lines.length - availableHeight; hiddenLinesCount = sliceIndex; lines = lines.slice(sliceIndex); } } return ( {lines.map((line, index) => { const contentToRender = highlightAndRenderLine( line, language, activeTheme, ); return ( {showLineNumbers && ( {`${String(index + 1 + hiddenLinesCount).padStart(padWidth, ' ')} `} )} {contentToRender} ); })} ); } catch (error) { debugLogger.error( `[colorizeCode] Error highlighting code for language "${language}":`, error, ); return renderFallbackLines( codeToHighlight, availableHeight, maxWidth, activeTheme, showLineNumbers, ); } }