diff --git a/index.js b/index.js index 2f6c71e..e6e0d6e 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,8 @@ const escapeStringRegexp = require('escape-string-regexp'); const ansiStyles = require('ansi-styles'); const supportsColor = require('supports-color'); +const template = require('./templates.js'); + const isSimpleWindowsTerm = process.platform === 'win32' && !process.env.TERM.toLowerCase().startsWith('xterm'); // `supportsColor.level` → `ansiStyles.color[name]` mapping @@ -11,10 +13,37 @@ const levelMapping = ['ansi', 'ansi', 'ansi256', 'ansi16m']; // `color-convert` models to exclude from the Chalk API due to conflicts and such const skipModels = new Set(['gray']); -function Chalk(options) { +const styles = Object.create(null); + +function applyOptions(obj, options) { + options = options || {}; + // Detect level if not set manually - this.level = Number(!options || options.level === undefined ? supportsColor.level : options.level); - this.enabled = options && 'enabled' in options ? options.enabled : this.level > 0; + obj.level = options.level === undefined ? supportsColor.level : options.level; + obj.enabled = 'enabled' in options ? options.enabled : obj.level > 0; +} + +function Chalk(options) { + // We check for this.template here since calling chalk.constructor() + // by itself will have a `this` of a previously constructed chalk object. + if (!this || !(this instanceof Chalk) || this.template) { + const chalk = {}; + applyOptions(chalk, options); + + chalk.template = function () { + const args = [].slice.call(arguments); + return chalkTag.apply(null, [chalk.template].concat(args)); + }; + + Object.setPrototypeOf(chalk, Chalk.prototype); + Object.setPrototypeOf(chalk.template, chalk); + + chalk.template.constructor = Chalk; + + return chalk.template; + } + + applyOptions(this, options); } // Use bright blue on Windows as the normal blue color is illegible @@ -22,8 +51,6 @@ if (isSimpleWindowsTerm) { ansiStyles.blue.open = '\u001B[94m'; } -const styles = Object.create(null); - for (const key of Object.keys(ansiStyles)) { ansiStyles[key].closeRe = new RegExp(escapeStringRegexp(ansiStyles[key].close), 'g'); @@ -164,7 +191,24 @@ function applyStyle() { return str; } +function chalkTag(chalk, strings) { + const args = [].slice.call(arguments, 2); + + if (!Array.isArray(strings)) { + return strings.toString(); + } + + const parts = [strings.raw[0]]; + + for (let i = 1; i < strings.length; i++) { + parts.push(args[i - 1].toString().replace(/[{}]/g, '\\$&')); + parts.push(strings.raw[i]); + } + + return template(chalk, parts.join('')); +} + Object.defineProperties(Chalk.prototype, styles); -module.exports = new Chalk(); +module.exports = Chalk(); // eslint-disable-line new-cap module.exports.supportsColor = supportsColor; diff --git a/package.json b/package.json index 11ca559..a040744 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "coveralls": "nyc report --reporter=text-lcov | coveralls" }, "files": [ - "index.js" + "index.js", + "templates.js" ], "keywords": [ "color", diff --git a/readme.md b/readme.md index 5ef6ed4..18590bd 100644 --- a/readme.md +++ b/readme.md @@ -78,6 +78,13 @@ RAM: ${chalk.green('40%')} DISK: ${chalk.yellow('70%')} `); +// ES2015 tagged template literal +log(chalk` +CPU: {red ${cpu.totalPercent}%} +RAM: {green ${ram.used / ram.total * 100}%} +DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} +`); + // Use RGB colors in terminal emulators that support it. log(chalk.keyword('orange')('Yay for orange colored text!')); log(chalk.rgb(123, 45, 67).underline('Underlined reddish color')); @@ -206,6 +213,36 @@ Explicit 256/Truecolor mode can be enabled using the `--color=256` and `--color= - `bgWhiteBright` +## Tagged template literal + +Chalk can be used as a [tagged template literal](http://exploringjs.com/es6/ch_template-literals.html#_tagged-template-literals). + +```js +const chalk = require('chalk'); + +const miles = 18; +const calculateFeet = miles => miles * 5280; + +console.log(chalk` + There are {bold 5280 feet} in a mile. + In {bold ${miles} miles}, there are {green.bold ${calculateFeet(miles)} feet}. +`); +``` + +Blocks are delimited by an opening curly brace (`{`), a style, some content, and a closing curly brace (`}`). + +Template styles are chained exactly like normal Chalk styles. The following two statements are equivalent: + +```js +console.log(chalk.bold.rgb(10, 100, 200)('Hello!')); +console.log(chalk`{bold.rgb(10,100,200) Hello!}`); +``` + +Note that function styles (`rgb()`, `hsl()`, `keyword()`, etc.) may not contain spaces between parameters. + +All interpolated values (`` chalk`${foo}` ``) are converted to strings via the `.toString()` method. All curly braces (`{` and `}`) in interpolated value strings are escaped. + + ## 256 and Truecolor color support Chalk supports 256 colors and [Truecolor](https://gist.github.com/XVilka/8346728) (16 million colors) on supported terminal apps. diff --git a/templates.js b/templates.js new file mode 100644 index 0000000..afc6696 --- /dev/null +++ b/templates.js @@ -0,0 +1,175 @@ +'use strict'; + +function data(parent) { + return { + styles: [], + parent, + contents: [] + }; +} + +const zeroBound = n => n < 0 ? 0 : n; +const lastIndex = a => zeroBound(a.length - 1); + +const last = a => a[lastIndex(a)]; + +const takeWhileReverse = (array, predicate, start) => { + const out = []; + + for (let i = start; i >= 0 && i <= start; i--) { + if (predicate(array[i])) { + out.unshift(array[i]); + } else { + break; + } + } + + return out; +}; + +/** + * Checks if the character at position i in string is a normal character a.k.a a non control character. + * */ +const isNormalCharacter = (string, i) => { + const char = string[i]; + const backslash = '\\'; + + if (!(char === backslash || char === '{' || char === '}')) { + return true; + } + + const n = i === 0 ? 0 : takeWhileReverse(string, x => x === '\\', zeroBound(i - 1)).length; + + return n % 2 === 1; +}; + +const collectStyles = data => data ? collectStyles(data.parent).concat(data.styles) : ['reset']; + +/** + * Computes the style for a given data based on it's style and the style of it's parent. Also accounts for !style styles + * which remove a style from the list if present. + * */ +const sumStyles = data => { + const negateRegex = /^~.+/; + let out = []; + + for (const style of collectStyles(data)) { + if (negateRegex.test(style)) { + const exclude = style.slice(1); + out = out.filter(x => x !== exclude); + } else { + out.push(style); + } + } + + return out; +}; + +/** + * Takes a string and parses it into a tree of data objects which inherit styles from their parent. + * */ +function parse(string) { + const root = data(null); + let pushingStyle = false; + + let current = root; + + for (let i = 0; i < string.length; i++) { + const char = string[i]; + + const addNormalCharacter = () => { + const lastChunk = last(current.contents); + + if (typeof lastChunk === 'string') { + current.contents[lastIndex(current.contents)] = lastChunk + char; + } else { + current.contents.push(char); + } + }; + + if (pushingStyle) { + if (' \t'.indexOf(char) > -1) { + pushingStyle = false; + } else if (char === '\n') { + pushingStyle = false; + addNormalCharacter(); + } else if (char === '.') { + current.styles.push(''); + } else { + current.styles[lastIndex(current.styles)] = (last(current.styles) || '') + char; + } + } else if (isNormalCharacter(string, i)) { + addNormalCharacter(); + } else if (char === '{') { + pushingStyle = true; + const nCurrent = data(current); + current.contents.push(nCurrent); + current = nCurrent; + } else if (char === '}') { + current = current.parent; + } + } + + if (current !== root) { + throw new Error('literal template has an unclosed block'); + } + + return root; +} + +/** + * Takes a tree of data objects and flattens it to a list of data objects with the inherited and negations styles + * accounted for. + * */ +function flatten(data) { + let flat = []; + + for (const content of data.contents) { + if (typeof content === 'string') { + flat.push({ + styles: sumStyles(data), + content + }); + } else { + flat = flat.concat(flatten(content)); + } + } + + return flat; +} + +function assertStyle(chalk, style) { + if (!chalk[style]) { + throw new Error(`invalid Chalk style: ${style}`); + } +} + +/** + * Checks if a given style is valid and parses style functions. + * */ +function parseStyle(chalk, style) { + const fnMatch = style.match(/^\s*(\w+)\s*\(\s*([^)]*)\s*\)\s*/); + if (!fnMatch) { + assertStyle(chalk, style); + return chalk[style]; + } + + const name = fnMatch[1].trim(); + const args = fnMatch[2].split(/,/g).map(s => s.trim()); + + assertStyle(chalk, name); + + return chalk[name].apply(chalk, args); +} + +/** + * Performs the actual styling of the string, essentially lifted from cli.js. + * */ +function style(chalk, flat) { + return flat.map(data => { + const fn = data.styles.reduce(parseStyle, chalk); + return fn(data.content.replace(/\n$/, '')); + }).join(''); +} + +module.exports = (chalk, string) => style(chalk, flatten(parse(string))); diff --git a/test.js b/test.js index 4c36a88..a7a4a6b 100644 --- a/test.js +++ b/test.js @@ -19,6 +19,10 @@ console.log('host TERM=', process.env.TERM || '[none]'); console.log('host platform=', process.platform || '[unknown]'); describe('chalk', () => { + it('should not add any styling when called as the base function', () => { + assert.equal(chalk('foo'), 'foo'); + }); + it('should style string', () => { assert.equal(chalk.underline('foo'), '\u001B[4mfoo\u001B[24m'); assert.equal(chalk.red('foo'), '\u001B[31mfoo\u001B[39m'); @@ -242,3 +246,94 @@ describe('chalk.constructor', () => { assert.equal(ctx.red('foo'), '\u001B[31mfoo\u001B[39m'); }); }); + +describe('tagged template literal', () => { + it('should return an empty string for an empty literal', () => { + const ctx = chalk.constructor(); + assert.equal(ctx``, ''); + }); + + it('should return a regular string for a literal with no templates', () => { + const ctx = chalk.constructor({level: 0}); + assert.equal(ctx`hello`, 'hello'); + }); + + it('should correctly perform template parsing', () => { + const ctx = chalk.constructor({level: 0}); + assert.equal(ctx`{bold Hello, {cyan World!} This is a} test. {green Woo!}`, + ctx.bold('Hello,', ctx.cyan('World!'), 'This is a') + ' test. ' + ctx.green('Woo!')); + }); + + it('should correctly perform template substitutions', () => { + const ctx = chalk.constructor({level: 0}); + const name = 'Sindre'; + const exclamation = 'Neat'; + assert.equal(ctx`{bold Hello, {cyan.inverse ${name}!} This is a} test. {green ${exclamation}!}`, + ctx.bold('Hello,', ctx.cyan.inverse(name + '!'), 'This is a') + ' test. ' + ctx.green(exclamation + '!')); + }); + + it('should correctly parse and evaluate color-convert functions', () => { + const ctx = chalk.constructor({level: 3}); + assert.equal(ctx`{bold.rgb(144,10,178).inverse Hello, {~inverse there!}}`, + '\u001B[0m\u001B[1m\u001B[38;2;144;10;178m\u001B[7mHello, ' + + '\u001B[27m\u001B[39m\u001B[22m\u001B[0m\u001B[0m\u001B[1m' + + '\u001B[38;2;144;10;178mthere!\u001B[39m\u001B[22m\u001B[0m'); + + assert.equal(ctx`{bold.bgRgb(144,10,178).inverse Hello, {~inverse there!}}`, + '\u001B[0m\u001B[1m\u001B[48;2;144;10;178m\u001B[7mHello, ' + + '\u001B[27m\u001B[49m\u001B[22m\u001B[0m\u001B[0m\u001B[1m' + + '\u001B[48;2;144;10;178mthere!\u001B[49m\u001B[22m\u001B[0m'); + }); + + it('should properly handle escapes', () => { + const ctx = chalk.constructor({level: 3}); + assert.equal(ctx`{bold hello \{in brackets\}}`, + '\u001B[0m\u001B[1mhello {in brackets}\u001B[22m\u001B[0m'); + }); + + it('should throw if there is an unclosed block', () => { + const ctx = chalk.constructor({level: 3}); + try { + console.log(ctx`{bold this shouldn't appear ever\}`); + assert.fail(); + } catch (err) { + assert.equal(err.message, 'literal template has an unclosed block'); + } + }); + + it('should throw if there is an invalid style', () => { + const ctx = chalk.constructor({level: 3}); + try { + console.log(ctx`{abadstylethatdoesntexist this shouldn't appear ever}`); + assert.fail(); + } catch (err) { + assert.equal(err.message, 'invalid Chalk style: abadstylethatdoesntexist'); + } + }); + + it('should properly style multiline color blocks', () => { + const ctx = chalk.constructor({level: 3}); + assert.equal( + ctx`{bold + Hello! This is a + ${'multiline'} block! + :) + } {underline + I hope you enjoy + }`, + '\u001B[0m\u001B[1m\u001B[22m\u001B[0m\n' + + '\u001B[0m\u001B[1m\t\t\t\tHello! This is a\u001B[22m\u001B[0m\n' + + '\u001B[0m\u001B[1m\t\t\t\tmultiline block!\u001B[22m\u001B[0m\n' + + '\u001B[0m\u001B[1m\t\t\t\t:)\u001B[22m\u001B[0m\n' + + '\u001B[0m\u001B[1m\t\t\t\u001B[22m\u001B[0m\u001B[0m \u001B[0m\u001B[0m\u001B[4m\u001B[24m\u001B[0m\n' + + '\u001B[0m\u001B[4m\t\t\t\tI hope you enjoy\u001B[24m\u001B[0m\n' + + '\u001B[0m\u001B[4m\t\t\t\u001B[24m\u001B[0m' + ); + }); + + it('should escape interpolated values', () => { + const ctx = chalk.constructor({level: 0}); + assert.equal(ctx`Hello {bold hi}`, 'Hello hi'); + assert.equal(ctx`Hello ${'{bold hi}'}`, 'Hello {bold hi}'); + }); +});