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..427ed8a 100644
--- a/readme.md
+++ b/readme.md
@@ -78,6 +78,13 @@ RAM: ${chalk.green('40%')}
DISK: ${chalk.yellow('70%')}
`);
+// ES2015/ES2016 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'));
@@ -205,6 +212,37 @@ Explicit 256/Truecolor mode can be enabled using the `--color=256` and `--color=
- `bgCyanBright`
- `bgWhiteBright`
+## Template Literal Tagging
+
+`chalk` by itself can be used as a [tagged template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_template_literals).
+
+This means you can use template strings in ES2015/ES2016 literals (chalk`hello`) to template large or multi-line strings.
+
+```javascript
+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.
+
+```javascript
+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
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..5be7ea7 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('chalk tag 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}');
+ });
+});