Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -2548,7 +2548,8 @@ changes:
-->

* `format` {string | Array} A text format or an Array
of text formats defined in `util.inspect.colors`.
of text formats defined in `util.inspect.colors`, or a hex color in `#RGB`
or `#RRGGBB` form.
* `text` {string} The text to to be formatted.
* `options` {Object}
* `validateStream` {boolean} When true, `stream` is checked to see if it can handle colors. **Default:** `true`.
Expand Down Expand Up @@ -2611,6 +2612,30 @@ console.log(

The special format value `none` applies no additional styling to the text.

In addition to predefined color names, `util.styleText()` supports hex color
strings using ANSI TrueColor (24-bit) escape sequences. Hex colors can be
specified in either 3-digit (`#RGB`) or 6-digit (`#RRGGBB`) format:

```mjs
import { styleText } from 'node:util';

// 6-digit hex color
console.log(styleText('#ff5733', 'Orange text'));

// 3-digit hex color (shorthand)
console.log(styleText('#f00', 'Red text'));
```

```cjs
const { styleText } = require('node:util');

// 6-digit hex color
console.log(styleText('#ff5733', 'Orange text'));

// 3-digit hex color (shorthand)
console.log(styleText('#f00', 'Red text'));
```

The full list of formats can be found in [modifiers][].

## Class: `util.TextDecoder`
Expand Down
64 changes: 64 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ const {
ObjectValues,
ReflectApply,
RegExp,
RegExpPrototypeExec,
RegExpPrototypeSymbolReplace,
StringPrototypeSlice,
StringPrototypeToWellFormed,
} = primordials;

Expand All @@ -48,10 +50,12 @@ const {
codes: {
ERR_FALSY_VALUE_REJECTION,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_OUT_OF_RANGE,
},
isErrorStackTraceLimitWritable,
} = require('internal/errors');
const { Buffer } = require('buffer');
const {
format,
formatWithOptions,
Expand Down Expand Up @@ -112,6 +116,53 @@ function escapeStyleCode(code) {
return `\u001b[${code}m`;
}

// Matches #RGB or #RRGGBB
const hexColorRegExp = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

/**
* Validates whether a string is a valid hex color code.
* @param {string} hex The hex string to validate (e.g., '#fff' or '#ffffff')
* @returns {boolean} True if valid hex color, false otherwise
*/
function isValidHexColor(hex) {
return typeof hex === 'string' && RegExpPrototypeExec(hexColorRegExp, hex) !== null;
}

/**
* Parses a hex color string into RGB components.
* Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats.
* @param {string} hex A valid hex color string
* @returns {[number, number, number]} The RGB components
*/
function hexToRgb(hex) {
// Normalize to 6 digits
let hexStr;
if (hex.length === 4) {
// Expand #RGB to #RRGGBB
hexStr = hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
} else if (hex.length === 7) {
hexStr = StringPrototypeSlice(hex, 1);
} else {
throw new ERR_OUT_OF_RANGE('hex', '#RGB or #RRGGBB', hex);
}

// TODO(araujogui): use Uint8Array.fromHex
const buffer = Buffer.from(hexStr, 'hex');

return [buffer[0], buffer[1], buffer[2]];
}

/**
* Generates the ANSI TrueColor (24-bit) escape sequence for a foreground color.
* @param {number} r Red component (0-255)
* @param {number} g Green component (0-255)
* @param {number} b Blue component (0-255)
* @returns {string} The ANSI escape sequence
*/
function rgbToAnsi24Bit(r, g, b) {
return `38;2;${r};${g};${b}`;
}

/**
* @param {string | string[]} format
* @param {string} text
Expand Down Expand Up @@ -144,9 +195,22 @@ function styleText(format, text, { validateStream = true, stream = process.stdou
const codes = [];
for (const key of formatArray) {
if (key === 'none') continue;

if (isValidHexColor(key)) {
if (skipColorize) continue;
const { 0: r, 1: g, 2: b } = hexToRgb(key);
ArrayPrototypePush(codes, [rgbToAnsi24Bit(r, g, b), 39]);
continue;
}

const formatCodes = inspect.colors[key];
// If the format is not a valid style, throw an error
if (formatCodes == null) {
// Check if it looks like an invalid hex color (starts with #)
if (typeof key === 'string' && key[0] === '#') {
throw new ERR_INVALID_ARG_VALUE('format', key,
'must be a valid hex color (#RGB or #RRGGBB)');
}
validateOneOf(key, 'format', ObjectKeys(inspect.colors));
}
if (skipColorize) continue;
Expand Down
248 changes: 248 additions & 0 deletions test/parallel/test-util-styletext-hex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
'use strict';

const common = require('../common');
const assert = require('node:assert');
const { describe, it } = require('node:test');
const util = require('node:util');
const { WriteStream } = require('node:tty');

describe('util.styleText hex color support', () => {
describe('valid 6-digit hex colors', () => {
it('should parse #ffcc00 as RGB(255, 204, 0)', () => {
const styled = util.styleText('#ffcc00', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;204;0mtest\u001b[39m');
});

it('should parse #000000 as RGB(0, 0, 0) - black', () => {
const styled = util.styleText('#000000', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;0;0;0mtest\u001b[39m');
});

it('should parse #ffffff as RGB(255, 255, 255) - white', () => {
const styled = util.styleText('#ffffff', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;255;255mtest\u001b[39m');
});

it('should parse uppercase #AABBCC as RGB(170, 187, 204)', () => {
const styled = util.styleText('#AABBCC', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;170;187;204mtest\u001b[39m');
});

it('should parse mixed case #aAbBcC as RGB(170, 187, 204)', () => {
const styled = util.styleText('#aAbBcC', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;170;187;204mtest\u001b[39m');
});
});

describe('valid 3-digit hex colors (shorthand)', () => {
it('should expand #fc0 to #ffcc00 -> RGB(255, 204, 0)', () => {
const styled = util.styleText('#fc0', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;204;0mtest\u001b[39m');
});

it('should parse #000 as RGB(0, 0, 0)', () => {
const styled = util.styleText('#000', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;0;0;0mtest\u001b[39m');
});

it('should parse #fff as RGB(255, 255, 255)', () => {
const styled = util.styleText('#fff', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;255;255mtest\u001b[39m');
});

it('should parse uppercase #FFF as RGB(255, 255, 255)', () => {
const styled = util.styleText('#FFF', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;255;255mtest\u001b[39m');
});

it('should expand #abc to #aabbcc -> RGB(170, 187, 204)', () => {
const styled = util.styleText('#abc', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;170;187;204mtest\u001b[39m');
});
});

describe('combining hex colors with other formats', () => {
it('should combine bold and hex color', () => {
const styled = util.styleText(['bold', '#ff0000'], 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[1m\u001b[38;2;255;0;0mtest\u001b[39m\u001b[22m');
});

it('should combine hex color and underline', () => {
const styled = util.styleText(['#00ff00', 'underline'], 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;0;255;0m\u001b[4mtest\u001b[24m\u001b[39m');
});

it('should handle none format with hex color', () => {
const styled = util.styleText(['none', '#ff0000'], 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;0;0mtest\u001b[39m');
});
});

describe('invalid hex strings', () => {
it('should throw for missing # prefix', () => {
assert.throws(() => {
util.styleText('ffcc00', 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
});
});

it('should throw for invalid characters', () => {
assert.throws(() => {
util.styleText('#gggggg', 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /must be a valid hex color/,
});
});

it('should throw for wrong length (4 digits)', () => {
assert.throws(() => {
util.styleText('#ffcc', 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /must be a valid hex color/,
});
});

it('should throw for wrong length (5 digits)', () => {
assert.throws(() => {
util.styleText('#ffcc0', 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /must be a valid hex color/,
});
});

it('should throw for wrong length (7 digits)', () => {
assert.throws(() => {
util.styleText('#ffcc000', 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /must be a valid hex color/,
});
});

it('should throw for empty after #', () => {
assert.throws(() => {
util.styleText('#', 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /must be a valid hex color/,
});
});

it('should throw for invalid hex in array', () => {
assert.throws(() => {
util.styleText(['bold', '#xyz'], 'test', { validateStream: false });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /must be a valid hex color/,
});
});
});

describe('environment variable behavior', () => {
const styledHex = '\u001b[38;2;255;204;0mtest\u001b[39m';
const noChange = 'test';

const fd = common.getTTYfd();
if (fd === -1) {
it.skip('Could not create TTY fd', () => {});
} else {
const writeStream = new WriteStream(fd);
const originalEnv = { ...process.env };

const testCases = [
{
isTTY: true,
env: {},
expected: styledHex,
description: 'isTTY=true with no env vars',
},
{
isTTY: false,
env: {},
expected: noChange,
description: 'isTTY=false with no env vars',
},
{
isTTY: true,
env: { NODE_DISABLE_COLORS: '1' },
expected: noChange,
description: 'NODE_DISABLE_COLORS=1',
},
{
isTTY: true,
env: { NO_COLOR: '1' },
expected: noChange,
description: 'NO_COLOR=1',
},
{
isTTY: true,
env: { FORCE_COLOR: '1' },
expected: styledHex,
description: 'FORCE_COLOR=1',
},
{
isTTY: true,
env: { FORCE_COLOR: '1', NODE_DISABLE_COLORS: '1' },
expected: styledHex,
description: 'FORCE_COLOR=1 overrides NODE_DISABLE_COLORS',
},
{
isTTY: false,
env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' },
expected: styledHex,
description: 'FORCE_COLOR=1 overrides all disable flags',
},
{
isTTY: true,
env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' },
expected: styledHex,
description: 'FORCE_COLOR=1 wins with all flags',
},
{
isTTY: true,
env: { FORCE_COLOR: '0' },
expected: noChange,
description: 'FORCE_COLOR=0 disables colors',
},
];

for (const testCase of testCases) {
it(`should respect ${testCase.description}`, () => {
writeStream.isTTY = testCase.isTTY;
process.env = {
...originalEnv,
...testCase.env,
};
const output = util.styleText('#ffcc00', 'test', { stream: writeStream });
assert.strictEqual(output, testCase.expected);
process.env = originalEnv;
});
}
}
});

describe('nested hex colors', () => {
it('should handle nested hex color styling', () => {
const inner = util.styleText('#0000ff', 'inner', { validateStream: false });
const outer = util.styleText('#ff0000', `before${inner}after`, { validateStream: false });
assert.strictEqual(
outer,
'\u001b[38;2;255;0;0mbefore\u001b[38;2;0;0;255minner\u001b[38;2;255;0;0mafter\u001b[39m'
);
});
});

describe('multiple hex colors in array', () => {
it('should apply multiple hex colors in order', () => {
const styled = util.styleText(['#ff0000', '#00ff00'], 'test', { validateStream: false });
assert.strictEqual(
styled,
'\u001b[38;2;255;0;0m\u001b[38;2;0;255;0mtest\u001b[39m\u001b[39m'
);
});
});
});
Loading