feat: add gradient and theme features with corresponding tests and documentation

This commit is contained in:
HarshaVardhan 2026-02-01 17:12:54 -05:00
parent aa06bb5ac3
commit c017dd5b04
8 changed files with 505 additions and 13 deletions

36
source/index.d.ts vendored Normal file → Executable file
View file

@ -121,6 +121,21 @@ export interface ChalkInstance {
*/
bgAnsi256: (index: number) => this;
/**
Create a gradient between colors and apply it to the text.
@param colors - Array of colors (hex strings or RGB arrays) to gradient between.
@example
```
import chalk from 'chalk';
chalk.gradient('#ff0000', '#0000ff')('Gradient text');
chalk.gradient([255, 0, 0], [0, 0, 255])('RGB gradient');
```
*/
gradient: (...colors: Array<string | [number, number, number]>) => this;
/**
Modifier: Reset the current style.
*/
@ -228,6 +243,27 @@ export interface ChalkInstance {
readonly bgMagentaBright: this;
readonly bgCyanBright: this;
readonly bgWhiteBright: this;
/**
Create a themed Chalk instance with custom styles.
@param theme - An object where keys are style names and values are Chalk instances.
@example
```
import chalk from 'chalk';
const themedChalk = chalk.theme({
error: chalk.red.bold,
success: chalk.green,
warning: chalk.yellow,
});
console.log(themedChalk.error('This is an error'));
console.log(themedChalk.success('This is a success'));
```
*/
theme<T extends Record<string, this>>(theme: T): this & T;
}
/**

61
source/index.js Normal file → Executable file
View file

@ -3,6 +3,8 @@ import supportsColor from '#supports-color';
import { // eslint-disable-line import/order
stringReplaceAll,
stringEncaseCRLFWithFirstIndex,
createGradientStyler,
applyGradient,
} from './utilities.js';
const {stdout: stdoutColor, stderr: stderrColor} = supportsColor;
@ -71,6 +73,18 @@ styles.visible = {
},
};
styles.theme = {
value(theme) {
const themed = createBuilder(this, this[STYLER], this[IS_EMPTY]);
for (const [key, value] of Object.entries(theme)) {
Object.defineProperty(themed, key, {value});
}
return themed;
},
};
const getModelAnsi = (model, level, type, ...arguments_) => {
if (model === 'rgb') {
if (level === 'ansi16m') {
@ -116,6 +130,12 @@ for (const model of usedModels) {
};
}
styles.gradient = {
get() {
return (...colors) => createBuilder(this, createGradientStyler(colors), this[IS_EMPTY]);
},
};
const proto = Object.defineProperties(() => {}, {
...styles,
level: {
@ -176,7 +196,39 @@ const applyStyle = (self, string) => {
return string;
}
const {openAll, closeAll} = styler;
// Find gradient styler in the chain
let gradientStyler = null;
let current = styler;
while (current) {
if (current.gradient) {
gradientStyler = current;
break;
}
current = current.parent;
}
let openAll;
let closeAll;
if (gradientStyler) {
// Build openAll/closeAll excluding gradient stylers
openAll = '';
closeAll = '';
current = styler;
while (current) {
if (!current.gradient) {
openAll = current.open + openAll;
closeAll += current.close;
}
current = current.parent;
}
} else {
({openAll, closeAll} = styler);
}
if (string.includes('\u001B')) {
while (styler !== undefined) {
// Replace any instances already present with a re-opening code
@ -188,6 +240,13 @@ const applyStyle = (self, string) => {
}
}
// Apply gradient if present
if (gradientStyler) {
string = applyGradient(string, gradientStyler.colors, self.level);
}
// We can move both next actions out of loop, because remaining actions in loop won't have
// We can move both next actions out of loop, because remaining actions in loop won't have
// any/visible effect on parts we add here. Close the styling before a linebreak and reopen
// after next line to fix a bleed issue on macOS: https://github.com/chalk/chalk/pull/92

55
source/utilities.js Normal file → Executable file
View file

@ -1,3 +1,5 @@
import ansiStyles from '#ansi-styles';
// TODO: When targeting Node.js 16, use `String.prototype.replaceAll`.
export function stringReplaceAll(string, substring, replacer) {
let index = string.indexOf(substring);
@ -31,3 +33,56 @@ export function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
returnValue += string.slice(endIndex);
return returnValue;
}
export const interpolateRgb = (color1, color2, factor) => [
Math.round(color1[0] + ((color2[0] - color1[0]) * factor)),
Math.round(color1[1] + ((color2[1] - color1[1]) * factor)),
Math.round(color1[2] + ((color2[2] - color1[2]) * factor)),
];
export const createGradientStyler = colors => ({
gradient: true,
colors: colors.map(color => {
if (typeof color === 'string') {
return ansiStyles.hexToRgb(color);
}
return color; // Assume [r, g, b]
}),
open: '',
close: ansiStyles.color.close,
openAll: '',
closeAll: ansiStyles.color.close,
parent: undefined,
});
export const applyGradient = (string, colors, level) => {
if (colors.length < 2 || !string) {
return string;
}
const chars = [...string];
let result = '';
const segments = colors.length - 1;
for (let i = 0; i < chars.length; i++) {
const factor = i / (chars.length - 1 || 1);
const segmentIndex = Math.min(Math.floor(factor * segments), segments - 1);
const localFactor = (factor * segments) - segmentIndex;
const color = interpolateRgb(colors[segmentIndex], colors[segmentIndex + 1], localFactor);
let code;
if (level === 3) {
code = ansiStyles.color.ansi16m(...color);
} else if (level === 2) {
code = ansiStyles.color.ansi256(ansiStyles.rgbToAnsi256(...color));
} else {
code = ansiStyles.color.ansi(ansiStyles.rgbToAnsi(...color));
}
result += code + chars[i];
}
result += ansiStyles.color.close;
return result;
};