Skip to content

Commit 012abfa

Browse files
committed
util: colorize text with hex colors
1 parent 150d154 commit 012abfa

File tree

3 files changed

+339
-1
lines changed

3 files changed

+339
-1
lines changed

doc/api/util.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2548,7 +2548,8 @@ changes:
25482548
-->
25492549
25502550
* `format` {string | Array} A text format or an Array
2551-
of text formats defined in `util.inspect.colors`.
2551+
of text formats defined in `util.inspect.colors`, or a hex color in `#RGB`
2552+
or `#RRGGBB` form.
25522553
* `text` {string} The text to to be formatted.
25532554
* `options` {Object}
25542555
* `validateStream` {boolean} When true, `stream` is checked to see if it can handle colors. **Default:** `true`.
@@ -2611,6 +2612,30 @@ console.log(
26112612
26122613
The special format value `none` applies no additional styling to the text.
26132614
2615+
In addition to predefined color names, `util.styleText()` supports hex color
2616+
strings using ANSI TrueColor (24-bit) escape sequences. Hex colors can be
2617+
specified in either 3-digit (`#RGB`) or 6-digit (`#RRGGBB`) format:
2618+
2619+
```mjs
2620+
import { styleText } from 'node:util';
2621+
2622+
// 6-digit hex color
2623+
console.log(styleText('#ff5733', 'Orange text'));
2624+
2625+
// 3-digit hex color (shorthand)
2626+
console.log(styleText('#f00', 'Red text'));
2627+
```
2628+
2629+
```cjs
2630+
const { styleText } = require('node:util');
2631+
2632+
// 6-digit hex color
2633+
console.log(styleText('#ff5733', 'Orange text'));
2634+
2635+
// 3-digit hex color (shorthand)
2636+
console.log(styleText('#f00', 'Red text'));
2637+
```
2638+
26142639
The full list of formats can be found in [modifiers][].
26152640
26162641
## Class: `util.TextDecoder`

lib/util.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ const {
3838
ObjectValues,
3939
ReflectApply,
4040
RegExp,
41+
RegExpPrototypeExec,
4142
RegExpPrototypeSymbolReplace,
43+
StringPrototypeSlice,
4244
StringPrototypeToWellFormed,
4345
} = primordials;
4446

@@ -48,10 +50,12 @@ const {
4850
codes: {
4951
ERR_FALSY_VALUE_REJECTION,
5052
ERR_INVALID_ARG_TYPE,
53+
ERR_INVALID_ARG_VALUE,
5154
ERR_OUT_OF_RANGE,
5255
},
5356
isErrorStackTraceLimitWritable,
5457
} = require('internal/errors');
58+
const { Buffer } = require('buffer');
5559
const {
5660
format,
5761
formatWithOptions,
@@ -112,6 +116,53 @@ function escapeStyleCode(code) {
112116
return `\u001b[${code}m`;
113117
}
114118

119+
// Regex for validating hex color strings (#RGB or #RRGGBB)
120+
const hexColorRegExp = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
121+
122+
/**
123+
* Validates whether a string is a valid hex color code.
124+
* @param {string} hex The hex string to validate (e.g., '#fff' or '#ffffff')
125+
* @returns {boolean} True if valid hex color, false otherwise
126+
*/
127+
function isValidHexColor(hex) {
128+
return typeof hex === 'string' && RegExpPrototypeExec(hexColorRegExp, hex) !== null;
129+
}
130+
131+
/**
132+
* Parses a hex color string into RGB components.
133+
* Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats.
134+
* @param {string} hex A valid hex color string
135+
* @returns {[number, number, number]} The RGB components
136+
*/
137+
function hexToRgb(hex) {
138+
// Normalize to 6 digits
139+
let hexStr;
140+
if (hex.length === 4) {
141+
// Expand #RGB to #RRGGBB
142+
hexStr = hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
143+
} else if (hex.length === 7) {
144+
hexStr = StringPrototypeSlice(hex, 1);
145+
} else {
146+
throw new ERR_OUT_OF_RANGE('hex', '#RGB or #RRGGBB', hex);
147+
}
148+
149+
// TODO(araujogui): use Uint8Array.fromHex
150+
const buffer = Buffer.from(hexStr, 'hex');
151+
152+
return [buffer[0], buffer[1], buffer[2]];
153+
}
154+
155+
/**
156+
* Generates the ANSI TrueColor (24-bit) escape sequence for a foreground color.
157+
* @param {number} r Red component (0-255)
158+
* @param {number} g Green component (0-255)
159+
* @param {number} b Blue component (0-255)
160+
* @returns {string} The ANSI escape sequence
161+
*/
162+
function rgbToAnsi24Bit(r, g, b) {
163+
return `38;2;${r};${g};${b}`;
164+
}
165+
115166
/**
116167
* @param {string | string[]} format
117168
* @param {string} text
@@ -144,9 +195,23 @@ function styleText(format, text, { validateStream = true, stream = process.stdou
144195
const codes = [];
145196
for (const key of formatArray) {
146197
if (key === 'none') continue;
198+
199+
// Check if the key is a hex color string
200+
if (isValidHexColor(key)) {
201+
if (skipColorize) continue;
202+
const { 0: r, 1: g, 2: b } = hexToRgb(key);
203+
ArrayPrototypePush(codes, [rgbToAnsi24Bit(r, g, b), 39]);
204+
continue;
205+
}
206+
147207
const formatCodes = inspect.colors[key];
148208
// If the format is not a valid style, throw an error
149209
if (formatCodes == null) {
210+
// Check if it looks like an invalid hex color (starts with #)
211+
if (typeof key === 'string' && key[0] === '#') {
212+
throw new ERR_INVALID_ARG_VALUE('format', key,
213+
'must be a valid hex color (#RGB or #RRGGBB)');
214+
}
150215
validateOneOf(key, 'format', ObjectKeys(inspect.colors));
151216
}
152217
if (skipColorize) continue;
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('node:assert');
5+
const { describe, it } = require('node:test');
6+
const util = require('node:util');
7+
const { WriteStream } = require('node:tty');
8+
9+
describe('util.styleText hex color support', () => {
10+
describe('valid 6-digit hex colors', () => {
11+
it('should parse #ffcc00 as RGB(255, 204, 0)', () => {
12+
const styled = util.styleText('#ffcc00', 'test', { validateStream: false });
13+
assert.strictEqual(styled, '\u001b[38;2;255;204;0mtest\u001b[39m');
14+
});
15+
16+
it('should parse #000000 as RGB(0, 0, 0) - black', () => {
17+
const styled = util.styleText('#000000', 'test', { validateStream: false });
18+
assert.strictEqual(styled, '\u001b[38;2;0;0;0mtest\u001b[39m');
19+
});
20+
21+
it('should parse #ffffff as RGB(255, 255, 255) - white', () => {
22+
const styled = util.styleText('#ffffff', 'test', { validateStream: false });
23+
assert.strictEqual(styled, '\u001b[38;2;255;255;255mtest\u001b[39m');
24+
});
25+
26+
it('should parse uppercase #AABBCC as RGB(170, 187, 204)', () => {
27+
const styled = util.styleText('#AABBCC', 'test', { validateStream: false });
28+
assert.strictEqual(styled, '\u001b[38;2;170;187;204mtest\u001b[39m');
29+
});
30+
31+
it('should parse mixed case #aAbBcC as RGB(170, 187, 204)', () => {
32+
const styled = util.styleText('#aAbBcC', 'test', { validateStream: false });
33+
assert.strictEqual(styled, '\u001b[38;2;170;187;204mtest\u001b[39m');
34+
});
35+
});
36+
37+
describe('valid 3-digit hex colors (shorthand)', () => {
38+
it('should expand #fc0 to #ffcc00 -> RGB(255, 204, 0)', () => {
39+
const styled = util.styleText('#fc0', 'test', { validateStream: false });
40+
assert.strictEqual(styled, '\u001b[38;2;255;204;0mtest\u001b[39m');
41+
});
42+
43+
it('should parse #000 as RGB(0, 0, 0)', () => {
44+
const styled = util.styleText('#000', 'test', { validateStream: false });
45+
assert.strictEqual(styled, '\u001b[38;2;0;0;0mtest\u001b[39m');
46+
});
47+
48+
it('should parse #fff as RGB(255, 255, 255)', () => {
49+
const styled = util.styleText('#fff', 'test', { validateStream: false });
50+
assert.strictEqual(styled, '\u001b[38;2;255;255;255mtest\u001b[39m');
51+
});
52+
53+
it('should parse uppercase #FFF as RGB(255, 255, 255)', () => {
54+
const styled = util.styleText('#FFF', 'test', { validateStream: false });
55+
assert.strictEqual(styled, '\u001b[38;2;255;255;255mtest\u001b[39m');
56+
});
57+
58+
it('should expand #abc to #aabbcc -> RGB(170, 187, 204)', () => {
59+
const styled = util.styleText('#abc', 'test', { validateStream: false });
60+
assert.strictEqual(styled, '\u001b[38;2;170;187;204mtest\u001b[39m');
61+
});
62+
});
63+
64+
describe('combining hex colors with other formats', () => {
65+
it('should combine bold and hex color', () => {
66+
const styled = util.styleText(['bold', '#ff0000'], 'test', { validateStream: false });
67+
assert.strictEqual(styled, '\u001b[1m\u001b[38;2;255;0;0mtest\u001b[39m\u001b[22m');
68+
});
69+
70+
it('should combine hex color and underline', () => {
71+
const styled = util.styleText(['#00ff00', 'underline'], 'test', { validateStream: false });
72+
assert.strictEqual(styled, '\u001b[38;2;0;255;0m\u001b[4mtest\u001b[24m\u001b[39m');
73+
});
74+
75+
it('should handle none format with hex color', () => {
76+
const styled = util.styleText(['none', '#ff0000'], 'test', { validateStream: false });
77+
assert.strictEqual(styled, '\u001b[38;2;255;0;0mtest\u001b[39m');
78+
});
79+
});
80+
81+
describe('invalid hex strings', () => {
82+
it('should throw for missing # prefix', () => {
83+
assert.throws(() => {
84+
util.styleText('ffcc00', 'test', { validateStream: false });
85+
}, {
86+
code: 'ERR_INVALID_ARG_VALUE',
87+
});
88+
});
89+
90+
it('should throw for invalid characters', () => {
91+
assert.throws(() => {
92+
util.styleText('#gggggg', 'test', { validateStream: false });
93+
}, {
94+
code: 'ERR_INVALID_ARG_VALUE',
95+
message: /must be a valid hex color/,
96+
});
97+
});
98+
99+
it('should throw for wrong length (4 digits)', () => {
100+
assert.throws(() => {
101+
util.styleText('#ffcc', 'test', { validateStream: false });
102+
}, {
103+
code: 'ERR_INVALID_ARG_VALUE',
104+
message: /must be a valid hex color/,
105+
});
106+
});
107+
108+
it('should throw for wrong length (5 digits)', () => {
109+
assert.throws(() => {
110+
util.styleText('#ffcc0', 'test', { validateStream: false });
111+
}, {
112+
code: 'ERR_INVALID_ARG_VALUE',
113+
message: /must be a valid hex color/,
114+
});
115+
});
116+
117+
it('should throw for wrong length (7 digits)', () => {
118+
assert.throws(() => {
119+
util.styleText('#ffcc000', 'test', { validateStream: false });
120+
}, {
121+
code: 'ERR_INVALID_ARG_VALUE',
122+
message: /must be a valid hex color/,
123+
});
124+
});
125+
126+
it('should throw for empty after #', () => {
127+
assert.throws(() => {
128+
util.styleText('#', 'test', { validateStream: false });
129+
}, {
130+
code: 'ERR_INVALID_ARG_VALUE',
131+
message: /must be a valid hex color/,
132+
});
133+
});
134+
135+
it('should throw for invalid hex in array', () => {
136+
assert.throws(() => {
137+
util.styleText(['bold', '#xyz'], 'test', { validateStream: false });
138+
}, {
139+
code: 'ERR_INVALID_ARG_VALUE',
140+
message: /must be a valid hex color/,
141+
});
142+
});
143+
});
144+
145+
describe('environment variable behavior', () => {
146+
const styledHex = '\u001b[38;2;255;204;0mtest\u001b[39m';
147+
const noChange = 'test';
148+
149+
const fd = common.getTTYfd();
150+
if (fd === -1) {
151+
it.skip('Could not create TTY fd', () => {});
152+
} else {
153+
const writeStream = new WriteStream(fd);
154+
const originalEnv = { ...process.env };
155+
156+
const testCases = [
157+
{
158+
isTTY: true,
159+
env: {},
160+
expected: styledHex,
161+
description: 'isTTY=true with no env vars',
162+
},
163+
{
164+
isTTY: false,
165+
env: {},
166+
expected: noChange,
167+
description: 'isTTY=false with no env vars',
168+
},
169+
{
170+
isTTY: true,
171+
env: { NODE_DISABLE_COLORS: '1' },
172+
expected: noChange,
173+
description: 'NODE_DISABLE_COLORS=1',
174+
},
175+
{
176+
isTTY: true,
177+
env: { NO_COLOR: '1' },
178+
expected: noChange,
179+
description: 'NO_COLOR=1',
180+
},
181+
{
182+
isTTY: true,
183+
env: { FORCE_COLOR: '1' },
184+
expected: styledHex,
185+
description: 'FORCE_COLOR=1',
186+
},
187+
{
188+
isTTY: true,
189+
env: { FORCE_COLOR: '1', NODE_DISABLE_COLORS: '1' },
190+
expected: styledHex,
191+
description: 'FORCE_COLOR=1 overrides NODE_DISABLE_COLORS',
192+
},
193+
{
194+
isTTY: false,
195+
env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' },
196+
expected: styledHex,
197+
description: 'FORCE_COLOR=1 overrides all disable flags',
198+
},
199+
{
200+
isTTY: true,
201+
env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' },
202+
expected: styledHex,
203+
description: 'FORCE_COLOR=1 wins with all flags',
204+
},
205+
{
206+
isTTY: true,
207+
env: { FORCE_COLOR: '0' },
208+
expected: noChange,
209+
description: 'FORCE_COLOR=0 disables colors',
210+
},
211+
];
212+
213+
for (const testCase of testCases) {
214+
it(`should respect ${testCase.description}`, () => {
215+
writeStream.isTTY = testCase.isTTY;
216+
process.env = {
217+
...originalEnv,
218+
...testCase.env,
219+
};
220+
const output = util.styleText('#ffcc00', 'test', { stream: writeStream });
221+
assert.strictEqual(output, testCase.expected);
222+
process.env = originalEnv;
223+
});
224+
}
225+
}
226+
});
227+
228+
describe('nested hex colors', () => {
229+
it('should handle nested hex color styling', () => {
230+
const inner = util.styleText('#0000ff', 'inner', { validateStream: false });
231+
const outer = util.styleText('#ff0000', `before${inner}after`, { validateStream: false });
232+
assert.strictEqual(
233+
outer,
234+
'\u001b[38;2;255;0;0mbefore\u001b[38;2;0;0;255minner\u001b[38;2;255;0;0mafter\u001b[39m'
235+
);
236+
});
237+
});
238+
239+
describe('multiple hex colors in array', () => {
240+
it('should apply multiple hex colors in order', () => {
241+
const styled = util.styleText(['#ff0000', '#00ff00'], 'test', { validateStream: false });
242+
assert.strictEqual(
243+
styled,
244+
'\u001b[38;2;255;0;0m\u001b[38;2;0;255;0mtest\u001b[39m\u001b[39m'
245+
);
246+
});
247+
});
248+
});

0 commit comments

Comments
 (0)