diff --git a/.changeset/commit.cjs b/.changeset/commit.cjs index 560a3c16..258c1f6b 100644 --- a/.changeset/commit.cjs +++ b/.changeset/commit.cjs @@ -1,7 +1,3 @@ -function getAddMessage(changeset) { - return `[changeset] ${changeset.summary}`; -} - function getVersionMessage(releasePlan) { const releases = releasePlan.releases.filter((release) => release.type !== 'none'); const lines = [`[release] Releasing ${releases.length} package(s)`]; @@ -12,6 +8,5 @@ function getVersionMessage(releasePlan) { } exports['default'] = { - getAddMessage, getVersionMessage, }; diff --git a/.changeset/config.json b/.changeset/config.json index 7c4a17d0..38529534 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": ["@changesets/changelog-github", { "repo": "trulysimple/tsargp" }], - "commit": ["./commit.cjs", {}], + "commit": "./commit.cjs", "fixed": [], "linked": [], "access": "public", diff --git a/.changeset/tiny-tables-confess.md b/.changeset/tiny-tables-confess.md new file mode 100644 index 00000000..2b8269ad --- /dev/null +++ b/.changeset/tiny-tables-confess.md @@ -0,0 +1,6 @@ +--- +'tsargp': patch +--- + +You can now set the `NO_COLOR` environment variable to _omit_ styles from error and help messages. +You can also set `FORCE_COLOR` to _emit_ styles even when the output is being redirected. diff --git a/packages/tsargp/examples/demo.options.ts b/packages/tsargp/examples/demo.options.ts index 3afdd269..dfe46210 100644 --- a/packages/tsargp/examples/demo.options.ts +++ b/packages/tsargp/examples/demo.options.ts @@ -59,7 +59,7 @@ export default { footer: ` MIT License. Copyright (c) 2024 - ${style(tf.bold, tf.italic, fg.cyan)}TrulySimple${style(tf.clear)} + ${style(tf.bold, tf.italic)}TrulySimple${style(tf.clear)} Report a bug: ${style(tf.faint)}https://github.com/trulysimple/tsargp/issues${style(tf.clear)}`, diff --git a/packages/tsargp/lib/parser.ts b/packages/tsargp/lib/parser.ts index 16501737..0a16e741 100644 --- a/packages/tsargp/lib/parser.ts +++ b/packages/tsargp/lib/parser.ts @@ -261,8 +261,8 @@ class ParserLoop { } catch (err) { // do not propagate errors during completion if (!this.completing) { - if (suggestName(option)) { - handleUnknown(this.validator, value, err as ErrorMessage); + if (err instanceof ErrorMessage && suggestName(option)) { + handleUnknown(this.validator, value, err); } throw err; } diff --git a/packages/tsargp/lib/styles.ts b/packages/tsargp/lib/styles.ts index 8473879e..250d82d2 100644 --- a/packages/tsargp/lib/styles.ts +++ b/packages/tsargp/lib/styles.ts @@ -355,9 +355,10 @@ class TerminalString { * @param result The resulting strings to append to * @param column The current terminal column * @param width The desired terminal width (or zero to avoid wrapping) + * @param emitStyles True if styles should be emitted * @returns The updated terminal column */ - wrapToWidth(result: Array, column: number, width?: number): number { + wrapToWidth(result: Array, column: number, width: number, emitStyles: boolean): number { function shortenLine() { while (result.length && column > start) { const last = result[result.length - 1]; @@ -409,12 +410,12 @@ class TerminalString { } const len = this.lengths[i]; if (!len) { - if (width) { + if (emitStyles) { result.push(str); } continue; } - if (!width) { + if (!emitStyles) { str = str.replace(regex.styles, ''); } if (!column) { @@ -456,13 +457,14 @@ class ErrorMessage extends Error { /** * Wraps the error message to a specified width. - * @param width The terminal width (in number of columns) + * @param width The terminal width (or zero to avoid wrapping) + * @param emitStyles True if styles should be emitted * @returns The message to be printed on a terminal */ - wrap(width = process.stderr.columns): string { + wrap(width = process.stderr.columns ?? 0, emitStyles = !omitStyles(width)): string { const result = new Array(); - this.str.wrapToWidth(result, 0, width); - if (width) { + this.str.wrapToWidth(result, 0, width, emitStyles); + if (emitStyles) { result.push(style(tf.clear)); } return result.join(''); @@ -482,16 +484,17 @@ class HelpMessage extends Array { /** * Wraps the help message to a specified width. - * @param width The terminal width (in number of columns) + * @param width The terminal width (or zero to avoid wrapping) + * @param emitStyles True if styles should be emitted * @returns The message to be printed on a terminal */ - wrap(width = process.stdout.columns): string { + wrap(width = process.stdout.columns ?? 0, emitStyles = !omitStyles(width)): string { const result = new Array(); let column = 0; for (const str of this) { - column = str.wrapToWidth(result, column, width); + column = str.wrapToWidth(result, column, width, emitStyles); } - if (width) { + if (emitStyles) { result.push(style(tf.clear)); } return result.join(''); @@ -501,6 +504,18 @@ class HelpMessage extends Array { //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- +/** + * @param width The terminal width (in number of columns) + * @returns True if styles should be omitted from terminal strings + * @see https://clig.dev/#output + */ +function omitStyles(width: number): boolean { + return ( + !process.env['FORCE_COLOR'] && + (!width || !!process.env['NO_COLOR'] || process.env['TERM'] === 'dumb') + ); +} + /** * Creates a CSI sequence. * @template P The type of the sequence parameter diff --git a/packages/tsargp/test/styles.spec.ts b/packages/tsargp/test/styles.spec.ts index be65330c..14eacd8d 100644 --- a/packages/tsargp/test/styles.spec.ts +++ b/packages/tsargp/test/styles.spec.ts @@ -160,158 +160,187 @@ describe('TerminalString', () => { it('should not wrap', () => { const str = new TerminalString(); const result = new Array(); - str.splitText('abc def').wrapToWidth(result, 0); + str.splitText('abc def').wrapToWidth(result, 0, 0, false); expect(result).toEqual(['abc', ' def']); }); it('should preserve indentation', () => { const str = new TerminalString(2); const result = new Array(); - str.addWord('abc').wrapToWidth(result, 0); + str.addWord('abc').wrapToWidth(result, 0, 0, false); expect(result).toEqual([' ', 'abc']); }); it('should not preserve indentation if the string is empty', () => { const str = new TerminalString(2); const result = new Array(); - str.wrapToWidth(result, 0); + str.wrapToWidth(result, 0, 0, false); expect(result).toEqual([]); }); it('should not preserve indentation if the string starts with a line break', () => { const str = new TerminalString(2); const result = new Array(); - str.addBreaks(1).wrapToWidth(result, 0); + str.addBreaks(1).wrapToWidth(result, 0, 0, false); expect(result).toEqual(['\n']); }); it('should shorten the current line (1)', () => { const str = new TerminalString(0); const result = [' ']; - str.splitText('abc def').wrapToWidth(result, 2); + str.splitText('abc def').wrapToWidth(result, 2, 0, false); expect(result).toEqual(['abc', ' def']); }); it('should shorten the current line (2)', () => { const str = new TerminalString(0); const result = [' ']; - str.splitText('abc def').wrapToWidth(result, 2); + str.splitText('abc def').wrapToWidth(result, 2, 0, false); expect(result).toEqual([' ', 'abc', ' def']); }); it('should not shorten the current line if the string is empty', () => { const str = new TerminalString(0); const result = [' ']; - str.wrapToWidth(result, 2); + str.wrapToWidth(result, 2, 0, false); expect(result).toEqual([' ']); }); it('should not shorten the current line if the string starts with a line break', () => { const str = new TerminalString(0); const result = [' ']; - str.addBreaks(1).wrapToWidth(result, 2); + str.addBreaks(1).wrapToWidth(result, 2, 0, false); expect(result).toEqual([' ', '\n']); }); it('should preserve line breaks', () => { const str = new TerminalString(0); const result = new Array(); - str.splitText('abc\n\ndef').wrapToWidth(result, 0); + str.splitText('abc\n\ndef').wrapToWidth(result, 0, 0, false); expect(result).toEqual(['abc', '\n\n', 'def']); }); - it('should remove styles', () => { + it('should omit styles', () => { const str = new TerminalString(0); const result = new Array(); - str.splitText(`abc${style(tf.clear)} ${style(tf.clear)} def`).wrapToWidth(result, 0); + str + .splitText(`abc${style(tf.clear)} ${style(tf.clear)} def`) + .wrapToWidth(result, 0, 0, false); expect(result).toEqual(['abc', ' def']); }); + + it('should emit styles', () => { + const str = new TerminalString(); + const result = new Array(); + str + .splitText(`abc${style(tf.clear)} ${style(tf.clear)} def`) + .wrapToWidth(result, 0, 0, true); + expect(result).toEqual(['abc' + style(tf.clear), style(tf.clear), ' def']); + }); }); describe('when a width is provided', () => { it('should wrap relative to the beginning when the largest word does not fit the width (1)', () => { const str = new TerminalString(1); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 0, 5); + str.splitText('abc largest').wrapToWidth(result, 0, 5, false); expect(result).toEqual(['abc', '\nlargest']); }); it('should wrap relative to the beginning when the largest word does not fit the width (2)', () => { const str = new TerminalString(1); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 1, 5); + str.splitText('abc largest').wrapToWidth(result, 1, 5, false); expect(result).toEqual(['\n', 'abc', '\nlargest']); }); it('should wrap relative to the beginning when the largest word does not fit the width (3)', () => { const str = new TerminalString(1); const result = new Array(); - str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 1, 5); + str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 1, 5, false); expect(result).toEqual(['\n', 'abc', '\nlargest']); }); it('should wrap with a move sequence when the largest word fits the width (1)', () => { const str = new TerminalString(1); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 1, 15); + str.splitText('abc largest').wrapToWidth(result, 1, 15, false); expect(result).toEqual(['abc', ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (2)', () => { const str = new TerminalString(1); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 2, 15); + str.splitText('abc largest').wrapToWidth(result, 2, 15, false); expect(result).toEqual([move(2, mv.cha), 'abc', ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (3)', () => { const str = new TerminalString(2); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 1, 15); + str.splitText('abc largest').wrapToWidth(result, 1, 15, false); expect(result).toEqual([move(3, mv.cha), 'abc', ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (4)', () => { const str = new TerminalString(1); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 1, 10); + str.splitText('abc largest').wrapToWidth(result, 1, 10, false); expect(result).toEqual(['abc', `\n${move(2, mv.cha)}largest`]); }); it('should wrap with a move sequence when the largest word fits the width (5)', () => { const str = new TerminalString(); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 0, 15); + str.splitText('abc largest').wrapToWidth(result, 0, 15, false); expect(result).toEqual(['abc', ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (6)', () => { const str = new TerminalString(); const result = new Array(); - str.splitText('abc largest').wrapToWidth(result, 1, 15); + str.splitText('abc largest').wrapToWidth(result, 1, 15, false); expect(result).toEqual([move(1, mv.cha), 'abc', ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (7)', () => { const str = new TerminalString(); const result = new Array(); - str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 1, 15); + str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 1, 15, false); expect(result).toEqual(['\n', 'abc', ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (8)', () => { const str = new TerminalString(1); const result = new Array(); - str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 2, 15); + str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 2, 15, false); expect(result).toEqual(['\n', `${move(2, mv.cha)}abc`, ' largest']); }); it('should wrap with a move sequence when the largest word fits the width (9)', () => { const str = new TerminalString(2); const result = new Array(); - str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 1, 15); + str.addBreaks(1).splitText('abc largest').wrapToWidth(result, 1, 15, false); expect(result).toEqual(['\n', `${move(3, mv.cha)}abc`, ' largest']); }); + + it('should omit styles', () => { + const str = new TerminalString(0); + const result = new Array(); + str + .splitText(`abc${style(tf.clear)} ${style(tf.clear)} def`) + .wrapToWidth(result, 0, 10, false); + expect(result).toEqual(['abc', ' def']); + }); + + it('should emit styles', () => { + const str = new TerminalString(); + const result = new Array(); + str + .splitText(`abc${style(tf.clear)} ${style(tf.clear)} def`) + .wrapToWidth(result, 0, 10, true); + expect(result).toEqual(['abc' + style(tf.clear), style(tf.clear), ' def']); + }); }); }); }); @@ -322,8 +351,10 @@ describe('ErrorMessage', () => { str.splitText('type script'); const err = new ErrorMessage(str); expect(err.message).toMatch(/type script/); - expect(err.wrap(0)).toEqual('type script'); - expect(err.wrap(11)).toEqual('type script' + style(tf.clear)); + expect(err.wrap(0, false)).toEqual('type script'); + expect(err.wrap(0, true)).toEqual('type script' + style(tf.clear)); + expect(err.wrap(11, false)).toEqual('type script'); + expect(err.wrap(11, true)).toEqual('type script' + style(tf.clear)); }); it('should be thrown and caught', () => { @@ -343,7 +374,9 @@ describe('HelpMessage', () => { const help = new HelpMessage(); help.push(str); expect(help.toString()).toMatch(/type script/); - expect(help.wrap(0)).toEqual('type script'); - expect(help.wrap(11)).toEqual('type script' + style(tf.clear)); + expect(help.wrap(0, false)).toEqual('type script'); + expect(help.wrap(0, true)).toEqual('type script' + style(tf.clear)); + expect(help.wrap(11, false)).toEqual('type script'); + expect(help.wrap(11, true)).toEqual('type script' + style(tf.clear)); }); });