Skip to content

Commit 5105f43

Browse files
escapedcatarmano2
andauthoredNov 6, 2021
feat(prompt): rewrite codebase to use inquirer - UPDATED with current master (#2697)
* feat(prompt): rewrite codebase to use inquirer * fix(prompt): simplify logic used to compute maxLength * test(prompt): add basic input test * fix(prompt): small code refactor * fix: correct linting issues, add missing dependencies * fix: add missing tsconfig reference * fix: update lock file after merge * fix: correct issue with mac-os tab completion * chore: code review * fix: integrate review feedback * style: prettier Co-authored-by: Armano <armano2@users.noreply.github.com>
1 parent 42b3984 commit 5105f43

16 files changed

+474
-580
lines changed
 

‎.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ module.exports = {
5252
'@typescript-eslint/no-var-requires': 'off',
5353
'@typescript-eslint/no-inferrable-types': 'off',
5454
'@typescript-eslint/no-non-null-assertion': 'off',
55+
'@typescript-eslint/triple-slash-reference': 'off',
5556

5657
// TODO: enable those rules?
5758
'no-empty': 'off',

‎@commitlint/prompt-cli/cli.js

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
#!/usr/bin/env node
22
const execa = require('execa');
3+
const inquirer = require('inquirer');
34
const {prompter} = require('@commitlint/prompt');
45

5-
const _ = undefined;
6-
const prompt = () => prompter(_, commit);
7-
86
main().catch((err) => {
97
setTimeout(() => {
108
throw err;
@@ -21,7 +19,7 @@ function main() {
2119
process.exit(1);
2220
}
2321
})
24-
.then(() => prompt());
22+
.then(() => prompter(inquirer, commit));
2523
}
2624

2725
function isStageEmpty() {

‎@commitlint/prompt-cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
},
3838
"dependencies": {
3939
"@commitlint/prompt": "^14.1.0",
40+
"inquirer": "^6.5.2",
4041
"execa": "^5.0.0"
4142
},
4243
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"

‎@commitlint/prompt/package.json

+7-3
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,19 @@
3838
},
3939
"devDependencies": {
4040
"@commitlint/utils": "^14.0.0",
41-
"commitizen": "4.2.4"
41+
"@commitlint/types": "^13.2.0",
42+
"@commitlint/config-angular": "^13.2.0",
43+
"@types/inquirer": "^6.5.0",
44+
"inquirer": "^6.5.2",
45+
"commitizen": "^4.2.4"
4246
},
4347
"dependencies": {
4448
"@commitlint/ensure": "^14.1.0",
4549
"@commitlint/load": "^14.1.0",
4650
"@commitlint/types": "^14.0.0",
4751
"chalk": "^4.0.0",
48-
"throat": "^6.0.0",
49-
"vorpal": "^1.12.0"
52+
"lodash": "^4.17.19",
53+
"inquirer": "^6.5.2"
5054
},
5155
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"
5256
}

‎@commitlint/prompt/src/index.ts

+4-12
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
1-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2-
// @ts-ignore
3-
import vorpal from 'vorpal';
4-
import input from './input';
1+
import inquirer from 'inquirer';
2+
import {input} from './input';
53

64
type Commit = (input: string) => void;
75

8-
/**
9-
* Entry point for commitizen
10-
* @param _ inquirer instance passed by commitizen, unused
11-
* @param commit callback to execute with complete commit message
12-
* @return {void}
13-
*/
14-
export function prompter(_: unknown, commit: Commit): void {
15-
input(vorpal).then((message) => {
6+
export function prompter(cz: typeof inquirer, commit: Commit): void {
7+
input(cz.prompt).then((message) => {
168
commit(message);
179
});
1810
}

‎@commitlint/prompt/src/input.test.ts

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {Answers, PromptModule, QuestionCollection} from 'inquirer';
2+
/// <reference path="./inquirer/inquirer.d.ts" />
3+
import {input} from './input';
4+
import chalk from 'chalk';
5+
6+
jest.mock(
7+
'@commitlint/load',
8+
() => {
9+
return () => require('@commitlint/config-angular');
10+
},
11+
{
12+
virtual: true,
13+
}
14+
);
15+
16+
test('should work with all fields filled', async () => {
17+
const prompt = stub({
18+
'input-custom': {
19+
type: 'fix',
20+
scope: 'test',
21+
subject: 'subject',
22+
body: 'body',
23+
footer: 'footer',
24+
},
25+
});
26+
const message = await input(prompt);
27+
expect(message).toEqual('fix(test): subject\n' + 'body\n' + 'footer');
28+
});
29+
30+
test('should work without scope', async () => {
31+
const prompt = stub({
32+
'input-custom': {
33+
type: 'fix',
34+
scope: '',
35+
subject: 'subject',
36+
body: 'body',
37+
footer: 'footer',
38+
},
39+
});
40+
const message = await input(prompt);
41+
expect(message).toEqual('fix: subject\n' + 'body\n' + 'footer');
42+
});
43+
44+
test('should fail without type', async () => {
45+
const spy = jest.spyOn(console, 'error').mockImplementation();
46+
const prompt = stub({
47+
'input-custom': {
48+
type: '',
49+
scope: '',
50+
subject: '',
51+
body: '',
52+
footer: '',
53+
},
54+
});
55+
const message = await input(prompt);
56+
expect(message).toEqual('');
57+
expect(console.error).toHaveBeenCalledTimes(1);
58+
expect(console.error).toHaveBeenLastCalledWith(
59+
new Error(`⚠ ${chalk.bold('type')} may not be empty.`)
60+
);
61+
spy.mockRestore();
62+
});
63+
64+
function stub(config: Record<string, Record<string, unknown>>): PromptModule {
65+
const prompt = async (questions: QuestionCollection): Promise<any> => {
66+
const result: Answers = {};
67+
const resolvedConfig = Array.isArray(questions) ? questions : [questions];
68+
for (const promptConfig of resolvedConfig) {
69+
const configType = promptConfig.type || 'input';
70+
const questions = config[configType];
71+
if (!questions) {
72+
throw new Error(`Unexpected config type: ${configType}`);
73+
}
74+
const answer = questions[promptConfig.name!];
75+
if (answer == null) {
76+
throw new Error(`Unexpected config name: ${promptConfig.name}`);
77+
}
78+
const validate = promptConfig.validate;
79+
if (validate) {
80+
const validationResult = validate(answer, result);
81+
if (validationResult !== true) {
82+
throw new Error(validationResult || undefined);
83+
}
84+
}
85+
86+
result[promptConfig.name!] = answer;
87+
}
88+
return result;
89+
};
90+
prompt.registerPrompt = () => {
91+
return prompt;
92+
};
93+
prompt.restoreDefaultPrompts = () => true;
94+
prompt.prompts = {};
95+
return prompt as any as PromptModule;
96+
}

‎@commitlint/prompt/src/input.ts

+27-38
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
11
import load from '@commitlint/load';
2-
import throat from 'throat';
2+
import {DistinctQuestion, PromptModule} from 'inquirer';
33

44
import format from './library/format';
55
import getPrompt from './library/get-prompt';
66
import settings from './settings';
7-
import {InputSetting, Prompter, Result} from './library/types';
8-
import {getHasName, getMaxLength, getRules} from './library/utils';
7+
import type {InputSetting, Result} from './library/types';
98

10-
export default input;
9+
import {getHasName, getMaxLength, getRules} from './library/utils';
10+
import InputCustomPrompt from './inquirer/InputCustomPrompt';
1111

1212
/**
1313
* Get user input by interactive prompt based on
1414
* conventional-changelog-lint rules.
1515
* @param prompter
1616
* @return commit message
1717
*/
18-
async function input(prompter: () => Prompter): Promise<string> {
19-
const results: Result = {
20-
type: null,
21-
scope: null,
22-
subject: null,
23-
body: null,
24-
footer: null,
25-
};
26-
18+
export async function input(prompter: PromptModule): Promise<string> {
2719
const {rules} = await load();
2820
const parts = ['type', 'scope', 'subject', 'body', 'footer'] as const;
2921
const headerParts = ['type', 'scope', 'subject'];
@@ -33,31 +25,28 @@ async function input(prompter: () => Prompter): Promise<string> {
3325
);
3426
const maxLength = getMaxLength(headerLengthRule);
3527

36-
await Promise.all(
37-
parts.map(
38-
throat(1, async (input) => {
39-
const inputRules = getRules(input, rules);
40-
const inputSettings: InputSetting = settings[input];
41-
42-
if (headerParts.includes(input) && maxLength < Infinity) {
43-
inputSettings.header = {
44-
length: maxLength,
45-
};
46-
}
47-
48-
results[input] = await getPrompt(input, {
49-
rules: inputRules,
50-
settings: inputSettings,
51-
results,
52-
prompter,
53-
});
54-
})
55-
)
56-
).catch((err) => {
28+
try {
29+
const questions: DistinctQuestion<Result>[] = [];
30+
prompter.registerPrompt('input-custom', InputCustomPrompt);
31+
32+
for (const input of parts) {
33+
const inputSetting: InputSetting = settings[input];
34+
const inputRules = getRules(input, rules);
35+
if (headerParts.includes(input) && maxLength < Infinity) {
36+
inputSetting.header = {
37+
length: maxLength,
38+
};
39+
}
40+
const question = getPrompt(input, inputRules, inputSetting);
41+
if (question) {
42+
questions.push(question);
43+
}
44+
}
45+
46+
const results = await prompter<Result>(questions);
47+
return format(results);
48+
} catch (err) {
5749
console.error(err);
5850
return '';
59-
});
60-
61-
// Return the results
62-
return format(results);
51+
}
6352
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/// <reference path="./inquirer.d.ts" />
2+
import chalk from 'chalk';
3+
import inquirer from 'inquirer';
4+
import InputPrompt from 'inquirer/lib/prompts/input';
5+
import observe from 'inquirer/lib/utils/events';
6+
import {Interface as ReadlineInterface, Key} from 'readline';
7+
import type {Subscription} from 'rxjs/internal/Subscription';
8+
9+
import Answers = inquirer.Answers;
10+
import InputCustomOptions = inquirer.InputCustomOptions;
11+
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;
12+
13+
interface KeyDescriptor {
14+
value: string;
15+
key: Key;
16+
}
17+
18+
export default class InputCustomPrompt<
19+
TQuestion extends InputCustomOptions = InputCustomOptions
20+
> extends InputPrompt<TQuestion> {
21+
private lineSubscription: Subscription;
22+
private readonly tabCompletion: string[];
23+
24+
constructor(
25+
question: TQuestion,
26+
readLine: ReadlineInterface,
27+
answers: Answers
28+
) {
29+
super(question, readLine, answers);
30+
31+
if (this.opt.log) {
32+
this.rl.write(this.opt.log(answers));
33+
}
34+
35+
if (!this.opt.maxLength) {
36+
this.throwParamError('maxLength');
37+
}
38+
39+
const events = observe(this.rl);
40+
this.lineSubscription = events.keypress.subscribe(
41+
this.onKeyPress2.bind(this)
42+
);
43+
this.tabCompletion = (this.opt.tabCompletion || [])
44+
.map((item) => item.value)
45+
.sort((a, b) => a.localeCompare(b));
46+
}
47+
48+
onEnd(state: SuccessfulPromptStateData): void {
49+
this.lineSubscription.unsubscribe();
50+
super.onEnd(state);
51+
}
52+
53+
/**
54+
* @see https://nodejs.org/api/readline.html#readline_rl_write_data_key
55+
* @see https://nodejs.org/api/readline.html#readline_rl_line
56+
*/
57+
updateLine(line: string): void {
58+
this.rl.write(null as any, {ctrl: true, name: 'b'});
59+
this.rl.write(null as any, {ctrl: true, name: 'd'});
60+
this.rl.write(line.substr(this.rl.line.length));
61+
}
62+
63+
onKeyPress2(e: KeyDescriptor): void {
64+
if (e.key.name === 'tab' && this.tabCompletion.length > 0) {
65+
let line = this.rl.line.trim();
66+
if (line.length > 0) {
67+
for (const item of this.tabCompletion) {
68+
if (item.startsWith(line) && item !== line) {
69+
line = item;
70+
break;
71+
}
72+
}
73+
}
74+
this.updateLine(line);
75+
}
76+
}
77+
78+
measureInput(input: string): number {
79+
if (this.opt.filter) {
80+
return this.opt.filter(input).length;
81+
}
82+
return input.length;
83+
}
84+
85+
render(error?: string): void {
86+
const answered = this.status === 'answered';
87+
88+
let message = this.getQuestion();
89+
const length = this.measureInput(this.rl.line);
90+
91+
if (answered) {
92+
message += chalk.cyan(this.answer);
93+
} else if (this.opt.transformer) {
94+
message += this.opt.transformer(this.rl.line, this.answers, {});
95+
}
96+
97+
let bottomContent = '';
98+
99+
if (error) {
100+
bottomContent = chalk.red('>> ') + error;
101+
} else if (!answered) {
102+
const maxLength = this.opt.maxLength(this.answers);
103+
if (maxLength < Infinity) {
104+
const lengthRemaining = maxLength - length;
105+
const color =
106+
lengthRemaining <= 5
107+
? chalk.red
108+
: lengthRemaining <= 10
109+
? chalk.yellow
110+
: chalk.grey;
111+
bottomContent = color(`${lengthRemaining} characters left`);
112+
}
113+
}
114+
115+
this.screen.render(message, bottomContent);
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {Answers, InputQuestionOptions} from 'inquirer';
2+
3+
declare module 'inquirer' {
4+
interface InputCustomCompletionOption {
5+
value: string;
6+
description?: string;
7+
}
8+
9+
export interface InputCustomOptions<T extends Answers = Answers>
10+
extends InputQuestionOptions<T> {
11+
/**
12+
* @inheritdoc
13+
*/
14+
type?: 'input-custom';
15+
log?(answers?: T): string;
16+
tabCompletion?: InputCustomCompletionOption[];
17+
maxLength(answers?: T): number;
18+
}
19+
20+
interface QuestionMap<T extends Answers = Answers> {
21+
'input-custom': InputCustomOptions<T>;
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {Result} from './types';
2+
import format from './format';
3+
4+
test('should return empty string', () => {
5+
const result: Result = {};
6+
expect(format(result)).toBe(' ');
7+
});
8+
9+
test('should omit scope', () => {
10+
const result: Result = {
11+
type: 'fix',
12+
subject: 'test',
13+
};
14+
expect(format(result)).toBe('fix: test');
15+
});
16+
17+
test('should include scope', () => {
18+
const result: Result = {
19+
type: 'fix',
20+
scope: 'prompt',
21+
subject: 'test',
22+
};
23+
expect(format(result)).toBe('fix(prompt): test');
24+
});
25+
26+
test('should include body', () => {
27+
const result: Result = {
28+
type: 'fix',
29+
scope: 'prompt',
30+
subject: 'test',
31+
body: 'some body',
32+
};
33+
expect(format(result)).toBe('fix(prompt): test\nsome body');
34+
});
35+
36+
test('should include footer', () => {
37+
const result: Result = {
38+
type: 'fix',
39+
scope: 'prompt',
40+
subject: 'test',
41+
footer: 'some footer',
42+
};
43+
expect(format(result)).toBe('fix(prompt): test\nsome footer');
44+
});
45+
46+
test('should include body and footer', () => {
47+
const result: Result = {
48+
type: 'fix',
49+
scope: 'prompt',
50+
subject: 'test',
51+
body: 'some body',
52+
footer: 'some footer',
53+
};
54+
expect(format(result)).toBe('fix(prompt): test\nsome body\nsome footer');
55+
});

‎@commitlint/prompt/src/library/format.ts

+17-11
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,35 @@
11
import chalk from 'chalk';
2-
import {Result} from './types';
3-
4-
export default format;
2+
import {Result, ResultPart} from './types';
53

64
/**
75
* Get formatted commit message
86
* @param input object containing structured results
97
* @param debug show debug information in commit message
108
* @return formatted debug message
119
*/
12-
function format(input: Result, debug = false): string {
10+
export default function format(input: Result, debug = false): string {
11+
const defaultInput = {
12+
type: undefined,
13+
scope: undefined,
14+
subject: undefined,
15+
body: undefined,
16+
footer: undefined,
17+
...input,
18+
};
1319
const results = debug
14-
? Object.entries(input).reduce<Result>((registry, [name, value]) => {
15-
registry[name as 'type' | 'scope' | 'subject' | 'body' | 'footer'] =
16-
value === null ? chalk.grey(`<${name}>`) : chalk.bold(value);
20+
? Object.entries(defaultInput).reduce<Result>((registry, [name, value]) => {
21+
registry[name as ResultPart] =
22+
value === undefined ? chalk.grey(`<${name}>`) : chalk.bold(value);
1723
return registry;
1824
}, {})
19-
: input;
25+
: defaultInput;
2026

2127
// Return formatted string
2228
const {type, scope, subject, body, footer} = results;
2329
return [
24-
`${type}${scope ? '(' : ''}${scope}${scope ? ')' : ''}${
25-
type || scope ? ':' : ''
26-
} ${subject}`,
30+
`${type || ''}${scope ? `(${scope})` : ''}${type || scope ? ':' : ''} ${
31+
subject || ''
32+
}`,
2733
body,
2834
footer,
2935
]

‎@commitlint/prompt/src/library/get-prompt.test.ts

-100
This file was deleted.
+83-199
Original file line numberDiff line numberDiff line change
@@ -1,239 +1,123 @@
11
import chalk from 'chalk';
2+
import {InputCustomOptions} from 'inquirer';
23

3-
import type {InputSetting, Prompter, Result, RuleEntry} from './types';
4+
import type {InputSetting, RuleEntry, Result, ResultPart} from './types';
45

56
import format from './format';
67
import getForcedCaseFn from './get-forced-case-fn';
78
import getForcedLeadingFn from './get-forced-leading-fn';
89
import meta from './meta';
910
import {
1011
enumRuleIsActive,
11-
ruleIsNotApplicable,
12-
ruleIsApplicable,
13-
ruleIsActive,
1412
getHasName,
1513
getMaxLength,
14+
ruleIsActive,
15+
ruleIsApplicable,
16+
ruleIsNotApplicable,
1617
} from './utils';
1718

19+
const EOL = '\n';
20+
1821
/**
1922
* Get a cli prompt based on rule configuration
2023
* @param type type of the data to gather
21-
* @param context rules to parse
24+
* @param rules
25+
* @param settings
2226
* @return prompt instance
2327
*/
2428
export default function getPrompt(
25-
type: string,
26-
context: {
27-
rules?: RuleEntry[];
28-
settings?: InputSetting;
29-
results?: Result;
30-
prompter?: () => Prompter;
31-
} = {}
32-
): Promise<string | undefined> {
33-
const {rules = [], settings = {}, results = {}, prompter} = context;
34-
35-
if (typeof prompter !== 'function') {
36-
throw new TypeError('Missing prompter function in getPrompt context');
37-
}
38-
39-
const prompt = prompter();
40-
41-
if (typeof prompt.removeAllListeners !== 'function') {
42-
throw new TypeError(
43-
'getPrompt: prompt.removeAllListeners is not a function'
44-
);
45-
}
46-
47-
if (typeof prompt.command !== 'function') {
48-
throw new TypeError('getPrompt: prompt.command is not a function');
49-
}
50-
51-
if (typeof prompt.catch !== 'function') {
52-
throw new TypeError('getPrompt: prompt.catch is not a function');
53-
}
54-
55-
if (typeof prompt.addListener !== 'function') {
56-
throw new TypeError('getPrompt: prompt.addListener is not a function');
57-
}
58-
59-
if (typeof prompt.log !== 'function') {
60-
throw new TypeError('getPrompt: prompt.log is not a function');
61-
}
62-
63-
if (typeof prompt.delimiter !== 'function') {
64-
throw new TypeError('getPrompt: prompt.delimiter is not a function');
65-
}
66-
67-
if (typeof prompt.show !== 'function') {
68-
throw new TypeError('getPrompt: prompt.show is not a function');
69-
}
70-
71-
const enumRule = rules.filter(getHasName('enum')).find(enumRuleIsActive);
72-
73-
const emptyRule = rules.find(getHasName('empty'));
74-
75-
const mustBeEmpty =
76-
emptyRule && ruleIsActive(emptyRule) && ruleIsApplicable(emptyRule);
29+
type: ResultPart,
30+
rules: RuleEntry[] = [],
31+
settings: InputSetting = {}
32+
): InputCustomOptions<Result> | null {
33+
const emptyRule = rules.filter(getHasName('empty')).find(ruleIsActive);
7734

78-
const mayNotBeEmpty =
79-
emptyRule && ruleIsActive(emptyRule) && ruleIsNotApplicable(emptyRule);
80-
81-
const mayBeEmpty = !mayNotBeEmpty;
35+
const mustBeEmpty = emptyRule ? ruleIsApplicable(emptyRule) : false;
8236

8337
if (mustBeEmpty) {
84-
prompt.removeAllListeners('keypress');
85-
prompt.removeAllListeners('client_prompt_submit');
86-
prompt.ui.redraw.done();
87-
return Promise.resolve(undefined);
38+
return null;
8839
}
8940

90-
const caseRule = rules.find(getHasName('case'));
91-
92-
const forceCaseFn = getForcedCaseFn(caseRule);
41+
const required = emptyRule ? ruleIsNotApplicable(emptyRule) : false;
9342

94-
const leadingBlankRule = rules.find(getHasName('leading-blank'));
95-
96-
const forceLeadingBlankFn = getForcedLeadingFn(leadingBlankRule);
43+
const forceCaseFn = getForcedCaseFn(rules.find(getHasName('case')));
44+
const forceLeadingBlankFn = getForcedLeadingFn(
45+
rules.find(getHasName('leading-blank'))
46+
);
9747

9848
const maxLengthRule = rules.find(getHasName('max-length'));
9949
const inputMaxLength = getMaxLength(maxLengthRule);
10050

101-
const headerLength = settings.header ? settings.header.length : Infinity;
102-
103-
const remainingHeaderLength = headerLength
104-
? headerLength -
105-
[
106-
results.type,
107-
results.scope,
108-
results.scope ? '()' : '',
109-
results.type && results.scope ? ':' : '',
110-
results.subject,
111-
].join('').length
112-
: Infinity;
113-
114-
const maxLength = Math.min(inputMaxLength, remainingHeaderLength);
115-
116-
return new Promise((resolve) => {
117-
// Add the defined enums as sub commands if applicable
118-
if (enumRule) {
119-
const [, [, , enums]] = enumRule;
51+
const enumRule = rules.filter(getHasName('enum')).find(enumRuleIsActive);
12052

121-
enums.forEach((enumerable) => {
53+
const tabCompletion = enumRule
54+
? enumRule[1][2].map((enumerable) => {
12255
const enumSettings = (settings.enumerables || {})[enumerable] || {};
123-
prompt
124-
.command(enumerable)
125-
.description(enumSettings.description || '')
126-
.action(() => {
127-
prompt.removeAllListeners();
128-
prompt.ui.redraw.done();
129-
return resolve(forceLeadingBlankFn(forceCaseFn(enumerable)));
130-
});
56+
return {
57+
value: forceLeadingBlankFn(forceCaseFn(enumerable)),
58+
description: enumSettings.description || '',
59+
};
60+
})
61+
: [];
62+
63+
const maxLength = (res: Result) => {
64+
let remainingHeaderLength = Infinity;
65+
if (settings.header && settings.header.length) {
66+
const header = format({
67+
type: res.type,
68+
scope: res.scope,
69+
subject: res.subject,
13170
});
132-
} else {
133-
prompt.catch('[text...]').action((parameters) => {
134-
const {text = ''} = parameters;
135-
prompt.removeAllListeners();
136-
prompt.ui.redraw.done();
137-
return resolve(forceLeadingBlankFn(forceCaseFn(text.join(' '))));
138-
});
139-
}
140-
141-
if (mayBeEmpty) {
142-
// Add an easy exit command
143-
prompt
144-
.command(':skip')
145-
.description('Skip the input if possible.')
146-
.action(() => {
147-
prompt.removeAllListeners();
148-
prompt.ui.redraw.done();
149-
resolve('');
150-
});
71+
remainingHeaderLength = settings.header.length - header.length;
15172
}
152-
153-
// Handle empty input
154-
const onSubmit = (input: string) => {
155-
if (input.length > 0) {
156-
return;
73+
return Math.min(inputMaxLength, remainingHeaderLength);
74+
};
75+
76+
return {
77+
type: 'input-custom',
78+
name: type,
79+
message: `${type}:`,
80+
validate(input, answers) {
81+
if (input.length > maxLength(answers || {})) {
82+
return 'Input contains too many characters!';
15783
}
158-
159-
// Show help if enum is defined and input may not be empty
160-
if (mayNotBeEmpty) {
161-
prompt.ui.log(chalk.yellow(`⚠ ${chalk.bold(type)} may not be empty.`));
84+
if (required && input.trim().length === 0) {
85+
// Show help if enum is defined and input may not be empty
86+
return `⚠ ${chalk.bold(type)} may not be empty.`;
16287
}
16388

164-
if (mayBeEmpty) {
165-
prompt.ui.log(
166-
chalk.blue(
167-
`ℹ Enter ${chalk.bold(':skip')} to omit ${chalk.bold(type)}.`
168-
)
169-
);
89+
const tabValues = tabCompletion.map((item) => item.value);
90+
if (
91+
input.length > 0 &&
92+
tabValues.length > 0 &&
93+
!tabValues.includes(input)
94+
) {
95+
return `⚠ ${chalk.bold(type)} must be one of ${tabValues.join(', ')}.`;
17096
}
171-
172-
if (enumRule) {
173-
prompt.exec('help');
97+
return true;
98+
},
99+
tabCompletion,
100+
log(answers?: Result) {
101+
let prefix =
102+
`${chalk.white('Please enter a')} ${chalk.bold(type)}: ${meta({
103+
optional: !required,
104+
required: required,
105+
'tab-completion': typeof enumRule !== 'undefined',
106+
header: typeof settings.header !== 'undefined',
107+
'multi-line': settings.multiline,
108+
})}` + EOL;
109+
110+
if (settings.description) {
111+
prefix += chalk.grey(`${settings.description}`) + EOL;
174112
}
175-
};
176-
177-
const drawRemaining = (length: number) => {
178-
if (length < Infinity) {
179-
const colors = [
180-
{
181-
threshold: 5,
182-
color: chalk.red,
183-
},
184-
{
185-
threshold: 10,
186-
color: chalk.yellow,
187-
},
188-
{
189-
threshold: Infinity,
190-
color: chalk.grey,
191-
},
192-
];
193-
194-
const el = colors.find((item) => item.threshold >= length);
195-
const color = el ? el.color : chalk.grey;
196-
prompt.ui.redraw(color(`${length} characters left`));
113+
if (answers) {
114+
prefix += EOL + `${format(answers, true)}` + EOL;
197115
}
198-
};
199-
200-
const onKey = (event: {value: string}) => {
201-
const sanitized = forceCaseFn(event.value);
202-
const cropped = sanitized.slice(0, maxLength);
203-
204-
// We **could** do live editing, but there are some quirks to solve
205-
/* const live = merge({}, results, {
206-
[type]: cropped
207-
});
208-
prompt.ui.redraw(`\n\n${format(live, true)}\n\n`); */
209-
210-
if (maxLength) {
211-
drawRemaining(maxLength - cropped.length);
212-
}
213-
prompt.ui.input(cropped);
214-
};
215-
216-
prompt.addListener('keypress', onKey);
217-
prompt.addListener('client_prompt_submit', onSubmit);
218-
219-
prompt.log(
220-
`\n\nPlease enter a ${chalk.bold(type)}: ${meta({
221-
optional: !mayNotBeEmpty,
222-
required: mayNotBeEmpty,
223-
'tab-completion': typeof enumRule !== 'undefined',
224-
header: typeof settings.header !== 'undefined',
225-
'multi-line': settings.multiline,
226-
})}`
227-
);
228-
229-
if (settings.description) {
230-
prompt.log(chalk.grey(`${settings.description}\n`));
231-
}
232-
233-
prompt.log(`\n\n${format(results, true)}\n\n`);
234-
235-
drawRemaining(maxLength);
236-
237-
prompt.delimiter(`❯ ${type}:`).show();
238-
});
116+
return prefix + EOL;
117+
},
118+
maxLength,
119+
transformer(value: string) {
120+
return forceCaseFn(value);
121+
},
122+
};
239123
}

‎@commitlint/prompt/src/library/types.ts

+2-33
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,6 @@ export type InputSetting = {
1919
};
2020
};
2121

22-
export type Result = Partial<
23-
Record<'type' | 'scope' | 'subject' | 'body' | 'footer', null | string>
24-
>;
22+
export type ResultPart = 'type' | 'scope' | 'subject' | 'body' | 'footer';
2523

26-
export interface PrompterCommand {
27-
description(value: string): this;
28-
action(
29-
action: (args: {
30-
[key: string]: any;
31-
options: {
32-
[key: string]: any;
33-
};
34-
}) => Promise<void> | void
35-
): this;
36-
}
37-
38-
export interface Prompter {
39-
delimiter(value: string): this;
40-
show(): this;
41-
exec(command: string): Promise<any>;
42-
log(text?: string): void;
43-
catch(command: string, description?: string): PrompterCommand;
44-
command(command: string, description?: string): PrompterCommand;
45-
removeAllListeners(input?: string): void;
46-
addListener(input: string, cb: (event: any) => void): void;
47-
ui: {
48-
log(text?: string): void;
49-
input(text?: string): string;
50-
redraw: {
51-
(text: string, ...texts: string[]): void;
52-
done(): void;
53-
};
54-
};
55-
}
24+
export type Result = Partial<Record<ResultPart, string | undefined>>;

‎@commitlint/prompt/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
},
88
"include": ["./src"],
99
"exclude": ["./src/**/*.test.ts", "./lib/**/*"],
10-
"references": [{"path": "../cli"}]
10+
"references": [{"path": "../types"}, {"path": "../load"}]
1111
}

‎yarn.lock

+38-179
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.