Colorize code blocks.

- This changeset uses lowlight.js to parse the code in codeblocks to derive an AST, it then translates that into CSS themes that are widely known via highlight.js (things that GitHub use), finally I translate those css.color attributes into Ink colors and effectivel do <Text color={the color}>the text</Text>.
 - To do this I needed to build color mappings from css -> Ink
 - I introduced a new `Theme` type that will be used to represent many different color themes. It also enabled the color mappings to be seamless.
 - Added a theme manager that only has one theme for now (VS2015). The theme works very well with our colorization.
- Some other bits was removal of borders around our codeblocks since they now have richer rendering.
- Most complex bits of code in this PR is in the `CodeColorizer.tsx`

Fixes https://b.corp.google.com/issues/412433479
This commit is contained in:
Taylor Mullen 2025-04-22 18:37:58 -07:00 committed by N. Taylor Mullen
parent ffe368afed
commit e163e02499
7 changed files with 639 additions and 14 deletions

63
package-lock.json generated
View File

@ -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"
},

View File

@ -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"
},

View File

@ -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();

View File

@ -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<Record<string, string>>;
// --- 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<Record<string, string>> = {
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<string, CSSProperties>) {
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<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,
// 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;
}
}

View File

@ -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%',
},
});

View File

@ -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 <Text color={inheritedColor}>{node.value}</Text>;
}
// 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) => (
<React.Fragment key={index}>
{renderHastNode(child, theme, colorToPassDown)}
</React.Fragment>
),
);
// Element nodes now only group children; color is applied by Text nodes.
// Use a React Fragment to avoid adding unnecessary elements.
return <React.Fragment>{children}</React.Fragment>;
}
// 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) => (
<React.Fragment key={index}>
{renderHastNode(child, theme, inheritedColor)}
</React.Fragment>
));
}
// 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 <Text> 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 (
<Text color={activeTheme.defaultColor}>
{renderHastNode(hastTree, activeTheme, undefined)}
</Text>
);
} catch (error) {
console.error(
`[colorizeCode] Error highlighting code for language "${language}":`,
error,
);
// Fallback to plain text with default color on error
return <Text color={activeTheme.defaultColor}>{codeToHighlight}</Text>;
}
}

View File

@ -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 (
<Box
key={key}
borderStyle="round"
borderColor={Colors.SubtleComment}
borderLeft={false}
borderRight={false}
flexDirection="column"
>
{lang && <Text dimColor>{lang}</Text>}
{/* Render each line preserving whitespace (within Text component) */}
{content.map((line, idx) => (
<Text key={idx}>{line}</Text>
))}
<Box key={key} flexDirection="column" padding={1}>
{colorizedCode}
</Box>
);
}